create-agent-rig 0.4.0 → 0.6.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 +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: worktree-task
|
|
3
|
+
description: Start and finish a task in its own git worktree — creation off the remote default branch, and the full cleanup after the merge. Use at the start of every implementation task and after every merge, and whenever two sessions might touch this repo at once.
|
|
4
|
+
allowed-tools: Bash, Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Worktree task lifecycle
|
|
8
|
+
|
|
9
|
+
`.claude/rules/workflow.md` says **one task, one branch**. A worktree is how
|
|
10
|
+
that rule survives a second session: two branches checked out at once, in two
|
|
11
|
+
directories, with one `.git`. Without it, an unattended run and a hand-driven
|
|
12
|
+
session share a working tree and overwrite each other's edits.
|
|
13
|
+
|
|
14
|
+
**Use one when** anything else may touch this repo while you work — an
|
|
15
|
+
unattended `loop` run, a colleague, a second Claude session. A single attended
|
|
16
|
+
session on a quiet repo can just use a branch; the discipline that is never
|
|
17
|
+
optional is the branch, not the worktree.
|
|
18
|
+
|
|
19
|
+
## Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd "$(git rev-parse --show-toplevel)" # ALWAYS anchor cwd first — see gotcha 1
|
|
23
|
+
git fetch origin
|
|
24
|
+
git worktree add -b <type>/<slug> .claude/worktrees/<slug> origin/HEAD
|
|
25
|
+
git worktree list # verify the path is DIRECTLY under .claude/worktrees/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Branch off the remote default branch**, never a local copy — a stale local
|
|
29
|
+
default is how a task gets built on code that was replaced last week
|
|
30
|
+
(`.claude/rules/autonomy.md`, "Session staleness"). If `origin/HEAD` is not
|
|
31
|
+
set locally, name the branch explicitly: `origin/<default-branch>`.
|
|
32
|
+
- **Branch name: `<type>/<slug>`**, type one of `feat|fix|docs|chore|refactor`.
|
|
33
|
+
If the project's queue gives work an id, the branch carries it —
|
|
34
|
+
`feat/<id>-<slug>` — because the branch name is often the only thread between
|
|
35
|
+
a queue item and its code. Work with no queue item keeps the plain form: **do
|
|
36
|
+
not invent an id.**
|
|
37
|
+
- If tests will run there, install inside the worktree first. A worktree gets a
|
|
38
|
+
fresh, empty `node_modules`; a missing install fails as a confusing
|
|
39
|
+
module-resolution error rather than as "you forgot to install".
|
|
40
|
+
|
|
41
|
+
## Finish (after the PR is merged)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd "$(git -C <repo root> rev-parse --show-toplevel)" # cd OUT of the worktree first — gotcha 2
|
|
45
|
+
git worktree remove .claude/worktrees/<slug> --force
|
|
46
|
+
git worktree prune
|
|
47
|
+
git branch -D <type>/<slug>
|
|
48
|
+
git push origin --delete <type>/<slug> # if the merge did not already delete it
|
|
49
|
+
rm -rf .claude/worktrees/<slug> # only if `remove` left the directory behind
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Add `.claude/worktrees/` to `.gitignore` once, so a live worktree never shows
|
|
53
|
+
up as untracked noise in every `git status`.
|
|
54
|
+
|
|
55
|
+
## Gotchas
|
|
56
|
+
|
|
57
|
+
1. **A stale cwd creates worktrees inside dead paths.** If your shell sits in a
|
|
58
|
+
directory that has been removed (typically the *previous* task's worktree), a
|
|
59
|
+
relative `git worktree add .claude/worktrees/x` recreates the dead path and
|
|
60
|
+
nests the new worktree inside it. Always `cd` to the repo root by an
|
|
61
|
+
**absolute** path first, and verify with `git worktree list` afterwards.
|
|
62
|
+
2. **`git worktree remove` fails while your shell is inside it** — or while a
|
|
63
|
+
watcher or install still holds a handle on `node_modules`. `cd` out first. If
|
|
64
|
+
the directory survives `remove --force`, run `git worktree prune` (the
|
|
65
|
+
metadata is then clean) and delete the leftover directory; retry later if it
|
|
66
|
+
is still busy.
|
|
67
|
+
3. **Never touch a worktree you did not create.** Concurrent sessions make
|
|
68
|
+
worktrees appear and vanish mid-task. Removing another session's worktree
|
|
69
|
+
destroys unmerged work — and it is indistinguishable, afterwards, from that
|
|
70
|
+
session never having done the work.
|
|
71
|
+
4. **One worktree per task.** Do not reuse a finished task's worktree: its
|
|
72
|
+
branch state and its install are both stale, and the reuse is invisible in
|
|
73
|
+
the diff.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: code-reviewer
|
|
3
|
-
description: Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR. Blocking findings must be resolved, not argued with.
|
|
3
|
+
description: Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -49,3 +49,42 @@ do not block on them.
|
|
|
49
49
|
- Quote the checklist item a blocking finding violates. If nothing blocks, say
|
|
50
50
|
so explicitly — "no blocking findings" is a valid, useful verdict.
|
|
51
51
|
- Do not request rewrites of working, tested code for style alone.
|
|
52
|
+
|
|
53
|
+
## The verdict block
|
|
54
|
+
|
|
55
|
+
Write your report for the human, then end it with **exactly one** fenced `json`
|
|
56
|
+
block of this shape, and nothing after it. That block is what the calling gate
|
|
57
|
+
reads; a report that never writes one is read as whatever the caller expected.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"gate": "code-reviewer",
|
|
62
|
+
"verdict": "HOLD",
|
|
63
|
+
"blockers": [
|
|
64
|
+
{
|
|
65
|
+
"file": "packages/core/src/note.ts",
|
|
66
|
+
"line": 42,
|
|
67
|
+
"rule": "checklist item 2 — test integrity",
|
|
68
|
+
"note": "the failing case was deleted rather than fixed"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"advisories": [],
|
|
72
|
+
"evidence": ["diffed against origin/master", "queue item supplied"],
|
|
73
|
+
"headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
|
|
78
|
+
- Every blocker names the `rule` it violates. `file` and `line` travel together
|
|
79
|
+
and are both omitted when the finding has no single location.
|
|
80
|
+
- A `HOLD` with an empty `blockers` list is **refused**, and so is a `SHIP`
|
|
81
|
+
carrying one: `node .claude/scripts/verdict.mjs check <report> <this gate>` is
|
|
82
|
+
what refuses them, and the shape it enforces is in
|
|
83
|
+
`.claude/scripts/lib/verdict.mjs`. The gate name is what stops your answer
|
|
84
|
+
being read as somebody else's.
|
|
85
|
+
- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
|
|
86
|
+
checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
|
|
87
|
+
<commit>` tell "this gate answered for the commit being merged" from "it
|
|
88
|
+
answered two pushes ago". A verdict naming no commit is counted as neither
|
|
89
|
+
covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
|
|
90
|
+
runs that check, so a session that skips the gate skips this with it.
|
|
@@ -50,7 +50,39 @@ about it here, so the two gates never file competing opinions on one paragraph.
|
|
|
50
50
|
two bypasses, sells cover it does not have. Both are blocking, and both are
|
|
51
51
|
found the same way: read the mechanism, then read what the text claims about
|
|
52
52
|
it.
|
|
53
|
-
5. **
|
|
53
|
+
5. **An unbacked behaviour claim.** A sentence asserts what a mechanism does, how
|
|
54
|
+
much something costs, or how often it happens, and **nothing backs it**: no
|
|
55
|
+
test you can name, no command output, no citation to the code. Per
|
|
56
|
+
`.claude/rules/invariants.md` ("State the limits") such a sentence must be
|
|
57
|
+
**generated** from what it describes or be a **pointer to a test** — the form is
|
|
58
|
+
`see <test file> › "<test name>"`, and the name has to be greppable in a file the
|
|
59
|
+
reader has. This is a blocker **by rule**, so you do not have to prove the claim
|
|
60
|
+
wrong; an unbacked claim about behaviour is the finding.
|
|
61
|
+
|
|
62
|
+
⚠ A pointer into a test suite the reader's project does not carry is normally
|
|
63
|
+
item 2, not backing. There is one narrow inherited-snapshot exception from
|
|
64
|
+
`invariants.md`: a generator-authored hook may point to upstream generator
|
|
65
|
+
tests that are absent locally **only while the hook is unchanged downstream**
|
|
66
|
+
and its hook header identifies those tests as absent locally. If that hook is
|
|
67
|
+
edited downstream or appears as changed in the current diff, the exception
|
|
68
|
+
expires and the local test is yours; then an absent pointer is item 2 again.
|
|
69
|
+
|
|
70
|
+
🔴 Three things this is not. It is not item 1: that one is about enforcement the
|
|
71
|
+
mechanism does not provide, this one is about any claim with nothing behind it,
|
|
72
|
+
including a true one. It is not item 4 either, and the split is worth getting
|
|
73
|
+
right because both can reach one sentence: **item 4 is for a limit you checked
|
|
74
|
+
against the mechanism and found wrong or missing; item 5 is for a claim you did
|
|
75
|
+
not have to check, because nothing is offered as backing.** If you opened the
|
|
76
|
+
hook and it disagrees with the text, file item 4 and quote the line. If there was
|
|
77
|
+
nothing offered to open, file item 5. If you opened it and the claim was right,
|
|
78
|
+
there is no finding. One sentence, one item. And it is not an attack on rationale — "we chose X
|
|
79
|
+
because Y" needs no test. The target is a **factual assertion about behaviour**:
|
|
80
|
+
a number, a rate, a limit, a "measured" anything.
|
|
81
|
+
|
|
82
|
+
The remedy has two forms and rewording is neither: the sentence goes, or it
|
|
83
|
+
becomes a pointer. Say which you would expect, and where the test lives if one
|
|
84
|
+
exists.
|
|
85
|
+
6. **Domain that must not travel.** In a layer meant to be neutral: a provider or
|
|
54
86
|
vendor name, a host-specific absolute path, a tracker key, a company or
|
|
55
87
|
product name, credentials or personal data in an example. State which layer
|
|
56
88
|
the file belongs to and why the mention breaks it.
|
|
@@ -102,3 +134,41 @@ You read text and the mechanisms it names. You cannot tell whether a rule is
|
|
|
102
134
|
claim about the world outside this repository is true. Those are the owner's
|
|
103
135
|
questions, and answering them from this seat would be exactly the overreach
|
|
104
136
|
item 1 exists to catch.
|
|
137
|
+
|
|
138
|
+
## The verdict block
|
|
139
|
+
|
|
140
|
+
End your report with **exactly one** fenced `json` block of this shape, and
|
|
141
|
+
nothing after it. The prose above it is for the human; this block is what the
|
|
142
|
+
calling gate reads.
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"gate": "prose-reviewer",
|
|
147
|
+
"verdict": "HOLD",
|
|
148
|
+
"blockers": [
|
|
149
|
+
{
|
|
150
|
+
"file": ".claude/rules/invariants.md",
|
|
151
|
+
"line": 118,
|
|
152
|
+
"rule": "item 5 — an unbacked behaviour claim",
|
|
153
|
+
"note": "no test named, and the hook it describes does not do this"
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
"advisories": [],
|
|
157
|
+
"evidence": ["opened .claude/hooks/guard-bash.mjs and quoted the line"],
|
|
158
|
+
"headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `verdict` is `SHIP`, `HOLD` or `NOT_APPLICABLE` — no other word.
|
|
163
|
+
- Every blocker names the `rule` it violates; give the `file` and `line` of the
|
|
164
|
+
text, and cite the contradicting mechanism in the `note`.
|
|
165
|
+
- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
|
|
166
|
+
`node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
|
|
167
|
+
them, and the gate name is what stops your answer being read as somebody
|
|
168
|
+
else's.
|
|
169
|
+
- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
|
|
170
|
+
checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
|
|
171
|
+
<commit>` tell "this gate answered for the commit being merged" from "it
|
|
172
|
+
answered two pushes ago". A verdict naming no commit is counted as neither
|
|
173
|
+
covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
|
|
174
|
+
runs that check, so a session that skips the gate skips this with it.
|
|
@@ -38,3 +38,43 @@ blocking findings stop the PR until resolved.
|
|
|
38
38
|
the smallest fix. No theoretical lectures without a code path.
|
|
39
39
|
- If the change is outside your triggers, say so and return quickly — a clean
|
|
40
40
|
"not security-relevant" is a valid verdict.
|
|
41
|
+
|
|
42
|
+
## The verdict block
|
|
43
|
+
|
|
44
|
+
End your report with **exactly one** fenced `json` block of this shape, and
|
|
45
|
+
nothing after it. It is what the calling gate reads; the prose above it is for
|
|
46
|
+
the human who has to fix the finding.
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"gate": "security-scanner",
|
|
51
|
+
"verdict": "HOLD",
|
|
52
|
+
"blockers": [
|
|
53
|
+
{
|
|
54
|
+
"file": "services/api/src/handlers/upload.ts",
|
|
55
|
+
"line": 31,
|
|
56
|
+
"rule": "unvalidated input",
|
|
57
|
+
"note": "the filename reaches the shell unescaped — attacker-controlled"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"advisories": [],
|
|
61
|
+
"evidence": ["grepped for the pattern across services/"],
|
|
62
|
+
"headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- `verdict` is `SHIP` (nothing blocking), `HOLD`, or `NOT_APPLICABLE` when the
|
|
67
|
+
change is outside your triggers — that last one is the structured form of the
|
|
68
|
+
clean "not security-relevant" answer above.
|
|
69
|
+
- Every blocker names the `rule` it violates, with `file` and `line` when the
|
|
70
|
+
finding has a location and neither when it does not.
|
|
71
|
+
- A `HOLD` naming no blocker is **refused**, and so is a `SHIP` carrying one:
|
|
72
|
+
`node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
|
|
73
|
+
them, and the gate name is what stops your answer being read as somebody
|
|
74
|
+
else's.
|
|
75
|
+
- **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
|
|
76
|
+
checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
|
|
77
|
+
<commit>` tell "this gate answered for the commit being merged" from "it
|
|
78
|
+
answered two pushes ago". A verdict naming no commit is counted as neither
|
|
79
|
+
covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
|
|
80
|
+
runs that check, so a session that skips the gate skips this with it.
|
|
@@ -9,21 +9,174 @@
|
|
|
9
9
|
// - stop_hook_active in the payload means we already blocked this stop once
|
|
10
10
|
// — never block again, or an agent that cannot go green spins forever;
|
|
11
11
|
// - a clean git tree stops instantly: nothing changed, nothing to gate;
|
|
12
|
-
// - fail open on any hook
|
|
13
|
-
// unquittable.
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// - fail open on any fault of the hook's OWN — a crashed gate must not make
|
|
13
|
+
// the session unquittable. A check that ran and was killed is NOT one of
|
|
14
|
+
// those; the 🔴 block below is the rule that separates the two.
|
|
15
|
+
//
|
|
16
|
+
// 🔴 The budget, and the one place this gate fails CLOSED. The checks are a
|
|
17
|
+
// real test suite, and it can outrun the wiring `timeout` in
|
|
18
|
+
// `.claude/settings.json`. So the hook carries a budget of its own and spends
|
|
19
|
+
// it BEFORE the wiring's runs out.
|
|
20
|
+
//
|
|
21
|
+
// The rule the whole file follows, stated once because two earlier readings of
|
|
22
|
+
// it disagreed:
|
|
23
|
+
//
|
|
24
|
+
// a check that did not produce a VERDICT blocks;
|
|
25
|
+
// a gate that could not START stays open.
|
|
26
|
+
//
|
|
27
|
+
// `spawnSync` reports a timeout and a buffer overrun the same way — an `error`
|
|
28
|
+
// with `status: null` — and in both the check's result is unknown. Unmeasured
|
|
29
|
+
// is not a pass. The hook's OWN faults (an unreadable payload, a config it
|
|
30
|
+
// cannot use, a shell it cannot spawn) keep failing open and announcing
|
|
31
|
+
// themselves, so a crashed gate still cannot make the session unquittable.
|
|
32
|
+
//
|
|
33
|
+
// The budget is ONE allowance for the whole suite, not a fresh one per check:
|
|
34
|
+
// a per-step budget is how three checks quietly cost three times the wall
|
|
35
|
+
// clock the wiring allows (`.claude/rules/invariants.md` — "an explicit total
|
|
36
|
+
// budget rather than a per-step one").
|
|
37
|
+
// see hooks.test.ts › "gates the stop when a check outruns the budget — unmeasured is not a pass"
|
|
38
|
+
// see hooks.test.ts › "spends one budget across the whole suite, not a fresh one per check"
|
|
39
|
+
// see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
40
|
+
//
|
|
41
|
+
// ⚠ This rests on ONE assumption about the harness that nothing in this
|
|
42
|
+
// repository can prove or falsify: that a hook outrunning its timeout is
|
|
43
|
+
// killed, and that a killed Stop hook does not block the stop. If it is false
|
|
44
|
+
// the budget is not merely inert — it becomes the only thing that would stop a
|
|
45
|
+
// suite still running at the wiring timeout. It is stated rather than asserted
|
|
46
|
+
// because the whole design follows from it.
|
|
47
|
+
//
|
|
48
|
+
// Limits, each with the test that pins it:
|
|
49
|
+
// - a check whose output outgrows the buffer is UNMEASURED, and blocks like
|
|
50
|
+
// any other unmeasured check —
|
|
51
|
+
// see hooks.test.ts › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
|
|
52
|
+
// - below that buffer, output volume is not a verdict: a chatty check that
|
|
53
|
+
// passes, passes —
|
|
54
|
+
// see hooks.test.ts › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
|
|
55
|
+
// - the RIG_DOD_BUDGET_MS override may only LOWER the budget, and an
|
|
56
|
+
// override this hook did not honour is announced rather than ignored —
|
|
57
|
+
// see hooks.test.ts › "clamps a budget override that would outlive the harness, and names the budget it used"
|
|
58
|
+
// - a fail-open is announced on stderr, because a silent exit 0 and a clean
|
|
59
|
+
// pass are the same observation from outside —
|
|
60
|
+
// see hooks.test.ts › "announces a fail-open instead of returning a silent clean pass"
|
|
61
|
+
// - an ABSENT config is not a failure and says nothing; only a config that
|
|
62
|
+
// exists and cannot be used announces —
|
|
63
|
+
// see hooks.test.ts › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
|
|
64
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
65
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
66
|
+
import { fileURLToPath } from 'node:url';
|
|
67
|
+
|
|
68
|
+
import { withoutGitLocation } from '../scripts/git-env.mjs';
|
|
69
|
+
|
|
70
|
+
// 🔴 The tree this gate measures is the project the hook BELONGS to — the
|
|
71
|
+
// directory above `.claude/hooks/` — never the directory the session happens
|
|
72
|
+
// to be in. Observed twice in one session (AR-119): reviewing another branch
|
|
73
|
+
// in a worktree under `.claude/worktrees/`, the gate ran the suite in the
|
|
74
|
+
// worktree the shell had last cd-ed into and reported THAT branch's failing
|
|
75
|
+
// test as this session's own Definition-of-Done failure. Reviewing a foreign
|
|
76
|
+
// branch in a worktree is a first-class motion here, so an inherited cwd is
|
|
77
|
+
// the wrong tree often enough to matter. `git status` and every check run
|
|
78
|
+
// with this as their `cwd`, and both refusals name it, so a failure that is
|
|
79
|
+
// still foreign is visible at a glance. It is the session's ROOT, not its
|
|
80
|
+
// branch: a session started at the main checkout whose own task lives in a
|
|
81
|
+
// `worktree-task` worktree is measured at the main checkout, and the refusal
|
|
82
|
+
// says so. Resolved inside `main()`, so a throw here reaches the backstop
|
|
83
|
+
// and announces itself like every other fault of the gate's own.
|
|
84
|
+
// see hooks.test.ts › "runs the checks in the project root, so a check reading the tree sees the session project"
|
|
85
|
+
// see hooks.test.ts › "asks "is the tree clean?" about the project, not about the cwd"
|
|
86
|
+
// see hooks.test.ts › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
|
|
87
|
+
const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
|
|
88
|
+
|
|
89
|
+
// The default total budget, and the allowance for everything that happens
|
|
90
|
+
// OUTSIDE it.
|
|
91
|
+
//
|
|
92
|
+
// The relation the gate actually needs is not "the budget is below the wiring
|
|
93
|
+
// `timeout`" — it is `budget + preamble <= timeout`. The budget's clock starts
|
|
94
|
+
// after node has booted, the payload has been read, `git status` has answered
|
|
95
|
+
// and the config has been parsed, so a budget merely below the wiring timeout
|
|
96
|
+
// still lets the whole hook outrun it. That was a real reader: 890_000 ms
|
|
97
|
+
// against a 900 s wiring timeout obeys "strictly below" and restores the silent
|
|
98
|
+
// kill this hook exists to remove.
|
|
99
|
+
//
|
|
100
|
+
// So the preamble gets a number and a leash: `git status` below is given this
|
|
101
|
+
// as its own timeout, which turns the allowance from a guess into a bound.
|
|
102
|
+
// Both numbers are read from this file and compared against the wiring —
|
|
103
|
+
// see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
104
|
+
// see hooks.test.ts › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
|
|
105
|
+
const DEFAULT_BUDGET_MS = 600_000;
|
|
106
|
+
const PREAMBLE_MARGIN_MS = 60_000;
|
|
107
|
+
|
|
108
|
+
/** The gate stays open, and says so — a silent 0 is indistinguishable from clean. */
|
|
109
|
+
function failOpen(reason) {
|
|
110
|
+
process.stderr.write(`[hook fail-open] ${reason}\n`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Error codes that mean the child never ran, so there is no verdict to lose.
|
|
116
|
+
* Everything else that surfaces as `result.error` — a timeout, a buffer
|
|
117
|
+
* overrun — killed a check that WAS running, and that is the unmeasured case.
|
|
118
|
+
*/
|
|
119
|
+
// 🔴 `E2BIG` looks like it belongs here by the letter of the rule — the exec
|
|
120
|
+
// never started — and adding it would reopen the round-1 bypass: one
|
|
121
|
+
// over-long entry would then fail the WHOLE suite open. It blocks instead, and
|
|
122
|
+
// that is deliberate.
|
|
123
|
+
const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENFILE', 'ENOMEM']);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The total budget for the whole suite, and a note when the override was not
|
|
127
|
+
* taken at face value.
|
|
128
|
+
*
|
|
129
|
+
* 🔴 The override may only LOWER the budget. Raising it past the wiring's
|
|
130
|
+
* `timeout` puts back the exact defect this hook exists to remove — the
|
|
131
|
+
* harness kills the gate before it can report, and that kill is silent.
|
|
132
|
+
* Raising the ceiling is a two-file decision (this constant and the wiring),
|
|
133
|
+
* deliberately not something one environment variable can do.
|
|
134
|
+
*
|
|
135
|
+
* An override this hook did not honour is announced, never silently dropped:
|
|
136
|
+
* an operator who set a budget and got a different one has to be able to see
|
|
137
|
+
* it. `Number.isSafeInteger` is the test rather than `isFinite`, because
|
|
138
|
+
* `spawnSync` throws on a fractional `timeout` — and a throw here would land in
|
|
139
|
+
* the backstop and open the gate completely.
|
|
140
|
+
* see hooks.test.ts › "runs the gate on the default budget when the override is unusable, instead of not running it"
|
|
141
|
+
*/
|
|
142
|
+
function budgetMs(env) {
|
|
143
|
+
const raw = env.RIG_DOD_BUDGET_MS;
|
|
144
|
+
if (raw === undefined || raw === '') return { ms: DEFAULT_BUDGET_MS, notice: null };
|
|
145
|
+
|
|
146
|
+
const declared = Number(raw);
|
|
147
|
+
if (!Number.isSafeInteger(declared) || declared <= 0) {
|
|
148
|
+
return {
|
|
149
|
+
ms: DEFAULT_BUDGET_MS,
|
|
150
|
+
notice:
|
|
151
|
+
`RIG_DOD_BUDGET_MS=${raw} is not a whole number of milliseconds above zero — ` +
|
|
152
|
+
`using the default ${DEFAULT_BUDGET_MS} ms instead.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (declared > DEFAULT_BUDGET_MS) {
|
|
156
|
+
return {
|
|
157
|
+
ms: DEFAULT_BUDGET_MS,
|
|
158
|
+
notice:
|
|
159
|
+
`RIG_DOD_BUDGET_MS=${raw} is above this gate's ceiling and was clamped to ` +
|
|
160
|
+
`${DEFAULT_BUDGET_MS} ms — the budget must expire before the Stop hook's ` +
|
|
161
|
+
`\`timeout\` in .claude/settings.json, or the gate is killed before it can report. ` +
|
|
162
|
+
`Raise both, in their own files, to raise it at all.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return { ms: declared, notice: null };
|
|
166
|
+
}
|
|
16
167
|
|
|
17
168
|
function main() {
|
|
18
169
|
let input;
|
|
19
170
|
try {
|
|
20
171
|
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
21
|
-
} catch {
|
|
22
|
-
return
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return failOpen(`the Stop payload could not be read: ${error.message}`);
|
|
23
174
|
}
|
|
24
175
|
if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
|
|
25
176
|
if (input.stop_hook_active) return 0;
|
|
26
177
|
|
|
178
|
+
const PROJECT_ROOT = projectRootOf();
|
|
179
|
+
|
|
27
180
|
try {
|
|
28
181
|
// The environment loses the variables that locate a repository first. A
|
|
29
182
|
// process started under a git hook inherits an absolute GIT_DIR, and this
|
|
@@ -31,23 +184,32 @@ function main() {
|
|
|
31
184
|
// different repository entirely: gated on somebody else's uncommitted
|
|
32
185
|
// work, or waved through despite its own.
|
|
33
186
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
187
|
+
// The canonical list, imported. This file used to carry four of the eight
|
|
188
|
+
// inline, justified by "it ships into generated projects, so it cannot
|
|
189
|
+
// import the canonical list from the generator" — a reason that expired the
|
|
190
|
+
// day `.claude/scripts/git-env.mjs` started shipping into generated projects
|
|
191
|
+
// too. Both are in the same layer, one directory apart.
|
|
192
|
+
//
|
|
193
|
+
// The four it dropped were not a smaller opinion. `GIT_OBJECT_DIRECTORY`
|
|
194
|
+
// pointing anywhere but this repository's objects makes `git status` exit
|
|
195
|
+
// 128 (`fatal: bad object HEAD`) — which the catch below reads as "not a git
|
|
196
|
+
// repo" and waves through, running the whole Definition-of-Done suite
|
|
197
|
+
// against a tree it never managed to read.
|
|
39
198
|
//
|
|
40
199
|
// 🔴 Limit: only THIS command is sanitised. The Definition-of-Done checks
|
|
41
200
|
// below run with the environment as given, because they are the project's
|
|
42
201
|
// own commands and their environment is the project's business.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
202
|
+
// `timeout` below is the preamble's own leash: this is the one call that
|
|
203
|
+
// runs before the budget's clock starts. A repository slow enough to exceed
|
|
204
|
+
// it throws into the catch and the checks run anyway — the safe direction,
|
|
205
|
+
// and the reason the bound can be generous. The options object stays terse
|
|
206
|
+
// on purpose; a sibling test matches it by a bounded window.
|
|
47
207
|
const status = execSync('git status --porcelain', {
|
|
208
|
+
cwd: PROJECT_ROOT,
|
|
48
209
|
encoding: 'utf8',
|
|
49
210
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
-
env,
|
|
211
|
+
env: withoutGitLocation(),
|
|
212
|
+
timeout: PREAMBLE_MARGIN_MS,
|
|
51
213
|
});
|
|
52
214
|
if (status.trim() === '') return 0;
|
|
53
215
|
} catch {
|
|
@@ -57,21 +219,110 @@ function main() {
|
|
|
57
219
|
let checks;
|
|
58
220
|
try {
|
|
59
221
|
checks = JSON.parse(readFileSync(new URL('./dod-checks.json', import.meta.url), 'utf8'));
|
|
60
|
-
} catch {
|
|
61
|
-
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// An absent config is the documented "nothing to gate" case, not an error
|
|
224
|
+
// to announce; anything else is a config that exists and cannot be used.
|
|
225
|
+
if (error.code === 'ENOENT') return 0;
|
|
226
|
+
return failOpen(`dod-checks.json could not be read: ${error.message}`);
|
|
62
227
|
}
|
|
63
|
-
if (!Array.isArray(checks)
|
|
228
|
+
if (!Array.isArray(checks)) return failOpen('dod-checks.json is not an array of commands');
|
|
229
|
+
// Judged per ENTRY, and the two ways to get this wrong are mirror images.
|
|
230
|
+
//
|
|
231
|
+
// Refusing the whole config on one bad entry hides a FAILING check standing
|
|
232
|
+
// next to the bad one: the refusal returns 0 and the neighbour never runs.
|
|
233
|
+
// Skipping the bad entry and returning 0 when nothing usable is left hides
|
|
234
|
+
// the bad entry itself: a declared check produced no verdict and the session
|
|
235
|
+
// still ended green. Both were shipped here, one after the other, and both
|
|
236
|
+
// are closed the same way — every usable entry runs, and a skipped entry is
|
|
237
|
+
// a check with no verdict, which blocks.
|
|
238
|
+
// see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
|
|
239
|
+
// see hooks.test.ts › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
|
|
240
|
+
//
|
|
241
|
+
// The predicate is exactly the three shapes `spawnSync` throws on, and no
|
|
242
|
+
// wider: the shell answers every other unrunnable string with 127, which is
|
|
243
|
+
// a verdict. Filtering past these three converts a real block into a skip.
|
|
244
|
+
// see hooks.test.ts › "refuses the stop for a config it could read but cannot use, and names the file to fix"
|
|
245
|
+
const runnable = (command) =>
|
|
246
|
+
typeof command === 'string' && command !== '' && !command.includes('\0');
|
|
247
|
+
const usable = checks.filter(runnable);
|
|
248
|
+
const skipped = checks.map((command, index) => index).filter((index) => !runnable(checks[index]));
|
|
249
|
+
|
|
250
|
+
// Nothing DECLARED is the documented "nothing to gate" case. Everything
|
|
251
|
+
// declared being unusable is not the same state, and reading them as one is
|
|
252
|
+
// what let a skipped check pass for a passing suite.
|
|
253
|
+
if (checks.length === 0) return 0;
|
|
254
|
+
|
|
255
|
+
// Said UP FRONT, because the block for it comes last: a check that failed is
|
|
256
|
+
// the more useful first message, and without this line a run that stops on a
|
|
257
|
+
// failing check would never mention the entry it could not run at all.
|
|
258
|
+
//
|
|
259
|
+
// Deliberately NOT the `[hook fail-open]` marker: that marker means the gate
|
|
260
|
+
// stayed open, and this path ends in a block. One vocabulary, one meaning.
|
|
261
|
+
if (skipped.length > 0) {
|
|
262
|
+
process.stderr.write(
|
|
263
|
+
`gate-stop-dod: dod-checks.json entr${skipped.length > 1 ? 'ies' : 'y'} at index ` +
|
|
264
|
+
`${skipped.join(', ')} cannot be run as a command and will not be measured.\n`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const budget = budgetMs(process.env);
|
|
269
|
+
if (budget.notice) process.stderr.write(`gate-stop-dod: ${budget.notice}\n`);
|
|
270
|
+
const deadline = Date.now() + budget.ms;
|
|
271
|
+
|
|
272
|
+
for (const command of usable) {
|
|
273
|
+
// A 1 ms floor rather than a branch for "the budget is already gone": the
|
|
274
|
+
// check then times out through the ordinary path, which names the command
|
|
275
|
+
// that did not fit instead of blaming it for spending what an earlier one
|
|
276
|
+
// spent. One path, and no branch that only a race can reach.
|
|
277
|
+
const result = spawnSync(command, {
|
|
278
|
+
cwd: PROJECT_ROOT,
|
|
279
|
+
shell: true,
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
282
|
+
timeout: Math.max(1, deadline - Date.now()),
|
|
283
|
+
// Output volume is not a verdict — below this bound. Past it the child is
|
|
284
|
+
// killed and its result is unknown, which the error branch treats as
|
|
285
|
+
// unmeasured rather than as a failure or a pass. The old 1 MB default
|
|
286
|
+
// made a chatty but PASSING suite look like a failing check.
|
|
287
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (result.error) {
|
|
291
|
+
// 🔴 The rule this file follows, at the point where it is decided.
|
|
292
|
+
//
|
|
293
|
+
// A spawn that never started is the gate's own problem — no shell, no
|
|
294
|
+
// file descriptors — and there is no verdict to lose, so it stays open
|
|
295
|
+
// and says so.
|
|
296
|
+
if (SPAWN_NEVER_STARTED.has(result.error.code)) {
|
|
297
|
+
return failOpen(`the check \`${command}\` could not be started: ${result.error.message}`);
|
|
298
|
+
}
|
|
299
|
+
// Everything else killed a check that WAS running — a timeout, a buffer
|
|
300
|
+
// overrun, anything unrecognised. `status` is null, so the verdict is
|
|
301
|
+
// unknown, and an unmeasured Definition of Done is not a passed one.
|
|
302
|
+
const timedOut = result.error.code === 'ETIMEDOUT';
|
|
303
|
+
process.stderr.write(
|
|
304
|
+
`STOP GATED — \`${command}\` produced no verdict (measured in ${PROJECT_ROOT}): ` +
|
|
305
|
+
`${result.error.message}\n` +
|
|
306
|
+
(timedOut
|
|
307
|
+
? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
|
|
308
|
+
`(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
|
|
309
|
+
`and the Stop hook's \`timeout\` in .claude/settings.json together, in that ` +
|
|
310
|
+
`order — the budget must stay the smaller of the two).\n`
|
|
311
|
+
: `It was killed before it could report — output past the buffer is the usual ` +
|
|
312
|
+
`cause. Run it yourself to see the result.\n`) +
|
|
313
|
+
`The check's outcome is UNMEASURED, which is not a pass. ` +
|
|
314
|
+
`This gate never fires twice in a row.\n`,
|
|
315
|
+
);
|
|
316
|
+
return 2;
|
|
317
|
+
}
|
|
64
318
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
execSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
68
|
-
} catch (error) {
|
|
69
|
-
const tail = String(error.stdout ?? '')
|
|
319
|
+
if (result.status !== 0) {
|
|
320
|
+
const tail = String(result.stdout ?? '')
|
|
70
321
|
.split('\n')
|
|
71
322
|
.slice(-15)
|
|
72
323
|
.join('\n');
|
|
73
324
|
process.stderr.write(
|
|
74
|
-
`STOP GATED — a Definition of Done check fails: ${command}\n` +
|
|
325
|
+
`STOP GATED — a Definition of Done check fails (measured in ${PROJECT_ROOT}): ${command}\n` +
|
|
75
326
|
(tail.trim() ? `${tail}\n` : '') +
|
|
76
327
|
`Fix the failure before ending the session. If this failure has resisted ` +
|
|
77
328
|
`repeated attempts, follow the stop rules instead: end with a written ` +
|
|
@@ -80,7 +331,31 @@ function main() {
|
|
|
80
331
|
return 2;
|
|
81
332
|
}
|
|
82
333
|
}
|
|
334
|
+
|
|
335
|
+
// Reached only when every check that COULD run passed. A skipped entry is a
|
|
336
|
+
// declared check that produced no verdict, and unmeasured is not a pass —
|
|
337
|
+
// the same rule the killed-check branch above answers to. It is reported
|
|
338
|
+
// last because a check that actually failed is the more useful message.
|
|
339
|
+
if (skipped.length > 0) {
|
|
340
|
+
const plural = skipped.length > 1;
|
|
341
|
+
process.stderr.write(
|
|
342
|
+
`STOP GATED — dod-checks.json entr${plural ? 'ies' : 'y'} at index ` +
|
|
343
|
+
`${skipped.join(', ')} cannot be run as a command, so ${plural ? 'those checks' : 'that check'} ` +
|
|
344
|
+
`produced no verdict. Every other check passed; these were never measured, ` +
|
|
345
|
+
`and unmeasured is not a pass. Fix the entr${plural ? 'ies' : 'y'} — an empty string, ` +
|
|
346
|
+
`a NUL byte or a non-string is not a command. This gate never fires twice in a row.\n`,
|
|
347
|
+
);
|
|
348
|
+
return 2;
|
|
349
|
+
}
|
|
83
350
|
return 0;
|
|
84
351
|
}
|
|
85
352
|
|
|
86
|
-
|
|
353
|
+
let code;
|
|
354
|
+
try {
|
|
355
|
+
code = main();
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// The backstop. A gate that throws must not make the session unquittable —
|
|
358
|
+
// but it must not look clean either.
|
|
359
|
+
code = failOpen(`the gate itself threw: ${error.message}`);
|
|
360
|
+
}
|
|
361
|
+
process.exit(code);
|