@sabaiway/agent-workflow-kit 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 3.14.0 — the closing state block gets a checker (AD-075)
8
+
9
+ A new opt-in `Stop` hook reads the turn's final assistant message and warns when the closing state
10
+ block lies about the turn that just ended.
11
+
12
+ Every mechanised bar in this kit gates FILES — the gate declaration, release-scan, doc-parity, the
13
+ commit guard. The closing state block is CHAT output, which no file gate can see, and that is the
14
+ structural reason one contract recurred five times across three sessions while every file-level bar
15
+ held. This closes the gap for two shapes that a `Stop` hook can judge as FALSE rather than merely
16
+ discouraged, because at Stop time the turn is over: a «what I need from you» slot answering
17
+ *nothing* — a turn that has ended always needs a resume, so the slot must name it — and a
18
+ first-person promise of imminent work in a turn that produced none.
19
+
20
+ **It is detection, never prevention, and that is stated on every surface.** A `Stop` hook cannot
21
+ un-send the message it judges. What it buys is that a recurrence stops being silent.
22
+
23
+ The warning rides `systemMessage` on stdout at exit 0, because a `Stop` hook's stderr at exit 0
24
+ reaches the debug log and nobody else — a guard warning down an invisible channel would be
25
+ decoration. It never emits a blocking field and exits 0 on every path, including every failure path;
26
+ a guard that cannot see the turn says so rather than going quiet.
27
+
28
+ The absent-block report is opt-in behind `--require-block`: this kit does not mandate the
29
+ three-part block, and a hook that runs on every turn must not warn on every turn. An unrecognised
30
+ argument refuses the turn loudly instead of silently running weaker than you asked for.
31
+
32
+ `references/modes/state-block-guard.md` carries the check-first wiring block, the trust posture, and
33
+ every residual by name — including the ones that are limits of a lexical layer rather than bugs
34
+ awaiting a fix. There is **no writer** yet, and the reason is in that doc rather than hidden: a
35
+ second placement path through the gate-approval writer would grow a flag matrix on the component
36
+ that wires command auto-approval, and a dedicated second writer would duplicate hardened placement
37
+ logic.
38
+
7
39
  ## 3.13.0 — the commit guard proves the INDEX carries the verified tree (AD-074)
8
40
 
9
41
  `commit-guard --check` now refuses an index that lags the working tree, so «verified» and «about to
package/README.md CHANGED
@@ -248,6 +248,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
248
248
  | `/agent-workflow-kit velocity` | Claude Code · opt-in | **onboarding velocity profile** — seeds a fixed, audited **read-only** allowlist into `.claude/settings.json` so routine read-only commands stop idling on approval prompts while you're away; opt-in `acceptEdits`; plus a **read-only advisory** of likely project gate commands to add by hand. Writes **only** `.claude/settings.json` — **never** allowlists commit/push/publish, never writes `settings.local.json`, never commits. A seeded entry is a **trust posture, not a sandbox** (a runtime residual remains at the settings level — its guard ships as the opt-in `hook` command); a direct commit/push/publish still asks. `--dry-run` first. |
249
249
  | `/agent-workflow-kit agents` | Claude Code · opt-in | **cheap-lane subagents** — places bundled subagent definitions (`.claude/agents/`) pinned to a **cheap model** (haiku, low effort, read-only tools) for mechanical work: extraction sweeps, changelog fact-skeletons, gate-failure triage. Judgment, review, and real code stay on your main lane — these vehicles only extract and draft, and their output is verified. Preview by default (`--apply` writes); an existing customized file is **preserved, never overwritten**; never touches `settings*.json`, never commits. |
250
250
  | `/agent-workflow-kit hook` | Claude Code · opt-in | **gate-approval hook** — places a self-contained PreToolUse hook (`.claude/hooks/`) and wires it into `.claude/settings.json`: a Bash command **byte-identical** to a gate you declared in `docs/ai/gates.json` (run from the project root) is auto-approved — no prompt, no idle; a seeded read-only command carrying a runtime residual (output redirection, command substitution, `--output` writes) now **asks** even where an allow rule would have silently passed it (proven live — hook `ask` overrides an allow rule). Exact matches only, never patterns; never `deny`; a broken `gates.json` only disables auto-approval, never the guard. An opt-in **read-lane** (`--read-lane` → `docs/ai/lanes.json`, a **separate** file from `gates.json`) additionally auto-approves *compounds* of seeded read-only commands carrying no shell metaprogramming; `--apply --read-lane` verifies the placed hook is current first (delete-to-reseed otherwise). Preview by default; never writes `settings.local.json`; never commits. |
251
+ | `/agent-workflow-kit state-block-guard` | Claude Code · opt-in | **closing-block detector** — a `Stop` hook that reads the turn's final assistant message and warns when the closing state block is defective: the «what I need from you» slot answering *nothing* (a turn that ENDS always needs a resume, so the answer is false, not merely unhelpful), or a first-person promise of imminent work in a turn that is over. Judged on the slot's first clause, so a real ask followed by a clause break and "nothing else" passes, and a promise gated on something named passes; matching is word-bounded and Unicode-aware, quoted and fenced examples are stripped, and the last STARTED block decides — an incomplete tail counts as no block rather than falling back to an earlier one. A message carrying no block at all is silent unless you pass `--require-block` — this kit does not mandate the block, and a hook that runs every turn must not warn every turn. The judgement is lexical and the mode doc names every residual it leaves. **Detection, never prevention** — a `Stop` hook cannot un-send the message it judges; what it buys is that a silent recurrence becomes a visible one. Warns via `systemMessage` and **exits 0 on every path**: never `deny`, never blocks the stop, never re-enters the model. Reads nothing but the message, makes no network call, approves nothing. This mode is **read-only and has no writer** — it ships the self-contained hook and a paste-ready wiring block you merge into `.claude/settings.json` yourself. |
251
252
  | `/agent-workflow-kit bridge-settings` | opt-in, any time | **host-level bridge settings** — read or change the bridges' `KEY=VALUE` config file (`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`), the **one place a knob survives kit upgrades** (it lives outside every kit tree; a refresh never touches it, and now states loudly if it ever overwrote a local edit). First knobs: the **codex Fast tier** (`CODEX_SERVICE_TIER=priority` — ~1.5× speed at a **2.5× credit rate**, quality-neutral, default off — a consented spend), the codex/agy hard timeouts, the review byte cap, the oversized-review add-dir toggle. Allowed keys + value rules come from the bundled bridge manifests; **model/effort are never settable** (the quality guard is untouched). **Previews by default**; refuses an unknown key, an out-of-range value, or a duplicate-carrying file; writes atomically (symlink/TOCTOU-safe). Never commits, never runs a subscription CLI. |
