<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Efe Genç</title>
    <description>The latest articles on DEV Community by Efe Genç (@efe_genc).</description>
    <link>https://dev.to/efe_genc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110228%2F338f35c9-5ee2-4a88-9cc7-e393cb651dfa.jpg</url>
      <title>DEV Community: Efe Genç</title>
      <link>https://dev.to/efe_genc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/efe_genc"/>
    <language>en</language>
    <item>
      <title>Twelve Checks That Could Not Fail</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Thu, 17 Sep 2026 10:55:05 +0000</pubDate>
      <link>https://dev.to/efe_genc/twelve-checks-that-could-not-fail-4lea</link>
      <guid>https://dev.to/efe_genc/twelve-checks-that-could-not-fail-4lea</guid>
      <description>&lt;p&gt;Four days ago I published a piece about a release pipeline that installed the package it had just published, confirmed the version, and reported success against a version the registry was not serving. A reader called &lt;code&gt;build996&lt;/code&gt; left a comment that went one level below anything I had written:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; in that npx line is doing quiet damage too: it merges npm's error output into the same variable the case statement reads, so the failure text is handed straight to the matcher that decides success. Without it, &lt;code&gt;$out&lt;/code&gt; would have been empty on a miss and the pattern could not have matched.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had described the check. He had found the mechanism. So I measured it, because a claim that good deserves a run rather than a nod.&lt;/p&gt;

&lt;p&gt;The line, as it shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"smoke ok"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"smoke failed"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Asking npm for a version that does not exist, with the redirect in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;captured bytes: 346
first line:     npm error code ETARGET
VERDICT:        smoke ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same check, with stderr left where it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;captured bytes: 0
VERDICT:        smoke FAILED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three characters. npm's error text quotes the package and version back at you, &lt;code&gt;proactive-gate@99.99.99&lt;/code&gt;, and the pattern was looking for the version string anywhere in the output. Merging stderr into stdout handed the matcher a sentence that contains exactly what it was searching for, at the moment the thing being tested had failed. Take the redirect away and the variable is empty, the pattern misses, and the check that was supposed to work does.&lt;/p&gt;

&lt;p&gt;I did not put that in the article because I had not understood it. The fix I shipped happened to remove the problem, by separating "did it install" from "what did it print", but I shipped it for a different reason than the one that made it work.&lt;/p&gt;

&lt;p&gt;That comment sent me back through the week, and the count is worse than I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;A check that can only report agreement is not a check.&lt;/p&gt;

&lt;p&gt;The question that finds them is short, and I have started asking it of everything: &lt;strong&gt;what does this do when it finds nothing, and is that distinguishable from finding everything in order?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In one week I found twelve in my own repositories. Not in code I inherited. In checks I wrote, on purpose, to catch exactly the thing they were failing to catch. Here is what they looked like, because the shapes repeat and the shapes are learnable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A proxy standing in for the condition
&lt;/h2&gt;

&lt;p&gt;My CV build skipped regeneration when the PDF was newer than its source. That is not the question. The question is whether the source changed, and mtime is a rumour about it: &lt;code&gt;git checkout&lt;/code&gt; sets it, &lt;code&gt;cp&lt;/code&gt; sets it, an editor that saves without changing a byte sets it. The build was answering a cheaper question and presenting the answer as if it were the expensive one.&lt;/p&gt;

&lt;p&gt;It now hashes the source and compares the hash, and writes the stamp last, after the page check and the copies, so an interrupted run cannot leave a stamp claiming work it did not finish.&lt;/p&gt;

&lt;p&gt;The general form: when a check reads something correlated with the condition rather than the condition, it passes for reasons unrelated to what it is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failing direction, which looks like diligence
&lt;/h2&gt;

&lt;p&gt;A test compared a rendered link against a literal string. It had passed for two months. It passed because the three items it happened to compare were all external links, where the rendered form and the literal form agree. The first internal link would have broken it, and the breakage would have been the test noticing a difference that did not matter.&lt;/p&gt;

&lt;p&gt;A check can be wrong in the direction of passing or in the direction of failing, and the second kind is more comfortable to live with, because a test that goes red looks like a test doing its job. Nobody audits a check that has never fired.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three of the twelve were repairs, not checks
&lt;/h2&gt;

&lt;p&gt;This is the sub-pattern I did not see until the count got high enough, and it is the one I would tell you about first.&lt;/p&gt;

&lt;p&gt;A fixture I added to cover a gap turned out to duplicate coverage that already existed. A verification rule I wrote, to confirm a generated file matched its source by hashing it, could not verify anything, because the generator embeds a timestamp and produces three different hashes from three runs of unchanged input. A revert I ran succeeded, reported success, and changed nothing, because the string I was reverting to and the string I was reverting from were identical; the actual bad change was in the commit before the one I looked at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The repair is where the thinking is finished and the attention has left.&lt;/strong&gt; You have found the bug, you know what to do, and the part where you confirm you did it is the part that feels like paperwork. Three times in one week, that was where the hole was.&lt;/p&gt;

&lt;p&gt;For a revert the concrete rule is: confirm the restored content differs from what was there. A no-op revert and a real one produce identical output at exactly the point where people stop looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Could not reproduce" wears the same costume
&lt;/h2&gt;

&lt;p&gt;I failed to reproduce a reported bug, and my inability to reproduce it was the finding rather than the absence of one.&lt;/p&gt;

&lt;p&gt;A run that does not reproduce a defect is a measurement of your setup, not of the defect. It is only evidence when you can say what would have shown up if the bug were present, and confirm that your run could have shown it. Otherwise "I ran it and nothing happened" and "I ran it wrong" produce the same sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deriving is necessary and not sufficient
&lt;/h2&gt;

&lt;p&gt;I fixed a class of brittle test by deriving expected values from the source instead of typing them in. That is the right move and it does not finish the job. Where a value crosses a boundary that transforms it, rendering a link, formatting a date, normalising a path, deriving it still leaves the question of &lt;em&gt;which&lt;/em&gt; form you are asserting on. Both sides can be derived and still disagree, and then the test is pinning a coincidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Availability is not identity
&lt;/h2&gt;

&lt;p&gt;A deploy check fetched a URL and treated &lt;code&gt;200&lt;/code&gt; as proof the right file was there. A &lt;code&gt;200&lt;/code&gt; proves a file is served. It says nothing about which file. The site sat a week behind a green check, because the alias kept serving the previous good deployment and the check kept confirming that something answered.&lt;/p&gt;

&lt;p&gt;Read the artefact's own content, not its status code. For a PDF that meant &lt;code&gt;pdftotext | grep&lt;/code&gt; for the claim that was supposed to have changed, which is slower and is the only version that can fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twelfth was the checker built to catch the other eleven
&lt;/h2&gt;

&lt;p&gt;I have a script that compares every published number about my work, package versions, contribution counts, essay totals, against a snapshot collected from the registries. It exists precisely because those numbers were all wrong at once, on a page that said it kept version numbers off it because they would rot.&lt;/p&gt;

&lt;p&gt;Yesterday it printed &lt;code&gt;ok, every claim in 7 surfaces matches&lt;/code&gt; while six of them were stale. The snapshot had been collected twenty minutes before a merge that changed the count.&lt;/p&gt;

&lt;p&gt;It was not wrong. It was silent about the only thing that could make it wrong. "The surfaces match the snapshot" and "the surfaces are correct" are the same sentence only while the snapshot is current, and nothing bounded its age.&lt;/p&gt;

&lt;p&gt;It now refuses a snapshot older than six hours, treats an unreadable timestamp as a failure rather than skipping it, prints the age on the success line so &lt;code&gt;ok&lt;/code&gt; carries its own scope, and exits with a distinct code for staleness so the commit hook can refresh and ask again instead of blocking.&lt;/p&gt;

&lt;p&gt;That is the general lesson from a checker that compares against a cached reference: &lt;strong&gt;ask what it is comparing against, and what proves that reference is still true.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find out whether a check can fail
&lt;/h2&gt;

&lt;p&gt;Break the thing it watches and confirm it goes red. That is the whole method, and it costs a minute.&lt;/p&gt;

&lt;p&gt;But there is a distinction I got wrong this week and had to correct, so I will state it plainly.&lt;/p&gt;

&lt;p&gt;I had a set of passing tests and I mutated one assertion inside each, in a throwaway copy, to expect a value the code does not produce. All of them failed. I wrote that this proved the tests were not vacuous. Someone reading my notes pointed out that it proves only that the chosen assertion is reached and evaluated. Nothing about the behaviour had changed, so nothing had been shown about whether the test would catch a regression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutating an assertion tests the assertion. Mutating behaviour tests the test.&lt;/strong&gt; They are different experiments and only one of them is evidence about a check's usefulness. When I did change behaviour, in a single source file, three of four tests caught it and the fourth did not, because it did not depend on that boundary. One behaviour mutation is not expected to break every test, and expecting that would be its own mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually take from twelve
&lt;/h2&gt;

&lt;p&gt;Not that I am careless. I wrote all twelve deliberately, several of them in direct response to being burned by the previous one.&lt;/p&gt;

&lt;p&gt;What I take is that the moment of writing a check is the moment you are most convinced the condition is understood, and that conviction is what removes the step where you confirm the check can fail. The checks in this list were not lazy. They were confident.&lt;/p&gt;

&lt;p&gt;So the habit I am keeping is smaller than a process. Every time I write something that reports a verdict, I break the thing it watches once, and watch it go red before I trust it green. And when a check tells me everything is in order, I ask what it is comparing against.&lt;/p&gt;

&lt;p&gt;Twelve is what one week of asking produced in code I already trusted. I would not bet on your number being zero.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks to &lt;code&gt;build996&lt;/code&gt; for the mechanism I had missed, and to &lt;code&gt;beusebiu&lt;/code&gt; and &lt;code&gt;raknaos&lt;/code&gt;, whose comments on the previous piece are in this one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>bash</category>
      <category>codequality</category>
    </item>
    <item>
      <title>My release smoke test passed on the message that says it failed</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Sun, 13 Sep 2026 13:49:49 +0000</pubDate>
      <link>https://dev.to/efe_genc/my-release-smoke-test-passed-on-the-message-that-says-it-failed-1lh0</link>
      <guid>https://dev.to/efe_genc/my-release-smoke-test-passed-on-the-message-that-says-it-failed-1lh0</guid>
      <description>&lt;p&gt;The last step of my release pipeline installs the package I just published, on Linux, Windows and macOS, and confirms it reports the version that was released. It is the only thing in the whole chain that tests the artifact a stranger would actually download, rather than the source it was built from. Last night it reported success on all three operating systems against a version the registry was not serving.&lt;/p&gt;

&lt;p&gt;Here is the step, as it had been since the first release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TAG&lt;/span&gt;&lt;span class="p"&gt;#v&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5 6&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break
  echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;20
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"smoke ok: &lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"expected version &lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt; in the output"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last line on its own. It passes when the released version appears anywhere in the output. Now read what npm prints when the version is not there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm error notarget No matching version found for ai-slop-linter@0.1.5.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure message contains the version. So when the registry was still processing the publish and all six attempts failed, &lt;code&gt;$out&lt;/code&gt; held that error, the pattern matched it, and the job printed &lt;code&gt;smoke ok&lt;/code&gt; and went green. Three operating systems, three green checks, nothing installed on any of them.&lt;/p&gt;

&lt;p&gt;Nothing was broken underneath. The package had published correctly and npm was simply slow to serve it, which it warns you about in its own output: &lt;code&gt;Your package is being processed and may take a few minutes to become available.&lt;/code&gt; So the check did not hide a bad release. What it revealed is worse in a quiet way. It could not have caught one. The success condition was contained in the failure output, which means that for as long as that pipeline had existed, its final verification had been decorative.&lt;/p&gt;

&lt;p&gt;I found it because I was watching the registry by hand while waiting for a version number to change, and the number would not change while the workflow insisted it had. Without that accident it would still be there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five more of the same shape, in one night
&lt;/h2&gt;

