@sabaiway/agent-workflow-kit 5.9.0 → 5.11.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 +139 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
- package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +107 -45
- package/references/modes/commit-guard.md +11 -8
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +32 -10
- package/references/modes/set-recipe.md +8 -5
- package/references/modes/state-block-guard.md +39 -31
- package/references/modes/worktrees.md +47 -3
- package/references/scripts/check-docs-size-cli.test.mjs +2 -2
- package/references/shared/report-footer.md +2 -2
- package/references/templates/agent_rules.md +1 -0
- package/tools/advisor-matrix.mjs +165 -0
- package/tools/commands.mjs +2 -2
- package/tools/commit-guard.mjs +74 -17
- package/tools/core-evidence.mjs +10 -0
- package/tools/detect-backends.mjs +1 -0
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +68 -14
- package/tools/ensure-configs.mjs +4 -4
- package/tools/flow-check-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +20 -5
- package/tools/lens-region.mjs +13 -1
- package/tools/observation-builder.mjs +123 -0
- package/tools/satellite-locator.mjs +179 -0
- package/tools/source-size-scope.mjs +3 -1
- package/tools/worktree-handoff-return.mjs +369 -0
- package/tools/worktree-prompt.mjs +190 -0
- package/tools/worktrees-record.mjs +171 -0
- package/tools/worktrees.mjs +308 -297
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"name": "antigravity-cli-bridge",
|
|
5
5
|
"kind": "execution-backend",
|
|
6
|
-
"version": "5.
|
|
6
|
+
"version": "5.3.0",
|
|
7
7
|
"provides": ["review", "probe"],
|
|
8
8
|
"posture": { "model": "Gemini 3.7 Flash (High)" },
|
|
9
9
|
"roles": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"receipt": "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, an ATTESTING review with AGY_MODEL explicitly emptied refuses pre-spend, and a model string carrying control bytes refuses pre-spend in every mode; delivery = how the change set REACHED the model, currently emitted as 'inline' (the whole set rode one prompt — proven by construction) or 'fed' (a chunked feed whose per-part echo proof verified); REQUIRED on every agy code receipt and its ABSENCE is what stops a pre-fed-lane receipt attesting, while the gate accepts any well-formed declaration rather than a particular value; absent by construction on plan/diff/continuation receipts, which carry no change set; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review",
|
|
35
35
|
"notes": [
|
|
36
|
+
"transport: every review dispatch drives the CLI in --output-format json (plus --disable-slash-commands) and the returned envelope is parsed in node (bin/agy-envelope.mjs) — the operator-facing invocations and flags above do NOT change, and on a ZERO exit the wrapper still PRINTS the review text, never JSON. A missing or unreadable envelope on a zero exit is a loud failure with NO receipt, never a downgraded verdict and never a fallback to raw-stdout parsing; a non-zero CLI exit keeps its own code and message, and publishes the captured stdout unchanged from the SINGLE dispatch or the FINAL fed turn (which may therefore be a JSON or partial payload — the envelope is parsed only on a zero exit); an INTERMEDIATE feed turn is the exception, its output stays private (Invariant E) and its failure prints only a named error. Enforced by a PRE-SPEND capability probe, not a version floor: agy --help must advertise --output-format and --disable-slash-commands, node must be >= 22, and bin/agy-envelope.mjs must be present — otherwise the review refuses before any run is spent and names the missing capability",
|
|
36
37
|
"pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt",
|
|
37
38
|
"the review posture banner appends a banner-only timeout=<duration> field — exactly the duration agy-run hands to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and the field never enters the receipt posture or the D5 banner↔receipt parity",
|
|
38
39
|
"quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts"
|
|
@@ -229,7 +230,7 @@
|
|
|
229
230
|
"max": 100000000,
|
|
230
231
|
"default": "240000",
|
|
231
232
|
"appliesTo": ["agy-review"],
|
|
232
|
-
"effect": "agy-review code: the ceiling on the SUM of all outgoing prompt bytes the chunked feed may send (every
|
|
233
|
+
"effect": "agy-review code: the ceiling on the SUM of all outgoing prompt bytes the chunked feed may send (every feed-turn framing block, every body, the final turn). Checked BEFORE the first turn is spent; past it the fed review refuses. An economy guard, not the correctness guard — correctness is the per-part delivery echo."
|
|
233
234
|
}
|
|
234
235
|
],
|
|
235
236
|
"networkHosts": ["*.googleapis.com", "accounts.google.com", "antigravity-unleash.goog", "lh3.googleusercontent.com"],
|
|
@@ -6,13 +6,25 @@ were captured from **v1.1.13**; if the binary disagrees, the binary wins. The wr
|
|
|
6
6
|
|
|
7
7
|
## Headless behaviour
|
|
8
8
|
|
|
9
|
-
Use `-p`, `--print`, or `--prompt` to run one non-interactive prompt and print the text response.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
Use `-p`, `--print`, or `--prompt` to run one non-interactive prompt and print the text response.
|
|
10
|
+
Both wrappers run headless `-p`, and they differ in **transport**:
|
|
11
|
+
|
|
12
|
+
- **`agy-run` stays text.** It prints the CLI's text response unchanged. JSON can ride passthrough
|
|
13
|
+
(`agy-run "…" -- --output-format json`) with NO parsing or validation — if you need structure here,
|
|
14
|
+
ask for Markdown, bullets, tables or fenced blocks and validate the text yourself.
|
|
15
|
+
- **`agy-review` drives every dispatch in `--output-format json`** and reads the returned envelope
|
|
16
|
+
(`bin/agy-envelope.mjs`), so the answer TEXT and the conversation id come from **named fields**
|
|
17
|
+
instead of a guess at raw stdout. The **review contract stays prose-shaped**: the verdict is still
|
|
18
|
+
parsed out of that text's `### Verdict` section, exactly as before. It also passes
|
|
19
|
+
`--disable-slash-commands`, so a delivered change-set line that begins with a slash command stays
|
|
20
|
+
BODY rather than being expanded by the CLI. **On a zero exit** what it PRINTS is the review text,
|
|
21
|
+
never JSON — the envelope is wire format, not output format. When the **single dispatch or the
|
|
22
|
+
FINAL fed turn** exits non-zero the captured stdout is published as-is (the CLI's own failure wins,
|
|
23
|
+
and the envelope is parsed only on a zero exit), so a failing run may print a JSON or partial
|
|
24
|
+
payload. An **intermediate** feed turn is the exception: its output stays private and its failure
|
|
25
|
+
prints only a named error.
|
|
26
|
+
|
|
27
|
+
`--json-schema` is **not** adopted and was measured and rejected; see the flags table below.
|
|
16
28
|
|
|
17
29
|
## Wrapper contract
|
|
18
30
|
|
|
@@ -70,6 +82,15 @@ agy-review --continue | --conversation <id> [--decided @f] [--focus "…"] #
|
|
|
70
82
|
passthrough (it owns the posture). The service can still **stall on large/substantive prompts**
|
|
71
83
|
(Issue-001), so keep reviews **focused**; the hard timeout is the guard.
|
|
72
84
|
|
|
85
|
+
**Host requirements, enforced BEFORE any run is spent.** Because the review reads agy's JSON
|
|
86
|
+
envelope in node, `agy-review` needs **Node ≥ 22** on `PATH`; and because it passes flags the CLI
|
|
87
|
+
must honour, it probes `agy --help` for **`--output-format`** and **`--disable-slash-commands`**
|
|
88
|
+
first. A missing flag, an unreadable `agy --help`, a missing or too-old node, or a missing
|
|
89
|
+
`bin/agy-envelope.mjs` each refuse with a named cause and spend nothing. This is a **capability
|
|
90
|
+
probe, not a version floor** — the release that introduced `--output-format` is not measurable from
|
|
91
|
+
one installed build, so a guessed floor would refuse working installs. A failed probe is never read
|
|
92
|
+
as "capability present".
|
|
93
|
+
|
|
73
94
|
## Models
|
|
74
95
|
|
|
75
96
|
Pass the **exact display string** from `agy models`, or set `AGY_MODEL`.
|
|
@@ -112,19 +133,31 @@ AGY_MODEL="Claude Sonnet 4.6 (Thinking)" AGY_TIMEOUT=10m agy-run @review-prompt.
|
|
|
112
133
|
| `--dangerously-skip-permissions` | auto-approve all tool permissions | avoid by default; use only with explicit user approval |
|
|
113
134
|
| `--sandbox` | run with terminal restrictions enabled | prefer when delegating a prompt that might trigger tool/terminal work |
|
|
114
135
|
| `--log-file <path>` | override the CLI log-file path | keep logs secret-free and out of committed artifacts |
|
|
115
|
-
| `--output-format <fmt>` | print-mode output: `text` (default), `json`, `stream-json` |
|
|
116
|
-
| `--json-schema <s\|path>` | enforce structured output (stream-json final result) |
|
|
136
|
+
| `--output-format <fmt>` | print-mode output: `text` (default), `json`, `stream-json` | **ADOPTED by `agy-review`** — every dispatch runs `json` and the envelope is parsed (`bin/agy-envelope.mjs`). `agy-run` stays `text`; JSON there rides passthrough, unparsed. `stream-json` is adopted by neither (see below) |
|
|
137
|
+
| `--json-schema <s\|path>` | enforce structured output (stream-json final result) | **REJECTED, and the reason is a measurement.** It is not a constrained decode: the model answers in prose and the CLI then spends a **second turn** asking it to restate that answer in schema shape (`num_turns: 2`). Matched control — same prompt, same model, schema off vs on: **16,585 vs 33,446 total tokens**. So a schema does not remove prose parsing; it replaces a free, deterministic, anchored regex with a billed non-deterministic re-read (the structured `reason` came back reworded, not quoted) and adds a failure mode — a run dying between the two turns has prose but no `structured_output` |
|
|
117
138
|
| `--effort <low\|medium\|high>` | reasoning effort for the session | NEW in 1.1.x; the display strings already carry an effort tier — the wrapper keeps model selection in ONE place (`AGY_MODEL`) |
|
|
118
139
|
| `--mode <m>` | agent execution mode (`accept-edits`, `plan`) | NEW in 1.1.x; not used by the wrapper |
|
|
119
140
|
| `--agent` / `--project <id>` / `--new-project` | agent + project selection for the session | NEW in 1.1.x; not used by the wrapper |
|
|
120
|
-
| `--disable-slash-commands` | disable slash command/skill expansion in print mode |
|
|
141
|
+
| `--disable-slash-commands` | disable slash command/skill expansion in print mode | **ADOPTED by `agy-review`** on every dispatch — a delivered change-set line beginning with a slash command stays BODY instead of being expanded, so the model reviews the delivered bytes. Not used by `agy-run` |
|
|
121
142
|
|
|
122
143
|
## Subcommands (v1.1.13)
|
|
123
144
|
|
|
124
145
|
`agent` / `agents`, `changelog`, `help`, `install`, `models`, `plugin` / `plugins`, `update`.
|
|
125
146
|
|
|
126
|
-
**Still not available in v1.1.13:** any `agy inspect`.
|
|
127
|
-
|
|
147
|
+
**Still not available in v1.1.13:** any `agy inspect`. On output format the two wrappers differ:
|
|
148
|
+
`agy-run` is a **text-by-default passthrough** — it hands back the CLI's stdout unchanged, so
|
|
149
|
+
`agy-run "…" -- --output-format json` really does print JSON; `agy-review` prints the review text it
|
|
150
|
+
read out of the JSON envelope on a **successful** run. (When its single dispatch or FINAL fed turn
|
|
151
|
+
exits non-zero, `agy-review` publishes that captured stdout unchanged — never an intermediate feed
|
|
152
|
+
turn's; see the transport bullet above.)
|
|
153
|
+
|
|
154
|
+
**`--output-format stream-json` is deliberately NOT adopted.** Its two measured gains are queued as
|
|
155
|
+
follow-ups rather than built: its `init` event names the **resolved** model (neither the plain `json`
|
|
156
|
+
envelope nor the stream's own `result` event carries one), which would let the receipt record what
|
|
157
|
+
actually ran; and its live `step_update` events would make a stalled run visible (Issue-001). The
|
|
158
|
+
seam built here makes the FLAG easy to switch, but neither gain is free: `bin/agy-envelope.mjs`
|
|
159
|
+
parses ONE JSON object and refuses anything else, so NDJSON needs a stream-aware reader, and live
|
|
160
|
+
progress needs an event relay rather than a captured file.
|
|
128
161
|
|
|
129
162
|
## Project-context flags
|
|
130
163
|
|
|
@@ -89,6 +89,12 @@ itself can never reveal the very text it asks for, and the wrapper needs no seco
|
|
|
89
89
|
construction) or `fed` (proven by echo). The kit's review-state gate requires the field present and
|
|
90
90
|
well-formed, never a particular value, so a receipt minted before this lane existed no longer
|
|
91
91
|
attests. The recovery is stated: re-run the review.
|
|
92
|
+
- **Later turns are routed by a NAMED field.** Turn 1's `--output-format json` envelope carries
|
|
93
|
+
`conversation_id`; the wrapper validates it against the UUID grammar and dispatches every later
|
|
94
|
+
turn with `--conversation <id>`. There is no `--continue` fallback in this lane: an id that is
|
|
95
|
+
absent, wrong-typed or malformed stops the run **before turn 2 is spent**, with NO receipt. (It was
|
|
96
|
+
formerly scraped from `agy`'s own run log — a format that is `agy`'s to change, so the pin could
|
|
97
|
+
rot silently; a named field fails loudly instead.)
|
|
92
98
|
|
|
93
99
|
### Honest residuals (recorded, not engineered away)
|
|
94
100
|
|
|
@@ -99,9 +105,6 @@ itself can never reveal the very text it asks for, and the wrapper needs no seco
|
|
|
99
105
|
- A change set whose parts carry **no unique interior line** in the 24..200-byte window (a single
|
|
100
106
|
huge minified line, for instance) cannot be proven delivered, so the wrapper **refuses** rather
|
|
101
107
|
than reviewing unprovably. Split the review, or exclude the blob.
|
|
102
|
-
- The conversation id is parsed from `agy`'s own run log, whose format is `agy`'s to change. An
|
|
103
|
-
unparseable log **degrades loudly** to `--continue`; correctness still rests on the echo proof,
|
|
104
|
-
which fails closed when the wrong conversation answers.
|
|
105
108
|
|
|
106
109
|
## agy's own permission ask — surfaced, never applied
|
|
107
110
|
|
|
@@ -9,11 +9,18 @@ works in any project.
|
|
|
9
9
|
```bash
|
|
10
10
|
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
11
11
|
export PATH="$HOME/.local/bin:$PATH" # add to ~/.bashrc / ~/.zshrc to persist
|
|
12
|
-
agy --version #
|
|
12
|
+
agy --version # captured here from 1.1.13 — see the capability note below
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
- The binary is **`agy`** (not `antigravity`); it installs to `~/.local/bin/agy`.
|
|
16
16
|
- Keep `$HOME/.local/bin` on `PATH` (the wrapper also prepends it defensively).
|
|
17
|
+
- **There is no version floor.** The docs in this skill were captured from **v1.1.13**, but the
|
|
18
|
+
release that introduced each flag is not measurable from one installed build, so a guessed floor
|
|
19
|
+
would refuse working installs. What actually gates a review is a **capability probe** —
|
|
20
|
+
`agy-review` reads `agy --help` and refuses BEFORE spending a run unless the install advertises
|
|
21
|
+
both **`--output-format`** and **`--disable-slash-commands`**, plus **Node ≥ 22** on `PATH` (the
|
|
22
|
+
review parses agy's JSON envelope in node) and the skill's own `bin/agy-envelope.mjs`. Each
|
|
23
|
+
refusal names the missing capability and the recovery. `agy-run` needs none of these.
|
|
17
24
|
|
|
18
25
|
## 2. Sign in once (subscription only)
|
|
19
26
|
|
|
@@ -59,13 +66,19 @@ agy --version
|
|
|
59
66
|
echo "say OK" | agy-run -
|
|
60
67
|
```
|
|
61
68
|
|
|
62
|
-
Expected:
|
|
63
|
-
|
|
69
|
+
Expected: a version prints (any — `agy-review` probes its required capabilities, not a version
|
|
70
|
+
floor; `agy-run` probes nothing), then a short reply containing `OK`. If `agy-run` reports
|
|
71
|
+
`'agy' not found`, fix your `PATH` (step 1). If it asks you to sign in, complete step 2. If
|
|
72
|
+
`agy-review` later refuses naming a missing flag or Node, that is the capability probe from step 1,
|
|
73
|
+
not a broken install — follow the recovery it prints.
|
|
64
74
|
|
|
65
75
|
## Notes
|
|
66
76
|
|
|
67
|
-
- `agy-run` is headless and text
|
|
68
|
-
|
|
77
|
+
- `agy-run` is headless and text: `--output-format json` can ride passthrough
|
|
78
|
+
(`-- --output-format json`) but that wrapper adds no parsing/validation. `agy-review` is the one
|
|
79
|
+
that drives `--output-format json` first-class and reads the envelope (a successful run still
|
|
80
|
+
prints text; when its single dispatch or FINAL fed turn exits non-zero it publishes that captured
|
|
81
|
+
stdout unchanged, never an intermediate feed turn's).
|
|
69
82
|
- `AGY_MODEL` selects the exact model display string; `AGY_TIMEOUT` controls `--print-timeout`.
|
|
70
83
|
- Extra `agy` flags go after `--`, e.g. `agy-run @prompt.md -- --add-dir .`.
|
|
71
84
|
- Re-run interactive `agy` only when the OAuth token expires or the account changes.
|
|
@@ -1198,7 +1198,7 @@ describe('codex-review.sh — review receipts (AD-038)', () => {
|
|
|
1198
1198
|
// The grammar ENUMERATES the ASCII set (no ranges): a locale-collated [A-Za-z] could admit a
|
|
1199
1199
|
// non-ASCII letter the kit's JS reader then refuses, breaking correlation after a paid run.
|
|
1200
1200
|
const utf8 = makeSandbox();
|
|
1201
|
-
const r2 = run(utf8, { env: { AW_REVIEW_NONCE: '
|
|
1201
|
+
const r2 = run(utf8, { env: { AW_REVIEW_NONCE: 'r\u00e91', LC_ALL: 'en_US.UTF-8', LANG: 'en_US.UTF-8' } });
|
|
1202
1202
|
rmSync(utf8.root, { recursive: true, force: true });
|
|
1203
1203
|
assert.equal(r2.status, 2, 'a non-ASCII nonce letter refuses whatever the locale collation says');
|
|
1204
1204
|
assert.match(r2.stderr, /safe nonce grammar/);
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.11.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",
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
//
|
|
8
8
|
// At Stop time the turn is ENDING. That single fact turns two closing shapes from "discouraged" into
|
|
9
9
|
// FALSE, so they can be judged mechanically:
|
|
10
|
-
//
|
|
10
|
+
// * the "what I need from you" slot answering "nothing" — a stopped turn always needs a resume, so
|
|
11
11
|
// the slot must NAME it. "Nothing needed" is honest only while work is actually running;
|
|
12
|
-
//
|
|
12
|
+
// * a first-person promise of imminent work in the closing block — the turn is over, so the work
|
|
13
13
|
// is not starting. This is the announce-and-stop shape.
|
|
14
14
|
// A promise GATED on something named (after your yes, when CI finishes) is honest and passes.
|
|
15
15
|
//
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
// required a second classifier to decide what counts as a real ask, the rule was DELETED and its
|
|
20
20
|
// residual written down instead — twice. The mode contract lists every residual by name.
|
|
21
21
|
//
|
|
22
|
+
// ONE VOCABULARY, ENGLISH. The kit ships English markers and enumerates no other language: a
|
|
23
|
+
// language list would be a shipped guess about somebody else's dialogue, and it never stops growing.
|
|
24
|
+
// A deployment whose dialogue language is not English extends the arrays in ITS OWN placed copy —
|
|
25
|
+
// this file is copied into the project and self-contained, so that edit is local and survives an
|
|
26
|
+
// uninstalled kit. Until then such a deployment gets no detection, which the mode contract states.
|
|
27
|
+
//
|
|
22
28
|
// HONEST LIMIT, stated because it bounds what this can claim: a Stop hook cannot un-send the turn it
|
|
23
29
|
// judges. This is DETECTION — it converts a silent recurrence into a loud one. It never blocks, and
|
|
24
30
|
// every anomaly path exits 0: a hook that fires on every turn must not become the blocker or the
|
|
@@ -39,18 +45,17 @@ const EXIT_OK = 0;
|
|
|
39
45
|
// The three slots, matched on their LABEL and ANCHORED TO A LINE START. The anchor is not cosmetic:
|
|
40
46
|
// a message that DISCUSSES this contract — which the sessions this guard was built for do constantly
|
|
41
47
|
// — mentions the labels inline, and an unanchored match would judge that prose instead of the real
|
|
42
|
-
// closing block.
|
|
43
|
-
// keep the guard usable in an English-dialogue deployment.
|
|
48
|
+
// closing block.
|
|
44
49
|
const LINE_LEAD = '^[ \\t\\-•]*';
|
|
45
50
|
const SLOT_PATTERNS = Object.freeze({
|
|
46
|
-
now: new RegExp(`${LINE_LEAD}(
|
|
47
|
-
fromYou: new RegExp(`${LINE_LEAD}(
|
|
48
|
-
next: new RegExp(`${LINE_LEAD}(
|
|
51
|
+
now: new RegExp(`${LINE_LEAD}(?:now|where we are|state now)\\s*[::]`, 'gim'),
|
|
52
|
+
fromYou: new RegExp(`${LINE_LEAD}(?:what i need from you|from you)\\s*[::]`, 'gim'),
|
|
53
|
+
next: new RegExp(`${LINE_LEAD}(?:what(?:'s| is)? next|next)\\s*[::]`, 'gim'),
|
|
49
54
|
});
|
|
50
55
|
|
|
51
56
|
// Quoted and fenced material is EXAMPLE text, not the turn's own closing block. Stripped before any
|
|
52
57
|
// label search — otherwise pasting the very specimen this guard catches would make the guard judge
|
|
53
|
-
// the paste. Emphasis marks are stripped after, so
|
|
58
|
+
// the paste. Emphasis marks are stripped after, so `**Now:**` still parses.
|
|
54
59
|
// Scanned line by line rather than matched as one regex: an UNCLOSED fence must run to the end of
|
|
55
60
|
// the message. A pattern that only removes CLOSED fences would take an unfinished example's labels
|
|
56
61
|
// for a real block — judging a demonstration as if it were the turn, which is precisely the mistake
|
|
@@ -84,9 +89,11 @@ const stripFences = (text) => {
|
|
|
84
89
|
|
|
85
90
|
const BLOCKQUOTE_LINE = /^[ \t]*>.*$/gm;
|
|
86
91
|
const EMPHASIS_MARKS = /[*_`]/g;
|
|
87
|
-
// A model writes
|
|
88
|
-
// ASCII one, so matching only ASCII made
|
|
89
|
-
|
|
92
|
+
// A model writes "What's next" and "I'll" with a typographic apostrophe far more often than with the
|
|
93
|
+
// ASCII one, so matching only ASCII made every label and every promise marker fail silently on
|
|
94
|
+
// ordinary output. U+02BC rides as an escape because it is a modifier LETTER, and this family's
|
|
95
|
+
// sources carry no non-ASCII letter; the escaped class matches exactly the same characters.
|
|
96
|
+
const APOSTROPHES = /[’‘\u02bc‛]/g;
|
|
90
97
|
const toProse = (text) => stripFences(text)
|
|
91
98
|
.replace(BLOCKQUOTE_LINE, '')
|
|
92
99
|
.replace(EMPHASIS_MARKS, '')
|
|
@@ -103,69 +110,93 @@ const openingAnswer = (slot) => {
|
|
|
103
110
|
const at = slot.search(CLAUSE_END);
|
|
104
111
|
return at === -1 ? slot : slot.slice(0, at);
|
|
105
112
|
};
|
|
106
|
-
// A rule excusing
|
|
113
|
+
// A rule excusing "…, nothing else is needed" was built, tightened twice, and then DELETED. Every
|
|
107
114
|
// version let a banned answer through behind some harmless prefix, and the next version would have
|
|
108
115
|
// needed an "is this a real ask" recogniser — a second lexical classifier with its own edge set. The
|
|
109
116
|
// residual is stated instead of coded: a comma-joined qualifier IS flagged, and the writer's fix is
|
|
110
117
|
// a clause break, which the first-clause rule already passes.
|
|
111
118
|
|
|
112
119
|
const NOTHING_FORMS = Object.freeze([
|
|
113
|
-
'ничего',
|
|
114
|
-
'ничем',
|
|
115
|
-
'не требуется',
|
|
116
|
-
'не нужно',
|
|
117
|
-
'не нужен',
|
|
118
|
-
'нет необходимости',
|
|
119
120
|
'nothing',
|
|
120
121
|
'none',
|
|
121
122
|
'n/a',
|
|
122
123
|
'no action',
|
|
124
|
+
'no need',
|
|
125
|
+
'not needed',
|
|
126
|
+
'not required',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
// -- the "what is happening NOW" slot is not a report of what was DONE ----------------------------
|
|
130
|
+
// The third judgeable shape. The three slots answer three different questions; when the now-slot
|
|
131
|
+
// opens with finished work, the reader's one question — what is running — is buried behind a
|
|
132
|
+
// summary they can already read in the message body above.
|
|
133
|
+
// Judged by TOKEN ORDER, the technique the promise/gate rule uses: a COMPLETION marker standing
|
|
134
|
+
// BEFORE the first CURRENCY marker means the running fact was buried. Two consequences are
|
|
135
|
+
// deliberate — a slot with NO currency marker is never judged here (nothing running is a legitimate
|
|
136
|
+
// state, and whether that is honest is the from-you slot's question), and a slot that LEADS with
|
|
137
|
+
// what is running passes whatever it adds afterwards.
|
|
138
|
+
// Stated residual, in this file's own style: a CURRENT state phrased with a COMPLETION MARKER
|
|
139
|
+
// ("the work is closed, waiting on your call" — `closed` is in the list below) is flagged, and the
|
|
140
|
+
// writer's fix is to phrase the state rather than the transition. Telling that from a real
|
|
141
|
+
// completion report needs a classifier this layer lacks. The specimen is deliberately one the list
|
|
142
|
+
// really carries: a residual written with a word the code does not match would document a false
|
|
143
|
+
// positive that cannot happen, which is worse than no residual at all.
|
|
144
|
+
const COMPLETION_FORMS = Object.freeze([
|
|
145
|
+
'done', 'completed', 'finished', 'closed', 'landed', 'shipped', 'merged', 'published',
|
|
146
|
+
'committed', 'fixed', 'added', 'removed', 'updated', 'recorded', 'measured', 'marked',
|
|
147
|
+
'verified', 'ran', 'wrote',
|
|
148
|
+
"i've", 'i have',
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
// Running STATE only, never a time-deictic. "now" is the slot's own label, so accepting it as a
|
|
152
|
+
// currency marker would put one at position 0 of nearly every slot and every completion after it
|
|
153
|
+
// would read as honest — one word defeating the whole rule. A marker that names WHEN rather than
|
|
154
|
+
// WHAT-IS-HAPPENING cannot carry this judgement.
|
|
155
|
+
const CURRENCY_FORMS = Object.freeze([
|
|
156
|
+
'running', 'in progress', 'waiting', 'awaiting', 'pending', 'is up', 'under way', 'underway',
|
|
157
|
+
'in flight', 'blocked on',
|
|
123
158
|
]);
|
|
124
159
|
|
|
125
160
|
// First-person promises of imminent work. Present tense counts: at Stop it describes work that is
|
|
126
161
|
// NOT running.
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// a
|
|
130
|
-
//
|
|
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.
|
|
162
|
+
// The markers are pronoun+modal or pronoun+verb, so they cannot tell "I'll start" from "I'll wait".
|
|
163
|
+
// Waiting is not work: it is what a turn that ENDS actually does, and it needs no gate to be honest.
|
|
164
|
+
// So a modal followed by a waiting verb is excluded rather than the modal being dropped — dropping
|
|
165
|
+
// it would need an open-ended list of every action verb English can put after it.
|
|
134
166
|
const WAITING_CONTINUATIONS = Object.freeze([
|
|
135
167
|
"i'll wait", 'i will wait', "i'll be waiting", "i'll stand by", 'i will stand by',
|
|
136
168
|
"i'll hold", 'i will hold', "i'll stay", 'i will stay', "i'll remain", 'i will remain',
|
|
137
169
|
]);
|
|
138
170
|
|
|
139
171
|
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',
|
|
172
|
+
"i'll", 'i will', "i'm going to", 'i am going to', "i'm starting", "i'm taking",
|
|
173
|
+
'i start', 'i begin', 'i take', 'i move on', 'next i',
|
|
143
174
|
]);
|
|
144
175
|
|
|
145
176
|
// A gate the promise may depend on. What is actually checked is TOKEN ORDER inside one segment: a
|
|
146
177
|
// gate is treated as excusing a promise when it appears in the same segment and earlier in it. That
|
|
147
178
|
// is a lexical approximation of dependency, not dependency itself, and it carries two named
|
|
148
|
-
// residuals — an honest TRAILING gate (
|
|
149
|
-
// an earlier comma-clause (
|
|
150
|
-
// that follows it. Both are accepted limits of a lexical layer,
|
|
179
|
+
// residuals — an honest TRAILING gate ("I'll take it when you say so") is flagged, and a gate
|
|
180
|
+
// belonging to an earlier comma-clause ("if the test fails I'll report, and now I start the next
|
|
181
|
+
// class") wrongly excuses a promise that follows it. Both are accepted limits of a lexical layer,
|
|
182
|
+
// documented in the mode contract.
|
|
151
183
|
const CONDITIONAL_FORMS = Object.freeze([
|
|
152
|
-
'
|
|
153
|
-
'after', 'when', 'if', 'as soon as', 'once', 'pending',
|
|
184
|
+
'after', 'when', 'if', 'as soon as', 'once', 'upon', 'pending',
|
|
154
185
|
]);
|
|
155
186
|
|
|
156
|
-
// Segment boundaries for the promise/gate rule. Deliberately NOT the comma:
|
|
157
|
-
//
|
|
187
|
+
// Segment boundaries for the promise/gate rule. Deliberately NOT the comma: "if you agree, I'll take
|
|
188
|
+
// the class" is one honest thought, and splitting it would flag the shape the rule exists to permit.
|
|
158
189
|
const PROMISE_SEGMENT_BREAK = /;|\n|[.!?](?=\s|$)/;
|
|
159
190
|
|
|
160
|
-
// Word-bounded matching, Unicode-aware.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
191
|
+
// Word-bounded matching, Unicode-aware. The markers are English but the TEXT is the project's
|
|
192
|
+
// dialogue language, which need not be ASCII, and JavaScript's `\b` is ASCII-only: under it every
|
|
193
|
+
// non-ASCII letter reads as a word break, so an English marker sitting inside a non-ASCII word would
|
|
194
|
+
// match as if it stood alone. The Unicode class costs nothing and keeps that door shut; on the
|
|
195
|
+
// English side it is what stops `none` matching inside `nonexistent`.
|
|
165
196
|
const WORD_CHAR = '\\p{L}\\p{N}';
|
|
166
197
|
const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
167
198
|
const boundedPattern = (phrase) => new RegExp(
|
|
168
|
-
`(?<![${WORD_CHAR}])${escapeForRegExp(phrase)}(?![${WORD_CHAR}])
|
|
199
|
+
`(?<![${WORD_CHAR}])${escapeForRegExp(phrase)}(?![${WORD_CHAR}])`,
|
|
169
200
|
'iu',
|
|
170
201
|
);
|
|
171
202
|
const firstMatchAt = (haystack, needles) => needles.reduce((best, needle) => {
|
|
@@ -175,6 +206,25 @@ const firstMatchAt = (haystack, needles) => needles.reduce((best, needle) => {
|
|
|
175
206
|
}, -1);
|
|
176
207
|
const containsAny = (haystack, needles) => firstMatchAt(haystack, needles) !== -1;
|
|
177
208
|
|
|
209
|
+
// Nearly every English completion marker doubles as an ADJECTIVE, which the Russian verbs this rule
|
|
210
|
+
// was first written against did not: "the updated test suite is running" is an honest now-slot that
|
|
211
|
+
// leads with the running fact, and reading `updated` there as a completion report would flag it. A
|
|
212
|
+
// marker sitting directly after a DETERMINER is attributive — it modifies the noun after it instead
|
|
213
|
+
// of reporting a transition — so it is not counted.
|
|
214
|
+
// Residual, stated: this also loses a completion phrased attributively ("the finished council, the
|
|
215
|
+
// matrix is running" passes). That trade is deliberate. A false flag on a correct slot is noise from
|
|
216
|
+
// a hook that fires on EVERY turn, and noise is the failure this file is most careful to avoid; a
|
|
217
|
+
// missed marginal shape is not.
|
|
218
|
+
const DETERMINERS = Object.freeze(['the', 'a', 'an', 'this', 'that', 'these', 'those', 'its', 'my', 'our', 'your', 'their']);
|
|
219
|
+
const AFTER_DETERMINER = new RegExp(`(?:^|[^${WORD_CHAR}])(?:${DETERMINERS.join('|')})[ \\t]+$`, 'iu');
|
|
220
|
+
const matchesIn = (haystack, needle) => [...haystack.matchAll(new RegExp(boundedPattern(needle).source, 'giu'))]
|
|
221
|
+
.map((match) => match.index);
|
|
222
|
+
const firstReportedAt = (haystack, needles) => needles.reduce((best, needle) => {
|
|
223
|
+
const at = matchesIn(haystack, needle).find((index) => !AFTER_DETERMINER.test(haystack.slice(0, index)));
|
|
224
|
+
if (at === undefined) return best;
|
|
225
|
+
return best === -1 ? at : Math.min(best, at);
|
|
226
|
+
}, -1);
|
|
227
|
+
|
|
178
228
|
const normalise = (value) => (typeof value === 'string'
|
|
179
229
|
? value.replace(EMPHASIS_MARKS, '').replace(APOSTROPHES, "'").toLowerCase()
|
|
180
230
|
: '');
|
|
@@ -197,7 +247,7 @@ const groupLabels = (labels) => labels.reduce((state, label) => {
|
|
|
197
247
|
: { current: candidate, completed: state.completed };
|
|
198
248
|
}, { current: [], completed: null });
|
|
199
249
|
|
|
200
|
-
// findStateBlock(text)
|
|
250
|
+
// findStateBlock(text) -> { now, fromYou, next } or null. The LAST STARTED candidate decides: if the
|
|
201
251
|
// message ends mid-block, the turn did not end on a block at all, and falling back to an earlier
|
|
202
252
|
// complete one would judge text the turn already moved past.
|
|
203
253
|
export const findStateBlock = (text) => {
|
|
@@ -213,7 +263,7 @@ export const findStateBlock = (text) => {
|
|
|
213
263
|
return block;
|
|
214
264
|
};
|
|
215
265
|
|
|
216
|
-
// decideStop({ closingText, requireBlock })
|
|
266
|
+
// decideStop({ closingText, requireBlock }) -> { ok, reasons }. Never throws: junk input decides
|
|
217
267
|
// "no block", which is a reportable state rather than a crash. An ABSENT block is reported only
|
|
218
268
|
// under `requireBlock`: this kit does not mandate the three-part block, so warning about its absence
|
|
219
269
|
// by default would fire on nearly every turn of a project that never adopted it — noise, from a hook
|
|
@@ -225,7 +275,7 @@ export const decideStop = (options) => {
|
|
|
225
275
|
return requireBlock
|
|
226
276
|
? {
|
|
227
277
|
ok: false,
|
|
228
|
-
reasons: ['no state block: the closing message must end with
|
|
278
|
+
reasons: ['no state block: the closing message must end with "now / what I need from you / what\'s next"'],
|
|
229
279
|
}
|
|
230
280
|
: { ok: true, reasons: [] };
|
|
231
281
|
}
|
|
@@ -236,6 +286,11 @@ export const decideStop = (options) => {
|
|
|
236
286
|
'from-you slot answers "nothing": the turn is ENDING, so a resume from the maintainer IS required — name the real unblocker instead',
|
|
237
287
|
);
|
|
238
288
|
}
|
|
289
|
+
if (buriesTheRunningFact(normalise(block.now))) {
|
|
290
|
+
reasons.push(
|
|
291
|
+
'the now-slot reports finished work before it names what is running — "now" is the state at this instant; move what you completed into the message body above the block',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
239
294
|
if (hasUngatedPromise(normalise(block.next))) {
|
|
240
295
|
reasons.push(
|
|
241
296
|
'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',
|
|
@@ -244,6 +299,13 @@ export const decideStop = (options) => {
|
|
|
244
299
|
return { ok: reasons.length === 0, reasons };
|
|
245
300
|
};
|
|
246
301
|
|
|
302
|
+
const buriesTheRunningFact = (nowSlot) => {
|
|
303
|
+
const currencyAt = firstMatchAt(nowSlot, CURRENCY_FORMS);
|
|
304
|
+
if (currencyAt === -1) return false;
|
|
305
|
+
const completionAt = firstReportedAt(nowSlot, COMPLETION_FORMS);
|
|
306
|
+
return completionAt !== -1 && completionAt < currencyAt;
|
|
307
|
+
};
|
|
308
|
+
|
|
247
309
|
const hasUngatedPromise = (nextSlot) => nextSlot.split(PROMISE_SEGMENT_BREAK).some((segment) => {
|
|
248
310
|
const promiseAt = firstMatchAt(segment, PROMISE_FORMS);
|
|
249
311
|
if (promiseAt === -1) return false;
|
|
@@ -285,7 +347,7 @@ const parsePayload = (rawInput) => {
|
|
|
285
347
|
//
|
|
286
348
|
// An EMPTY string is delivered text, not an absent one: a turn that ended with no prose really did
|
|
287
349
|
// end without a closing block, and `--require-block` should be able to say so.
|
|
288
|
-
// runHook(raw, {requireBlock})
|
|
350
|
+
// runHook(raw, {requireBlock}) -> a decision, or null when there is genuinely nothing to judge.
|
|
289
351
|
// Never throws.
|
|
290
352
|
export const runHook = (rawInput, deps) => {
|
|
291
353
|
const requireBlock = (deps ?? {}).requireBlock === true;
|
|
@@ -7,13 +7,16 @@ The **read-only pre-commit guard** (strip-the-kit D10) — the last line of the
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` — it refuses, each with a named recovery, on:
|
|
8
8
|
|
|
9
9
|
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;
|
|
10
|
-
2. no
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
13
|
-
5.
|
|
14
|
-
6.
|
|
15
|
-
7.
|
|
16
|
-
8.
|
|
10
|
+
2. **a CONTENT-FREE fingerprint over a DIRTY index** — the payload carries no bytes while the index really carries a change, which means staged content the fingerprint domain cannot see: a submodule gitlink hidden from `git diff` by `submodule.<name>.ignore` or `diff.ignoreSubmodules`. No receipt can describe what that commit will carry, so the guard refuses and names the CONFIGURATION as the recovery — clear the ignore setting (or set it to `none`) until `git diff --cached --no-ext-diff` shows the change, then re-run `run-gates --final`. Re-staging is not the recovery: the change is already staged;
|
|
11
|
+
3. no completed final record for the CURRENT fingerprint (the tree moved after the final run — any edit re-stales it);
|
|
12
|
+
4. a RED latest attempt (a dead green never revives — the latest attempt at a fingerprint is authoritative);
|
|
13
|
+
5. fingerprint before ≠ after on the receipt (the tree moved UNDER the final run);
|
|
14
|
+
6. a LATER `final-start` whose attempt never completed (interrupted run / failed receipt append — an attempt of unknown outcome never lets an earlier green stand);
|
|
15
|
+
7. declaration content drift (the current `docs/ai/gates.json` {id, cmd} array no longer matches the receipt's recorded one);
|
|
16
|
+
8. 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);
|
|
17
|
+
9. 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).
|
|
18
|
+
|
|
19
|
+
**The empty-commit lane is a PASS that attests NOTHING — and it consults no receipt.** A content-free fingerprint over a CLEAN index means the index contributes no tree-content delta, so the commit changes no tree content: `git commit --allow-empty`, a message- or signature-only `--amend`, an empty merge. (What such a commit DOES change — its message, its parents, its signature — was never in the guard's domain.) The guard passes and says exactly that, because its whole claim is about the bytes a commit carries and here there are none. The receipt arms are SKIPPED rather than satisfied: the content-free fingerprint is the one value every clean moment shares, so a receipt found there cannot be correlated to THIS moment or base — letting it decide would make the outcome depend on which stray moment the store recorded last, the same reason `flow-check`'s `#65` rung steps over a content-free RED. The flow arm still runs (the commit still moves HEAD, and the chain bookkeeping is about that), with its own fingerprint-keyed correlations dropped through the explicit `treeCarriesBytes: false` the guard declares — the checker never derives that fact, because a routine clean-tree check still wants those rungs.
|
|
17
20
|
|
|
18
21
|
**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).
|
|
19
22
|
|
|
@@ -21,7 +24,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/commit-guard.mjs --check [--cwd <dir>]` —
|
|
|
21
24
|
|
|
22
25
|
**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.
|
|
23
26
|
|
|
24
|
-
**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.
|
|
27
|
+
**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. **Narrowed:** the case where the hidden gitlink is the ONLY staged change is now closed by refusal 2 — there the payload is empty and the index is not, which is decidable without touching the payload definition. A hidden gitlink riding ALONGSIDE other visible changes still produces an ordinary fingerprint and still reuses its receipt; that remainder is what the lockstep owns.
|
|
25
28
|
|
|
26
29
|
**Human residual (stated, accepted):** `git commit --no-verify` bypasses any pre-commit hook — a self-discipline mechanism, not a security boundary.
|
|
27
30
|
|
|
@@ -7,7 +7,7 @@ The ONE **loop-evidence writer** (strip-the-kit D6a/D7) — every core evidence
|
|
|
7
7
|
Run `node ${CLAUDE_SKILL_DIR}/tools/core-evidence.mjs <verb> …`:
|
|
8
8
|
|
|
9
9
|
1. **`red-proof "<test-file>#<test-name-pattern>"`** — the D3(c) observed-red DECLARATION, minted **BEFORE a bugfix is applied**: it runs the named test N times (default 3, `AW_CORE_EVIDENCE_RERUNS`; per-run timeout `AW_CORE_EVIDENCE_TIMEOUT_S`) on the CURRENT (pre-fix) tree, requires red N/N (observed green / unresolvable / mixed / timed-out are DISTINGUISHED refusals — nothing is written; mixed/timeout is QUARANTINE, no override lane), and records { testId · repo-relative file · content sha256 (custody) · N/N red · base = HEAD sha · the pre-fix tree fingerprint }. The final run (`coverage-check --check`) then requires every current-base record's test green N/N with the hash unchanged; a record whose pre-fix fingerprint EQUALS the current tree never satisfies (nothing changed — reuse/forgery); a commit expires records (base moves); editing a bound test file means re-observing red (the new record supersedes the old — same {base, testId} key).
|
|
10
|
-
2. **`degrade --backend <name> --reason "<why>"`** — the ONLY escape from a required review backend (D3(b)): an explicit per-backend
|
|
10
|
+
2. **`degrade --backend <name> --reason "<why>"`** — the ONLY escape from a required review backend (D3(b)): an explicit per-backend record at the CURRENT fingerprint. Never all backends: the review gate still requires ≥1 non-degraded ship-class receipt whenever ≥1 backend is configured. Any tree edit that MOVES the fingerprint re-stales the record — which is every edit the payload can see, and no more: a change the payload cannot see (a gitlink hidden from `git diff` by an ignore configuration) leaves the record standing. **The per-tree claim has one honest exception, and it is not a corner case:** a degrade minted while the work tree is CLEAN binds the CONTENT-FREE fingerprint — the one value every clean moment of every repository shares — so it identifies no working state and, since the record carries no base, no moment either. Such a record is therefore OUTSIDE the `#64` degrade-before-final ordering rung (`flow-check` records the skip as an advisory naming how many were skipped), because the ORDER of two records that cannot be shown to belong to the same moment states nothing about any tree. The writer still accepts it — refusing would strand the one lane that honestly records an unavailable backend during a plan round, which attests no tree at all. Closing the exception needs a durable `base` on the record, which is the queued store migration.
|
|
11
11
|
3. **`summary`** — the ONE stateless end-of-loop render (D6): gate result from the latest final attempt, per-backend verdicts from the review receipts, red-proof outcomes, degrade records. Computed from the stores at read time — no ledger, no rounds, nothing remembered. A malformed store exits non-zero and WITHHOLDS the affected section, never renders a partial as complete. The final-run line NAMES an absent coverage verdict from the `coverage` token the run RECORDED (an ADDITIVE optional field on the `final` record — the closed `certified | not-run | unknown` set, never `none`, and `certified` requires a bound `lcovSha256`): GREEN never renders unqualified over a run that issued no verdict. A LEGACY receipt carrying no token and no lcov digest is named as exactly that, never as a claim about what it read — the withheld verdict travels here too, as DETAIL beside the unchanged status word.
|
|
12
12
|
|
|
13
13
|
**Promptless writer lane (D7):** both verbs ride plain `node ${CLAUDE_SKILL_DIR}/tools/core-evidence.mjs …` invocations — surface the paste-ready allow entries once (`.claude/settings.json`), and a full loop transcript runs at zero writer approval prompts.
|