252
253
  | `/agent-workflow-kit autonomy-doctor` | opt-in, any time | **guarded sandbox provisioner "doctor"** — can this machine run the Claude sandbox, and (only with your consent) fix it: macOS Seatbelt built-in / Linux+WSL2 needs `bwrap`+`socat` / native Windows → WSL2. Flagless = **FS-only preview** (the diagnosis, the exact absolute-path command it WOULD run, the exact `--apply <pm>:<pkgs>` consent tuple — runs nothing, never claims ready); `--verify` runs the unprivileged proof (a `bwrap` user-namespace smoke + `socat -V`) — the **only** source of a Linux "ready (verified)" claim; `--apply <pm>:<pkgs>` is the consent-gated privileged install (the tuple must equal the previewed plan; everything executed resolves to absolute paths inside `/usr/bin:/bin:/usr/sbin:/sbin`; the exact command is re-printed immediately before execution), then verifies. Honest loud degrades (unknown PM, untrusted location, nested-sandbox INDETERMINATE, root-unproven) and a stated restart step. Sits **outside every velocity auto-approve tier**; never auto-runs, never writes repo files, never commits. |
253
254
 
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '3.13.0'
6
+ version: '3.14.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -159,6 +159,10 @@ writer — read `${CLAUDE_SKILL_DIR}/references/modes/agents.md` before acting.
159
159
 
160
160
  writer — read `${CLAUDE_SKILL_DIR}/references/modes/hook.md` before acting.
161
161
 