&lt;p&gt;That was not the only one. It was the third of six found in about four hours, across the same set of repositories, and I want to list them because the pattern only becomes obvious as a set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A version checker that knew four phrasings.&lt;/strong&gt; I keep a script that compares every version number written in my documents against what npm and PyPI actually serve, because all five of them were wrong at once, on a page that said version numbers were deliberately kept off it because they would rot. The script knew four ways a version can be written in a sentence. A table cell writes a fifth: the package name sits in the first column and the version three columns away. So &lt;code&gt;npm 0.1.1&lt;/code&gt; in a document that goes to an assessor matched nothing, and the script printed &lt;code&gt;ok, every claim in 6 surfaces matches&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When I reintroduced the two wrong numbers to see what the old version would say, it reported neither of them, and instead demanded a correction to the one number in the whole pack that was right: a deliberately frozen snapshot dated 5 September, which it wanted raised to today's release. That is not a hypothesis about how the snapshot had been corrupted. It was the corruption happening again on demand, and it explained a line I had been puzzled by, a pairing of an old npm version with a current PyPI one that had existed on no day in either registry's history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A checker reading the right file at the wrong moment.&lt;/strong&gt; Another script guards the claims on a public page about my published packages. It read the conformance table from the default branch. Every other claim on that page is about the version people install. So the moment a pull request merged, it called a correct sentence stale. It now reads the table at the tag of the released version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard present on two claims and missing on the third.&lt;/strong&gt; The same file already had two match-nothing guards, added after that exact failure had cost something twice. The third claim, the one comparing npm against PyPI, had none. Reword the sentence and the comparison silently stops running, with the file reported clean. Knowing the lesson is not the same as having applied it to every claim in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five tests that failed for the wrong reason.&lt;/strong&gt; Publishing three articles meant adding one row to a data file. Five tests broke, none of which was testing publication: the homepage's first link, a feed's build date, a pagination assertion, a static route list, and a fixture guard whose message read "twelve items (eleven essays plus one external piece)". Every one of them had a title, a URL, a date or a count typed into it. The repair that suggests itself is to retype the literals, which restores the green and the defect together, and buys the identical morning on the next publish.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And two numbers I wrote from memory.&lt;/strong&gt; Writing the record of all of this, I described a cache lag as lasting "about twenty minutes". I had not measured it. Neither had the reviewer who had written "hours" for the same interval. Both of us invented a figure inside a document about figures that arrive by inference and get recorded as measurements. The defensible version, from timestamps: the three articles published at 21:25:31Z, 21:26:28Z and 21:27:15Z, and the index was serving all seven by 21:37:47Z. How long it had been stale before that is not something either of us recorded, and the record now says so instead of picking a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I had never asked of a check
&lt;/h2&gt;

&lt;p&gt;Six things, and they look unrelated. A shell pattern, a regular expression, a git ref, a missing guard, five hard-coded literals, two remembered intervals. They are one defect.&lt;/p&gt;

&lt;p&gt;Every one of them reported agreement for a reason that had nothing to do with what it was checking. The version checker found nothing because it did not know the sentence shape, and silence rendered as success. The conformance checker read a real file at a moment when that file was not the subject. The smoke test read a real string in output that said the opposite. None of them failed, and none of them could have, for the specific thing they existed to catch.&lt;/p&gt;

&lt;p&gt;The question I had been asking when I wrote each of these was "does this find the problem". I had tested each one against a broken input at the moment I wrote it, in the sense that I had convinced myself it would fire. The question I had never asked is the one that matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does this do when it finds nothing, and is that distinguishable from finding everything in order?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A check whose answer to that is "they look the same" is not a check. It is a comment that runs.&lt;/p&gt;

&lt;p&gt;The cheap test follows directly. Before trusting a clean result, run the thing against a deliberately broken input and confirm it fails, and that it fails for the right reason. It takes a minute. I did it for each repair last night: reintroducing the two wrong versions so the old checker could be caught calling them fine, and running the new smoke condition against the real npm failure text. For the shell one the whole fix is to stop testing a substring and start tracking two facts separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5 6&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; did not install:"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;20
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$installed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"the registry never served &lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;printed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\r'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'[:space:]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$printed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"installed, but --version printed '&lt;/span&gt;&lt;span class="nv"&gt;$printed&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Did it install, and did the thing it printed equal the thing we released. Two questions with two answers, neither of which the failure message can satisfy by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direction that looks like diligence
&lt;/h2&gt;

&lt;p&gt;The five broken tests belong to the same family, seen from the other side, and they are the more dangerous half.&lt;/p&gt;

&lt;p&gt;A test pinned to a literal does not pass for the wrong reason. It fails for the wrong reason. Five red tests after adding one row of data feel like a suite doing its job, and that feeling is what makes the trap work: the fix that suggests itself is to update the literals, which is fast, restores the green, and leaves the defect exactly where it was. A silent green at least feels like something you ought to verify. A loud red feels like something you ought to fix, and fixing it is not the same as understanding it.&lt;/p&gt;

&lt;p&gt;Those five now derive from the same function the pages derive from, and assert properties rather than snapshots: that the first link on the homepage is the newest item, that the last page is whatever page the count implies rather than page two. The person who rewrote them also checked both against deliberately broken sources, a reversed slice and a clock-derived build date, before trusting the green. That is the rule applied to the repair rather than only to the thing repaired, which is the step everyone skips, me included until that night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why writing it down worked when re-reading did not
&lt;/h2&gt;

&lt;p&gt;The part I keep coming back to is not any of the six. It is how the last two were caught.&lt;/p&gt;

&lt;p&gt;The work was reviewed across two sessions, with review passing between them; the repositories disclose that the work is AI-assisted, and this is the shape of that assistance rather than the subject of this piece. Three of the six were found by one reviewer looking at the other's work, which is unremarkable. The last two were each of us catching our own wrong number, which sounds like carefulness and is not.&lt;/p&gt;

&lt;p&gt;Neither of us caught our own figure by re-reading it. I caught mine because a question forced me to state it precisely enough to be compared against a timestamp. The other was caught because a correction in the identical shape had just been written down next to it. Neither correction started with suspicion. Both started with a claim that had been recorded somewhere specific enough that comparing it to reality was a thirty-second operation rather than a project.&lt;/p&gt;

&lt;p&gt;That is a property of the record, not of the people keeping it, which is the only reason it is worth writing down. Carefulness does not survive a long night. A claim written so it can be checked will sit there being checkable whether or not anyone is currently suspicious, and it will still be checkable next week when everyone has forgotten which parts were measured and which were remembered.&lt;/p&gt;

&lt;p&gt;All six were claims that had been written somewhere and never re-run. A table cell, a conformance table, a shell condition, an unguarded pattern, five test literals, and two intervals. Not one of them was found by noticing that something felt wrong. Every one was found because somebody eventually compared the written thing against the thing it described.&lt;/p&gt;

&lt;p&gt;The smoke test is the one I would keep if I could only keep one, because it is the purest form: a check whose success condition was contained in its own failure message, sitting at the end of a release pipeline, printing &lt;code&gt;smoke ok&lt;/code&gt; on three operating systems for as long as the pipeline had existed. If that can happen in a repository where every number in the documentation is compared against a registry by a script on a schedule, it can happen anywhere.&lt;/p&gt;




&lt;p&gt;I write about the engineering decisions behind a proactive assistant and five small open-source packages at &lt;a href="https://efe-genc-portfolio.vercel.app/writing/" rel="noopener noreferrer"&gt;efe-genc-portfolio.vercel.app&lt;/a&gt;, where this piece is the canonical copy. If you have one of these six in your own pipeline, or you think the rule is wrong, I would rather read the disagreement than the agreement.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>cicd</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I wrote a linter for AI-writing tells. It gave one of my own READMEs a C.</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Sat, 12 Sep 2026 21:27:15 +0000</pubDate>
      <link>https://dev.to/efe_genc/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c-1mmf</link>
      <guid>https://dev.to/efe_genc/i-wrote-a-linter-for-ai-writing-tells-it-gave-one-of-my-own-readmes-a-c-1mmf</guid>
      <description>&lt;p&gt;A pull request description came back from my coding agent and I read it the way the reviewer would. An em dash in the first sentence. A bold label on every bullet. &lt;code&gt;Let me know if you need anything else&lt;/code&gt; at the bottom. I deleted all of it by hand, the same pass as the day before. Readers have learned these tells. Once they see one, they stop reading, whoever typed it.&lt;/p&gt;

&lt;p&gt;So I wrote the pass down as a linter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ai-slop-linter README.md          &lt;span class="c"&gt;# one file, exit 1 if it has errors&lt;/span&gt;
npx ai-slop-linter &lt;span class="nt"&gt;--commit&lt;/span&gt;           &lt;span class="c"&gt;# the last commit message&lt;/span&gt;
npx ai-slop-linter README.md &lt;span class="nt"&gt;--fix&lt;/span&gt;    &lt;span class="c"&gt;# apply the safe fixes in place&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies, Node 20 or newer, MIT. Source: &lt;a href="https://github.com/Bubblegunn/ai-slop-linter" rel="noopener noreferrer"&gt;github.com/Bubblegunn/ai-slop-linter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To read the rules against your own text before installing anything, paste it into &lt;a href="https://bubblegunn.github.io/ai-slop-linter/" rel="noopener noreferrer"&gt;bubblegunn.github.io/ai-slop-linter&lt;/a&gt;. The page runs this engine in your own browser: there is no request in it that could carry your text anywhere, and it loads nothing from a third party. Two tests assert that, and the deploy builds the page from the repository rather than from anything uploaded by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it says
&lt;/h2&gt;

&lt;p&gt;The test folder has a 259-word file written to trip every rule once. The first eleven of its fifty findings, at version 0.1.4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test/fixtures/sloppy.md  F (score 194.2, 259 words, 50 findings)
     5:1   info    title-case-heading   Title Case heading; sentence case reads as written by a person
     7:1   warning announcing           "Let's dive into": make the point instead of announcing it
     7:26  error   dash                 em dash
     7:32  warning inflated             "is a testament to": say what happened; let the reader judge the importance
     7:37  warning ai-vocabulary        "testament": a word models reach for; use the plain one
     7:72  warning ai-vocabulary        "In today's fast-paced": a word models reach for; use the plain one
     7:83  info    hyphen-density       7.7 hyphenated compounds per 100 words; drop the hyphen after the noun ("the report is high quality")
     7:115 warning inflated             "stands as a": say what happened; let the reader judge the importance
     7:127 warning inflated             "pivotal moment": say what happened; let the reader judge the importance
     7:127 warning ai-vocabulary        "pivotal": a word models reach for; use the plain one
     7:156 warning ing-tail             ", highlighting": cut the tail or make it its own sentence with a fact in it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each finding has a line, a column, a severity, a rule id and a sentence saying what to do. The sentence matters more to me than the id. &lt;code&gt;say what happened; let the reader judge the importance&lt;/code&gt; is something a person can act on. A probability is not. &lt;code&gt;--explain dash&lt;/code&gt; prints why that pattern reads as a tell, a before and after, and when to ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rules come from
&lt;/h2&gt;

&lt;p&gt;Twenty rules. Eighteen come from the Wikipedia guideline &lt;a href="https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing" rel="noopener noreferrer"&gt;Signs of AI writing&lt;/a&gt;, written by the editors who review machine-written edits. Each rule names its section: &lt;code&gt;dash&lt;/code&gt; cites "Em dashes", &lt;code&gt;vague-source&lt;/code&gt; cites "Vague attributions", &lt;code&gt;closer&lt;/code&gt; cites "Generic conclusions". &lt;code&gt;npx ai-slop-linter --rules&lt;/code&gt; prints the list with the source next to each id.&lt;/p&gt;

