@sabaiway/agent-workflow-kit 3.12.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 +92 -0
- package/README.md +2 -1
- 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/commit-guard.md +14 -7
- package/references/modes/hook.md +3 -1
- package/references/modes/state-block-guard.md +188 -0
- package/tools/commands.mjs +8 -1
- package/tools/commit-guard.mjs +139 -8
- package/tools/core-evidence.mjs +229 -18
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,98 @@ 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
|
+
|
|
39
|
+
## 3.13.0 — the commit guard proves the INDEX carries the verified tree (AD-074)
|
|
40
|
+
|
|
41
|
+
`commit-guard --check` now refuses an index that lags the working tree, so «verified» and «about to
|
|
42
|
+
be committed» are the same bytes by construction instead of by operator discipline.
|
|
43
|
+
|
|
44
|
+
The gap it closes was real and it fired: the gates and the tree fingerprint both describe the
|
|
45
|
+
WORKING tree, while `git commit` builds the commit from the INDEX alone — and against an
|
|
46
|
+
otherwise-empty index the fingerprint is byte-identical whether a hunk sits staged or unstaged. A
|
|
47
|
+
lagging index therefore passed every gate and every guard arm, and the commit shipped a strict
|
|
48
|
+
subset of what was verified. The kit's own 3.12.0 release commit did exactly that: a fix landed
|
|
49
|
+
without its regression arm, and only the publish dispatcher's dirty-tree refusal caught it, one step
|
|
50
|
+
later.
|
|
51
|
+
|
|
52
|
+
- **A new FIRST refusal, ahead of the fingerprint.** It fires on tracked paths differing
|
|
53
|
+
index↔worktree or on reviewable untracked-not-ignored paths — the same never-committable stat
|
|
54
|
+
filter the fingerprint applies, so ignored paths and device/FIFO/socket nodes never refuse. It runs
|
|
55
|
+
first because its recovery re-stages the tree and re-mints the receipt, which re-decides every arm
|
|
56
|
+
below it.
|
|
57
|
+
- **The message is actionable and bounded.** Offending paths are named up to a cap with the
|
|
58
|
+
remainder stated, each rendered through the same escaper the review-state report uses, so no
|
|
59
|
+
filename can break or forge an output line. The recovery is the complete whole-tree sequence
|
|
60
|
+
(`git add -A`, re-run `--final`, commit the whole tree) — a truncated list cannot serve as a
|
|
61
|
+
complete `git add -- <paths>` argument.
|
|
62
|
+
- **The probes cannot be blinded by configuration or index bits.** Entries carrying `skip-worktree`
|
|
63
|
+
or `assume-unchanged` are invisible to `git diff`, so they are compared directly against the
|
|
64
|
+
worktree — type, symlink target, executable bit where `core.fileMode` applies, and the blob oid
|
|
65
|
+
through git's own clean filters. A de-materialised skip-worktree path is an ordinary sparse
|
|
66
|
+
checkout and never refuses; a missing assume-unchanged path does. The submodule probe forces
|
|
67
|
+
`--ignore-submodules=none`, so `diff.ignoreSubmodules` cannot erase a dirty submodule either.
|
|
68
|
+
- **A tracked submodule the index cannot prove current is named separately, with its own recovery**
|
|
69
|
+
(commit or clean inside the submodule, then stage the gitlink): a root-level `git add -A` cannot
|
|
70
|
+
reach a submodule's own worktree, so offering it there would be a recovery known in advance to
|
|
71
|
+
fail. A submodule whose gitlink itself carries one of those index bits is not probed at all — it
|
|
72
|
+
lags by construction. That reduction is deliberate: three review rounds each found a new way for a
|
|
73
|
+
nested probe to answer "clean" wrongly, so the guard stops asking rather than accumulate patches.
|
|
74
|
+
It stays a converging refusal (clear the bit and the guard falls silent), and an unflagged
|
|
75
|
+
submodule is judged exactly as before.
|
|
76
|
+
- **Fail-closed.** An undecidable git probe refuses with its own named cause. The guard's claim is
|
|
77
|
+
that the committed bytes ARE the verified bytes; it cannot make that claim about a tree it failed
|
|
78
|
+
to read.
|
|
79
|
+
|
|
80
|
+
**Behaviour change worth knowing:** a deliberate partial commit is now blocked. `git commit --only
|
|
81
|
+
<path>` hands the hook a temporary index carrying less than the verified tree — precisely the
|
|
82
|
+
blindness this closes — so it refuses. No opt-out flag exists, deliberately: a flag that suspends
|
|
83
|
+
the arm would suspend the guard's whole claim. `git commit --no-verify` remains the stated residual.
|
|
84
|
+
`git commit -a` is unaffected when it captures the whole verified tree, and refuses when a reviewable
|
|
85
|
+
untracked path would be left behind.
|
|
86
|
+
|
|
87
|
+
The tree fingerprint itself is unchanged — making it stage-sensitive would have closed the same gap
|
|
88
|
+
at the cost of the lockstep with the wrappers' bash twin. Internally, one new computation of the
|
|
89
|
+
index↔worktree split now serves both this arm and `isTreeClean`, so the two can never disagree.
|
|
90
|
+
|
|
91
|
+
**Stated residual.** This makes the COMMIT capture the whole current working tree; it does not make
|
|
92
|
+
the RECEIPT unforgeable. The fingerprint payload still runs its diffs without
|
|
93
|
+
`--ignore-submodules=none`, so under `diff.ignoreSubmodules=all` a submodule can be changed and its
|
|
94
|
+
gitlink staged after a green final run while the fingerprint stays put, and the stale receipt is
|
|
95
|
+
reused. That is a receipt collision, not an under-capture — no commit ships less than the working
|
|
96
|
+
tree because of it — and closing it means moving the node payload and both bash twins in one
|
|
97
|
+
release. Tracked as its own class, deliberately not folded here.
|
|
98
|
+
|
|
7
99
|
## 3.12.0 — `--resume` tolerates the session's work: the verify proves per placed path (AD-073)
|
|
8
100
|
|
|
9
101
|
`provision --resume` no longer refuses a satellite you have worked in. The closing slice of the
|
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
|
|
|
240
240
|
| `/agent-workflow-kit grounding` | any time | **grounded-review facts assembler** — mechanizes populating `agy-review --facts @f`: slices your entry-point's **Hard Constraints** section verbatim (exactly one match, else a loud stop) and/or a plan's decision-bearing sections (`## Approach` + `## Verification` required, `## Decisions (locked)` when present; duplicates stop), under the same byte budget the agy wrapper enforces (minus `--reserve-bytes` for the artifact share), with a loud tail-trim on overflow. `--autonomy` (AD-044) appends the COMPUTED effective autonomy policy from the git-top `docs/ai/autonomy.json` (every red-line + per-activity level, stated source line; absent file → the computed defaults ARE the policy, exit 0; a malformed policy fails CLOSED, exit 1). Prints to stdout; `--out` writes **one scratch file only** — system-temp outside the repo ($TMPDIR / /tmp, rewritable) or a **fresh** gitignored in-repo path (create-only, exclusive write; an existing in-repo file, even gitignored, is refused — the `.env` clobber class); tracked, not-ignored-in-repo, other outside-repo, and symlink/non-regular destinations are all refused. Never commits, never runs a subscription CLI. |
|
|
241
241
|
| `/agent-workflow-kit core-evidence` | any time | **the ONE loop-evidence writer** (strip-the-kit) — every core evidence record lands in a single append-only JSONL store inside the git dir (never committable; versioned schema, latest-per-key supersession, byte-identical duplicates refused, malformed lines fail every reader closed). `red-proof "<file>#<pattern>"` declares an observed-red **BEFORE a bugfix** (N/N red runs + content custody + base + the pre-fix fingerprint; green/mixed/timeout are DISTINGUISHED refusals — nothing written); `degrade --backend --reason` is the ONLY escape for an unavailable review backend (per-tree, never all backends); `summary` renders the whole loop state statelessly (gate result · per-backend verdicts · red-proofs · degrades) — no ledger, no rounds, nothing remembered. Honest residual: records are forgeable — self-discipline, not a security boundary. Never commits, never runs a subscription CLI. |
|
|
242
242
|
| `/agent-workflow-kit coverage-check` | any time | **the final-run checker** (D3(c)+(d)) — reads the lcov the declared `unit-tests` gate produced at the FIXED git-dir path and fails on any uncovered CHANGED executable Node line (listed `file:line`; a changed file absent from the map is a file-level red; out-of-domain/unsupported files are LISTED — the claim narrowed honestly); VERIFIES every current-base red-proof declaration (bound test exists · custody hash unchanged · green N/N now · pre-fix fingerprint differs); prints `lcov-sha256=<hex|none>` of the exact bytes it consumed — the sha the `--final` receipt binds and re-hashes. An absent lcov is a LOUD `skipped-no-lcov`; a symlinked path is a refusal. `--check` is the gate exit code — declare it as the LAST gate (`run-gates --final` refuses otherwise). Read-only. |
|
|
243
|
-
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
243
|
+
| `/agent-workflow-kit commit-guard` | any time | **the read-only pre-commit guard** (D10) — makes the commit capture the whole current working tree, so «verified» and «about to be committed» are the same bytes (the receipt itself has a stated residual — see the mode doc). FIRST it refuses an **INDEX that lags the verified working tree** (the gates and the fingerprint describe the WORKING tree while `git commit` takes the INDEX alone, and the fingerprint cannot tell them apart — so a lagging index used to ship a strict subset of what was verified): unstaged tracked paths or reviewable untracked-not-ignored paths, named up to a bounded cap with the remainder stated, a dirty tracked **submodule** named separately with its own recovery, and fail-closed on an undecidable probe. This deliberately blocks a partial commit. Then it binds the LATEST completed `run-gates --final` receipt to the EXACT current tree: refuses on a missing/red/stale receipt, fingerprint drift under the run, a dangling later attempt, declaration content drift, evidence-hash or lcov drift, or unsatisfied review obligations (the same review-state decision, recomputed over a sanitized env — forged out-of-repo stores never satisfy). Re-runs NO gate or test. Wire it into `.git/hooks/pre-commit` (the installer writes the RESOLVED invocation). `git commit --no-verify` stays the stated residual. |
|
|
244
244
|
| `/agent-workflow-kit recommendations` | any time (every `upgrade` ends with it) | **read-only deployment advisor** (AD-044) — computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, OS sandbox unavailable, gates undeclared, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe) and renders **verdict-first**: one composed verdict line (does anything need attention?), then each item as **{severity · what · one-line benefit · an optional `recipe:` line (the sandbox-lane live recipe, or the worktrees-dir hand-apply-first grant advice) · the exact consent-gated apply one-liner}**. The agent PRESENTS the section in the user's conversational language — every fact and count, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request — and runs EXACTLY the rendered one-liners only on your yes, surfacing each item's posture note first. Renders **present-even-when-empty** (`no recommendations — flow optimal.`); a failed probe degrades to a stated skip line. Registry strings are fact-true frozen one-line data (posture/risk notes live in the mode doc at the consent moment); the kit never seeds `sandbox.network.allowedDomains` / `filesystem.allowWrite` (**HAND-APPLY** territory), and the sandbox-lane item's convergence is a neutral fingerprint acknowledgement recorded by a consent-gated ack writer into `docs/ai/acks.json` — never a security key (the recipe is documented per bridge in `capability.json` `networkHosts` + `writableDirs`). `--cwd` is required (the target project is explicit); never writes, never commits, never runs a subscription CLI. |
|
|
245
245
|
| `/agent-workflow-kit doc-parity` | any time | **read-only doc-parity lint** (AD-049) — kills the doc-drift class where a mode-contract doc silently lags a code constant (a `--check` doc still reading `300` after the diff cap moved to `400`): a **closed, exported registry** binds each live constant (review caps, schema versions, the ledger's own class/scope vocabulary, and the autonomy-doctor EXIT/status/trusted-dir contract) to the exact token its `references/modes/*.md` contract must carry, and asserts the CURRENT value renders into every bound file — a drifted doc, an unreadable file, or an absent token **fails closed**. The values are sourced from the live imports (never re-typed), so the lint can't itself go stale; adding a binding is adding a checked entry (closed-world, edit-safe). `--check` is a gate exit code for `docs/ai/gates.json`. Never writes, never commits, never runs a subscription CLI. |
|
|
246
246
|
| `/agent-workflow-kit worktrees` | any time | **parallel feature worktrees** — run several features in DIFFERENT agent sessions on one repo, zero interference on working-tree files (the ONE exception is the dependency cache, below): `provision <slug> --plan <file>` creates a sibling git worktree on branch `aw/<slug>` and populates it (registry-derived footprint copy-if-missing — a tracked file is never overwritten; EXACTLY ONE seeded feature plan; the `handoff-<slug>.md` record from minute zero; `node_modules` symlinked where the link stays ignored — a shared MUTABLE dependency cache: writes through it hit MAIN's node_modules; for isolation run the printed isolated-install command (`--install` only PRINTS it; on `--resume` run the printed unlink-first recovery first); absolute root-pinned gate commands rebased on untracked copies only, and only while their bytes equal the MAIN source or its rebased form — user-modified copies stay untouched); `list` is read-only (slug, branch, base, dirty, handoff); `land <slug> --prepare` locks the common git dir, fail-closes on divergence or incomplete satellite state, transfers the complete accepted satellite diff onto a CLEAN main, runs sync plus the declared gates, and reports HEAD/TRANSFER/PREPARED OIDs — the commit ALWAYS stays a dialogue ask; `cleanup <slug>` takes the same lock and removes a LANDED worktree only after live landed-verification against main HEAD, while `--abandon` is the ONE destructive arm (destroys unlanded work; **no preview step** on any writer). The parent dir is the `docs/ai/worktrees.json` `parentDir` setting (default: the repo's sibling parent); an unwritable parent degrades to printed maintainer-pasted commands, and the one-time host consent that makes it promptless surfaces via `recommendations`. Never commits, never pushes, never runs a subscription CLI. |
|
|
@@ -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; });
|
|
@@ -4,16 +4,23 @@ The **read-only pre-commit guard** (strip-the-kit D10) — the last line of the
|
|
|
4
4
|
|
|
5
5
|
Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` — it refuses, each with a named recovery, on:
|
|
6
6
|
|
|
7
|
-
1.
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
11
|
-
5.
|
|
12
|
-
6.
|
|
13
|
-
7.
|
|
7
|
+
1. **an INDEX that lags the verified working tree** — the gates and the fingerprint both describe the WORKING tree, while `git commit` builds the commit from the INDEX alone, and the fingerprint domain is identical whether a hunk sits staged or unstaged (staging a lone tracked modification does not even move it). So this arm runs FIRST, before the fingerprint is computed: it refuses when the working tree holds anything the index does not — tracked paths differing between index and worktree, or reviewable untracked-not-ignored paths (the SAME never-committable stat filter the fingerprint applies; ignored paths never refuse). An index entry carrying **skip-worktree** or **assume-unchanged** is invisible to `git diff`, so those entries are compared DIRECTLY against the worktree (type, symlink target, executable bit where `core.fileMode` says so, and the blob oid through git's own clean filters) — a de-materialised skip-worktree path is an ordinary sparse checkout and never refuses, while a missing assume-unchanged path does. The submodule probe forces `--ignore-submodules=none`, so `diff.ignoreSubmodules` / `submodule.<name>.ignore` cannot erase a dirty submodule from the comparison. Offending paths are named in THREE categories — plain lagging paths, paths held back by an index bit, and submodules — because their recoveries differ; ONE shared cap spans all of them, each category reserves a slot so no clause is ever nameless, the remainder is stated once, and every path is rendered as one escaped line. Recovery is ONE ordered sequence, and `git add -A` alone is NOT it: a bit-carrying entry must have its `skip-worktree` / `assume-unchanged` bit cleared FIRST (scoped to the paths the refusal names — never to everything `git ls-files -v` reports, which includes de-materialised sparse paths whose deletions would then be staged), then `git add -A`, then re-run `run-gates --final` at its RESOLVED path beside this tool, then commit the WHOLE tree. The listed paths are capped, so the loop is the completion signal: re-run the guard until it names none. A tracked **submodule** the index cannot prove current is named separately with its own recovery — commit or clean inside the submodule and stage the gitlink — because a root-level `git add -A` cannot capture a submodule's internal worktree changes. A submodule whose gitlink itself carries one of those index bits is **not probed at all**: it lags by construction. That is a deliberate REDUCTION — three consecutive review rounds each found a new way for a nested probe to answer "clean" wrongly (inherited superproject `GIT_*`, status config blindness, the submodule's OWN flagged entries, a symlink standing in for the directory) — and it stays a CONVERGING refusal, since clearing the bit is a recovery the guard prints and then falls silent on. An UNflagged submodule is judged by the ordinary probe exactly as before. **Fail-closed:** an undecidable git probe refuses with its own named cause, never a silent pass;
|
|
8
|
+
2. no completed final record for the CURRENT fingerprint (the tree moved after the final run — any edit re-stales it);
|
|
9
|
+
3. a RED latest attempt (a dead green never revives — the latest attempt at a fingerprint is authoritative);
|
|
10
|
+
4. fingerprint before ≠ after on the receipt (the tree moved UNDER the final run);
|
|
11
|
+
5. a LATER `final-start` whose attempt never completed (interrupted run / failed receipt append — an attempt of unknown outcome never lets an earlier green stand);
|
|
12
|
+
6. declaration content drift (the current `docs/ai/gates.json` {id, cmd} array no longer matches the receipt's recorded one);
|
|
13
|
+
7. evidence-hash drift (the store's canonical red-proof/degrade serializations moved under the receipt) or lcov drift (the consumed file's sha moved or vanished);
|
|
14
|
+
8. unsatisfied review obligations — the SAME normative decision `review-state --check` computes (configured recipe backends, ship-class-only on the latest normal receipt, veto, the explicit degrade escape), recomputed over a SANITIZED env: the guard resolves FIXED git-dir paths for its own reads and ignores `AW_REVIEW_RECEIPTS`/`AW_CORE_EVIDENCE` (producer test seams are never guard inputs — a forged out-of-repo store never satisfies).
|
|
14
15
|
|
|
15
16
|
**Wiring:** this repo's dogfood rides `scripts/install-git-hooks.mjs`; a consumer install is a consented surface (init/recommendations) — the hook INSTALLER resolves the installed kit location at install time and writes the RESOLVED invocation into the hook it places (no runtime guessing). The final-run ordering that keeps the guard green is D13: stage everything FIRST → run the reviews on the staged tree → `run-gates --final` → commit immediately (any index/worktree mutation after the final run re-stales the receipt).
|
|
16
17
|
|
|
18
|
+
**The deliberate partial commit is BLOCKED — stated, accepted.** Refusal 1 means staging a subset on purpose no longer commits: a pathspec commit (`git commit --only <path>`, `git commit <path>`) hands the hook a temporary index carrying less than the verified tree, and that is exactly the capture blindness this arm closes. No opt-out flag exists, deliberately — the guard's whole claim is that the committed bytes ARE the verified bytes, and a flag that suspends it would suspend the claim. An intentional partial commit stays `--no-verify` territory.
|
|
19
|
+
|
|
20
|
+
**Boundary — what «the commit captures the tree» means for submodules.** The guard proves the SUPERPROJECT commit captures the SUPERPROJECT working tree. A superproject commit stores a submodule as a gitlink OID and never captures its file content — that content is the submodule's own commit boundary. The dirty-submodule refusal is therefore a best-effort COURTESY beyond the guard's boundary, and its completeness is bounded by what the submodule itself reports: a file held behind a `skip-worktree` / `assume-unchanged` bit in the SUBMODULE's own index is invisible to the submodule's status and so to this refusal. Deepening the nested probe was tried and abandoned — three consecutive review rounds each produced a new way for it to answer wrongly — so the honest contract is the boundary, not a completeness claim. Tracked as its own class.
|
|
21
|
+
|
|
22
|
+
**Stated residual — the fingerprint can still be blinded by config.** Refusal 1 makes the COMMIT capture the whole current working tree. It does not make the RECEIPT unforgeable: `computeFingerprintPayload` still runs its diffs without `--ignore-submodules=none`, so under `diff.ignoreSubmodules=all` a submodule can be changed and its gitlink staged AFTER a green `--final` while the fingerprint stays put, and the stale receipt is reused. That is a receipt-collision defect, not an under-capture one — no commit ships less than the working tree because of it — and closing it means moving the node payload and both bash twins together (the AD-044 lockstep). Tracked as its own class.
|
|
23
|
+
|
|
17
24
|
**Human residual (stated, accepted):** `git commit --no-verify` bypasses any pre-commit hook — a self-discipline mechanism, not a security boundary.
|
|
18
25
|
|
|
19
26
|
**Invariants:** read-only · re-runs nothing · fixed git-dir reads (env overrides ignored) · exit 0 pass / 1 refused (reason + recovery named) / 2 usage.
|
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.
|