162
+ ### Mode: state-block-guard
163
+
164
+ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/state-block-guard.md` before acting.
165
+
162
166
  ### Mode: bridge-settings
163
167
 
164
168
  guarded — read `${CLAUDE_SKILL_DIR}/references/modes/bridge-settings.md` before acting.
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "3.13.0",
6
+ "version": "3.14.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ // state-block-guard.mjs — the CONTINUATION-STALL detector, wired as a `Stop` hook.
3
+ //
4
+ // Why a hook and not a rule: every mechanised bar in this family gates FILES (the gate declaration,
5
+ // release-scan, doc-parity, the commit guard). The closing state block is CHAT output, which no gate
6
+ // can see — which is exactly why that contract recurred five times while file-level bars held.
7
+ //
8
+ // At Stop time the turn is ENDING. That single fact turns two closing shapes from "discouraged" into
9
+ // FALSE, so they can be judged mechanically:
10
+ // • the «what I need from you» slot answering "nothing" — a stopped turn always needs a resume, so
11
+ // the slot must NAME it. "Nothing needed" is honest only while work is actually running;
12
+ // • a first-person promise of imminent work in the closing block — the turn is over, so the work
13
+ // is not starting. This is the announce-and-stop shape.
14
+ // A promise GATED on something named (after your yes, when CI finishes) is honest and passes.
15
+ //
16
+ // The judgement is LEXICAL, and that is a layer with limits rather than a temporary weakness: it
17
+ // approximates "this condition gates that promise" by token order, and it can neither recognise a
18
+ // freshly invented wording for "nothing needed" nor parse a sentence. Where a rule to close one edge
19
+ // required a second classifier to decide what counts as a real ask, the rule was DELETED and its
20
+ // residual written down instead — twice. The mode contract lists every residual by name.
21
+ //
22
+ // HONEST LIMIT, stated because it bounds what this can claim: a Stop hook cannot un-send the turn it
23
+ // judges. This is DETECTION — it converts a silent recurrence into a loud one. It never blocks, and
24
+ // every anomaly path exits 0: a hook that fires on every turn must not become the blocker or the
25
+ // noise. Self-contained by contract (a placed copy runs on machines without the kit): no imports
26
+ // beyond node built-ins, dependency-free, Node >= 22, no side effects on import.
27
+ //
28
+ // THE CHANNEL IS PART OF THE CONTRACT. At exit 0 a Stop hook's stderr reaches the debug log and
29
+ // nobody else; the one user-visible lane is JSON on stdout carrying `systemMessage`. So the warning
30
+ // rides that field — a guard warning down an invisible channel would be decoration. The blocking
31
+ // fields (`decision`, `continue`, `stopReason`, `hookSpecificOutput`) are deliberately never emitted:
32
+ // blocking the stop would re-enter the model on a message already sent to the reader.
33
+
34
+ import { pathToFileURL } from 'node:url';
35
+
36
+ export const HOOK_EVENT_NAME = 'Stop';
37
+ const EXIT_OK = 0;
38
+
39
+ // The three slots, matched on their LABEL and ANCHORED TO A LINE START. The anchor is not cosmetic:
40
+ // a message that DISCUSSES this contract — which the sessions this guard was built for do constantly
41
+ // — mentions the labels inline, and an unanchored match would judge that prose instead of the real
42
+ // closing block. Russian is the dialogue language this contract was written for; the English twins
43
+ // keep the guard usable in an English-dialogue deployment.
44
+ const LINE_LEAD = '^[ \\t\\-•]*';
45
+ const SLOT_PATTERNS = Object.freeze({
46
+ now: new RegExp(`${LINE_LEAD}(?:что сейчас|where we are|state now)\\s*[::]`, 'gim'),
47
+ fromYou: new RegExp(`${LINE_LEAD}(?:что нужно от вас|что от вас|what i need from you|from you)\\s*[::]`, 'gim'),
48
+ next: new RegExp(`${LINE_LEAD}(?:что дальше|what(?:'s| is)? next|next)\\s*[::]`, 'gim'),
49
+ });
50
+
51
+ // Quoted and fenced material is EXAMPLE text, not the turn's own closing block. Stripped before any
52
+ // label search — otherwise pasting the very specimen this guard catches would make the guard judge
53
+ // the paste. Emphasis marks are stripped after, so `**Что сейчас:**` still parses.
54
+ // Scanned line by line rather than matched as one regex: an UNCLOSED fence must run to the end of
55
+ // the message. A pattern that only removes CLOSED fences would take an unfinished example's labels
56
+ // for a real block — judging a demonstration as if it were the turn, which is precisely the mistake
57
+ // this stripper exists to prevent. A closing fence must be at least as long as its opener and carry
58
+ // nothing but whitespace after it.
59
+ const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})/;
60
+ // The trailing `\r` is not an edge case: the text is split on `\n`, so on a CRLF host EVERY line
61
+ // ends in one. A close pattern that forbids it leaves every fence open, the stripper swallows the
62
+ // real closing block, and the guard goes blind on that whole platform — silently.
63
+ const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t\r]*$/;
64
+ const closesFence = (line, open) => {
65
+ const close = line.match(FENCE_CLOSE);
66
+ return close !== null && close[1][0] === open[0] && close[1].length >= open.length;
67
+ };
68
+ // One pass, appending in place. A reduce that rebuilt the kept array per line was quadratic in the
69
+ // message length, and this hook runs on EVERY turn end under a documented 10-second timeout.
70
+ const stripFences = (text) => {
71
+ const kept = [];
72
+ const state = { open: null };
73
+ for (const line of text.split('\n')) {
74
+ if (state.open !== null) {
75
+ if (closesFence(line, state.open)) state.open = null;
76
+ continue;
77
+ }
78
+ const opened = line.match(FENCE_OPEN);
79
+ if (opened === null) kept.push(line);
80
+ else state.open = opened[1];
81
+ }
82
+ return kept.join('\n');
83
+ };
84
+
85
+ const BLOCKQUOTE_LINE = /^[ \t]*>.*$/gm;
86
+ const EMPHASIS_MARKS = /[*_`]/g;
87
+ // A model writes «What’s next» and «I’ll» with a typographic apostrophe far more often than with the
88
+ // ASCII one, so matching only ASCII made the English half fail silently on ordinary output.
89
+ const APOSTROPHES = /[’‘ʼ‛]/g;
90
+ const toProse = (text) => stripFences(text)
91
+ .replace(BLOCKQUOTE_LINE, '')
92
+ .replace(EMPHASIS_MARKS, '')
93
+ .replace(APOSTROPHES, "'");
94
+
95
+ // A "nothing" answer, judged ONLY over the slot's FIRST CLAUSE — the answer proper. A slot that
96
+ // names a real ask and then adds "nothing else is needed" is honest and must stay passing, or the
97
+ // contract becomes unwritable. A dot ends a clause only before whitespace or the end of the slot:
98
+ // `README.md` is a file name, and treating its dot as a sentence end both hid banned forms behind a
99
+ // short prefix and split honest sentences in half. There is no character cap — a cap decides where
100
+ // an answer ends by counting, and a long preamble would carry a banned form past it unseen.
101
+ const CLAUSE_END = /;|[.!?](?=\s|$)/;
102
+ const openingAnswer = (slot) => {
103
+ const at = slot.search(CLAUSE_END);
104
+ return at === -1 ? slot : slot.slice(0, at);
105
+ };
106
+ // A rule excusing «…, ничего другого не нужно» was built, tightened twice, and then DELETED. Every
107
+ // version let a banned answer through behind some harmless prefix, and the next version would have
108
+ // needed an "is this a real ask" recogniser — a second lexical classifier with its own edge set. The
109
+ // residual is stated instead of coded: a comma-joined qualifier IS flagged, and the writer's fix is
110
+ // a clause break, which the first-clause rule already passes.
111
+
112
+ const NOTHING_FORMS = Object.freeze([
113
+ 'ничего',
114
+ 'ничем',
115
+ 'не требуется',
116
+ 'не нужно',
117
+ 'не нужен',
118
+ 'нет необходимости',
119
+ 'nothing',
120
+ 'none',
121
+ 'n/a',
122
+ 'no action',
123
+ ]);
124
+
125
+ // First-person promises of imminent work. Present tense counts: at Stop it describes work that is
126
+ // NOT running.
127
+ // Action verbs only. «считаю» and «смотрю» were dropped rather than qualified: they are as often
128
+ // stative («считаю задачу завершённой», «смотрю на вопрос как на закрытый») as they are promises, and
129
+ // a form that cannot tell the two apart buys detection with false flags.
130
+ // The English markers are pronoun+modal rather than action verbs, so they cannot tell «I'll start» from
131
+ // «I'll wait». Waiting is not work: it is what a turn that ENDS actually does, and it needs no gate to
132
+ // be honest. So a modal followed by a waiting verb is excluded rather than the modal being dropped —
133
+ // dropping it would need an open-ended list of every action verb English can put after it.
134
+ const WAITING_CONTINUATIONS = Object.freeze([
135
+ "i'll wait", 'i will wait', "i'll be waiting", "i'll stand by", 'i will stand by',
136
+ "i'll hold", 'i will hold', "i'll stay", 'i will stay', "i'll remain", 'i will remain',
137
+ ]);
138
+
139
+ const PROMISE_FORMS = Object.freeze([
140
+ 'беру', 'начинаю', 'перехожу', 'сажусь', 'продолжаю', 'иду', 'проверяю', 'пишу', 'строю',
141
+ 'планирую', 'разбираюсь',
142
+ "i'll", 'i will', "i'm going to", 'i am going to', 'i start', 'i begin', 'next i',
143
+ ]);
144
+
145
+ // A gate the promise may depend on. What is actually checked is TOKEN ORDER inside one segment: a
146
+ // gate is treated as excusing a promise when it appears in the same segment and earlier in it. That
147
+ // is a lexical approximation of dependency, not dependency itself, and it carries two named
148
+ // residuals — an honest TRAILING gate («беру, когда вы скажете») is flagged, and a gate belonging to
149
+ // an earlier comma-clause («если тест упадёт, сообщу, а сейчас начинаю…») wrongly excuses a promise
150
+ // that follows it. Both are accepted limits of a lexical layer, documented in the mode contract.
151
+ const CONDITIONAL_FORMS = Object.freeze([
152
+ 'после', 'когда', 'если', 'как только',
153
+ 'after', 'when', 'if', 'as soon as', 'once', 'pending',
154
+ ]);
155
+
156
+ // Segment boundaries for the promise/gate rule. Deliberately NOT the comma: «если вы согласны, беру
157
+ // класс» is one honest thought, and splitting it would flag the shape the rule exists to permit.
158
+ const PROMISE_SEGMENT_BREAK = /;|\n|[.!?](?=\s|$)/;
159
+
160
+ // Word-bounded matching, Unicode-aware. JavaScript's `\b` is ASCII-only and therefore useless here:
161
+ // with raw substring matching «не нужно» hides inside the honest «мне нужно ваше подтверждение»
162
+ // («м|не нужно»), so the most natural way to name a real ask was read as answering "nothing".
163
+ // The trailing exclusion covers the interrogative particle: «не нужно ЛИ добавить тест» asks a
164
+ // question, it does not decline help.
165
+ const WORD_CHAR = '\\p{L}\\p{N}';
166
+ const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
167
+ const boundedPattern = (phrase) => new RegExp(
168
+ `(?<![${WORD_CHAR}])${escapeForRegExp(phrase)}(?![${WORD_CHAR}])(?!\\s+ли(?![${WORD_CHAR}]))`,
169
+ 'iu',
170
+ );
171
+ const firstMatchAt = (haystack, needles) => needles.reduce((best, needle) => {
172
+ const at = haystack.search(boundedPattern(needle));
173
+ if (at === -1) return best;
174
+ return best === -1 ? at : Math.min(best, at);
175
+ }, -1);
176
+ const containsAny = (haystack, needles) => firstMatchAt(haystack, needles) !== -1;
177
+
178
+ const normalise = (value) => (typeof value === 'string'
179
+ ? value.replace(EMPHASIS_MARKS, '').replace(APOSTROPHES, "'").toLowerCase()
180
+ : '');
181
+
182
+ const SLOT_COUNT = Object.keys(SLOT_PATTERNS).length;
183
+
184
+ const collectLabels = (prose) => Object.entries(SLOT_PATTERNS)
185
+ .flatMap(([slot, pattern]) => [...prose.matchAll(pattern)]
186
+ .map((match) => ({ slot, start: match.index, bodyAt: match.index + match[0].length })))
187
+ .sort((a, b) => a.start - b.start);
188
+
189
+ // Labels are grouped into candidate BLOCKS rather than picked per slot: picking each slot's last
190
+ // occurrence independently splices a trailing incomplete block onto an earlier one and fabricates a
191
+ // block nobody wrote. A repeated slot abandons the candidate and RESTARTS it at that label, because
192
+ // the repeat is itself the plausible first label of a new block.
193
+ const groupLabels = (labels) => labels.reduce((state, label) => {
194
+ const candidate = state.current.some((held) => held.slot === label.slot) ? [label] : [...state.current, label];
195
+ return candidate.length === SLOT_COUNT
196
+ ? { current: [], completed: candidate }
197
+ : { current: candidate, completed: state.completed };
198
+ }, { current: [], completed: null });
199
+
200
+ // findStateBlock(text) → { now, fromYou, next } or null. The LAST STARTED candidate decides: if the
201
+ // message ends mid-block, the turn did not end on a block at all, and falling back to an earlier
202
+ // complete one would judge text the turn already moved past.
203
+ export const findStateBlock = (text) => {
204
+ if (typeof text !== 'string' || text.trim() === '') return null;
205
+ const prose = toProse(text);
206
+ const { current, completed } = groupLabels(collectLabels(prose));
207
+ if (current.length > 0 || completed === null) return null;
208
+ const block = {};
209
+ completed.forEach((entry, index) => {
210
+ const end = index + 1 < completed.length ? completed[index + 1].start : prose.length;
211
+ block[entry.slot] = prose.slice(entry.bodyAt, end).trim();
212
+ });
213
+ return block;
214
+ };
215
+
216
+ // decideStop({ closingText, requireBlock }) → { ok, reasons }. Never throws: junk input decides
217
+ // "no block", which is a reportable state rather than a crash. An ABSENT block is reported only
218
+ // under `requireBlock`: this kit does not mandate the three-part block, so warning about its absence
219
+ // by default would fire on nearly every turn of a project that never adopted it — noise, from a hook
220
+ // that runs on every single turn.
221
+ export const decideStop = (options) => {
222
+ const { closingText, requireBlock = false } = options ?? {};
223
+ const block = findStateBlock(closingText);
224
+ if (block === null) {
225
+ return requireBlock
226
+ ? {
227
+ ok: false,
228
+ reasons: ['no state block: the closing message must end with «что сейчас · что нужно от вас · что дальше»'],
229
+ }
230
+ : { ok: true, reasons: [] };
231
+ }
232
+ const reasons = [];
233
+ const answer = openingAnswer(normalise(block.fromYou));
234
+ if (answer.trim() === '' || containsAny(answer, NOTHING_FORMS)) {
235
+ reasons.push(
236
+ 'from-you slot answers "nothing": the turn is ENDING, so a resume from the maintainer IS required — name the real unblocker instead',
237
+ );
238
+ }
239
+ if (hasUngatedPromise(normalise(block.next))) {
240
+ reasons.push(
241
+ 'announce-and-stop: the what-next slot promises imminent first-person work while the turn ends — either do it in this turn, or state what the work waits on',
242
+ );
243
+ }
244
+ return { ok: reasons.length === 0, reasons };
245
+ };
246
+
247
+ const hasUngatedPromise = (nextSlot) => nextSlot.split(PROMISE_SEGMENT_BREAK).some((segment) => {
248
+ const promiseAt = firstMatchAt(segment, PROMISE_FORMS);
249
+ if (promiseAt === -1) return false;
250
+ if (containsAny(segment, WAITING_CONTINUATIONS)) return false;
251
+ const gateAt = firstMatchAt(segment, CONDITIONAL_FORMS);
252
+ return !(gateAt !== -1 && gateAt < promiseAt);
253
+ });
254
+
255
+ // "Exit 0 on every path" and "say nothing on every path" are DIFFERENT promises, and only the first
256
+ // was ever earned. A guard that cannot see the turn is BLIND, which is a real failure, and this
257
+ // family forbids silent ones — so a failure is reported like any other finding. Having nothing to
258
+ // judge is not a failure and stays silent: a hook that fires every turn would otherwise warn every
259
+ // turn on a host that simply delivers no closing text.
260
+ // A FAILURE and a FINDING are different claims and must not share a headline: reporting "the closing
261
+ // state block is defective" when no block was ever read would blame the writer for the guard's own
262
+ // blindness.
263
+ export const FAILURE = 'failure';
264
+ export const FINDING = 'finding';
265
+ const guardFailure = (detail) => ({ ok: false, kind: FAILURE, reasons: [detail] });
266
+
267
+ const parsePayload = (rawInput) => {
268
+ try {
269
+ return { input: JSON.parse(rawInput) };
270
+ } catch (error) {
271
+ return { error: `the Stop payload could not be parsed as JSON (${error.message})` };
272
+ }
273
+ };
274
+
275
+ // An ABSENT field means the host delivers nothing (silent, above). A PRESENT field of the wrong type
276
+ // is a CORRUPT payload and is reported — the two are only indistinguishable if you never look.
277
+ //
278
+ // `last_assistant_message` is the ONLY source. A transcript fallback was built and then DELETED: the
279
+ // transcript file is written asynchronously, so a lagging one can END on the previous turn's
280
+ // assistant entry with nothing after it, and no tail check can tell that apart from the current
281
+ // turn. A fallback that can be confidently wrong about WHICH TURN it read is worse than no fallback,
282
+ // because being confidently wrong is the one failure a detector must not have. A host that does not
283
+ // deliver the field therefore gets no detection — stated in the mode contract, and silent rather
284
+ // than warning on every single turn.
285
+ //
286
+ // An EMPTY string is delivered text, not an absent one: a turn that ended with no prose really did
287
+ // end without a closing block, and `--require-block` should be able to say so.
288
+ // runHook(raw, {requireBlock}) → a decision, or null when there is genuinely nothing to judge.
289
+ // Never throws.
290
+ export const runHook = (rawInput, deps) => {
291
+ const requireBlock = (deps ?? {}).requireBlock === true;
292
+ const parsed = parsePayload(rawInput);
293
+ if (parsed.error !== undefined) return guardFailure(parsed.error);
294
+ const input = parsed.input;
295
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
296
+ return guardFailure('the Stop payload is not a JSON object');
297
+ }
298
+ if (!Object.prototype.hasOwnProperty.call(input, 'last_assistant_message')) return null;
299
+ const delivered = input.last_assistant_message;
300
+ if (typeof delivered !== 'string') {
301
+ return guardFailure(`the payload's last_assistant_message is ${delivered === null ? 'null' : typeof delivered}, not a string`);
302
+ }
303
+ return decideStop({ closingText: delivered, requireBlock });
304
+ };
305
+
306
+ // The stream is a parameter so the read path itself is exercisable, not just its caller.
307
+ export const readStdin = async (stream = process.stdin) => {
308
+ const chunks = [];
309
+ for await (const chunk of stream) chunks.push(chunk);
310
+ return Buffer.concat(chunks).toString('utf8');
311
+ };
312
+
313
+ const FINDING_HEADER = 'state-block-guard — the closing state block is defective (CONTINUATION-STALL):';
314
+ const FAILURE_HEADER = 'state-block-guard — this turn was NOT judged; the guard could not see it:';
315
+ const formatWarning = (decision) => [
316
+ decision.kind === FAILURE ? FAILURE_HEADER : FINDING_HEADER,
317
+ ...decision.reasons.map((reason) => `• ${reason}`),
318
+ ].join('\n');
319
+
320
+ // The ONE flag. A misspelling must never be read as "run in the default mode": that would silently
321
+ // downgrade the strictness the user deliberately opted into, which is the failure an opt-in must not
322
+ // have. So an unrecognised argument refuses the turn's judgement LOUDLY — and still exits 0.
323
+ export const REQUIRE_BLOCK_FLAG = '--require-block';
324
+ export const parseArgs = (argv = []) => ({
325
+ requireBlock: argv.includes(REQUIRE_BLOCK_FLAG),
326
+ unknown: argv.filter((arg) => arg !== REQUIRE_BLOCK_FLAG),
327
+ });
328
+
329
+ export const main = async ({
330
+ argv = process.argv.slice(2),
331
+ readInput = readStdin,
332
+ write = (line) => process.stdout.write(line),
333
+ } = {}) => {
334
+ // Exit 0 on EVERY path, always. This guard reports; it is never the thing that stops a session.
335
+ try {
336
+ const { requireBlock, unknown } = parseArgs(argv);
337
+ if (unknown.length > 0) {
338
+ write(`${JSON.stringify({
339
+ systemMessage: `state-block-guard: unrecognised argument ${unknown.join(' ')} — refusing to judge this turn rather than silently falling back to the default mode; ${REQUIRE_BLOCK_FLAG} is the only argument accepted`,
340
+ })}\n`);
341
+ return EXIT_OK;
342
+ }
343
+ const decision = runHook(await readInput(), { requireBlock });
344
+ if (decision !== null && decision.ok === false) {
345
+ write(`${JSON.stringify({ systemMessage: formatWarning(decision) })}\n`);
346
+ }
347
+ } catch (error) {
348
+ // Never silent, never fatal: the failure is reported and the exit code stays 0.
349
+ try {
350
+ write(`${JSON.stringify({
351
+ systemMessage: formatWarning({ kind: FAILURE, reasons: [error?.message ?? String(error)] }),
352
+ })}\n`);
353
+ } catch {
354
+ // The reporting channel itself is gone; there is nowhere left to report it to.
355
+ }
356
+ }
357
+ return EXIT_OK;
358
+ };
359
+
360
+ // `process.exitCode`, never `process.exit()`: an immediate exit can truncate a pending stdout write
361
+ // to a pipe, and that single warning is the entire product of this hook.
362
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
363
+ if (isDirectRun) main().then((code) => { process.exitCode = code; });
@@ -5,7 +5,7 @@ The opt-in **gate-approval PreToolUse hook** — the family's third `.claude/` w
5
5
  - **Auto-approve** a command **byte-identical** (leading/trailing trim only — no whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern matching, ever: patterns are what made AD-021 auto-seeding rejected) to a gate `cmd` declared in `docs/ai/gates.json` — read **LIVE on every call** (editing gates.json never needs re-wiring; one declaration, two consumers with `${CLAUDE_SKILL_DIR}/references/modes/gates.md`) — invoked **from the project root** (gates run from the root by contract; the same bytes from a subdirectory are NOT approved) and under `default`/`acceptEdits` permission mode (an approval never loosens `plan`/`bypassPermissions`).
