
Registered Twice: Six Entries for Four Scripts
Two hooks appeared in two separate matcher groups, so both ran twice per prompt. Duplicate registration is invisible until you count entries against distinct scripts.
View companion repoSix entries covering four scripts
I was looking at a hooks configuration because a prompt was running the same check twice. The arithmetic was the finding. Six registration entries. Four distinct scripts. Two of those scripts sat in two separate matcher groups, and both groups used * as the matcher, so both copies fired on every prompt.
The sentence lives at line 1407 of 08dff6ef-3d84-4a42-8823-96fc77bd05bd.jsonl, a session under the yt-transition-shorts-detector project tree: "Fix 3's root cause is concrete: secret-output-guardrail.cjs and simplify-gate.cjs are each registered twice in two separate * matcher groups — 6 entries for 4 distinct scripts, so both run twice per prompt. Checking all events for the same duplication."
That is the whole diagnosis. I am not going to invent the names of the other two scripts, or which events those matcher groups were attached to, or how the file was laid out on disk. The session names two scripts, a matcher, and a count pair. Scripts: secret-output-guardrail.cjs and simplify-gate.cjs. Matcher: *, twice, in two separate groups. Count pair: six entries, four distinct scripts. Consequence, stated in the same sentence: both run twice per prompt.
Six and four are different numbers. That difference is the defect. A config review that only asks "is each entry well-formed" will never produce it. You have to count the entries, count the distinct targets, and compare.
This is post 70. The series has spent a lot of pages on validation that looks green while proving nothing. Duplicate registration is the same family, applied to wiring rather than to a harness check. The dashboard of the config file is a list of entries. Every line can be correct. The list can still be wrong.
Duplicate registration is a relation, not a line
Each individual entry was valid. Neither group was malformed. secret-output-guardrail.cjs is a real script. simplify-gate.cjs is a real script. A catch-all matcher is a legal matcher. Registering a script under * is a thing you are allowed to do. Reading the file top to bottom, every line is a well-formed registration.
The defect does not live in any of those lines. It lives in the relation between two separately-correct blocks. Group A says: on *, run these scripts. Group B says: on *, run these scripts. The intersection of the two lists is the two names above. Because both groups match everything, the intersection is not a rare overlap on some tool. It is a second execution on every prompt.
Config review is usually per-entry. You open the file. You ask whether this hook points at a file that exists. You ask whether this matcher is the regex you meant. You ask whether this event is the one that should fire the script. Those are local questions. They have local answers. Duplicate registration is not local. It is a set question. The set of entries is larger than the set of distinct scripts, and the extra cardinality is explained by two names appearing twice under two groups that both match everything.
I have written hooks configs this way myself. You add a group for one event, copy a working list into it, then later add another group for a different reason and paste the same two names because they "should always run." Each paste is a reasonable local decision. The second * is also a reasonable local decision if you want a script to fire unconditionally. Two reasonable local decisions compose into a global double-fire. The composition is invisible to a line-by-line read because a line-by-line read never builds the set.
The matcher is load-bearing. Duplicate registration under a narrow matcher is still a bug, but it is a bug with a small blast radius: the script runs twice only when that matcher hits. Duplicate registration under two * groups is a bug with the maximum blast radius the event allows. Every prompt is a hit. The doubling is not intermittent. It is the wiring.
That is why the session's next move, in the same sentence, was to check all events for the same duplication. One pair of groups is a finding. The finding is a pattern. If two * groups can both carry secret-output-guardrail.cjs, other events can carry the same shape. I am not going to invent whether those other events were clean. The cited line stops at the decision to look.
The diagram is honest about what the session did not name. Two boxes are labeled as unnamed here because inventing those filenames would be fabrication. The crimson boxes are the two names the session gave. They appear in both columns. That repetition is the 6-for-4 arithmetic drawn as a picture.
What twice per prompt actually does
A hook that runs twice is not the same object as a hook that is noisy. Noisy is a property of the script: it prints too much, or it warns on cases you wished it would ignore. Twice is a property of the wiring. The script can be quiet, correct, and still appear twice, because the runtime invoked it twice.
The session does not record what those two scripts did on the doubled invocations. I will not invent a log line, a block, or a write. The consequence class is still forced by the wiring, and it splits on whether the script is idempotent.
For an idempotent check, the second run is a copy of the first. You see the same warning twice. You see the same refusal text twice. You see the same annotation twice. The symptom looks like a flaky UI: a message that stutters, a panel that lists the finding two times, a transcript that reads as if the model hit the same gate, thought about it, and hit it again. Debugging that as a script bug sends you into the wrong file. You will tune the warning, or add a debounce inside the hook, or decide the hook is "chatty." The hook is not chatty. It is installed twice.
For a non-idempotent hook the second run is a second effect. Double-block is the mild version: a PreToolUse that already refused still runs again, and the second refusal is redundant only if the first one cancelled the call. Double-write is the sharp version. A hook that appends to a log, that stamps a file, that increments a counter, that rewrites tool output, does that work twice. The second write is not a stutter. It is state. I am not claiming these two scripts did that. I am naming the class, because the class is why "it runs twice per prompt" is not a performance footnote.
The doubling also trains the wrong diagnosis. It reproduces on every prompt. A bug that happens every time does not feel like a race and does not feel like a matcher miss. It feels like personality. "This guardrail is aggressive." "This simplify gate keeps repeating itself." Those sentences describe the operator's experience of a duplicated registration. They do not describe the scripts. secret-output-guardrail.cjs and simplify-gate.cjs were each doing the job they were registered to do. They were registered to do it twice.
Wasted cycles are real and they are the least interesting cost. Two extra process starts on every prompt add up. They are also the cost you will notice if you are looking at timings, and the session did not give me timings, so I will not put a number on them. The cost I will keep is the semantic one. A control plane that fires a policy twice is a control plane whose observations you can no longer trust at face value. Did the secret guardrail fire because the output was secret, or because the second copy ran on the same output? The transcript cannot tell you. Both copies are the same name.
This is why counting entries against distinct scripts is not bookkeeping. It is how you find out whether the control plane is the shape you think it is. Four scripts is a policy set. Six entries is a policy set with a shadow. The shadow is the two names in the second * group.
Zero is a number you have to earn
Ten lines later in the same session, a neighbouring fix produced a different count that was also underdetermined.
The sentence lives at line 1417 of 08dff6ef-3d84-4a42-8823-96fc77bd05bd.jsonl, same project tree: "Fix 4 shows 0 warnings — but I need to confirm the session actually produced output, else that's a false negative."
Zero warnings is a number. It is consistent with two states of the world. State one: the session ran, produced output, and the thing Fix 4 watches did not warn. State two: the session produced no output, so the watcher had nothing to warn about. Those are not the same claim. Only the first one is a pass. The second is a silent skip wearing a zero.
The author stopped. That stop is the method. A raw count of zero does not earn a green until you have the denominator: output existed. Without the denominator, 0 warnings is the vacuous cousin of 6 entries. Both numbers are internally consistent. Both are compatible with a healthy system and with a broken one.
Six entries is consistent with six distinct hooks. It is also consistent with four hooks, two of them listed twice. You cannot tell which world you are in by staring at six. You have to count distinct scripts. Zero warnings is consistent with a clean run. It is also consistent with a run that never spoke. You cannot tell which world you are in by staring at zero. You have to confirm there was output.
I am not going to invent whether Fix 4's session had produced output, or what Fix 4 was, or what the warnings would have looked like. The cited line is the hesitation, not the resolution. The hesitation is the load-bearing part. "I need to confirm" is the sentence that keeps a false negative from becoming a shipped pass.
The two defects share a shape. A count is presented as a verdict. The count is compatible with more than one configuration of the world. The missing operation is a second count, or a presence check, that would discriminate. For the hooks file, the second count is distinct scripts. For Fix 4, the presence check is "did this session emit anything." Aggregation in one case, a non-empty probe in the other. Same refusal to treat a raw number as a state.
This series has a long memory for zeros that were not earned. A check that cannot fail prints green. A search that skipped the files it should have opened prints zero hits. A reviewer told to write nothing can look like a clean review if you only count diffs. I will not pin those stories onto this session. I will use them as vocabulary. Line 1417 is the author applying that vocabulary in real time: 0 warnings is not yet a result.
The pairing is why I am putting both citations in one entry. Line 1407 is too many. Line 1417 is too few. Too many entries for the number of scripts. Too few warnings for a pass, until output is shown. Both mistakes are counting mistakes. Both are easy to ship because the number you have is not obviously wrong. Six is a plausible size for a hooks file. Zero is the number you wanted. Plausible and desired are not verified.
Audit configuration by aggregation
The rule I am keeping is mechanical.
Count the registration entries. Count the distinct script paths. Compare. If the first number is larger, you have duplication. The duplication is not a style issue. It is a second invocation waiting on the next prompt.
Any matcher group that uses a catch-all deserves a second question: what else is catching all. Two * groups are not two opinions about coverage. They are two complete scans of the same event. If they share a script name, that name is in the scan twice. The * is not a default you sprinkle for safety. It is a claim that this list is the unconditional list. There should be one such list per event, or the overlap should be empty by construction.
Do not review a hooks file only as a sequence of lines. Review it as a bag of (event, matcher, script) triples, then group by script. Cardinality of the bag versus cardinality of the scripts is the test. I do not need a linter name for this. A four-line aggregation does it: list the entries, unique the targets, subtract. The session did that by hand and got 6 versus 4. That subtraction named secret-output-guardrail.cjs and simplify-gate.cjs as the two that would run twice.
When a later fix reports 0, ask what the 0 was counted over. Zero over a real output stream is evidence. Zero over an empty stream is a caption. The confirmation is not extra caution. It is the denominator.
I am not going to write a checklist that pretends I know the rest of that session. I have two sentences. They are enough for the rule because the rule is the comparison they already performed and the comparison they refused to skip. Six against four. Zero against "was there output." Both comparisons are available to anyone who opens a config or a log and is willing to count twice.
A hooks file is a control plane. Control planes fail in the seams between correct parts. The seam here was two * groups that both believed they owned the unconditional case. They both did. That is the bug.
Continue the series
- 69SeriesFilename-Scoped Search: How a Grep Overturned the Wrong ReportA report declared a 519-PNG manifest nonexistent. The manifest existed. The search had been scoped to filenames, and absence of a hit was read as absence of the thing.
- 68SeriesThe Placeholder That Could Not MatchFive premises arrived as unsubstituted template literals. A gate that halts on `{{PR_HEAD_SHA}}` is not being pedantic — it is refusing to invent a value.
- 67SeriesSymmetric Truncation: A Passing Gate With a Broken MethodThe verdict survived the correction. The method did not. A regex that ate flag suffixes on both sides of a comparison produced the right answer for the wrong reason.
- 66SeriesThe Glob That Skipped 222 BackupsA restore script that reported success while matching nothing: `*/` does not match dot-prefixed directories, and the safety net was the thing that failed.