&lt;p&gt;The other two are house rules and are marked as such in that output, so nobody mistakes my taste for the guideline: &lt;code&gt;reveal&lt;/code&gt;, for &lt;code&gt;The real question is&lt;/code&gt; and &lt;code&gt;At its core&lt;/code&gt;, and &lt;code&gt;announcing&lt;/code&gt;, for &lt;code&gt;Let's dive in&lt;/code&gt; and &lt;code&gt;In this article we will&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three severities. Errors should never ship: dashes, chatbot residue such as &lt;code&gt;I hope this helps&lt;/code&gt;, knowledge-cutoff disclaimers. Warnings need a sentence rewritten: &lt;code&gt;not just X but Y&lt;/code&gt;, forced groups of three, inflated importance, sales language, vague sources, bold labels in lists. Info is tidying: filler phrases, curly quotes, Title Case headings, too many hyphenated compounds.&lt;/p&gt;

&lt;p&gt;Fenced code, inline code, front matter, link targets, URLs, HTML tags and comments are masked before any rule runs, so a dash in a code sample is never a finding. This article quotes every tell inside a code span for that reason, and it passes its own linter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The score
&lt;/h2&gt;

&lt;p&gt;A file's score is its weighted findings per 1,000 words: an error counts 3, a warning 1, an info 0.3. The grade follows the score: A under 3, B under 8, C under 15, D under 30, F above that. The fixture scores 194.2, an F by a wide margin, as designed.&lt;/p&gt;

&lt;p&gt;The run exits 1 when any file has an error or a score above &lt;code&gt;--max-score&lt;/code&gt;, which defaults to 10. A repository can adopt it in CI without a cleanup commit first and tighten the threshold later. A &lt;code&gt;.slop.json&lt;/code&gt; at the root holds the defaults; this repository's own sets &lt;code&gt;maxScore&lt;/code&gt; to 3 and lints its own Markdown in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fixer will and will not touch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--fix&lt;/code&gt; does only what cannot change meaning. An em dash becomes a comma or a full stop, or disappears after punctuation. Curly quotes become straight. &lt;code&gt;in order to&lt;/code&gt; becomes &lt;code&gt;to&lt;/code&gt;. On the fixture that is seven fixes. The other forty-three findings stay, because a negative parallelism needs a rewritten sentence, and a tool that rewrites your sentences is a different tool with a different failure mode. The fixer runs to a fixed point: fixing a fixed file changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  In a repository
&lt;/h2&gt;

&lt;p&gt;The Action lints the pull request description and every changed Markdown file, and annotates the diff at the line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bubblegunn/ai-slop-linter@v0&lt;/span&gt;
  &lt;span class="c1"&gt;# with:&lt;/span&gt;
  &lt;span class="c1"&gt;#   max-score: "5"&lt;/span&gt;
  &lt;span class="c1"&gt;#   warn-only: "true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit hook is one line, &lt;code&gt;ai-slop-linter --commit-msg "$1"&lt;/code&gt;, and refuses a message with an error-severity tell. &lt;code&gt;git commit --no-verify&lt;/code&gt; skips it once. &lt;code&gt;--pr 42&lt;/code&gt; reads a pull request body through &lt;code&gt;gh&lt;/code&gt; and lints it like a file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--init&lt;/code&gt; writes a &lt;code&gt;.slop.json&lt;/code&gt; and whichever of those you pick. Inside it, &lt;code&gt;overrides&lt;/code&gt; set rules per path, so documentation can be stricter than commit messages, and &lt;code&gt;--baseline&lt;/code&gt; records what a repository already has so CI fails only on new findings. There is a commitlint plugin and a VS Code task for the same output.&lt;/p&gt;

&lt;p&gt;There is also a skill, installed with &lt;code&gt;npx skills add Bubblegunn/ai-slop-linter&lt;/code&gt;, that tells a coding agent to lint its own prose before handing it over. That is where most of these tells get written in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run on my own writing
&lt;/h2&gt;

&lt;p&gt;Before posting I ran it on the five READMEs I maintain, this one included, and the eleven essays on my site, at version 0.1.4 on 5 September 2026, with &lt;code&gt;--warn&lt;/code&gt;. The documents keep growing, so the version and the date are part of the claim; the same table is in the repository and is re-run rather than edited. The current release is 0.1.5, and &lt;code&gt;src/&lt;/code&gt; is byte for byte what 0.1.4 shipped, so these numbers are what you get today as well:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;text&lt;/th&gt;
&lt;th&gt;words&lt;/th&gt;
&lt;th&gt;grade&lt;/th&gt;
&lt;th&gt;findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/Bubblegunn/proactive-gate" rel="noopener noreferrer"&gt;proactive-gate&lt;/a&gt; README&lt;/td&gt;
&lt;td&gt;4,871&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/Bubblegunn/workproof" rel="noopener noreferrer"&gt;workproof&lt;/a&gt; README&lt;/td&gt;
&lt;td&gt;4,121&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/Bubblegunn/ai-slop-linter" rel="noopener noreferrer"&gt;ai-slop-linter&lt;/a&gt; README&lt;/td&gt;
&lt;td&gt;3,744&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/Bubblegunn/surviving-lines" rel="noopener noreferrer"&gt;surviving-lines&lt;/a&gt; README&lt;/td&gt;
&lt;td&gt;2,002&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none; the first run scored A (2.8) for 2 bold labels in a list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/Bubblegunn/product-engineer" rel="noopener noreferrer"&gt;product-engineer&lt;/a&gt; README&lt;/td&gt;
&lt;td&gt;1,755&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none; the first run scored C (8.7) for 7 bold labels in the rule list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 of 11 portfolio essays&lt;/td&gt;
&lt;td&gt;854 to 1,849 each&lt;/td&gt;
&lt;td&gt;A (0)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the other 3 essays&lt;/td&gt;
&lt;td&gt;866 to 1,601&lt;/td&gt;
&lt;td&gt;A (0.4 to 1.2)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;state-of-the-art&lt;/code&gt; once, &lt;code&gt;elevated&lt;/code&gt; once, &lt;code&gt;in order to&lt;/code&gt; twice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The C was real. The product-engineer README listed its seven rules as &lt;code&gt;**Name:** text&lt;/code&gt; bullets, which is exactly the pattern &lt;code&gt;bold-label&lt;/code&gt; flags. I wrote those bullets myself and thought they read fine. The linter disagreed, with a line number. Later the same day that README was rewritten to show the tool before explaining it, and the rule list became plain sentences on the way; it was not edited to please the linter, and the row keeps the first score so the table stays a record of runs rather than a trophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  I assumed the punctuation rules were language-neutral. They were not
&lt;/h2&gt;

&lt;p&gt;The vocabulary rules are English word lists, so outside English they simply never fire. I had assumed the typography rules were the portable half: a dash is a dash, a curly quote is a curly quote. Before publishing I checked, by running all twenty rules against correct published typography in thirteen languages, one public-domain text each, all typeset before any language model existed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dash&lt;/code&gt; carries the highest severity in this tool. Here is what it did per 1,000 words:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;language&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;dash&lt;/code&gt; findings per 1,000 words&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polish&lt;/td&gt;
&lt;td&gt;73.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hungarian&lt;/td&gt;
&lt;td&gt;52.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Russian&lt;/td&gt;
&lt;td&gt;24.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;French&lt;/td&gt;
&lt;td&gt;22.0, see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;German&lt;/td&gt;
&lt;td&gt;6.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English (human baseline)&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat belongs here rather than in a footnote, because I would want it if I were reading this. Polish, Hungarian, Russian and German are what the files measure as they stand. The French text reaches us with its em dashes typed as two hyphens attached to the following word, which the rule does not match, so it scores 0 as transcribed and 22.0 once the dashes the printed edition set are restored. The repository prints both columns. The restored figure is the one a French writer meets today, because nobody types a dash as two hyphens in a README.&lt;/p&gt;

&lt;p&gt;The Polish, Hungarian and Russian texts graded F for punctuating their own language correctly. The em dash is ordinary in French, opens dialogue in Spanish, and in Russian stands where the verb would go. &lt;code&gt;curly-quotes&lt;/code&gt; did the same to Chinese at 21.7 per 1,000 words, because those code points are Chinese quotation marks and the straight apostrophes nested inside them are correct, not a paste artifact.&lt;/p&gt;

&lt;p&gt;So a repository now declares what it writes in, &lt;code&gt;"language": "fr"&lt;/code&gt; in &lt;code&gt;.slop.json&lt;/code&gt; or &lt;code&gt;--language fr&lt;/code&gt;, and &lt;code&gt;dash&lt;/code&gt; stands down. The run prints which rules did not run, so a quiet result is never mistaken for a clean one. English stays the default, so nothing changes for anyone already using it. It is configuration rather than detection on purpose: a README with English headings over French prose defeats a guess in both directions.&lt;/p&gt;

&lt;p&gt;I tried a density threshold first, so the rule could stay on everywhere and only fire when dashes were unusually dense. It does not work, and the corpus says why: correct German prose sits at 6.7 findings per 1,000 words and the machine corpus sits at 5.7. The distributions overlap. No global number separates a German writer from a model.&lt;/p&gt;

&lt;p&gt;That leaves a real cost, and it belongs here rather than in a footnote: &lt;strong&gt;a machine-written French README will no longer be flagged for its dashes.&lt;/strong&gt; I would rather lose that than grade a French writer's correct punctuation an F.&lt;/p&gt;

&lt;p&gt;The corpus, the table and the argument are in the repository at &lt;code&gt;bench/TYPOGRAPHY.md&lt;/code&gt; and &lt;code&gt;docs/typography-across-languages.md&lt;/code&gt;, and CI re-runs the table so it cannot drift from the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs on good writing
&lt;/h2&gt;

&lt;p&gt;The objection I would raise first is false positives, so I measured them. Two corpora live in the repository. The human one is 5,924 words of public-domain prose written before any language model existed: Austen from 1813, Douglass from 1845, Darwin from 1859, and PEP 8 and PEP 257 from 2001, which are the nearest public-domain match to the register this tool aims at. The machine one is 1,045 words of unedited model output, written for the corpus.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;corpus&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;words&lt;/th&gt;
&lt;th&gt;grade&lt;/th&gt;
&lt;th&gt;findings&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;Austen&lt;/td&gt;
&lt;td&gt;1,120&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;Darwin&lt;/td&gt;
&lt;td&gt;1,199&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;Douglass&lt;/td&gt;
&lt;td&gt;1,202&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;PEP 257&lt;/td&gt;
&lt;td&gt;1,174&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;PEP 8&lt;/td&gt;
&lt;td&gt;1,234&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machine&lt;/td&gt;
&lt;td&gt;article intro&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machine&lt;/td&gt;
&lt;td&gt;commit messages&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machine&lt;/td&gt;
&lt;td&gt;docs page&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machine&lt;/td&gt;
&lt;td&gt;pull request&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;machine&lt;/td&gt;
&lt;td&gt;README section&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;td&gt;F&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight findings on the human side, and all eight are printed in the repository rather than tuned away: six are real dashes in nineteenth-century prose and &lt;code&gt;--&lt;/code&gt; in a 2001 style guide, one is &lt;code&gt;not just X but Y&lt;/code&gt; written by Frederick Douglass, one is &lt;code&gt;it's worth noting&lt;/code&gt; written by Guido van Rossum. They are the standing cost of those rules, and the reason the output is a line number and not a verdict.&lt;/p&gt;

&lt;p&gt;Measuring changed two rules rather than confirming them. &lt;code&gt;curly-quotes&lt;/code&gt; first graded the Austen excerpt D on 92 findings, all of them the same rule: it was measuring typography, since the machine corpus had none. It now fires only on a file that mixes curly and straight marks, which is the paste signature. &lt;code&gt;not-x-but-y&lt;/code&gt; could not match &lt;code&gt;isn't just X, it's Y&lt;/code&gt;, the shape people actually write, so its separation was zero; it is now 14.5 times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm run bench&lt;/code&gt; regenerates the per-rule table, and CI fails when the committed file does not match a fresh run. Seven thousand words cannot support an accuracy percentage, and the file says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Authorship. A person who writes &lt;code&gt;delve&lt;/code&gt; gets the same finding as a model. Passing says the listed tells are absent and nothing about who typed.&lt;/li&gt;
&lt;li&gt;Meaning. A hollow paragraph that avoids every listed phrase passes. Text that passes can still be empty.&lt;/li&gt;
&lt;li&gt;Style outside the list. Twenty rules cover the patterns editors flag most; a writer with a different tell walks through. Adding a rule is one function, one fixture sentence and one table row.&lt;/li&gt;
&lt;li&gt;Other languages. The vocabulary rules are English word lists and are inert elsewhere. The typography rules are not inert, and until 0.1.4 they were wrong; see the section above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is left on the roadmap is a rule set for a second language, which somebody has already claimed. A probability that a text was machine-written is deliberately not on it: seven commercial detectors once marked 61% of human-written TOEFL essays as machine-written (&lt;a href="https://doi.org/10.1016/j.patter.2023.100779" rel="noopener noreferrer"&gt;Liang et al., Patterns, 2023&lt;/a&gt;), and a tool that guesses authorship gets people accused. This one shows the tells and leaves the judgement where it belongs.&lt;/p&gt;