6
6
  - **Ask** on a command whose leading tokens match the velocity **seeded read-only core** when it carries the documented runtime residual — output redirection, command substitution, or the bounded `--output` write-flag family — surfacing a human prompt even where a seeded allow rule would have silently approved (**hook `ask` overrides an allow rule — proven live**: on Claude Code 2.1.185 a seeded `Bash(git log:*)` silently wrote a file via `git log --output=…`; with the hook wired the same call prompts). Detection is string-level and conservative: a quoted metacharacter may over-ask, never under-allow.
7
7
  - **Auto-approve a read-only COMPOUND** (opt-in — dark unless enabled): when `docs/ai/lanes.json` sets `{ "readLane": true }` (read **LIVE on every call**), a command whose EVERY separator-split segment is a plain seeded read-only core command, carrying **zero shell metaprogramming** anywhere (no `$`/expansion, quoting, backslash, brace, glob, redirection, substitution, `--output`, env-assignment prefix, or backgrounding), is auto-approved — a conservative **closed-world** allow (any doubt falls through, never a widening). Mode-fenced like gate auto-approval; **cwd-agnostic** (a read is a read from any directory). It runs AFTER the residual ask, so a core command that carries a residual still prompts (most-restrictive-wins). The lane is **bounded by the frozen audited read-only core** (the set velocity seeds) — a **standalone opt-in grant**, never a command OUTSIDE that audited core; enabling it auto-approves compounds (and singles) of that audited core **regardless of which of those commands you seeded** as individual settings rules (that is the trust the opt-in consent covers — not strictly a subset of your current settings).
