create-agent-rig 0.5.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 +140 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +11 -3
- package/templates/agent-os/init/CLAUDE.md +11 -3
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- 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 +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -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/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- 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-state.mjs +101 -3
- 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 +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +309 -49
- package/templates/release-ledger.json +9 -0
|
@@ -62,10 +62,30 @@
|
|
|
62
62
|
// exists and cannot be used announces —
|
|
63
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
64
|
import { execSync, spawnSync } from 'node:child_process';
|
|
65
|
-
import { readFileSync } from 'node:fs';
|
|
65
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
66
|
+
import { fileURLToPath } from 'node:url';
|
|
66
67
|
|
|
67
68
|
import { withoutGitLocation } from '../scripts/git-env.mjs';
|
|
68
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
|
+
|
|
69
89
|
// The default total budget, and the allowance for everything that happens
|
|
70
90
|
// OUTSIDE it.
|
|
71
91
|
//
|
|
@@ -155,6 +175,8 @@ function main() {
|
|
|
155
175
|
if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
|
|
156
176
|
if (input.stop_hook_active) return 0;
|
|
157
177
|
|
|
178
|
+
const PROJECT_ROOT = projectRootOf();
|
|
179
|
+
|
|
158
180
|
try {
|
|
159
181
|
// The environment loses the variables that locate a repository first. A
|
|
160
182
|
// process started under a git hook inherits an absolute GIT_DIR, and this
|
|
@@ -183,6 +205,7 @@ function main() {
|
|
|
183
205
|
// and the reason the bound can be generous. The options object stays terse
|
|
184
206
|
// on purpose; a sibling test matches it by a bounded window.
|
|
185
207
|
const status = execSync('git status --porcelain', {
|
|
208
|
+
cwd: PROJECT_ROOT,
|
|
186
209
|
encoding: 'utf8',
|
|
187
210
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
188
211
|
env: withoutGitLocation(),
|
|
@@ -252,6 +275,7 @@ function main() {
|
|
|
252
275
|
// that did not fit instead of blaming it for spending what an earlier one
|
|
253
276
|
// spent. One path, and no branch that only a race can reach.
|
|
254
277
|
const result = spawnSync(command, {
|
|
278
|
+
cwd: PROJECT_ROOT,
|
|
255
279
|
shell: true,
|
|
256
280
|
encoding: 'utf8',
|
|
257
281
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -277,7 +301,8 @@ function main() {
|
|
|
277
301
|
// unknown, and an unmeasured Definition of Done is not a passed one.
|
|
278
302
|
const timedOut = result.error.code === 'ETIMEDOUT';
|
|
279
303
|
process.stderr.write(
|
|
280
|
-
`STOP GATED — \`${command}\` produced no verdict
|
|
304
|
+
`STOP GATED — \`${command}\` produced no verdict (measured in ${PROJECT_ROOT}): ` +
|
|
305
|
+
`${result.error.message}\n` +
|
|
281
306
|
(timedOut
|
|
282
307
|
? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
|
|
283
308
|
`(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
|
|
@@ -297,7 +322,7 @@ function main() {
|
|
|
297
322
|
.slice(-15)
|
|
298
323
|
.join('\n');
|
|
299
324
|
process.stderr.write(
|
|
300
|
-
`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` +
|
|
301
326
|
(tail.trim() ? `${tail}\n` : '') +
|
|
302
327
|
`Fix the failure before ending the session. If this failure has resisted ` +
|
|
303
328
|
`repeated attempts, follow the stop rules instead: end with a written ` +
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
|
|
3
|
+
// the current item's allow-list (AR-51).
|
|
4
|
+
//
|
|
5
|
+
// The rulebook is the set of files that decide what this session may do — the
|
|
6
|
+
// hooks, their wiring, the queue config and adapters, the router and the gate
|
|
7
|
+
// sweep, the rules, `CLAUDE.md`. Every one of them was editable by the run it
|
|
8
|
+
// governs: a Write to `.claude/hooks/dod-checks.json` with `[]` switched the
|
|
9
|
+
// stop gate off, and nothing refused it until the merge sweep, long after.
|
|
10
|
+
//
|
|
11
|
+
// How it decides — and it is deliberately dumb:
|
|
12
|
+
// 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
|
|
13
|
+
// attended session → exit 0 for everything. A human at the keyboard edits
|
|
14
|
+
// the rulebook on purpose.
|
|
15
|
+
// 2. Flag present and readable → every edit fragment whose repo-relative path
|
|
16
|
+
// sits under a rulebook prefix is refused (exit 2) unless it also sits
|
|
17
|
+
// under one of the item's `allow` prefixes. Paths outside the rulebook are
|
|
18
|
+
// never judged.
|
|
19
|
+
// 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
|
|
20
|
+
// names the flag; an edit outside the rulebook still passes. Refusing to
|
|
21
|
+
// inspect is not allowing (`.claude/rules/invariants.md`).
|
|
22
|
+
//
|
|
23
|
+
// Limits — each stated here and each measured in the generator's
|
|
24
|
+
// `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
|
|
25
|
+
// test named beside it:
|
|
26
|
+
// - it sees one edit at a time, as text, before it lands — a rulebook file
|
|
27
|
+
// rewritten through a Bash redirect (`echo … > .claude/settings.json`), a
|
|
28
|
+
// generated file, or `git checkout` of another branch is not an edit tool
|
|
29
|
+
// call and never reaches it, and `guard-bash` does not cover that either —
|
|
30
|
+
// › "a Bash redirect into the rulebook is not an edit tool call and passes
|
|
31
|
+
// — guard-bash does not cover it either";
|
|
32
|
+
// - the flag in either home arms it (the env-derived one and the password
|
|
33
|
+
// database one, like the kill switch), and ONLY a flag arms it: an
|
|
34
|
+
// exported variable changes nothing, and an attended session that never
|
|
35
|
+
// set a flag is exactly as free as before — › "only a flag arms it — an
|
|
36
|
+
// exported RIG_UNATTENDED=1 with no flag changes nothing";
|
|
37
|
+
// - it judges paths, not content: a README that merely mentions
|
|
38
|
+
// `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
|
|
39
|
+
// path, not prose that mentions a guarded path";
|
|
40
|
+
// - it compares paths as text: the repo-relative tail is what is left after
|
|
41
|
+
// stripping `CLAUDE_PROJECT_DIR` (falling back to the working directory
|
|
42
|
+
// when the harness does not set it) from the front of the tool's absolute
|
|
43
|
+
// path, so a root spelled differently from the file path — a symlinked
|
|
44
|
+
// `/tmp` versus `/private/tmp`, a case difference on a case-insensitive
|
|
45
|
+
// disk — is not stripped, and the edit is not judged. Documented and
|
|
46
|
+
// measured, not fixed: the harness spells both from one root — › "compares
|
|
47
|
+
// paths as text: a root spelled differently from the file path is not
|
|
48
|
+
// judged (documented, fails open)";
|
|
49
|
+
// - an `allow` prefix is a string prefix of the repo-relative path and may
|
|
50
|
+
// not widen the rulebook — an entry that is itself a prefix of a rulebook
|
|
51
|
+
// prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
|
|
52
|
+
// and the guard refuses — › "a flag whose allow-list widens the rulebook is
|
|
53
|
+
// unreadable, so `--allow .` cannot disarm it";
|
|
54
|
+
// - fail-open on its own errors and on a payload it cannot parse — › "allows
|
|
55
|
+
// an empty payload object" and › "allows non-JSON stdin" — and fail-closed
|
|
56
|
+
// on a flag it cannot read — › "blocks a rulebook edit when the flag exists
|
|
57
|
+
// but cannot be read, and names the file": the guard targets drift, not an
|
|
58
|
+
// adversary.
|
|
59
|
+
//
|
|
60
|
+
// The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
|
|
61
|
+
import { readFileSync } from 'node:fs';
|
|
62
|
+
import { editFragments } from './lib/edit-input.mjs';
|
|
63
|
+
import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
|
|
64
|
+
|
|
65
|
+
export { RULEBOOK_PREFIXES, isRulebookPath };
|
|
66
|
+
|
|
67
|
+
const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
68
|
+
|
|
69
|
+
const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
|
|
70
|
+
|
|
71
|
+
/** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
|
|
72
|
+
export const relativeTo = (root, filePath) => {
|
|
73
|
+
const dir = toPosix(root).replace(/\/+$/, '');
|
|
74
|
+
const file = toPosix(filePath);
|
|
75
|
+
if (dir !== '' && file.startsWith(`${dir}/`)) return file.slice(dir.length + 1);
|
|
76
|
+
return file.replace(/^\.\//, '');
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const isAllowed = (rel, allow) =>
|
|
80
|
+
(Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
|
|
81
|
+
|
|
82
|
+
function main() {
|
|
83
|
+
let input;
|
|
84
|
+
try {
|
|
85
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
86
|
+
} catch {
|
|
87
|
+
return 0; // unparseable payload: not ours to judge
|
|
88
|
+
}
|
|
89
|
+
if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
|
|
90
|
+
|
|
91
|
+
const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
|
|
92
|
+
const paths = [];
|
|
93
|
+
for (const { filePath } of editFragments(input)) {
|
|
94
|
+
if (typeof filePath !== 'string' || filePath === '') continue;
|
|
95
|
+
const rel = relativeTo(root, filePath);
|
|
96
|
+
if (isRulebookPath(rel) && !paths.includes(rel)) paths.push(rel);
|
|
97
|
+
if (paths.length >= 64) break;
|
|
98
|
+
}
|
|
99
|
+
if (paths.length === 0) return 0; // nothing under the rulebook: never judged
|
|
100
|
+
|
|
101
|
+
const mode = readUnattended();
|
|
102
|
+
if (!mode.on) return 0; // attended session
|
|
103
|
+
|
|
104
|
+
if (mode.unreadable) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
|
|
107
|
+
'Refusing to inspect is not allowing: fix or remove the flag (`node .claude/scripts/unattended-flag.mjs off`), then retry.\n',
|
|
108
|
+
);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const refused = paths.filter((rel) => !isAllowed(rel, mode.allow));
|
|
113
|
+
if (refused.length === 0) return 0;
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
|
|
116
|
+
`(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +
|
|
117
|
+
'If the item really needs this path, it belongs in the allow-list the loop wrote at claim time — a decision, not a default. ' +
|
|
118
|
+
'See .claude/rules/autonomy.md, "Never".\n',
|
|
119
|
+
);
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
process.exit(main());
|
|
125
|
+
} catch {
|
|
126
|
+
process.exit(0); // a crashed guard must not block the session
|
|
127
|
+
}
|
|
@@ -45,6 +45,8 @@ const MAX_TOTAL_HUNK_LINES = 10_000;
|
|
|
45
45
|
const MAX_OUTPUT_LINES = 20_000;
|
|
46
46
|
const MAX_SPLICE_OPERATIONS = 1_000;
|
|
47
47
|
const MAX_PATCH_SECTIONS = 128;
|
|
48
|
+
/** A MultiEdit is capped before it is mapped — bounded work, never a spread of input. */
|
|
49
|
+
const MAX_MULTI_EDITS = 256;
|
|
48
50
|
const MAX_PATCH_PATH_COMPONENTS = 512;
|
|
49
51
|
|
|
50
52
|
export function editFragments(input) {
|
|
@@ -60,6 +62,27 @@ export function editFragments(input) {
|
|
|
60
62
|
},
|
|
61
63
|
];
|
|
62
64
|
}
|
|
65
|
+
// Claude Code's other two edit surfaces (AR-51). `MultiEdit` carries one
|
|
66
|
+
// file and a list of edits — one fragment per edit, same path — and
|
|
67
|
+
// `NotebookEdit` carries a cell's new source. Before this, both reached every
|
|
68
|
+
// guard through the unanchored `Write|Edit` matcher and yielded no fragment,
|
|
69
|
+
// so a `Date.now()` in a MultiEdit to the core passed unchecked.
|
|
70
|
+
if (toolName === 'MultiEdit') {
|
|
71
|
+
if (!Array.isArray(toolInput.edits)) return [];
|
|
72
|
+
const filePath = normalisePath(toolInput.file_path);
|
|
73
|
+
return toolInput.edits.slice(0, MAX_MULTI_EDITS).map((edit) => ({
|
|
74
|
+
filePath,
|
|
75
|
+
fragment: String(edit?.new_string ?? ''),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
if (toolName === 'NotebookEdit') {
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
filePath: normalisePath(toolInput.notebook_path),
|
|
82
|
+
fragment: String(toolInput.new_source ?? ''),
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
63
86
|
if (toolName !== 'apply_patch') return [];
|
|
64
87
|
const rawCommand = toolInput.command;
|
|
65
88
|
// ⚠ **Absent is not malformed, and the difference decides which way this fails.**
|
|
@@ -108,6 +108,14 @@ own cost figures are read next to the lane they do not cover.
|
|
|
108
108
|
at runtime instead of writing it out, or the check reports its own test data as
|
|
109
109
|
a leak.
|
|
110
110
|
- touch production data outside a reviewed migration
|
|
111
|
+
- edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it.
|
|
112
|
+
The rulebook: the hooks, their wiring, `.claude/queue.json`, the queue
|
|
113
|
+
adapters, the router, the gate sweep, the rules, `CLAUDE.md`. Mechanical:
|
|
114
|
+
the hook refuses the edit while the unattended flag the `loop` skill writes
|
|
115
|
+
at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
|
|
116
|
+
nothing in an attended session. ⚠ It sees edit tool calls only — a
|
|
117
|
+
shell redirect into `.claude/settings.json` is not one — and the flag, not
|
|
118
|
+
the run, is what arms it; its header states the rest of its limits.
|
|
111
119
|
|
|
112
120
|
## Stop rules — by work-state, not by feelings
|
|
113
121
|
|
|
@@ -168,9 +168,28 @@ Two rules that follow from it:
|
|
|
168
168
|
deliberately coarse and stop trying to out-parse the input. Where a false block
|
|
169
169
|
interrupts ordinary work, stay narrow and specific. Uniform precision
|
|
170
170
|
everywhere is how a guard ends up simultaneously too loose and too annoying.
|
|
171
|
-
- **One mechanism, one implementation.**
|
|
172
|
-
invariant, they will disagree — and the one nobody is
|
|
173
|
-
that is wrong. Export it from a single module and import
|
|
171
|
+
- **One mechanism, one implementation.** And one spelling of a fact. If two
|
|
172
|
+
files enforce the same invariant, they will disagree — and the one nobody is
|
|
173
|
+
looking at is the one that is wrong. Export it from a single module and import
|
|
174
|
+
it. The same holds for any mechanically expressible fact two artifacts encode
|
|
175
|
+
— a list, a vocabulary, a set of paths, a lane's reviewer floor: prefer one
|
|
176
|
+
source that the others derive from or are generated from. Where a second copy
|
|
177
|
+
has to stay (prose a reader needs), put a correspondence check between the two
|
|
178
|
+
that goes red in both directions — a copy that gains an entry the source
|
|
179
|
+
lacks, and a source that gains one the copy lacks — and put that check in
|
|
180
|
+
before adding prose or a memory note about keeping them aligned. The shape is
|
|
181
|
+
in the generator's `test/template/correspondence.test.ts` (absent in a
|
|
182
|
+
generated rig): the check itself, ›
|
|
183
|
+
"every point the module knows is named by the loop or pr-ship skill, and vice versa"
|
|
184
|
+
and ›
|
|
185
|
+
"the pr-ship fan-out bullets name exactly the floor of each lane, and only known lanes";
|
|
186
|
+
and the proof that it names the offender, one mutation per side, ›
|
|
187
|
+
"reports a point named in prose that no script knows (mutation: BEFORE_MERGE)",
|
|
188
|
+
› "reports a point the module knows that no prose mentions (mutation: extended POINTS)",
|
|
189
|
+
› "reports a reviewer added to a bullet the floor does not include (mutation: prose)"
|
|
190
|
+
and › "reports a floor widened in the mapping that the prose does not carry (mutation: mapping)".
|
|
191
|
+
What such a check costs is written where the check is, in the header of the
|
|
192
|
+
file that carries it.
|
|
174
193
|
|
|
175
194
|
## The worked example — and it is one project's answer, not a law
|
|
176
195
|
|
|
@@ -217,6 +236,17 @@ If a hook matters enough to keep, it is worth ten minutes to copy the shape from
|
|
|
217
236
|
`.claude/skills/new-invariant/guard-invariant.example.test.mjs` and pin the
|
|
218
237
|
behaviour you actually rely on.
|
|
219
238
|
|
|
239
|
+
**That boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
|
|
240
|
+
reads `.claude/.rig-manifest.json` and asks of every hook in `.claude/hooks/` (and
|
|
241
|
+
`.husky/`, when it exists) whether the project owns it — the bytes differ from
|
|
242
|
+
what the generator installed, or the manifest has no entry — and, if so, whether
|
|
243
|
+
`<hook>.test.mjs` sits beside it. A shipped, unchanged hook is not a finding; an
|
|
244
|
+
owned hook with no neighbour is; a rig with no manifest gets `unknown` for every
|
|
245
|
+
hook that has no test neighbour, never a pass. Exemptions are an explicit list with reasons in
|
|
246
|
+
`.claude/doctor-exemptions.json`, and the report ends with what the script did
|
|
247
|
+
not check. Pinned in the generator's `test/template/doctor.test.ts` — absent in a
|
|
248
|
+
generated rig — › "an owned hook without a test is a FAIL, and the run is STOP".
|
|
249
|
+
|
|
220
250
|
## Adding one
|
|
221
251
|
|
|
222
252
|
Use the `new-invariant` skill. It asks what the invariant is (it will not invent
|
|
@@ -927,12 +927,30 @@ export const route = ({ files, elevatedPaths } = {}) => {
|
|
|
927
927
|
};
|
|
928
928
|
};
|
|
929
929
|
|
|
930
|
+
/**
|
|
931
|
+
* The floor each lane sets, and nothing else — what `pr-ship` step 4 launches
|
|
932
|
+
* before any trigger adds to it. Exported as the ONE spelling of that fact:
|
|
933
|
+
* `pr-ship/SKILL.md` restates it in prose, and the generator's
|
|
934
|
+
* test/template/correspondence.test.ts › "the pr-ship fan-out bullets name
|
|
935
|
+
* exactly the floor of each lane, and only known lanes" keeps the two in step
|
|
936
|
+
* in both directions (AR-137). Only the `model` entry has a runtime consumer
|
|
937
|
+
* (`reviewersFor` below, which the router calls); the other two are checked
|
|
938
|
+
* against the prose alone — `route()` launches nothing on those lanes itself.
|
|
939
|
+
* Cost of the check: a floor change touches this function and one bullet, and
|
|
940
|
+
* the bullet must keep its `- \`lane\` → …` shape or the parse fails by name.
|
|
941
|
+
*/
|
|
942
|
+
export const reviewersForLane = (lane) => {
|
|
943
|
+
if (lane === 'model') return ['code-reviewer'];
|
|
944
|
+
if (lane === 'fast-path') return ['prose-reviewer'];
|
|
945
|
+
return [];
|
|
946
|
+
};
|
|
947
|
+
|
|
930
948
|
/**
|
|
931
949
|
* Who the expensive lane fans out to — `code-reviewer` first, always, and the
|
|
932
950
|
* conditional gates `pr-ship` already names, decided from the same paths.
|
|
933
951
|
*/
|
|
934
952
|
const reviewersFor = (files, risks) => {
|
|
935
|
-
const reviewers = ['
|
|
953
|
+
const reviewers = [...reviewersForLane('model')];
|
|
936
954
|
|
|
937
955
|
let wantsProse = false;
|
|
938
956
|
for (const file of files) {
|