&lt;p&gt;Which tell do you see most often that is not among the twenty? Name it and I will write the rule.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>writing</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I made my coding agent explain every change to the customer. Here is the skill.</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Sat, 12 Sep 2026 21:26:28 +0000</pubDate>
      <link>https://dev.to/efe_genc/i-made-my-coding-agent-explain-every-change-to-the-customer-here-is-the-skill-3gcc</link>
      <guid>https://dev.to/efe_genc/i-made-my-coding-agent-explain-every-change-to-the-customer-here-is-the-skill-3gcc</guid>
      <description>&lt;p&gt;The diff is never the expensive part. The expensive part comes after: explaining to the person who asked for the change what they actually got, and discovering that "done" meant "tests pass".&lt;/p&gt;

&lt;p&gt;I spent four years as a founding engineer on a hospitality platform and then alone on a proactive assistant, and the habits that saved me the most time had nothing to do with code. Restate the request as a customer outcome before building. End every commit with a plain-language block. Do not call anything done until you have watched it behave. Write down what you deliberately did not build, next to the code. Never print a number you did not count.&lt;/p&gt;

&lt;p&gt;Coding agents have none of these habits by default. So I wrote them down as a skill. It has since become a pack of four: &lt;code&gt;product-engineer&lt;/code&gt; carries the seven rules, &lt;code&gt;customer-block&lt;/code&gt; and &lt;code&gt;done-means-observed&lt;/code&gt; carry one habit each for people who want only that, and &lt;code&gt;release-notes&lt;/code&gt; is for an agent that writes about software instead of writing it. One installer places whatever you pick.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add Bubblegunn/product-engineer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That places the skills for the agents in your project: Claude Code, Codex, Cursor, Copilot, Gemini CLI and the rest. Claude Code can also take it as a plugin: &lt;code&gt;/plugin marketplace add Bubblegunn/product-engineer&lt;/code&gt; then &lt;code&gt;/plugin install product-engineer@bubblegunn&lt;/code&gt;. Agents that read instruction files rather than skills (Cursor rules, Copilot instructions, Gemini, Cline, Kiro, Windsurf) get the same seven rules from files generated out of &lt;code&gt;SKILL.md&lt;/code&gt;, and CI refuses a commit where the generated copies differ from the source. Source: &lt;a href="https://github.com/Bubblegunn/product-engineer" rel="noopener noreferrer"&gt;github.com/Bubblegunn/product-engineer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes
&lt;/h2&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix(notifications): classify IsSystem events as idempotent and skip the badge increment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix(notifications): classify IsSystem events as idempotent and skip the badge increment

For the customer:
What changed: Things the system handles on its own no longer light up the phone or the menu badge; only items that need a person do.
Why it matters: The badge count means "something needs you" again, so people stop ignoring it.
Automation effect: Automatic housekeeping events are now fully handled without anyone looking at them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent used to report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Done. All 42 tests pass.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Watched it in staging logs: 3 of 3 webhook retries logged, final failure recorded with the booking id.
Could not check: production, no deploy yet.
Not shipped:
- Dead-letter queue: three retries cover every failure in 90 days of logs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The seven rules
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Restate before building. One sentence of customer outcome, in the customer's words, or one question. One, not five.&lt;/li&gt;
&lt;li&gt;For the customer, every time. What changed, why it matters, automation effect only when a manual step really disappeared. Never an invented effect.&lt;/li&gt;
&lt;li&gt;Done means observed. Logs, data, or a real device. Or say exactly what you could not check.&lt;/li&gt;
&lt;li&gt;Build what was asked; name what you did not. A &lt;code&gt;Not shipped:&lt;/code&gt; list with reasons, in the PR, so the decision lives where the code lives.&lt;/li&gt;
&lt;li&gt;No number without a count. Every figure has a command behind it, and its scope.&lt;/li&gt;
&lt;li&gt;Speak the stakeholder's language. A jargon-to-plain table ships with the skill, in English, Turkish, Japanese and Chinese: "idempotent" becomes "doing it twice has the same result as doing it once".&lt;/li&gt;
&lt;li&gt;Smallest change that moves the metric. One ledger line before any design: cost against customer value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full text is one file, &lt;code&gt;SKILL.md&lt;/code&gt;, 89 lines. The reference files hold the template, the five questions to answer before building, the definition-of-done checklist, the four plain-language tables, the not-shipped format, and three short notes on the press-release restatement, ship-show-ask and appetite.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;If you want the rule enforced rather than suggested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sh scripts/install-hook.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That installs a commit-msg hook. A commit without the block is refused with a two-line explanation. Merges, fixups and reverts pass, and &lt;code&gt;[no-customer]&lt;/code&gt; anywhere in the message opts one commit out. The skill's own repository runs it, so every commit there is a demo.&lt;/p&gt;

&lt;p&gt;The same check runs on its own, on a file, on stdin, or on a pull request body through &lt;code&gt;gh&lt;/code&gt;. On a message without the block and then with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx product-engineer check &lt;span class="nb"&gt;test&lt;/span&gt;/fixtures/without-block.txt
&lt;span class="go"&gt;error no "For the customer:" block
1 error, 0 warnings

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npx product-engineer check &lt;span class="nb"&gt;test&lt;/span&gt;/fixtures/with-block.txt
&lt;span class="go"&gt;ok    "For the customer:" block with "What changed:"
ok    "Why it matters:" present
ok    "Automation effect:" present
info  readability of the block: Flesch 75 (easy), LIX 30
ok    "Not shipped:" lists 1 item with reasons
no errors, 0 warnings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also ships as a commitlint plugin with a shareable config, a pre-commit hook, and snippets for lefthook and husky, so a team that already has one of those adds a line rather than a tool. In CI it reads the pull request body and leaves one comment that it updates on every push, rather than a new one each time. &lt;code&gt;npx product-engineer doctor&lt;/code&gt; reports which agents on the machine have the skill and whether their copies are current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured, once
&lt;/h2&gt;

&lt;p&gt;Before posting this I ran eight small coding tasks in Claude Code, each once with the skill and once without, and scored the agent's own words and &lt;code&gt;git diff&lt;/code&gt; with five yes/no heuristics. The customer block went from 0/8 to 8/8, an observation or an honest "could not check" before "done" from 1/8 to 7/8, naming what was deliberately not built from 2/8 to 7/8. Two metrics did not move: a number with a method next to it scored 1/8 in both conditions, and every change in both conditions stayed within the requested files. The skill runs took about 60% more turns and cost about 45% more, because they verified more and wrote more. One run per task, so it is a smoke test, not a study; the harness, every transcript and the misses are in &lt;a href="https://github.com/Bubblegunn/product-engineer/blob/main/evals/RESULTS.md" rel="noopener noreferrer"&gt;evals/RESULTS.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One thing that table does not say, and the repository now does: those runs installed the core skill and nothing else. The pack also ships &lt;code&gt;customer-block&lt;/code&gt; and &lt;code&gt;done-means-observed&lt;/code&gt;, each carrying one rule on its own, and neither extraction has been measured separately; &lt;code&gt;release-notes&lt;/code&gt; has no task at all, because none of the eight writes release notes. Each skill file states its own gap. The alternative was to move three of four skills out of the pack and call it discipline, which would have said I shipped four things and believed in one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One real defect, with the skill and without
&lt;/h2&gt;

&lt;p&gt;The evaluation above uses small tasks. Afterwards I ran a real one twice: the same model, the same starting commit, and a defect I had actually shipped. The only source file in one of my packages held a literal NUL byte inside a string, so git classified the file as binary and no diff on it was readable, on GitHub or locally. An outside contributor could not see their own change.&lt;/p&gt;

&lt;p&gt;Both runs produced a byte-identical fix, both spotted that the fix commit still shows as binary against its binary parent, and both declined to add a &lt;code&gt;.gitattributes&lt;/code&gt; override for a reasoned cause. The skill did not make the model a better engineer, and I would not trust an article that claimed otherwise.&lt;/p&gt;

&lt;p&gt;What differed is what survived the session. Only the skill run's commit message carried the customer block, the observation it had actually made (edit a line, run &lt;code&gt;git diff&lt;/code&gt;, see a text hunk, revert), a &lt;code&gt;Not shipped:&lt;/code&gt; line, and a plain-language paragraph. The bare run said those things in the chat window, where they die with the session. The whole transcript of both is in &lt;a href="https://github.com/Bubblegunn/product-engineer/blob/main/docs/case-study.md" rel="noopener noreferrer"&gt;docs/case-study.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In that pair the skill run was also cheaper, 18 turns against 29, which is the opposite direction to the 45% above. One pair proves nothing either way; I am recording it because leaving it out would be picking the flattering number, which is rule five.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block does not have to be in English
&lt;/h2&gt;

&lt;p&gt;An audit today found the check rejecting a commit whose block was written as &lt;code&gt;Müşteri için:&lt;/code&gt;, in a repository that ships its plain-language tables in Turkish, Japanese and Chinese. Inviting a team to work in their language and then failing their commit is worse than not inviting them.&lt;/p&gt;

&lt;p&gt;The headings are data now, one row per language, read by the check, the hook and the pull request comment from the same file, with a test that fails if those three drift apart. English, Turkish, Japanese and Chinese are accepted with no configuration, a fullwidth colon reads as a colon, and a repository can name a heading for a language the table does not ship yet.&lt;/p&gt;

&lt;p&gt;The same audit caught the readability helper returning &lt;code&gt;Flesch 0, hard&lt;/code&gt; for Japanese. Flesch counts vowel runs as syllables, which is not what a syllable is in Japanese, Chinese, Korean, Arabic or Hebrew. It now refuses to score those scripts and says why, because rule five of this skill is that a number comes from a count, and a tool breaking its own rule on its own README's languages is the worst kind of wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;It runs no process and owns no workflow; it composes with whatever spec, TDD or review skill you already use. It does not write product strategy. It enforces nothing unless you install the hook.&lt;/p&gt;

&lt;p&gt;Which of the seven would you throw out, and what would you add? I would rather hear the disagreement than the agreement.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I open-sourced the part of a proactive AI assistant that decides when not to speak</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Sat, 12 Sep 2026 21:25:31 +0000</pubDate>
      <link>https://dev.to/efe_genc/i-open-sourced-the-part-of-a-proactive-ai-assistant-that-decides-when-not-to-speak-36fe</link>
      <guid>https://dev.to/efe_genc/i-open-sourced-the-part-of-a-proactive-ai-assistant-that-decides-when-not-to-speak-36fe</guid>
      <description>&lt;p&gt;A proactive assistant has two halves. The generating half decides what is worth saying. The suppressing half decides whether to say it now, later, or never. Almost everything written about proactive AI, and every framework I have used, is about the first half.&lt;/p&gt;

