@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 +32 -0
- package/README.md +1 -0
- package/SKILL.md +5 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +363 -0
- package/references/modes/hook.md +3 -1
- package/references/modes/state-block-guard.md +188 -0
- package/tools/commands.mjs +7 -0
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "3.
|
|
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; });
|
package/references/modes/hook.md
CHANGED
|
@@ -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.
|
package/tools/commands.mjs
CHANGED
|
@@ -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'),
|