8
- - **Stay silent otherwise** — the normal permission flow proceeds unchanged. The hook **never emits `deny`**; nothing is hard-blocked.
8
+ - **Stay silent otherwise** — the normal permission flow proceeds unchanged. The gate-approval hook **never emits `deny`**; nothing is hard-blocked.
9
9
 
10
10
  **Honest residual status (AD-037):** current engine builds already intercept `>` redirection and `$()` substitution upstream (observed headless on 2.1.185); the **`--output` family was proven open** and is the seam this hook demonstrably closes. The guard still covers all three documented classes (defense-in-depth — engine behavior may vary across surfaces/versions). **Fail-safe, decoupled:** a missing/broken/invalid `gates.json` disables ONLY gate auto-approval — the residual guard keeps running; every anomaly path exits 0 (the hook is never the blocker or the noise — the `gates` runner reports a broken declaration at its own point of use). **Not a sandbox:** it closes the named residual for the seeded core and auto-approves declared gates; it does not police arbitrary commands or user-added rules.
11
11
 
@@ -27,3 +27,5 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <
27
27
  **Invariants:** the base flow writes ONLY `.claude/hooks/agent-workflow-gates.mjs` + `.claude/settings.json`; `--read-lane` writes ONLY `docs/ai/lanes.json` (never settings, never `gates.json`) · never `settings.local.json` · never commits · exact-match approval only (no patterns) · never `deny` · never auto-wired by `init`/`upgrade` (placement stays opt-in — the AD-011/AD-034 boundary: init/upgrade may refresh placed things, never place new ones).