&lt;p&gt;I have been building a proactive assistant alone since February. The second half is where most of the hard decisions ended up, and I wrote about them in &lt;a href="https://dev.to/efe_genc/the-hardest-part-of-a-proactive-assistant-is-knowing-when-not-to-speak-44f0"&gt;The hardest part of a proactive assistant is knowing when not to speak&lt;/a&gt;. Several people asked for the code. Here it is, extracted and made framework-agnostic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;proactive-gate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createGate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultChecks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RedisStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;proactive-gate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;defaultChecks&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dailyLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quietHoursFloor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;onDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;})))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;surfaces&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies, TypeScript, Node 20 or newer. Source: &lt;a href="https://github.com/Bubblegunn/proactive-gate" rel="noopener noreferrer"&gt;github.com/Bubblegunn/proactive-gate&lt;/a&gt;; docs and a browser playground at &lt;a href="https://bubblegunn.github.io/proactive-gate/" rel="noopener noreferrer"&gt;bubblegunn.github.io/proactive-gate&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gate, twelve checks, in order
&lt;/h2&gt;

&lt;p&gt;Everything the agent might say passes through a single gate. The default order is the one that survived users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Kill switch. A production hard-stop that silences every producer at once.&lt;/li&gt;
&lt;li&gt;Consent. Before anything else, or you have evaluated preferences for someone who never agreed.&lt;/li&gt;
&lt;li&gt;Enabled on this profile.&lt;/li&gt;
&lt;li&gt;Operating mode. &lt;code&gt;focus&lt;/code&gt; gets nothing.&lt;/li&gt;
&lt;li&gt;Global snooze.&lt;/li&gt;
&lt;li&gt;Per-type mute.&lt;/li&gt;
&lt;li&gt;Intensity. The user's setting becomes a priority floor.&lt;/li&gt;
&lt;li&gt;Quiet hours, in the user's own time zone, bypassed only above a priority floor.&lt;/li&gt;
&lt;li&gt;Trust ramp. For seven days a new user hears only high priority. The system is least calibrated exactly when the user is least forgiving.&lt;/li&gt;
&lt;li&gt;Dismissal cooldown. Three dismissals of a type in thirty days buys a week of silence for that type.&lt;/li&gt;
&lt;li&gt;Adaptive timing. Never rejects; it can move a delivery to a better moment or narrow the surfaces.&lt;/li&gt;
&lt;li&gt;Daily budget, per local day.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every rejection carries the check that produced it and a sentence saying why. Every decision carries the full trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rejectedBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quietHours&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (high)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;killSwitch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pass&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pass&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quietHours&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quiet hours 22:00 to 08:00 …&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.09&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With one gate and a logged reason, "why was the user not told about this" has an answer. With checks scattered through a pipeline, the honest answer is "somewhere, something returned false".&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget is enforced at send time, not at evaluate time
&lt;/h2&gt;

&lt;p&gt;Two instances can both evaluate a candidate for the same user, both see four of five used, and both decide to send. The only race-safe place to enforce a cap is the atomic increment right before sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                 &lt;span class="c1"&gt;// reads the counter&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// INCR; false on the sixth&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RedisStore&lt;/code&gt; uses &lt;code&gt;INCR&lt;/code&gt; and attaches the day's TTL on the first increment. The counter is keyed on the user's local day, so budgets reset at the user's midnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail open, on purpose
&lt;/h2&gt;

&lt;p&gt;When Redis is down, the default lets the candidate through and writes &lt;code&gt;outcome: "skip", reason: "check threw (…); failing open"&lt;/code&gt; into the trace. A cache outage should not silence every user of a product whose whole point is to speak up. If your product would rather stay silent, &lt;code&gt;onStoreError: "closed"&lt;/code&gt; turns the same failure into a rejection that names the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay a day before you ship a policy
&lt;/h2&gt;

&lt;p&gt;Nothing to install and nothing to write: &lt;code&gt;npx proactive-gate simulate&lt;/code&gt; replays a generated week through the default order and through no gate at all, and prints what each policy did with every candidate. Point &lt;code&gt;--events&lt;/code&gt; at a JSONL file of your own and it answers the same question about your traffic.&lt;/p&gt;

&lt;p&gt;The examples below live in the repository rather than in the package, so they are &lt;code&gt;git clone&lt;/code&gt; and then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx proactive-gate replay examples/day.jsonl &lt;span class="nt"&gt;--commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17 candidates  ·  7 allowed (41.2%)  ·  10 rejected

check         stopped  example
---------------------------------------------------------------
intensity           3  priority low is below the "normal" intensity floor (normal)
consent             3  user has not consented to proactive behaviour
mode                2  operating mode "focus" does not allow proactive messages
quietHours          1  quiet hours 22:00 to 08:00 (thu 2026-09-03) Europe/Istanbul; priority normal is below the floor (critical)
dailyBudget         1  daily budget of 5 used (5)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed it a week of real candidates and a proposed policy, and you know the allow rate and the silence reasons before a single user does.&lt;/p&gt;

&lt;h2&gt;
  
  
  A policy is a JSON file
&lt;/h2&gt;

&lt;p&gt;The checks above are functions, and a policy made of functions cannot be diffed in a pull request, replayed by someone who does not run Node, or handed to a second implementation. So a policy is also data: &lt;code&gt;createGate({ policy })&lt;/code&gt; takes a JSON file with a &lt;code&gt;specVersion&lt;/code&gt; and an ordered list of check entries, and &lt;code&gt;replay --policy policy.json&lt;/code&gt; runs the same file from the command line. The functions stay as the escape hatch for checks the schema does not know. The same day of candidates through the repository's example policy, which caps the day at three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx proactive-gate replay examples/day.jsonl &lt;span class="nt"&gt;--policy&lt;/span&gt; examples/policy.json &lt;span class="nt"&gt;--commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;17 candidates  ·  5 allowed (29.4%)  ·  12 rejected

check         stopped  example
---------------------------------------------------------------
intensity           3  priority low is below the "normal" intensity floor (normal)
dailyBudget         3  daily budget of 3 used (3)
consent             3  user has not consented to proactive behaviour
mode                2  operating mode "focus" does not allow proactive messages
quietHours          1  quiet hours 22:00 to 08:00 (thu 2026-09-03) Europe/Istanbul; priority normal is below the floor (high)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Presets for the rules you did not write
&lt;/h2&gt;

