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,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: new-invariant
|
|
3
|
+
description: Turn a project rule into a mechanically enforced invariant — one stated rule, one PreToolUse hook that blocks its violation, one test for the hook. Use when a rule keeps being broken, when a review finding recurs, or when a post-mortem ends in "nothing stopped us doing that".
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Write, Edit, Bash
|
|
5
|
+
argument-hint: [the invariant, in one sentence]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You add **one** invariant to this project's enforced set, using the pattern in
|
|
9
|
+
`.claude/rules/invariants.md`: a stated rule, a hook that blocks its violation,
|
|
10
|
+
and a test for the hook. All three land in the same change.
|
|
11
|
+
|
|
12
|
+
## Step 0 — get the invariant from the project, not from your own judgement
|
|
13
|
+
|
|
14
|
+
🔴 **Do not invent the invariant.** If the argument did not name one, ask. The
|
|
15
|
+
whole point of this layer is that the project chooses what is load-bearing; an
|
|
16
|
+
agent that supplies its own answer has handed the project a rule nobody agreed
|
|
17
|
+
to, and that rule will be obeyed without thought.
|
|
18
|
+
|
|
19
|
+
Ask for, or find, three things:
|
|
20
|
+
|
|
21
|
+
1. **The rule, in one sentence**, in the form "X never happens in Y".
|
|
22
|
+
2. **What it cost the last time it was broken.** If nobody can finish that
|
|
23
|
+
sentence, stop and say so: an invariant with no incident behind it is a guess,
|
|
24
|
+
and a guessed invariant fires on honest work.
|
|
25
|
+
3. **The compliant form.** What the code should look like instead — you need it
|
|
26
|
+
for the allow case, and a rule with no stated alternative is a dead end.
|
|
27
|
+
|
|
28
|
+
Then check the fit against the table in `.claude/rules/invariants.md`. If it
|
|
29
|
+
cannot be decided from a single edit fragment — "this is too complex", "the
|
|
30
|
+
naming is off" — say so and stop: it belongs to `code-reviewer`, a lint rule or a
|
|
31
|
+
type, and forcing it into a hook produces a guard people fight.
|
|
32
|
+
|
|
33
|
+
Also check it is not already enforced. `grep` the existing hooks first; a second
|
|
34
|
+
guard for the same rule is two places to keep in step.
|
|
35
|
+
|
|
36
|
+
## Step 1 — write the failing test
|
|
37
|
+
|
|
38
|
+
TDD is not suspended here (`.claude/rules/workflow.md`). Copy
|
|
39
|
+
`guard-invariant.example.test.mjs` from this skill's directory as the shape, and
|
|
40
|
+
write the cases **before** the hook exists:
|
|
41
|
+
|
|
42
|
+
- **blocks** the violation — exit code 2, and the reason names the rule;
|
|
43
|
+
- **allows** the compliant form — exit code 0;
|
|
44
|
+
- **allows** a file outside the guarded scope — a guard that polices the whole
|
|
45
|
+
repo will be disabled by lunchtime;
|
|
46
|
+
- **allows** prose that merely mentions the violation. This case is not optional.
|
|
47
|
+
A guard that fires on a commit message, a doc line or a test fixture *about* the
|
|
48
|
+
rule produces **false positives**, and false positives are how guards get
|
|
49
|
+
switched off. Every real hook in this project strips or scopes text before
|
|
50
|
+
matching for exactly this reason.
|
|
51
|
+
|
|
52
|
+
Run it. Watch it fail for the right reason (the hook does not exist yet).
|
|
53
|
+
|
|
54
|
+
## Step 2 — write the hook
|
|
55
|
+
|
|
56
|
+
Copy `guard-invariant.example.mjs` from this skill's directory to
|
|
57
|
+
`.claude/hooks/guard-<invariant-name>.mjs` and replace the parts the comments
|
|
58
|
+
mark. Name the file after **the invariant**, never after the tool it intercepts —
|
|
59
|
+
`guard-core-purity`, not `guard-write`.
|
|
60
|
+
|
|
61
|
+
Hold the contract:
|
|
62
|
+
|
|
63
|
+
- JSON payload on stdin; **exit 0 = allow, exit 2 = block**; stderr is the reason
|
|
64
|
+
the agent reads, so write it as an instruction — what to do instead, and where
|
|
65
|
+
the rule is stated.
|
|
66
|
+
- **Scope first, match second.** Return 0 immediately for files the invariant does
|
|
67
|
+
not cover.
|
|
68
|
+
- **Fail open** on a malformed payload or any internal error. A crashed guard that
|
|
69
|
+
blocks everything gets deleted within the hour.
|
|
70
|
+
- Zero dependencies, `node:` builtins only.
|
|
71
|
+
|
|
72
|
+
Run the test. Make it pass. Do not weaken a case to get there.
|
|
73
|
+
|
|
74
|
+
## Step 3 — wire it, or it enforces nothing
|
|
75
|
+
|
|
76
|
+
Add the hook to `.claude/settings.json` under `PreToolUse`, in the block whose
|
|
77
|
+
`matcher` covers the tools the invariant needs (`Write|Edit` for file content,
|
|
78
|
+
`Bash` for commands). An unwired hook is a file that passes its own test and
|
|
79
|
+
guards nothing — verify by triggering the violation once, for real, and watching
|
|
80
|
+
it be refused.
|
|
81
|
+
|
|
82
|
+
## Step 4 — state the rule where a reader will look
|
|
83
|
+
|
|
84
|
+
Add the sentence to the relevant file in `.claude/rules/` (or
|
|
85
|
+
`.claude/rules/invariants.md` if it fits nowhere else), next to a line saying
|
|
86
|
+
which hook enforces it and what the hook cannot see. A check with no stated rule
|
|
87
|
+
is a booby trap: someone will hit it, not understand it, and route around it.
|
|
88
|
+
|
|
89
|
+
## Done when
|
|
90
|
+
|
|
91
|
+
- [ ] The test failed before the hook existed, and passes now
|
|
92
|
+
- [ ] The violation is refused in a real session, not only in the test
|
|
93
|
+
- [ ] The hook is wired in `settings.json`
|
|
94
|
+
- [ ] The rule is written down, with the hook named next to it
|
|
95
|
+
- [ ] The hook guards **one** invariant, and its scope is as narrow as the rule
|
|
96
|
+
|
|
97
|
+
## A candidate that is already sitting there
|
|
98
|
+
|
|
99
|
+
The stack rules say service code logs through the shared structured logger, never
|
|
100
|
+
`console.log` — and nothing enforces it. That is the invariant the shipped example
|
|
101
|
+
encodes, so if the rule matters in your project, promoting the example into a real
|
|
102
|
+
hook is a copy, a rename and a wiring line.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// TEMPLATE — copy to .claude/hooks/guard-<invariant-name>.mjs and edit the three
|
|
2
|
+
// marked parts. It is real, working code, not a sketch: the `new-invariant` skill
|
|
3
|
+
// tests it, so what you copy is known to work before you change anything.
|
|
4
|
+
//
|
|
5
|
+
// The invariant it encodes as the example:
|
|
6
|
+
//
|
|
7
|
+
// Service code logs through the shared structured logger, never `console.log`.
|
|
8
|
+
//
|
|
9
|
+
// That one is a genuine candidate — the stack rules state it and nothing enforces
|
|
10
|
+
// it — but it is here to show the SHAPE. Replace it with your project's invariant.
|
|
11
|
+
//
|
|
12
|
+
// Contract (Claude Code): JSON payload on stdin; exit 0 = allow, exit 2 = block,
|
|
13
|
+
// and stderr is shown to the agent as the reason. Zero dependencies.
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
// ── PART 1: the scope ────────────────────────────────────────────────────────
|
|
17
|
+
// Which files the invariant covers. Scope FIRST, match second: a guard that
|
|
18
|
+
// polices the whole repo fires on honest work and gets switched off by lunchtime.
|
|
19
|
+
const inScope = (filePath) => /^services\/[^/]+\/src\//.test(filePath);
|
|
20
|
+
|
|
21
|
+
// ── PART 2: the violation ────────────────────────────────────────────────────
|
|
22
|
+
// What the invariant forbids, decided from the text of THIS edit alone.
|
|
23
|
+
// Return a reason string, or null to allow.
|
|
24
|
+
const violation = (text) => {
|
|
25
|
+
// Strip line comments and string literals before matching: a doc line or a
|
|
26
|
+
// fixture that merely MENTIONS the forbidden form is prose, not a violation.
|
|
27
|
+
// Skipping this step is the single most common way a guard earns false
|
|
28
|
+
// positives — and false positives are how guards get disabled.
|
|
29
|
+
const code = text
|
|
30
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
31
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1')
|
|
32
|
+
.replace(/`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
|
|
33
|
+
|
|
34
|
+
const match = /\bconsole\.(log|info|warn|error|debug)\s*\(/.exec(code);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
return (
|
|
37
|
+
`BLOCKED — service code logs through the shared structured logger, never ` +
|
|
38
|
+
`console.${match[1]}(). Structured JSON lines are what makes a production ` +
|
|
39
|
+
`incident searchable; a bare console call is invisible the moment it matters. ` +
|
|
40
|
+
`Import the logger from the shared package instead. See .claude/rules/ for the rule.`
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ── PART 3: nothing below here normally changes ──────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** The text an edit is about to introduce, across the tool shapes that carry one. */
|
|
47
|
+
const incomingText = (toolInput) =>
|
|
48
|
+
[toolInput?.content, toolInput?.new_string, toolInput?.new_str]
|
|
49
|
+
.filter((value) => typeof value === 'string')
|
|
50
|
+
.join('\n');
|
|
51
|
+
|
|
52
|
+
function main() {
|
|
53
|
+
let input;
|
|
54
|
+
try {
|
|
55
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return 0; // malformed payload → fail open, never make the session unusable
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const filePath = String(input?.tool_input?.file_path ?? '').replaceAll('\\', '/');
|
|
61
|
+
if (!filePath || !inScope(filePath)) return 0;
|
|
62
|
+
|
|
63
|
+
const text = incomingText(input?.tool_input);
|
|
64
|
+
if (!text) return 0;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const reason = violation(text);
|
|
68
|
+
if (reason) {
|
|
69
|
+
process.stderr.write(`${reason}\n`);
|
|
70
|
+
return 2;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return 0; // a guard that crashes must not block the work
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
process.exit(main());
|
package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// TEMPLATE — the third of the three parts (.claude/rules/invariants.md).
|
|
2
|
+
//
|
|
3
|
+
// Copy alongside your hook and rewrite the cases for your invariant. Run with:
|
|
4
|
+
//
|
|
5
|
+
// node --test .claude/hooks/guard-<invariant-name>.test.mjs
|
|
6
|
+
//
|
|
7
|
+
// The four cases below are not a suggestion — they are the minimum set. The last
|
|
8
|
+
// two are the ones people skip, and they are exactly the ones that decide whether
|
|
9
|
+
// the guard survives contact with real work: a guard that polices out-of-scope
|
|
10
|
+
// files, or that fires on prose about its own rule, gets switched off.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const hook = join(dirname(fileURLToPath(import.meta.url)), 'guard-invariant.example.mjs');
|
|
18
|
+
|
|
19
|
+
/** Feed a synthetic PreToolUse payload to the hook, exactly as Claude Code does. */
|
|
20
|
+
const runHook = (filePath, content) =>
|
|
21
|
+
new Promise((resolve, reject) => {
|
|
22
|
+
const child = execFile(process.execPath, [hook], (error, stdout, stderr) => {
|
|
23
|
+
resolve({ code: error ? (error.code ?? 1) : 0, stderr });
|
|
24
|
+
});
|
|
25
|
+
if (!child.stdin) return reject(new Error('no stdin'));
|
|
26
|
+
child.stdin.write(
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
hook_event_name: 'PreToolUse',
|
|
29
|
+
tool_name: 'Write',
|
|
30
|
+
tool_input: { file_path: filePath, content },
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
child.stdin.end();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const IN_SCOPE = 'services/api/src/handlers/create-note.ts';
|
|
37
|
+
|
|
38
|
+
describe('the example invariant: service code logs through the shared logger', () => {
|
|
39
|
+
it('blocks the violation, and the reason names what to do instead', async () => {
|
|
40
|
+
const result = await runHook(IN_SCOPE, 'console.log("created", id);');
|
|
41
|
+
assert.equal(result.code, 2);
|
|
42
|
+
assert.match(result.stderr, /logger/i);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('blocks the other console methods too, not just log', async () => {
|
|
46
|
+
for (const method of ['info', 'warn', 'error', 'debug']) {
|
|
47
|
+
const result = await runHook(IN_SCOPE, `console.${method}("x");`);
|
|
48
|
+
assert.equal(result.code, 2, method);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('allows the compliant form', async () => {
|
|
53
|
+
const result = await runHook(IN_SCOPE, 'logger.info({ msg: "created", id });');
|
|
54
|
+
assert.equal(result.code, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('allows files outside the guarded scope', async () => {
|
|
58
|
+
// A guard with repo-wide reach fires on honest work and gets disabled.
|
|
59
|
+
for (const path of ['scripts/one-off.mjs', 'apps/web/src/app/page.tsx', 'README.md']) {
|
|
60
|
+
const result = await runHook(path, 'console.log("fine here");');
|
|
61
|
+
assert.equal(result.code, 0, path);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('allows prose that merely mentions the violation', async () => {
|
|
66
|
+
// The false-positive case. Skipping it is how a guard ends up switched off.
|
|
67
|
+
for (const content of [
|
|
68
|
+
'// never use console.log here — use the logger',
|
|
69
|
+
'/* console.error is banned in service code */',
|
|
70
|
+
'const banned = "console.log(";',
|
|
71
|
+
'const help = `do not call console.warn(...)`;',
|
|
72
|
+
]) {
|
|
73
|
+
const result = await runHook(IN_SCOPE, content);
|
|
74
|
+
assert.equal(result.code, 0, content);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('fails open on a payload it does not understand', async () => {
|
|
79
|
+
const result = await new Promise((resolve, reject) => {
|
|
80
|
+
const child = execFile(process.execPath, [hook], (error) => {
|
|
81
|
+
resolve({ code: error ? (error.code ?? 1) : 0 });
|
|
82
|
+
});
|
|
83
|
+
if (!child.stdin) return reject(new Error('no stdin'));
|
|
84
|
+
child.stdin.write('{not json');
|
|
85
|
+
child.stdin.end();
|
|
86
|
+
});
|
|
87
|
+
assert.equal(result.code, 0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pr-ship
|
|
3
|
+
description: The pre-merge gate. MUST run before a PR is opened or merged — runs the full check suite, fans out the reviewer gates, walks the DoD, and returns a SHIP / HOLD verdict with named blockers.
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Bash, Task
|
|
5
|
+
argument-hint: [branch-or-pr]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are the last gate before a change ships. You verify and report; you do not
|
|
9
|
+
fix — a HOLD goes back to the author (usually the main session) with named
|
|
10
|
+
blockers.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
0. **Count this round before you spend on it.** Run it on the branch **under
|
|
15
|
+
review** — if the PR is not checked out, do that first (step 2's warning covers
|
|
16
|
+
why); on a detached checkout the command refuses rather than counting under
|
|
17
|
+
`HEAD`:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
node .claude/scripts/queue/index.mjs gate-round --branch "$(git rev-parse --abbrev-ref HEAD)"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Read the exit code, not just its sign.**
|
|
24
|
+
|
|
25
|
+
- **0** — proceed to step 1.
|
|
26
|
+
- **2** — the rounds are spent. Return `HOLD` with one blocker whose `rule`
|
|
27
|
+
is *gate rounds exhausted*, quoting the round count in its `note`. Do not
|
|
28
|
+
run the fan-out.
|
|
29
|
+
- **1** — the command itself failed (unreadable config, unreadable counter,
|
|
30
|
+
detached checkout), **or the checkout cannot ship**: a dirty working tree,
|
|
31
|
+
a branch with no upstream, or commits the upstream has not seen. Nothing
|
|
32
|
+
was counted. This is **not** an exhausted cap: fix the cause — commit and
|
|
33
|
+
push — and run step 0 again. Treating it as exhaustion escalates a healthy
|
|
34
|
+
item. A rig with no remote can never pass this step — by design: the PR
|
|
35
|
+
flow, and this gate with it, begins when the project has a remote
|
|
36
|
+
(`workflow.md`, "PR flow"). The refusal exists because two rounds were once counted ahead of a
|
|
37
|
+
commit pre-commit then refused, so the counter and the fan-out's verdicts
|
|
38
|
+
named a head that never shipped (AR-141) — pinned in the generator's
|
|
39
|
+
`test/template/gate-rounds.test.ts` — absent in a generated rig — ›
|
|
40
|
+
"refuses to count a round on a dirty tree, and counts nothing".
|
|
41
|
+
|
|
42
|
+
The cap is **2 by default**, and no shipped `.claude/queue.json` carries the key
|
|
43
|
+
— the default lives in `core.mjs` as `DEFAULT_MAX_GATE_ROUNDS`. A project that
|
|
44
|
+
wants a different cap sets `options.maxGateRounds` there, which in a rig whose
|
|
45
|
+
`queue.json` is composed means changing what composes it, not editing the file.
|
|
46
|
+
Rounds are counted per branch in `.claude/gate-rounds.json`, so the count outlives
|
|
47
|
+
the session that spent them.
|
|
48
|
+
|
|
49
|
+
⚠ **Nothing forces this call.** No hook launches the gate, so step 0 holds
|
|
50
|
+
because it is written here — the same standing as every other step. What it
|
|
51
|
+
removes is the honest failure mode, a run that keeps re-reviewing because no
|
|
52
|
+
check ever went red; it does not stop a session that skips it.
|
|
53
|
+
|
|
54
|
+
1. **The diff first — and the branch's own premises next.** Establish what is actually shipping: fetch, then diff
|
|
55
|
+
against the **remote** default branch (`origin/<default>`), not a local
|
|
56
|
+
copy that may be behind — diagnosing from stale local code produces
|
|
57
|
+
confidently-wrong reviews. Everything below is scoped to this diff.
|
|
58
|
+
|
|
59
|
+
Then, on the fetched ref, ask whether the branch is still the branch the run
|
|
60
|
+
took up (AR-134):
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It compares two sources and names each one that moved: the item's `updatedAt`
|
|
67
|
+
against the take-up snapshot `next` recorded (`task:updatedAt`), and what the
|
|
68
|
+
default branch changed since this branch forked, on the paths the branch
|
|
69
|
+
touches or a `check-premises` record in this run cited (`main:<path>`). It
|
|
70
|
+
journals one `revalidation` event at `point: BEFORE_PR`; **exit code 2 is a HOLD**, with one blocker per named source: re-read the item, or the default
|
|
71
|
+
branch on that path, record what the re-read concluded —
|
|
72
|
+
`node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
|
|
73
|
+
— and come back through step 0. A hold with no outcome is counted by the
|
|
74
|
+
report as a re-read the run skipped. Exit 0 with
|
|
75
|
+
`unverifiable` means the task side could not be compared — no take-up
|
|
76
|
+
snapshot in this run, or no marker — and is stated in the evidence, not read
|
|
77
|
+
as a pass. Exit 1 is the command refusing (unknown point, no ticket, a base
|
|
78
|
+
that is not a revision): fix the call. Its limits are its own header's; the
|
|
79
|
+
cited-path set is a labelled assumption, not a recorded fact.
|
|
80
|
+
2. **Route the diff before you spend on it.** This gate always ran its most
|
|
81
|
+
expensive path, so a typo fix in a README bought the same fan-out as a
|
|
82
|
+
rewrite of the storage layer. The dispatcher decides which lane the change
|
|
83
|
+
earns, in ascending order of cost:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
`deterministic` → `fast-path` → `model`
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
node .claude/scripts/decision-router.mjs --base origin/<default> --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Pass the same base step 1 resolved.** The default is `origin/HEAD`, a ref
|
|
94
|
+
`git clone` sets and `git init` + `git remote add` does not — and a base that
|
|
95
|
+
is merely *different* rather than missing does not fail at all: the router
|
|
96
|
+
routes a narrower file set than the one this gate reviews, and the narrower
|
|
97
|
+
set is the one that can lose a risk flag.
|
|
98
|
+
|
|
99
|
+
⚠ **It routes the committed diff, `<base>...<head>` — never the working
|
|
100
|
+
tree.** An uncommitted edit is invisible to it, including reviewer fixes you
|
|
101
|
+
have applied but not committed, so commit before routing. If this gate was
|
|
102
|
+
invoked on a PR that is not checked out, check it out first.
|
|
103
|
+
|
|
104
|
+
`decision-router` reads the changed paths and returns the lane plus the
|
|
105
|
+
reviewers that lane requires. **Risk flags escalate ahead of all three** — a
|
|
106
|
+
file under a declared elevated path, a dependency manifest or a path naming
|
|
107
|
+
auth, secrets, tokens, sessions or permissions, a deleted test (including the
|
|
108
|
+
deletion half of a rename) — and any one of them lands the change in `model`
|
|
109
|
+
however cheap it otherwise looked. The elevated-path flag has one carve-out,
|
|
110
|
+
inherited from the gate sweep rather than invented here: `.md`/`.mdx` files
|
|
111
|
+
and test paths that provision nothing are **inert**, so `infra/README.md`
|
|
112
|
+
does not escalate while `infra/stack.ts` does. Note the mechanism is those
|
|
113
|
+
two extensions and test paths — **not** this router's own notion of prose,
|
|
114
|
+
which is `.md`/`.txt`. Neither set contains the other, and both differences
|
|
115
|
+
are deliberate: aligning the sweep to the router takes `requirements.txt` in
|
|
116
|
+
an elevated directory out of escalation, and aligning the router to the
|
|
117
|
+
sweep puts executable `.mdx` back on the prose lane. A rulebook file is
|
|
118
|
+
never inert.
|
|
119
|
+
|
|
120
|
+
🔴 **The lane is on stdout; the exit code says only that the router ran.**
|
|
121
|
+
Never chain it on `&&`, and never read `0` as "cheap" — that misreading turns
|
|
122
|
+
this gate into a rubber stamp. **Exit 1 is not a lane**: it means nothing was
|
|
123
|
+
routed — an unreadable diff, an empty file list, a project declaring no
|
|
124
|
+
elevated path, an unrecognised flag, a base or head that is not a revision,
|
|
125
|
+
or a run directory that is not there. Treat it as `model` and fix the cause;
|
|
126
|
+
it is never a reason to skip the gate.
|
|
127
|
+
|
|
128
|
+
🔴 **One rule covers every outcome: read STDOUT.** If a lane printed, that is
|
|
129
|
+
the answer; if stdout is empty, treat the change as `model`. Do **not** key
|
|
130
|
+
on the `run journal:` prefix — both journal failures wear it and they end
|
|
131
|
+
differently. A trace that can no longer accept records ends the *trace*, not
|
|
132
|
+
the routing, so the lane still prints and the exit stays 0 (start the next
|
|
133
|
+
run in a new run directory). A run directory that was never there exits 1
|
|
134
|
+
with nothing routed.
|
|
135
|
+
|
|
136
|
+
What each lane buys:
|
|
137
|
+
|
|
138
|
+
- `deterministic` — every changed file is a derived artifact, git says it was
|
|
139
|
+
`modified` or `removed`, and **none of them sits under a declared elevated
|
|
140
|
+
path**. The lane's floor is empty; step 4's triggers still apply on top.
|
|
141
|
+
- `fast-path` — documentation outside the rulebook, plus any derived file
|
|
142
|
+
travelling with it under those same two rules. `prose-reviewer` is the
|
|
143
|
+
floor.
|
|
144
|
+
- `model` — everything else, and `code-reviewer` runs on it **always**,
|
|
145
|
+
with the triggers in step 4 beside it. Anything the router cannot classify
|
|
146
|
+
lands here.
|
|
147
|
+
|
|
148
|
+
🔴 **State what the cheap lanes give up, because they do give something up.**
|
|
149
|
+
Dropping `code-reviewer` drops two of its checklist items that are *not*
|
|
150
|
+
about code — contract drift, and "contradicts the item it claims to
|
|
151
|
+
implement". Neither is decidable from paths. So the cheap lanes carry the
|
|
152
|
+
item text to whatever cold reader they do launch (step 4).
|
|
153
|
+
|
|
154
|
+
The `deterministic` lane launches none, and that rests on one claim: a file
|
|
155
|
+
is generator output, so a check already catches its drift. The claim needs a
|
|
156
|
+
prior output to have drifted **from** — which is why an added, copied,
|
|
157
|
+
renamed or status-less entry is refused the lane, and why a **test snapshot
|
|
158
|
+
is not a derived artifact here at all**. A snapshot is the behaviour claim,
|
|
159
|
+
rewritten by the run that then passes by construction; routing one to a lane
|
|
160
|
+
with no reviewer would be weakening a test with a dispatcher.
|
|
161
|
+
|
|
162
|
+
If you disagree with a lane, run the expensive one — never argue a diff
|
|
163
|
+
downward.
|
|
164
|
+
3. **The project's own checks.** Run the full check suite the project defines
|
|
165
|
+
(see its README / package scripts). Any failure is an instant HOLD — never
|
|
166
|
+
argue with a red check, never rerun flakiness to green
|
|
167
|
+
(`.claude/rules/workflow.md`).
|
|
168
|
+
4. **Reviewer fan-out.** The lane from step 2 sets the **floor**:
|
|
169
|
+
|
|
170
|
+
- `model` → launch the `code-reviewer` agent on the diff, always;
|
|
171
|
+
- `fast-path` → launch `prose-reviewer`;
|
|
172
|
+
- `deterministic` → the lane's floor is empty. The triggers below still
|
|
173
|
+
apply: a floor of zero is not permission to skip one.
|
|
174
|
+
|
|
175
|
+
**Whatever you launch, pass it the text of the queue item this branch
|
|
176
|
+
implements.** A reviewer given only a diff cannot check the change against
|
|
177
|
+
what was asked: a cold context has no way to know, and reconstructing it from
|
|
178
|
+
the PR description would mean trusting the run under review. If there is no
|
|
179
|
+
item — owner-directed work, a hotfix — say so when launching, and the
|
|
180
|
+
reviewer skips that check openly instead of guessing at it.
|
|
181
|
+
|
|
182
|
+
🔴 **The triggers below are lane-independent and may only ADD.** They read
|
|
183
|
+
*what the code does*; the router reads *paths*, and a path cannot say that a
|
|
184
|
+
module parses untrusted input — measured on this router's own first run,
|
|
185
|
+
which named `code-reviewer` and `prose-reviewer` for a diff doing exactly
|
|
186
|
+
that. So a `fast-path` diff still reaches `security-scanner` when it trips a
|
|
187
|
+
trigger, and no lane removes one:
|
|
188
|
+
|
|
189
|
+
- `security-scanner` when the diff touches auth, secrets or configuration,
|
|
190
|
+
input parsing, file handling, new outbound calls, dependency changes;
|
|
191
|
+
- `prose-reviewer` when the diff touches a rule file, a skill, an agent spec,
|
|
192
|
+
a decision record under `docs/decisions/`, `CLAUDE.md` or the README — a
|
|
193
|
+
rulebook that overstates its own enforcement fails silently and in the
|
|
194
|
+
direction of false confidence;
|
|
195
|
+
- an infrastructure review when it touches infrastructure (the stack layer
|
|
196
|
+
names the agent).
|
|
197
|
+
|
|
198
|
+
Run them as subagents, in parallel — a fresh context reviews better than the
|
|
199
|
+
session that wrote the code (see `.claude/rules/workflow.md`,
|
|
200
|
+
"Review-context isolation").
|
|
201
|
+
|
|
202
|
+
🔴 **Record the set you launched, as you launch it.** The router journals the
|
|
203
|
+
set it *routed*; the triggers above may only add, so what you actually
|
|
204
|
+
launched is a different list and this is the only place that knows it:
|
|
205
|
+
**Record the fan-out even when the launched set is empty.**
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
node --input-type=module -e '
|
|
209
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
210
|
+
if (!runDir) process.exit(0); // an undeclared run has no trace to write
|
|
211
|
+
const journal = await import("./.claude/scripts/run-journal.mjs");
|
|
212
|
+
try {
|
|
213
|
+
console.log(journal.recordDecision({
|
|
214
|
+
runDir,
|
|
215
|
+
gate: "reviewer-fan-out",
|
|
216
|
+
verdict: "launched",
|
|
217
|
+
// `argv[1]` is the first argument after the script — `argv[0]` is the
|
|
218
|
+
// node binary itself, and reading it here would record that path as
|
|
219
|
+
// the commit and shift every reviewer along by one.
|
|
220
|
+
headSha: process.argv[1],
|
|
221
|
+
reviewers: process.argv.slice(2), // every reviewer you just started
|
|
222
|
+
now: new Date().toISOString(),
|
|
223
|
+
}));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!journal.isTraceExhausted?.(error)) throw error;
|
|
226
|
+
process.stderr.write(`run journal: ${error.message}\n the fan-out above was NOT recorded.\n`);
|
|
227
|
+
}
|
|
228
|
+
' "$(git rev-parse HEAD)" <reviewer> <reviewer> …
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Substitute the reviewers you actually started — the point of the record is
|
|
232
|
+
that it is not derivable from the lane, so a list copied from this example
|
|
233
|
+
records somebody else's fan-out. **One argument each**, unquoted — a single
|
|
234
|
+
quoted string arrives as one reviewer whose name is both of theirs joined by
|
|
235
|
+
a space, and `recordDecision` accepts it: it checks for a list of strings and
|
|
236
|
+
nothing about what a name is.
|
|
237
|
+
|
|
238
|
+
**Launched is not answered, and the difference is the point.** The records
|
|
239
|
+
below are written per verdict that *parsed* — so a reviewer whose report came
|
|
240
|
+
back `incomplete` produced no record at all, and without this one nothing
|
|
241
|
+
afterwards can tell "that reviewer was never launched" from "it was launched
|
|
242
|
+
and did not answer". Those need opposite responses, and the round that has to
|
|
243
|
+
tell them apart is the one reading this trace after a compaction.
|
|
244
|
+
|
|
245
|
+
🔴 **Check each reviewer's answer before you believe it.** Every gate spec
|
|
246
|
+
ends in one fenced `json` block; save what each subagent returned and run
|
|
247
|
+
|
|
248
|
+
```sh
|
|
249
|
+
node .claude/scripts/verdict.mjs check <report> <the reviewer you launched>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
on it **before** you decide anything from it. 🔴 **Name the reviewer.** You
|
|
253
|
+
launched two or three of them and the check reads the report's LAST block, so
|
|
254
|
+
without the name a report carrying `code-reviewer`'s `HOLD` followed by
|
|
255
|
+
anything else answers about the anything else — a stop you never see. With
|
|
256
|
+
the name, a block claiming another gate is refused.
|
|
257
|
+
|
|
258
|
+
Exit 0 prints the parsed
|
|
259
|
+
verdict — including for a `HOLD`, because a reviewer that *found* something
|
|
260
|
+
is not a reviewer that broke. Exit 1 means the report does not end in a
|
|
261
|
+
verdict this gate can act on: no block or one that is not JSON, a word no
|
|
262
|
+
gate returns, a blocker naming no rule, or the case this check exists for —
|
|
263
|
+
a `HOLD` with an empty `blockers` list, and its mirror, a `SHIP` carrying
|
|
264
|
+
one. Where the shared vocabulary names the gate it also refuses a word that
|
|
265
|
+
belongs to a different one; for a gate it does not name — a reviewer this
|
|
266
|
+
project or its stack added — that one check is not made
|
|
267
|
+
(`.claude/scripts/lib/verdict.mjs`, limit 1), so read such a report's word
|
|
268
|
+
against the reviewer's own spec yourself.
|
|
269
|
+
|
|
270
|
+
Such a report is **`incomplete`**: the reviewer did not answer. Read it as
|
|
271
|
+
neither a pass nor a stop — relaunch that one reviewer with the shape, or
|
|
272
|
+
record `incomplete` as a blocker of your own. Never read "no blockers
|
|
273
|
+
parsed" as "no blockers found".
|
|
274
|
+
|
|
275
|
+
**Then journal each verdict that parsed**, so the round's blockers outlive
|
|
276
|
+
the session that read them — a gate round is counted and finite, and an
|
|
277
|
+
escalation written after a compaction otherwise carries the round count and
|
|
278
|
+
nothing about what was found:
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
node --input-type=module -e '
|
|
282
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
283
|
+
if (!runDir) process.exit(0); // an undeclared run has no trace to write
|
|
284
|
+
// `check` prints nothing when it refuses, and a refused report is not a
|
|
285
|
+
// verdict to record — parse only what it actually printed.
|
|
286
|
+
if (!process.argv[1]) process.exit(0);
|
|
287
|
+
const journal = await import("./.claude/scripts/run-journal.mjs");
|
|
288
|
+
const v = JSON.parse(process.argv[1]); // the block `check` printed
|
|
289
|
+
try {
|
|
290
|
+
console.log(journal.recordDecision({
|
|
291
|
+
runDir,
|
|
292
|
+
gate: v.gate,
|
|
293
|
+
verdict: v.verdict,
|
|
294
|
+
blockers: v.blockers,
|
|
295
|
+
headSha: v.headSha, // absent when the gate named no commit
|
|
296
|
+
now: new Date().toISOString(),
|
|
297
|
+
}));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// The split `queue/index.mjs` makes, for the reason `run-journal.mjs`
|
|
300
|
+
// gives: a trace that can accept no more records is over, and the GATE
|
|
301
|
+
// is not. Anything else is this call mis-declared, and stops it.
|
|
302
|
+
if (!journal.isTraceExhausted?.(error)) throw error;
|
|
303
|
+
process.stderr.write(`run journal: ${error.message}\n the verdict above was NOT recorded.\n`);
|
|
304
|
+
}
|
|
305
|
+
' "$(node .claude/scripts/verdict.mjs check <report> <reviewer>)"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
🔴 **The guard and the `catch` are the contract, not decoration** — an
|
|
309
|
+
exhausted trace must cost this round its record and nothing else. A round is
|
|
310
|
+
counted and capped, so a crash here spends one on a journal that was never
|
|
311
|
+
the thing under review.
|
|
312
|
+
5. 🔴 **Coverage — check your own fan-out before you believe it.** You recorded
|
|
313
|
+
what the route asked for, what you launched and what came back; this is the
|
|
314
|
+
step that compares them, and it is the only one that does — nothing else in
|
|
315
|
+
this gate would notice a reviewer that never answered:
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
node .claude/scripts/verdict.mjs coverage "$(git rev-parse HEAD)"
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
For a **declared run**, exit 0 is coverage. **Exit 1 is a `HOLD`.** A
|
|
322
|
+
reason-only unreadable-round failure always prints the evidence boundary;
|
|
323
|
+
remedies appear only when recovery is unambiguous. Reviewer lists cover the
|
|
324
|
+
four comparable cases — never launched (launch it),
|
|
325
|
+
launched and silent (go and read why), answered without naming a commit, or
|
|
326
|
+
answered for another commit (the head moved under the round). Record either
|
|
327
|
+
kind as a blocker of yours, in the same list as a failing check.
|
|
328
|
+
|
|
329
|
+
Two limits, stated because a step that looks mechanical is trusted like one.
|
|
330
|
+
It reads **this run's journal**, so with unset `RIG_RUN_DIR` the check is
|
|
331
|
+
skipped; exit 0 is then an honest nothing, not coverage. And it cannot see a
|
|
332
|
+
round that never reached this skill at all: a session that skips `pr-ship`
|
|
333
|
+
skips its coverage check with it (`docs/decisions/gate-coverage.md`).
|
|
334
|
+
6. **DoD walk.** Check the Definition of Done list in
|
|
335
|
+
`.claude/rules/workflow.md` item by item — test-first evidence, nothing
|
|
336
|
+
skipped or weakened, boundaries respected, docs updated, autonomy tier
|
|
337
|
+
honored.
|
|
338
|
+
7. **Named checks only.** The merge criterion is the project's *named* required
|
|
339
|
+
checks, all green. "Some checks passed" is not a criterion; an unnamed
|
|
340
|
+
green wall hides a red brick. Two traps here, both observed in the wild:
|
|
341
|
+
status watchers can exit while checks are **still unregistered** — poll the
|
|
342
|
+
head SHA's check runs and require each expected check *by name*; and a
|
|
343
|
+
result list containing only a scanner (no build, no tests) is **not** done,
|
|
344
|
+
it is a check set that has not arrived yet.
|
|
345
|
+
|
|
346
|
+
## Verdict
|
|
347
|
+
|
|
348
|
+
- `SHIP` — checks green, no blocking findings, DoD holds. Say so explicitly; a
|
|
349
|
+
clean gate is a real result.
|
|
350
|
+
- `HOLD` — name every blocker: the failing check by name, the reviewer finding
|
|
351
|
+
with its file:line, or the DoD item that does not hold. Blocking findings are
|
|
352
|
+
resolved, not argued with; after fixes, the gate runs again from **step 0** —
|
|
353
|
+
which counts the new round and is what makes "again" finite. Re-entering at
|
|
354
|
+
step 1 skips the counter, and the unbounded rounds this gate measured are
|
|
355
|
+
exactly what that produces.
|
|
356
|
+
|
|
357
|
+
Your own answer is a verdict like any other, so it ends the same way: prose for
|
|
358
|
+
the author, then **exactly one** fenced `json` block, and nothing after it.
|
|
359
|
+
|
|
360
|
+
```json
|
|
361
|
+
{
|
|
362
|
+
"gate": "pr-ship",
|
|
363
|
+
"verdict": "HOLD",
|
|
364
|
+
"blockers": [
|
|
365
|
+
{ "rule": "required check `ci`", "note": "red on the head commit: 3 tests failed" },
|
|
366
|
+
{
|
|
367
|
+
"file": "packages/core/src/note.ts",
|
|
368
|
+
"line": 42,
|
|
369
|
+
"rule": "code-reviewer — checklist item 2",
|
|
370
|
+
"note": "the failing case was deleted rather than fixed"
|
|
371
|
+
}
|
|
372
|
+
],
|
|
373
|
+
"advisories": [],
|
|
374
|
+
"evidence": ["lane: model", "reviewers: code-reviewer, prose-reviewer"],
|
|
375
|
+
"headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- `verdict` is `SHIP` or `HOLD` — this gate has no third answer.
|
|
380
|
+
- A failing check and a DoD line have no location, so `file` and `line` are
|
|
381
|
+
omitted there; every blocker names the `rule` it came from either way.
|
|
382
|
+
- A `HOLD` with an empty `blockers` list, and a `SHIP` carrying one, are both
|
|
383
|
+
answers this gate may not give. **Run the same command on your own block
|
|
384
|
+
before you return it** — `node .claude/scripts/verdict.mjs check <your-block>
|
|
385
|
+
pr-ship` — and fix what it refuses. Nothing downstream re-checks the gate's
|
|
386
|
+
own answer, so this call is the only thing between a malformed verdict and
|
|
387
|
+
whoever acts on it.
|
|
388
|
+
- **`headSha` is the commit you gated** — `git rev-parse HEAD`, the same one
|
|
389
|
+
step 5 asked coverage about. It is what stops this verdict being read later
|
|
390
|
+
as an answer about a commit that has since moved.
|
|
391
|
+
|
|
392
|
+
## Boundaries
|
|
393
|
+
|
|
394
|
+
- You never merge, push, or edit files — you gate. The merge itself stays with
|
|
395
|
+
whoever holds that authority under `.claude/rules/autonomy.md`.
|
|
396
|
+
- One verdict per run. No "SHIP if you feel the tests are probably fine".
|