28
28
 
29
29
  **Exit codes:** `0` done / dry-run (incl. the reported diverged-but-wired state); `1` a precondition STOP; `2` bad arguments.
30
+
31
+ **The kit's other hook.** `state-block-guard` (see `${CLAUDE_SKILL_DIR}/references/modes/state-block-guard.md`) is a separate, unrelated `Stop` hook that judges the closing state block of a turn. It has no writer (you paste its wiring), it never approves or denies anything, and nothing here places or wires it — the two hooks share only the `.claude/` directory they live in.
@@ -0,0 +1,188 @@
1
+ ### Mode: state-block-guard
2
+
3
+ The opt-in **closing-state-block detector**, wired as a Claude Code `Stop` hook. It ships as a
4
+ **self-contained** runtime at `references/hooks/state-block-guard.mjs` (no kit imports — a placed
5
+ copy keeps working if the kit is uninstalled) and it **writes nothing**: this mode is read-only, and
6
+ the wiring is yours to paste (see *No writer yet*, below).
7
+
8
+ **Read this first — what it is and is not.** A `Stop` hook runs when a turn has already ENDED, so it
9
+ **cannot un-send the message it judges**. This is **DETECTION, not prevention**. What it buys is
10
+ exactly one thing, and the thing is worth a release: a defect that previously depended on a human
11
+ re-reading every closing message becomes an immediate, visible warning. Any surface that describes
12
+ this as preventing the defect is describing it wrongly.
13
+
14
+ **What it detects.** Two shapes, both of which are not merely *discouraged* at Stop time but
15
+ **provably false** — which is what makes them mechanically judgeable at all:
16
+
17
+ - **The «what I need from you» slot answering "nothing".** A turn that ends is by definition idle, so
18
+ a resume from the reader IS required; a slot saying otherwise states something untrue. Judged over
19
+ the slot's **first clause only** — a slot that names a real ask and then, after a `;` or a full
20
+ stop, adds «ничего другого не нужно» is honest and passes. A COMMA does not open a new clause, so
21
+ the comma-joined version of that sentence is flagged; see the residuals. Hedged forms count: «не
22
+ требуется», «ваше присутствие не требуется прямо сейчас», «nothing right now», «n/a».
23
+ - **Announce-and-stop.** The «what next» slot promising first-person imminent work («беру…»,
24
+ «начинаю…», «I'll start…») while the turn ends. A promise **gated on something named** («после
25
+ вашего да — беру…», «once CI finishes…») states a dependency instead of a false start, and passes.
26
+
27
+ **A message with no recognisable block at all is SILENT by default.** This kit does not mandate the
28
+ three-part closing block — it is a per-project dialogue contract — so warning whenever a block is
29
+ absent would fire on nearly every turn of a project that never adopted it, and a hook that runs on
30
+ every single turn must not become that noise. Pass `--require-block` in the wiring to turn the
31
+ absent-block report on; the honest residual of the default is stated plainly below.
32
+
33
+ **What it does NOT judge.** Quoted material (`>` lines) and fenced code blocks are stripped before
34
+ anything is matched, and each slot label must start its own line. Otherwise a message that *discusses*
35
+ this contract — pasting the very specimen the guard catches — would be judged on the paste instead of
36
+ its own closing block. Labels are then grouped into whole blocks — never picked one slot at a time,
37
+ which would splice a stray trailing label onto an earlier block and judge a block nobody wrote. The
38
+ **last started** group decides: if the message ends mid-block, the turn did not end on a block at
39
+ all, and `--require-block` says so rather than falling back to an earlier one the turn had moved past.
40
+
41
+ **How it reports (the channel is part of the contract).** At exit 0 a `Stop` hook's **stderr goes to
42
+ the debug log and is seen by nobody**; the one user-visible lane is JSON on stdout carrying
43
+ `systemMessage`. The guard emits exactly that:
44
+
45
+ ```json
46
+ {"systemMessage": "state-block-guard — the closing state block is defective (CONTINUATION-STALL):\n• …"}
47
+ ```
48
+
49
+ The same channel carries FAILURES, under their own headline — reporting «the closing state block is
50
+ defective» when no block was ever read would blame the writer for the guard's own blindness.
51
+ «Exit 0 on every path» and «say nothing on every path» are different promises, and only the first is
52
+ kept: if the guard cannot see the turn — the payload will not parse, is not a JSON object, or
53
+ carries a `last_assistant_message` that is present but not a string — it is blind, and it says so
54
+ instead of going quiet. An ABSENT field is different: that is a host delivering nothing, and it stays
55
+ silent.
56
+
57
+ It **never** emits `decision`, `continue`, `stopReason`, or `hookSpecificOutput`, and it **exits 0 on
58
+ every path** — including every failure path. A hook that runs on every single turn must never become
59
+ the blocker or the noise. Blocking the stop would re-enter the model on a message the reader has
60
+ already seen, which is not what a detector is for; the payload does carry `stop_hook_active`, so a
61
+ future intervening variant has the loop guard it would need, but that variant is deliberately not
62
+ this one.
63
+
64
+ **Where it reads the closing message — one source, deliberately.** The `last_assistant_message` field
65
+ of the `Stop` payload, and nothing else. A `transcript_path` fallback was built and then **removed**:
66
+ the transcript file is written asynchronously, so a lagging one can END on the previous turn's
67
+ assistant entry with nothing after it, and no check on the file can tell that apart from the current
68
+ turn. A fallback that may be confidently wrong about WHICH TURN it read is worse than no fallback,
69
+ because being confidently wrong is the single failure a detector must not have.
70
+
71
+ The consequence is stated rather than hidden: **a host that does not deliver `last_assistant_message`
72
+ gets no detection at all**, silently — not a warning on every turn, which is what a host-shaped
73
+ condition would produce. An EMPTY delivered message is different: it is text, so a turn that ended
74
+ with no prose is judged as having no block, which `--require-block` will report.
75
+
76
+ **Language.** The slot labels and both banned sets carry Russian and English twins, because the
77
+ contract this enforces was written for a Russian-dialogue deployment. A deployment in another
78
+ dialogue language gets no detection until its labels are added — that is a real limit, not a
79
+ configuration you can set today.
80
+
81
+ **No writer yet — and why, plainly.** Every other placed thing in this kit arrives through a
82
+ consent-gated writer. This one does not, deliberately:
83
+
84
+ - extending the gate-approval writer (`tools/gate-hook.mjs`) would grow a second placement path
85
+ through the family's **highest-blast-radius** component — the one that wires command
86
+ auto-approval — for the benefit of a detector;
87
+ - a second dedicated writer would duplicate that writer's hardened placement discipline (symlink
88
+ refusal, malformed-settings refusal, unknown-script refusal, merge-don't-clobber), which this
89
+ project's own DRY rule forbids.
90
+
91
+ The wiring is eight lines of JSON, and hand-editing `settings.json` from a paste-ready block is the
92
+ form this family already sanctions. When the detector has earned a writer in real use, it gets one.
93
+
94
+ **Wiring it (paste-ready, two steps).**
95
+
96
+ 1. Copy the runtime into your project (it is self-contained; the copy keeps working without the kit).
97
+ **Check before you copy** — this is a plain copy, not a guarded writer, so the safety is yours:
98
+
99
+ ```
100
+ ls -ld .claude .claude/hooks
101
+ ls -l .claude/hooks/state-block-guard.mjs
102
+ ```
103
+
104
+ Stop if `.claude` or `.claude/hooks` is a **symlink** (a copy would write through it to somewhere
105
+ you did not choose), and stop if the target already exists — including a dangling symlink. Only
106
+ then:
107
+
108
+ ```
109
+ mkdir -p .claude/hooks
110
+ cp "${CLAUDE_SKILL_DIR}/references/hooks/state-block-guard.mjs" .claude/hooks/state-block-guard.mjs
111
+ ```
112
+
113
+ To UPDATE an existing copy, `diff` it against the bundled file first and replace it deliberately.
114
+ No `cp` flag makes this safe for you: `-n` is not portable in the same way everywhere, and none of
115
+ them refuse a symlinked parent directory.
116
+
117
+ 2. Merge this into `.claude/settings.json` — **merge, do not clobber.** Three cases, and getting the
118
+ third wrong silently disables someone else's hook:
119
+ - no `hooks` key → add the whole object below;
120
+ - a `hooks` key WITHOUT `Stop` (for example only the gate-approval `PreToolUse` entry) → add the
121
+ `Stop` key beside it;
122
+ - a `hooks.Stop` array that ALREADY EXISTS → **append one element to that array**, never replace
123
+ it and never write a second `Stop` key. If `hooks.Stop` is present but is not an array, stop and
124
+ fix that by hand first — pasting into it would destroy whatever is there.
125
+
126
+ ```json
127
+ {
128
+ "hooks": {
129
+ "Stop": [
130
+ {
131
+ "hooks": [
132
+ {
133
+ "type": "command",
134
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/state-block-guard.mjs\" --require-block",
135
+ "timeout": 10
136
+ }
137
+ ]
138
+ }
139
+ ]
140
+ }
141
+ }
142
+ ```
143
+
144
+ Drop `--require-block` if your project has not adopted the three-part closing block and you only want
145
+ the two lying-slot checks. `--require-block` is the **only** argument accepted: an unrecognised one
146
+ makes the guard refuse to judge the turn and say so, rather than quietly running in the weaker mode
147
+ you did not choose.
148
+
149
+ A `Stop` hook is read at session start, so it becomes live in the **next** session, not the current
150
+ one.
151
+
152
+ **Trust posture (state it plainly when asking consent):** the guard reads the closing assistant
153
+ message of each turn and writes one warning line. It runs a Node process on every turn end, it does
154
+ not read your repository, it makes no network call, and it can approve nothing — its blast radius is
155
+ the warning text. It is not a sandbox and not a permission control.
156
+
157
+ **Honest residuals:**
158
+
159
+ - **Detection, not prevention** — restated because it is the one thing that must not blur.
160
+ - **The judgement is LEXICAL, and that is a layer with limits, not a temporary weakness.** It matches
161
+ slot labels and phrase sets on Unicode-aware word boundaries, so an honest «мне нужно ваше
162
+ подтверждение» is not read as «не нужно» and an interrogative «не нужно ли…» is not read as a
163
+ refusal. It cannot parse a sentence, and it cannot recognise a wording it has never been told
164
+ about. Two rules that tried to close an edge here were **deleted rather than tightened a third
165
+ time**, because each next version needed a second classifier — the residuals below are what
166
+ replaced them. A named specimen of the cost: «подтвердите, что ничего не упущено» is a real ask
167
+ and is FLAGGED, because the banned word sits inside it. Phrase the ask without the word.
168
+ - **A comma-joined qualifier is flagged.** «одно да, ничего другого не нужно» warns; the same
169
+ sentence with a `;` or a full stop passes. The rule that tried to exempt the comma form kept
170
+ letting a real "nothing" through behind a harmless prefix, so it was removed and the false flag is
171
+ accepted instead. It costs one line and names its own fix.
172
+ - **A condition is bound to a promise by TOKEN ORDER inside one segment, which is an approximation.**
173
+ «после вашего да — беру класс» passes; «беру класс, а если тест упадёт, сообщу» is flagged. Two
174
+ known misreadings follow from the approximation, both accepted: an honest TRAILING gate («беру,
175
+ когда вы скажете») is flagged, and a gate belonging to an earlier comma-clause («если тест упадёт,
176
+ сообщу, а сейчас начинаю…») wrongly excuses the promise after it. Leading with the gate avoids the
177
+ first; the second is a miss this layer cannot close without parsing.
178
+ - **The English side is weaker than the Russian side, structurally.** Russian promises are action
179
+ verbs («беру», «начинаю»); English ones are pronoun+modal («I'll», «I will»), which cannot tell
180
+ starting from waiting. Waiting is explicitly excluded — «I'll wait for your approval» passes,
181
+ because waiting is what a turn that ends actually does — but the exclusion is a list, and an
182
+ unusual way of saying "I am waiting" will be flagged.
183
+ - **A host that does not deliver `last_assistant_message` gets no detection**, silently. There is no
184
+ transcript fallback, on purpose — see above.
185
+ - **Without `--require-block`, a turn that drops the block entirely is not detected.** That is the
186
+ cost of not warning every turn in projects that never adopted the block.
187
+ - **Removing the wiring silently removes the detector.** There is no rung that notices a hook that
188
+ stopped being wired.
@@ -140,6 +140,13 @@ const CATALOG = [
140
140
  kind: WRITER,
141
141
  oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
142
142
  },
143
+ {
144
+ key: 'state-block-guard',
145
+ invocation: invocationOf('state-block-guard'),
146
+ group: 'Configure',
147
+ kind: READ_ONLY,
148
+ oneLine: 'Warn when a turn ends on a defective closing state block — a "nothing needed from you" answer or a promise of work that never started (Claude Code Stop hook; detection, never prevention; you paste the wiring).',
149
+ },
143
150
  {
144
151
  key: 'bridge-settings',
145
152
  invocation: invocationOf('bridge-settings'),