&lt;p&gt;Most of the limits a product has to respect were written by a platform or a legislator. &lt;code&gt;proactive-gate/presets&lt;/code&gt; carries seventeen of them as ordered check lists, each with the pages its numbers come from and a note on what it leaves out: LINE's monthly push budget by plan, WeChat's subscription, customer-service and template message rules, WeCom's per-member rate, Kakao AlimTalk and brand messages (08:00 to 20:50 Asia/Seoul), Korea's Network Act night-consent window, Japan's anti-spam opt-in, China's minor mode, India's TCCCPR opt-in time bands, Brazil's LGPD marketing consent, the US TCPA calling hours (08:00 to 21:00 at the user's local time), the EU ePrivacy soft opt-in, the WhatsApp Business messaging limits, and the Telegram and Slack rate limits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;presets&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;proactive-gate/presets&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;presets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kakaoBrandMessage&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They are reviewable defaults, not legal advice. Several official sources disagree with each other, and the note on each preset says which value was chosen and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it plugs in
&lt;/h2&gt;

&lt;p&gt;Adapters on subpaths for the Vercel AI SDK, Mastra, LangChain and OpenAI Agents wrap the tool or step that would reach the user, and deny with the gate's reason. A &lt;code&gt;proactive-gate hook&lt;/code&gt; command answers Claude Code's &lt;code&gt;PreToolUse&lt;/code&gt; hook protocol, so a coding agent's own outreach tools pass through the same gate.&lt;/p&gt;

&lt;p&gt;The same gate exists in Python, in &lt;code&gt;python/&lt;/code&gt; of the same repository, with a sync &lt;code&gt;Gate&lt;/code&gt; and an &lt;code&gt;AsyncGate&lt;/code&gt; over &lt;code&gt;redis.asyncio&lt;/code&gt;. It is a sibling rather than a port: &lt;a href="https://github.com/Bubblegunn/proactive-gate/blob/main/spec/SPEC.md" rel="noopener noreferrer"&gt;&lt;code&gt;spec/SPEC.md&lt;/code&gt;&lt;/a&gt; states the behaviour as numbered requirements, and 57 language-neutral fixtures under &lt;code&gt;spec/fixtures&lt;/code&gt; (atomic commit, the ISO week, deferral, shadow mode, and a &lt;code&gt;clock/&lt;/code&gt; area for the days a clock misbehaves) run through both implementations in CI. A third implementation starts from the fixtures, not from the source, and the specification is tagged separately from the package so an implementation can pin the contract without depending on npm or PyPI.&lt;/p&gt;

&lt;p&gt;Four people I had never met have sent thirteen pull requests. &lt;a href="https://github.com/Aaqibhafeezkhan" rel="noopener noreferrer"&gt;@Aaqibhafeezkhan&lt;/a&gt; wrote the SQLite store, then came back and turned the store tests into a suite you can run against a store of your own (&lt;code&gt;proactive-gate/store-contract&lt;/code&gt;). &lt;a href="https://github.com/edwardsong08" rel="noopener noreferrer"&gt;@edwardsong08&lt;/a&gt; added the weekly budget. &lt;a href="https://github.com/shivam-070208" rel="noopener noreferrer"&gt;@shivam-070208&lt;/a&gt; added Markdown output to a sibling tool. &lt;a href="https://github.com/LouisDeconinck" rel="noopener noreferrer"&gt;@LouisDeconinck&lt;/a&gt; sent six in one day, including the sentence renderer that turns a decision into something a product manager can read.&lt;/p&gt;

&lt;p&gt;The thirteenth is the one I did not expect, and it is the reason I would recommend publishing a conformance suite to anyone shipping two implementations of the same thing. He wrote an adversarial clock suite: twenty-one fixtures for the days a clock misbehaves, deleted and repeated daylight saving hours, 23 and 25 hour local days, a mid-week timezone move, Apia's skipped calendar day, weeks whose ISO year is not the calendar year, and years below 1000. Seventeen of them agreed across both implementations, which was the point of running it. Two did not, and rather than adjust his own fixtures to pass he filed the bugs against my code and declared the failures.&lt;/p&gt;

&lt;p&gt;They were real. Below year 1000 the TypeScript side wrote a local day of &lt;code&gt;1-06-01&lt;/code&gt; instead of &lt;code&gt;0001-06-01&lt;/code&gt;, and &lt;code&gt;Date.UTC&lt;/code&gt; reads years 0 to 99 as offsets from 1900, so the weekly budget key came out as the literal string &lt;code&gt;NaN-WNaN&lt;/code&gt; and the monthly key stopped being a month at all, which meant the monthly cap silently never bound. The Python side was correct about the month and wrong about the week format. Installing both published packages side by side and asking them for the same three keys is what made it obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;counter    TypeScript 0.7.0          Python 0.7.0
daily      budget:u:1-06-01          budget:u:0001-06-01
weekly     weeklyBudget:u:NaN-WNaN   weeklyBudget:u:1-W22
monthly    monthlyBudget:u:1-06-01   monthlyBudget:u:0001-06
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three counters, three disagreements. One store serving both siblings was keeping two sets of counters and neither side could tell. All of it is fixed in 0.7.1 and the fixtures that found it are in the published package, so you can run them against your own implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;It does not decide what is worth saying. It does not estimate value or attention on its own: the optional &lt;code&gt;utilityFloor&lt;/code&gt; check applies Horvitz's expected-utility threshold and &lt;code&gt;boundedDeferral&lt;/code&gt; moves a delivery when the user is busy, but the probability and the costs come from your model, and both checks skip when you do not supply them. &lt;code&gt;adaptiveTiming&lt;/code&gt; stays a hook for your own model of the user's next good moment. It does not coordinate across products: three agents that each respect a budget of three still add up to nine. Tian Pan's &lt;a href="https://tianpan.co/blog/2026-05-13-background-agents-notification-budget-attention-economy" rel="noopener noreferrer"&gt;notification budget&lt;/a&gt; essay makes the product case for all of this and calls the cross-agent layer the open problem. I agree, and I have not solved it.&lt;/p&gt;

&lt;p&gt;If you have shipped an agent that reaches out to people, I would like to know what you ended up gating on, and which of these twelve you would remove.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Who really owns this code? A git blame sampler you can run in one command</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Sat, 05 Sep 2026 05:04:58 +0000</pubDate>
      <link>https://dev.to/efe_genc/who-really-owns-this-code-a-git-blame-sampler-you-can-run-in-one-command-3ph1</link>
      <guid>https://dev.to/efe_genc/who-really-owns-this-code-a-git-blame-sampler-you-can-run-in-one-command-3ph1</guid>
      <description>&lt;p&gt;Commit counts measure activity. They do not measure whether any of it is still there.&lt;/p&gt;

&lt;p&gt;I found this out while trying to describe my share of two private codebases in a way a stranger could check. Commits were the obvious number and the wrong one. On the frontend my share of surviving lines was higher than my commit share, which meant my code had replaced other people's. On the backend it was lower, which meant the opposite. Both facts said more than either count.&lt;/p&gt;

&lt;p&gt;So I wrote the script properly and published it. One file, no dependencies, Node 20 or newer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx surviving-lines &lt;span class="nt"&gt;--sample&lt;/span&gt; 5 &lt;span class="nt"&gt;--include&lt;/span&gt; &lt;span class="s1"&gt;'**/*.ts'&lt;/span&gt; &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="s1"&gt;'**/*.test.ts'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here it is on &lt;a href="https://github.com/langchain-ai/openwiki" rel="noopener noreferrer"&gt;langchain-ai/openwiki&lt;/a&gt; at &lt;code&gt;1e6d54c&lt;/code&gt;, run on 4 September 2026. It took 0.3 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ref HEAD  ·  files 50/203 sampled (1 in 5)  ·  14,722 of 59,049 lines attributed
git blame -w -M  ·  commits 339, merges excluded

author            lines   share  commits   share
------------------------------------------------
Colin Francis     7,718   52.4%       61   18.0%
Brace Sproul      3,314   22.5%       61   18.0%
Greg Land           356    2.4%        9    2.7%
…

What this cannot show: quality of the lines, review work, design done in documents,
or code that was deleted on purpose. Share of surviving lines is about survivorship, not merit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two people with the same commit share, 61 each. One of them wrote more than half of the code that is still alive in the sample; the other wrote just under a quarter. A commit log would have called them equal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it measures
&lt;/h2&gt;

&lt;p&gt;Two numbers, side by side, for one ref.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surviving lines.&lt;/strong&gt; &lt;code&gt;git blame -w -M --line-porcelain&lt;/code&gt; on each sampled file, counted by author. &lt;code&gt;-w&lt;/code&gt; ignores whitespace-only changes and &lt;code&gt;-M&lt;/code&gt; follows lines moved inside a file, so a reformat or a relocation does not steal authorship. Add &lt;code&gt;--copies&lt;/code&gt; for &lt;code&gt;-C&lt;/code&gt;, which also follows lines copied between files; it is slower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commits.&lt;/strong&gt; Non-merge commits reachable from the ref, optionally inside a &lt;code&gt;--since&lt;/code&gt; and &lt;code&gt;--until&lt;/code&gt; window. When you compare people who joined at different times, scope the window to a tenure, or the person who was there longest wins by default.&lt;/p&gt;

&lt;p&gt;Binary files are skipped. Identities go through the repository's &lt;code&gt;.mailmap&lt;/code&gt;, and when two rows still share a name the table shows the address so they cannot be confused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sample
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git blame&lt;/code&gt; walks history for every file, and on a large repository that is slow. Sampling makes the run cheap. The part I care about more is that the sample is deterministic: each path is hashed with FNV-1a, salted with an optional seed, and the file is in the sample when the hash is divisible by &lt;code&gt;n&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x811c9dc5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x01000193&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;inSample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;fnv1a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone with the repository and the same command gets the same files and the same numbers. If you suspect the sample is flattering, change the seed and run it again. &lt;code&gt;--sample 1&lt;/code&gt; blames every file, and on a 60k-line TypeScript repository that is still well under a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing blame without a dependency
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--line-porcelain&lt;/code&gt; repeats the full commit header for every line, so the parser is a scan, not a state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countBlameLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;porcelain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;porcelain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author-mail &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&amp;lt;|&amp;gt;$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file list and the total line count come from a single &lt;code&gt;git diff --numstat&lt;/code&gt; against the empty tree, which also marks binaries as &lt;code&gt;-&lt;/code&gt; so they can be dropped without opening them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot show
&lt;/h2&gt;

&lt;p&gt;The tool prints the caveat under every table on purpose, because the number is easy to misuse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing about the quality of the lines, or whether they should exist.&lt;/li&gt;
&lt;li&gt;Review comments, design documents, pairing and mentoring leave no lines behind.&lt;/li&gt;
&lt;li&gt;Code deleted on purpose counts for nobody, even when deleting it was the best contribution that month.&lt;/li&gt;
&lt;li&gt;Generated and vendored files inflate whoever committed them. Exclude them.&lt;/li&gt;
&lt;li&gt;A high share in a file nobody else touches is not the same as a high share in a file everybody touches. There is no weighting by contention.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It answers "whose code is still here?". It does not answer "who is the best engineer?", and I would distrust anyone who used it that way, including me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx surviving-lines &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, tests and a CI matrix across Linux, macOS and Windows: &lt;a href="https://github.com/Bubblegunn/surviving-lines" rel="noopener noreferrer"&gt;github.com/Bubblegunn/surviving-lines&lt;/a&gt;. MIT.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;node:test&lt;/code&gt; suite builds a throwaway repository with two authors, a partial rewrite, a rename and a binary file, and checks that the rename is followed and the binary is skipped. If you run it on your own repository and the shares surprise you, I would like to hear which way they surprised you.&lt;/p&gt;




&lt;p&gt;Update, later the same day: the whole report this script was written for is now a package too. &lt;a href="https://github.com/Bubblegunn/workproof" rel="noopener noreferrer"&gt;workproof&lt;/a&gt; runs six figures from git (tenure, commit share, cadence, footprint, tests and docs, surviving lines), prints the command behind each, and writes a hash anyone with the repository can verify with one command. surviving-lines is its only dependency.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>git</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Show Engineering Ownership When the Repositories Are Private</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:59:16 +0000</pubDate>
      <link>https://dev.to/efe_genc/how-to-show-engineering-ownership-when-the-repositories-are-private-3fon</link>
      <guid>https://dev.to/efe_genc/how-to-show-engineering-ownership-when-the-repositories-are-private-3fon</guid>
      <description>&lt;p&gt;I have spent more than four years shipping production software, and I have no public repositories worth showing you. Everything substantial I have written at work lives in private repositories that belong to the companies I wrote it for, and confidentiality obligations do not lapse when a role ends. So I cannot hand you the code, and I am not going to.&lt;/p&gt;

&lt;p&gt;I do not say that to sound careful. An engineer who sends a prospective employer another company's source code has told you exactly what they will do with yours. The restraint is part of what you are assessing, and I would want the same restraint applied to my own work later.&lt;/p&gt;

&lt;p&gt;That leaves a real problem. "I was the principal author of the web application" and "I contributed to the web application" are the same sentence to a reader who cannot check either one. Most engineering CVs resolve this by getting louder. The only honest resolution I have found is to publish the &lt;strong&gt;measurement method&lt;/strong&gt; instead of the artefact, in enough detail that someone who knows the tools can judge the figure on its own terms without ever seeing the repository.&lt;/p&gt;

&lt;p&gt;Here is the method I used, what it does badly, and the places where applying it forced me to shrink a claim I would have preferred to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three measurements, in increasing order of rigour
&lt;/h2&gt;

&lt;p&gt;The first is the all-branch commit count: total authored activity. Do not use it. It counts abandoned branches, spikes and experiments, and it inflates in favour of whoever is most willing to commit. It is the number that makes contribution graphs look impressive and says the least about a codebase.&lt;/p&gt;

&lt;p&gt;The second is the production-branch commit count, scoped to tenure: what reached customers while you were there. This is better, because it excludes work that never shipped, and because scoping to tenure stops you from taking credit for years of history that predate you, or being penalised for them. But it still counts commits, and a commit is a unit of activity, not a unit of code. Commit share rewards granular committers and punishes people who work in larger increments. It is the weakest of the three, and unfortunately the easiest to produce, which is why it is the one people quote.&lt;/p&gt;

&lt;p&gt;The third is &lt;code&gt;git blame&lt;/code&gt; on the production branch: which lines are alive in production today, and who wrote them. This is the authoritative one, because it measures surviving authorship. The code was written, shipped, survived every later refactor by everyone else, and is running now. Its weakness is that it is slow, so it has to be sampled.&lt;/p&gt;

&lt;p&gt;Two details make the blame figure defensible, and they carry the whole exercise.&lt;/p&gt;

&lt;p&gt;The flags. I ran &lt;code&gt;git blame -w -M&lt;/code&gt; against the production branch. &lt;code&gt;-w&lt;/code&gt; ignores whitespace-only changes and &lt;code&gt;-M&lt;/code&gt; detects lines moved or copied within a file. Both make the result &lt;em&gt;more conservative&lt;/em&gt; rather than more flattering, because reformatted or relocated code is attributed to whoever originally wrote it instead of whoever last touched it. If I had wanted a bigger number I would have left both flags off, and anybody who knows the command can verify that without access to anything.&lt;/p&gt;

&lt;p&gt;The sampling. Deterministic, every Nth file across the sorted file list. I did not hand-pick a directory, and I did not take a random draw that I could have re-rolled until it flattered me. Anyone with the same repository can reproduce it. Sampling by directory is where this kind of measurement usually goes wrong, because directories are exactly where authorship clusters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that produced, and why the gap is the interesting part
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://dev.to/projects/cendra/"&gt;Cendra&lt;/a&gt;, where I was one of two founding engineers, blame analysis on a deterministic one-in-seven sample of the production TypeScript sources (several hundred files) attributes &lt;strong&gt;84.7%&lt;/strong&gt; of surviving frontend code to me, with the remainder spread across eight other contributors. My production-branch commit share over the same period, scoped to my tenure, is &lt;strong&gt;80.7%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The script is public now as &lt;a href="https://github.com/Bubblegunn/surviving-lines" rel="noopener noreferrer"&gt;surviving-lines&lt;/a&gt;: one file, no dependencies, the same deterministic sample and the same &lt;code&gt;git blame -w -M&lt;/code&gt;, with the caveats printed under every table so the number cannot travel without them. The whole report, six figures with the command behind each and a hash that anyone with the repository can verify, is &lt;a href="https://github.com/Bubblegunn/workproof" rel="noopener noreferrer"&gt;workproof&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The gap between those two numbers is the claim, and it points in the direction most people would not expect. My share of surviving lines is &lt;em&gt;higher&lt;/em&gt; than my share of commits, which means code written by others was disproportionately replaced by code written by me. I did more than add volume to a growing surface; I rewrote and consolidated the one that was already there. That is an argument about architectural ownership rather than throughput, and it is why I describe myself as principal author instead of the person who happened to commit the most.&lt;/p&gt;

&lt;p&gt;Had the ratio gone the other way, with commit share above blame share, the honest reading would be that I produced a lot of code that other people later replaced. That is a real outcome and it happens. A method that could not have shown it would not be a measurement.&lt;/p&gt;

&lt;p&gt;Some figures need no method at all. Every commit to the &lt;code&gt;ios/&lt;/code&gt; and &lt;code&gt;android/&lt;/code&gt; projects across the entire history of the repository is mine, 47 and 28 respectively, along with the Capacitor configuration and the Playwright end-to-end suites at 11 of 11 each. No other author has ever committed to those paths. That is the least arguable claim I have, and it is arithmetic rather than analysis.&lt;/p&gt;

&lt;p&gt;I measured all of these while I held authorised access, using ordinary &lt;code&gt;git&lt;/code&gt; invocations. I am not offering to demonstrate them live, and you should be wary of anyone who would. Opening a former employer's private repository in a screen share is a breach in itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method is most useful when it makes you shrink things
&lt;/h2&gt;

&lt;p&gt;The uncomfortable half of publishing a method is that it applies to the parts of the work you were less central to, and it does not let you round them up.&lt;/p&gt;

&lt;p&gt;The same blame analysis run against the production C# sources attributes &lt;strong&gt;23.2%&lt;/strong&gt; of surviving backend code to me, with a 30.9% commit share over my tenure. I joined the backend roughly six months into a twelve-month tenure. It had been under active development for eighteen months before I touched it, by engineers who are still there. So the accurate sentence is that I was a substantial contributor to a codebase I joined late, and I was not its architect. I have tried hard not to imply otherwise anywhere.&lt;/p&gt;

&lt;p&gt;Where that contribution sits turns out to be more informative than the total. My share of commits since joining runs highest in the test suite at 83.2%, the domain services at 53.6% and the event-processing consumer at 55.4%, and lowest in the infrastructure at 24.1%, the core domain at 20.8% and the CQRS feature handlers at 16.6%. Low in shared CRUD surfaces, high where architectural judgement was required. I was not the highest-volume backend contributor. I owned the parts that needed design, and the test suite the rest of the team relied on. Other contributors' shares are their own information, and they are not mine to publish.&lt;/p&gt;

&lt;p&gt;Across all four repositories, scoped to my tenure, the combined figure is 5,717 of 10,402 production-branch commits, or 55.0%. That is the most conservative aggregate available, and I record it for that reason, but I would not lead an argument with it. A single number that spans a frontend I was principal author of and an integration service I touched occasionally is not really a claim about either. Any aggregate over unlike repositories describes them worse than the per-repository figures it averages. That is why the breakdown above exists, and why the 84.7% is the number I would defend first.&lt;/p&gt;

&lt;p&gt;The method cannot measure two more boundaries, but honesty requires them.&lt;/p&gt;

&lt;p&gt;The core AI architecture at Cendra was not mine. Our AI engineer owned the LangGraph services, the agent workflows and the RAG pipelines. I built the product layer those capabilities were surfaced through (agent configuration, the knowledge base, the agentic rule-authoring interface and the in-product assistant) and the transport between the two, over AG-UI, server-sent events and the realtime hub. I contributed to how the rule and guardrail semantics were shaped, because building an interface through which a non-technical operator expresses a rule an engine will enforce requires understanding those semantics as deeply as the person implementing the enforcement. But I did not architect the orchestration. "I worked on the AI product" is the easiest sentence in this industry to say and the least informative.&lt;/p&gt;

&lt;p&gt;Cendra's product-level metrics are not mine either. Automation rates and platform reach are company figures published by the company. I contributed to the systems behind them. I did not measure them, and presenting a company outcome as a personal one is the specific move that makes every other number on a CV suspect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where commit share is all you have
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://dev.to/projects/readyfly/"&gt;ReadyFly&lt;/a&gt;, a part-time founding-engineer role, I have commit shares and nothing better: 157 of 209 commits to the web application, roughly three quarters, and I made the first one; and 31 of 209 to the Python API. So I say principal author of the first and contributor to the second, and I do not dress the 15% up. The AI models themselves, semantic matching and candidate evaluation, were owned by the team's AI engineer. I built the product layer they were surfaced through, and the browser side of that story is in &lt;a href="https://dev.to/writing/an-interview-inside-the-browser/"&gt;An Interview Inside the Browser&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Commit share is the weakest of the three measurements. When it is the only one available, the right response is to say so, rather than to present it in the same voice as a blame figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What closes the gap
&lt;/h2&gt;

&lt;p&gt;Method plus disclosure gets you to a claim that can be &lt;em&gt;judged&lt;/em&gt;. It does not get you to a claim that has been independently &lt;em&gt;confirmed&lt;/em&gt;, and I do not think any amount of self-published measurement does.&lt;/p&gt;

&lt;p&gt;Three things do. Process artefacts that exist independently of my description of them: &lt;a href="https://dev.to/writing/the-spec-is-the-fast-path/"&gt;255 design specifications, 207 implementation plans and 17 production runbooks&lt;/a&gt;, alongside 377 production releases coordinated across four repositories over 239 active development days in a twelve-month tenure. Two Cendra co-founders who have agreed to act as references and can confirm scope and ownership without me in the room. And an offer that costs me nothing to make, because it is the part I am confident about: a walkthrough of the systems that are mine, line by line, including the parts I would now build differently.&lt;/p&gt;

&lt;p&gt;The measurements are there so the conversation can start somewhere better than mutual assertion. They are not the evidence. They describe how I would go about finding out, and I have published them so that you can disagree with them.&lt;/p&gt;




&lt;p&gt;This essay first appeared on my site, efe-genc-portfolio.vercel.app, where the rest of the series lives. If you have had to prove ownership of work you cannot show, I would like to hear how you did it. Disagreement is the most useful comment I can get.&lt;/p&gt;

</description>
      <category>career</category>
      <category>git</category>
      <category>softwareengineering</category>
      <category>discuss</category>
    </item>
    <item>
      <title>The Spec Is the Fast Path</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:59:12 +0000</pubDate>
      <link>https://dev.to/efe_genc/the-spec-is-the-fast-path-3m2l</link>
      <guid>https://dev.to/efe_genc/the-spec-is-the-fast-path-3m2l</guid>
      <description>&lt;p&gt;The standard objection to writing the design down is that it is what you give up in exchange for speed. Early-stage companies are meant to be the place where you skip it. No specifications, no plans, build the thing and find out. Documentation gets treated as a tax that mature organisations can afford and small ones cannot.&lt;/p&gt;

&lt;p&gt;I have worked the other way round for a while now, and I want to make the argument properly instead of just asserting it.&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://dev.to/projects/cendra/"&gt;Cendra&lt;/a&gt;, across a twelve-month tenure as one of two founding engineers, the work produced 255 design specifications, 207 implementation plans and 17 production runbooks. Over the same period there were 377 production releases coordinated across four repositories, and 482 merge commits across those repositories (an all-merges count, broader than the frontend-only first-parent integration figure on the Cendra page), spread over 239 active development days. The release figure works out at roughly one every twenty-two hours.&lt;/p&gt;

&lt;p&gt;All of those figures are tenure-scoped and self-measured against private repositories while I held authorised access to them, a caveat I will come back to. The shape is what I care about here. The documents and the shipping happened together, at the same time, by the same person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;p&gt;I cannot prove the specifications caused the cadence. There is no control group. It is entirely possible to imagine a version of that year with no documents and more releases, and I have no way to rule it out.&lt;/p&gt;

&lt;p&gt;What I can do is describe precisely what the documents removed from the critical path, and let you judge whether that is worth the hours. The mechanism matters more than the correlation to me, because the mechanism is the part that transfers to your codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The specification is a comprehension test I administer to myself
&lt;/h2&gt;

&lt;p&gt;People assume I write a design down before building it in order to communicate it. Communication is a side effect. Writing it is how I find out whether I actually understand the problem.&lt;/p&gt;

&lt;p&gt;There is a specific and reliable experience involved. Somewhere in the second half of a document, I reach a paragraph I cannot write. The words are not the difficulty. The thing I am trying to describe does not resolve. What happens when both of those conditions are true at once? Which side owns this state? What does the system do when the third case arrives, the one I have been mentally filing as "unlikely"?&lt;/p&gt;

&lt;p&gt;That paragraph is the design flaw, and it has surfaced while it costs a paragraph. The same flaw would surface on its own eventually, after the schema is written, after two features depend on the shape, after something is in production carrying data. The distance between those two moments is most of what people mean when they say a piece of work took longer than expected.&lt;/p&gt;

&lt;p&gt;So the cheapest place for a design to be wrong is in a document, and the second cheapest place is nowhere near as cheap. Everything below is bookkeeping on top of that one observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three artefacts because they answer three different questions
&lt;/h2&gt;

&lt;p&gt;The counts differ (255, 207, 17) because the three documents do different work and are needed at different rates.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;specification&lt;/strong&gt; says what a thing should do and why, including what it deliberately will not do. That last part earns its place more often than the rest. A written non-goal is the only defence I have found against a feature that grows a third of the way through implementation. "We said explicitly that this was out of scope" is a settled question. "I don't think we should do that" is a conversation.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;implementation plan&lt;/strong&gt; says in what order, what can break, and what has to land before what. It exists because a design being correct says nothing about a sequence being safe.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;runbook&lt;/strong&gt; says how the thing reaches production and how it comes back out. There are only seventeen because a runbook describes a recurring operation rather than a change: staged rollout, feature-flagged release, a forty-eight-hour post-merge soak, rollback procedures, cross-repository dependency ordering, smoke matrices, incident response and on-call handover.&lt;/p&gt;

&lt;p&gt;The runbooks were the ones I nearly did not write. They turned out to have the clearest payback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The runbook exists because of a single point of failure that looked like competence
&lt;/h2&gt;

&lt;p&gt;Releases spanned four repositories with real ordering constraints. Backend routes had to land before the frontend that called them. A web deployment and a mobile over-the-air bundle had to activate together, or a native shell would spend an afternoon calling an endpoint that did not exist yet.&lt;/p&gt;

&lt;p&gt;That ordering lived in my head, and I was good at it, which was the problem. An engineer who reliably gets a complex sequence right looks the same as a documented process right up until the day they are ill or gone. I had been treating "I know the release order" as a strength. It was a single point of failure that felt like one, and the fact that it felt good is what stopped me fixing it sooner.&lt;/p&gt;

&lt;p&gt;Writing it down was the fix. It also made the sequence reviewable, a second-order benefit I did not anticipate. An ordering constraint you can read is one somebody else can find a hole in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the speed actually comes from
&lt;/h2&gt;

&lt;p&gt;Nobody's bottleneck is typing. The expensive things in shipping software are rework and coordination, and specifications attack both.&lt;/p&gt;

&lt;p&gt;Rework, because a design flaw found in a document is fixed by editing the document. Coordination, because a written interface is how two people build against each other without a meeting. That second one was concrete for me. Our AI engineer owned the LangGraph services, the agent workflows and the RAG pipelines. I built the product layer those capabilities were surfaced through (agent configuration, the knowledge base, the agentic rule-authoring interface, the in-product assistant) plus the transport between them. I did not architect the orchestration underneath, and the boundary between us is exactly where a specification pays for itself. Two people can build towards a written interface at the same time. They cannot build towards a remembered one.&lt;/p&gt;

&lt;p&gt;The third piece is a completion standard rather than a document. Nothing counted as done until I had watched it behave correctly in production logs, in the database, or on a real device. I adopted that after being wrong often enough to stop trusting a passing build. The clearest illustration I have is &lt;a href="https://dev.to/writing/the-feature-i-chose-not-to-ship/"&gt;a mobile feature that passed everything and still had to be removed after I reproduced its behaviour on a physical handset&lt;/a&gt;. Specify, implement, then verify in production. That last step is where the specification either turns out to have been true or does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the artefacts turned out to be good for that I did not intend
&lt;/h2&gt;

&lt;p&gt;They are the only part of that work I can still point at. Every repository I have shipped production code into belongs to somebody else, so &lt;a href="https://dev.to/writing/showing-ownership-private-repositories/"&gt;demonstrating ownership becomes a problem of publishing a method rather than an artefact&lt;/a&gt;. Commit shares and blame analysis are self-measured; you are taking my word for the numbers and judging the method. Process artefacts sit differently. They existed in the repositories independently of my description of them, and two co-founders who were there have agreed to act as references and can confirm scope and ownership without me in the room.&lt;/p&gt;

&lt;p&gt;I did not write 255 documents in order to have evidence. But a habit that produces its own record turns out to be worth more than a habit that produces only working software, once the working software is behind an NDA.&lt;/p&gt;

&lt;p&gt;The same discipline runs in my own projects, at a scale suited to them. &lt;a href="https://dev.to/writing/default-deny-rights-documentary-pipeline/"&gt;The autonomous documentary pipeline&lt;/a&gt; carries 25 design specifications committed alongside the code, including a build-versus-buy roadmap with licence analysis per component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I would not write one, and what the counts do not tell you
&lt;/h2&gt;

&lt;p&gt;A specification for a change whose blast radius is smaller than the document is ceremony, and ceremony is what gives specifications their bad reputation. My rule of thumb is to write one when the decision is expensive to reverse, when it crosses a boundary someone else builds against, or when the state it introduces will outlive my memory of why. A copy change or a dependency bump meets none of those. Neither does a contained bug fix.&lt;/p&gt;

&lt;p&gt;The counts are counts of artefacts, not of quality. 255 documents is not 255 good documents, and I would rather say so than present a number as evidence of uniform rigour. The only claim a count of this kind can support is that the habit was consistent. The order is not always clean either. Sometimes the honest way to understand a problem is to build a throwaway first and write down what I learned afterwards, and a document produced that way is still worth having even though it arrived out of sequence.&lt;/p&gt;

&lt;p&gt;There is one more thing this discipline carries. LLM coding tools are central to how I work, and the specification is what makes that safe rather than fast and hopeful. It is the artefact a tool's output can be checked against, something outside my own memory of what I asked for. Working this way is what lets one engineer cover a large surface, and the specs, the tests and the production verification are the reason it holds.&lt;/p&gt;




&lt;p&gt;Ownership evidence and how each figure was measured: &lt;a href="https://dev.to/projects/cendra/"&gt;Cendra&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;This essay first appeared on my site, efe-genc-portfolio.vercel.app, where the rest of the series lives. If you have run a small team with a different ratio of writing to shipping, say so. Disagreement is the most useful comment I can get.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>ai</category>
      <category>productivity</category>
      <category>discuss</category>
    </item>
    <item>
      <title>The Hardest Part of a Proactive Assistant Is Knowing When Not to Speak</title>
      <dc:creator>Efe Genç</dc:creator>
      <pubDate>Fri, 04 Sep 2026 18:59:09 +0000</pubDate>
      <link>https://dev.to/efe_genc/the-hardest-part-of-a-proactive-assistant-is-knowing-when-not-to-speak-44f0</link>
      <guid>https://dev.to/efe_genc/the-hardest-part-of-a-proactive-assistant-is-knowing-when-not-to-speak-44f0</guid>
      <description>&lt;p&gt;Almost everything written about proactive AI is about the generating half. How the system notices a pattern, how it phrases the insight, which model reads the calendar. I no longer think that half is the hard part. The hard part is the decision immediately after. Having noticed something true, do you say it?&lt;/p&gt;

&lt;p&gt;A proactive assistant pays a lopsided price for its mistakes. Surfacing something useful earns a little trust. Interrupting at the wrong moment loses a great deal, and users do not give a second chance to a notification stream they have already learned to ignore. Once attention has been trained away from a channel, it does not come back. So the interesting engineering sits on the restraint side, and in my experience that side gets built last.&lt;/p&gt;

&lt;p&gt;I know that because on the first notification system I owned, I built it last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that does not work
&lt;/h2&gt;

&lt;p&gt;The common shape is to generate candidate insights, score them, and filter against a threshold. It fails in two specific ways.&lt;/p&gt;

&lt;p&gt;The threshold is a single scalar standing in for many unrelated reasons to stay quiet. "Not this person", "not at three in the morning", "not in the first week", "not again, they have dismissed this three times" and "not today, the budget is spent" are different rules with different owners and different failure modes. Compressing them into one number means none of them can be reasoned about, and tuning any one of them moves all the others.&lt;/p&gt;

&lt;p&gt;And nothing records &lt;em&gt;why&lt;/em&gt; anything was suppressed. A threshold returns false. So the suppression behaviour, which is the most important behaviour in the product, becomes the one part of the system that generates no data, and therefore the one part that cannot be improved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two questions, two places
&lt;/h2&gt;

&lt;p&gt;The design I settled on in &lt;a href="https://dev.to/projects/lila/"&gt;LILA&lt;/a&gt; separates the questions completely.&lt;/p&gt;

&lt;p&gt;Whether something is worth saying at all is a reasoning problem. It depends on the content, the evidence behind it, and whether the observation is one a product should be making. It has nothing to do with the time of day.&lt;/p&gt;

&lt;p&gt;Whether it should be said now, to this person, on this surface, is a policy problem. It depends on consent, preferences, timezone, history and budget. It has nothing to do with how interesting the insight is.&lt;/p&gt;

&lt;p&gt;They live in different places in the codebase, and only the first involves a model. Keeping them apart is what lets me change the phrasing of an insight without touching a single rule about when a user may be interrupted, and change the quiet-hours logic without any risk of altering what the system notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gate, twelve checks, in order
&lt;/h2&gt;

&lt;p&gt;Everything the system might say passes through a single delivery gate. Twelve checks, evaluated in a fixed order. First an engine kill-switch, a production hard-stop that silences every producer at once. Then consent, whether proactive behaviour is enabled on the profile, operating mode, a global snooze, a per-type mute, the user's intensity setting, timezone-aware quiet hours, a seven-day trust ramp for new users, a dismissal cooldown, and a daily interaction budget. Quiet hours can be bypassed only above a priority floor. The cooldown triggers when a user has dismissed three suggestions of a type within thirty days, and it buys a week of silence.&lt;/p&gt;

&lt;p&gt;On rejection the gate logs the specific reason and returns it. On allow it returns the set of surfaces the suggestion should be routed to: feed, push, chat or voice.&lt;/p&gt;

&lt;p&gt;I chose one gate rather than checks scattered through the pipeline for three reasons, and I would defend each of them.&lt;/p&gt;

&lt;p&gt;It is the only way to answer "why was the user not told about this". With checks distributed across a pipeline, the honest answer is "somewhere, something returned false". With one gate and a logged reason, the question has an answer, and the answer is a metric.&lt;/p&gt;

&lt;p&gt;Ordering is a design decision and it should be visible. Consent has to come before everything, or you have evaluated preferences for a user who never agreed to be contacted. Quiet hours have to come before the daily budget, or an item that arrives overnight consumes budget it was never eligible to spend. Ordering that is spread across a codebase is ordering nobody controls.&lt;/p&gt;

&lt;p&gt;Adding a rule is one change in one place. The dismissal cooldown was added long after the original design, the kill-switch later still, and in August 2026 an adaptive-timing check that does not reject at all. It strips the push surface during hours the user has historically dismissed and lets the suggestion wait in the feed. Each was a single insertion into an ordered list. None of them needed an audit of every path that could reach a user.&lt;/p&gt;

&lt;p&gt;The check I would defend hardest is the trust ramp. For the first seven days a new user hears from the system only when priority is high. A proactive assistant is at its least calibrated exactly when the user is deciding whether to trust it, which is the worst possible moment to be talkative. Being quiet early is what buys permission to be useful later.&lt;/p&gt;

&lt;p&gt;The gate is now a package. &lt;a href="https://github.com/Bubblegunn/proactive-gate" rel="noopener noreferrer"&gt;proactive-gate&lt;/a&gt; is the same twelve checks in the same order, framework-agnostic and dependency-free, with a reason on every rejection, a budget consumed by an atomic increment at send time, and a replay command that runs a day of candidates through a policy and reports what would have been allowed and why the rest stayed silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log turns suppression into a distribution
&lt;/h2&gt;

&lt;p&gt;Because every rejection is recorded with its reason, the operational question stops being "how many notifications did we send today". It becomes: of everything the system considered surfacing, how much did it suppress, and under which rule?&lt;/p&gt;

&lt;p&gt;That distribution tells you things a send count never can. Heavy suppression on quiet hours means detection is running at the wrong time of day, and the rule itself is probably fine. Heavy suppression on the daily budget means the correlation stage upstream is under-grouping and producing three suggestions where there was one situation. Heavy suppression on intensity means confidence calibration has drifted. You cannot see any of that if you only count what was sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then you have to measure the silence
&lt;/h2&gt;

&lt;p&gt;A gate you cannot measure is a set of guesses with good intentions. The uncomfortable thing about evaluating restraint is that ordinary evaluation datasets cannot express it. They pair an input with an expected output, and there is no natural way to write down "and here, correctly, nothing happened". So those cases never get built, and the metric ends up rewarding a system that talks too much.&lt;/p&gt;

&lt;p&gt;Scoring silence needs datasets constructed from the opposite direction: situations that &lt;em&gt;look&lt;/em&gt; like they contain a pattern and do not, or that contain one too weak or too personal to act on. The expected output is nothing, and the system is penalised for speaking.&lt;/p&gt;

&lt;p&gt;Silence is one of five dimensions in the pattern-detection rubric. The others are accuracy, confidence calibration, evidence, and what I call product truth, meaning whether an observation is one the product should make even when it is entirely correct. A pattern can be real and well-evidenced and still be something a user would find intrusive to have been noticed. Silence and product truth took the longest to get right, and they are the two no generic evaluation framework hands you.&lt;/p&gt;

&lt;p&gt;The result changed the product rather than just the dashboard. Restraint stopped being an implicit hope inside a prompt and became a measured behaviour with a number attached, which meant it could be regressed against. That is the only reason I trust the gate's thresholds at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is still weak
&lt;/h2&gt;

&lt;p&gt;Insight wording is scored by a separate model against a written rubric covering tone, judgement-free phrasing, privacy, evidence and actionability. It is the right tool for qualities that resist assertion-based testing. It is also the weakest part of the harness. Judges drift, they are sensitive to rubric wording, and mine is not calibrated against human raters. I treat judge scores as a regression signal, a way of asking whether this got worse, rather than as an absolute measure of quality. Establishing human agreement on a sample is the obvious next piece of work and it is not done.&lt;/p&gt;

&lt;p&gt;The gate's daily budget started as an in-memory counter keyed by user and date. That was correct for a single instance and wrong the moment the service scaled horizontally. It now lives in Redis under the same key, incremented atomically, and falls back to the in-memory counter when Redis is unreachable. The fallback fails open, so a Redis outage means a user could briefly get more than their daily allowance rather than nothing at all. I chose that direction on purpose, it is written down, and I would rather say it than have it found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I am confident this is the right shape
&lt;/h2&gt;

&lt;p&gt;Because I built the same thing in the opposite order first, and watched it not work.&lt;/p&gt;

&lt;p&gt;At Cendra I owned the notification and real-time system end to end, down to &lt;a href="https://dev.to/writing/one-outbox-record-per-notification/"&gt;the single outbox record that drove every channel&lt;/a&gt;. My initial design treated delivery as the problem and suppression as a later refinement. That ordering was mine and it was wrong. Volume is easy to add and very hard to take back once users have adjusted to it, so a notification system needs a suppression model before it needs a delivery model. The correction was a three-phase programme: event classification to separate the events people need to hear about from telemetry, then preference gating, rate limiting, recipient filtering and collapse keys. It eliminated approximately 19,000 unnecessary push notifications a day. That figure was measured in production log telemetry over a ten-minute observation window against the prior baseline and then scaled to a daily rate. It is not a directly observed twenty-four-hour count, and I would rather qualify it than round it up.&lt;/p&gt;

&lt;p&gt;LILA is what happens when you build the suppression model first. It is my own project, roughly nine and a half thousand commits since February 2026, self-counted in a private repository I own, with nobody else to verify it. So the decisions in it are mine to defend, including the ones above that I have not finished.&lt;/p&gt;

&lt;p&gt;The generation half of a proactive assistant is a solved-enough problem that you can buy it. The restraint half is the product, and it will decide whether anybody still has notifications switched on in a month.&lt;/p&gt;




&lt;p&gt;Both systems are written up in more detail: &lt;a href="https://dev.to/projects/lila/"&gt;LILA&lt;/a&gt; for the gate, the evaluation harness and &lt;a href="https://dev.to/writing/surviving-your-ai-providers/"&gt;the model gateway&lt;/a&gt;, and &lt;a href="https://dev.to/projects/cendra/"&gt;Cendra&lt;/a&gt; for the notification work that taught me the ordering.&lt;/p&gt;




&lt;p&gt;This essay first appeared on my site, efe-genc-portfolio.vercel.app, where the rest of the series lives. If you have shipped a proactive system and disagree with any of this, say so. Disagreement is the most useful comment I can get.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>productdesign</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
