create-agent-rig 0.5.0 → 0.6.1

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +170 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +15 -5
  10. package/templates/agent-os/init/CLAUDE.md +15 -5
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
  23. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  24. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
  25. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  26. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
  27. package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
  28. package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
  29. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
  30. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
  31. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  32. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  33. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
  34. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  35. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  37. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  40. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  41. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
  42. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
  43. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  44. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
  45. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
  46. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  47. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  48. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
  49. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  50. package/templates/agent-os/universal/.claude/settings.json +5 -1
  51. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  52. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
  53. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  54. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  55. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  56. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  57. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  58. package/templates/agent-os/universal/AGENTS.md +3 -1
  59. package/templates/agent-os/universal/CLAUDE.md +3 -1
  60. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  61. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  62. package/templates/agent-os/universal/layers.json +9 -0
  63. package/templates/hash-history.json +404 -51
  64. package/templates/release-ledger.json +10 -0
  65. package/templates/skeleton/aws-serverless/gitignore +2 -0
  66. package/templates/skeleton/node-service/gitignore +2 -0
@@ -95,10 +95,10 @@ own cost figures are read next to the lane they do not cover.
95
95
  - bypass pre-commit (`--no-verify` is hook-blocked anyway)
96
96
  - force-push a shared branch
97
97
  - put secrets in code, config, logs, or fixtures. One part of this is
98
- **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`, or
99
- `apply_patch` that names a credential file or carries a credential value,
98
+ **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`,
99
+ `MultiEdit`, `NotebookEdit`, or `apply_patch` that names a credential file or carries a credential value,
100
100
  reading its vocabulary from `.claude/scripts/lib/secrets.mjs`. ⚠ **Only that
101
- part.** The hook sees what an agent writes through those three tools and
101
+ part.** The hook sees what an agent writes through those five tools and
102
102
  nothing else — its own header states the four blind spots — so whether a
103
103
  credential typed by a human, or committed from disk, is also refused depends
104
104
  on whether this project has a commit-time check. Look at `.husky/` and the CI
@@ -108,6 +108,16 @@ 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 is both harnesses' instruction, agent, skill, script and hook
113
+ trees (`CLAUDE.md`, `AGENTS.md`, `.claude/{agents,hooks,rules,scripts,skills}`,
114
+ `.agents/`, `.codex/`), plus `.claude/settings.json`, the queue config and its
115
+ always-refused board selector, and the integrity manifest. Mechanical:
116
+ the hook refuses the edit while the unattended flag the `loop` skill writes
117
+ at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
118
+ nothing in an attended session. ⚠ It sees edit tool calls only — a
119
+ shell redirect into `.claude/settings.json` is not one — and the flag, not
120
+ the run, is what arms it; its header states the rest of its limits.
111
121
 
112
122
  ## Stop rules — by work-state, not by feelings
113
123
 
@@ -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.** If two files enforce the same
172
- invariant, they will disagree — and the one nobody is looking at is the one
173
- that is wrong. Export it from a single module and import it.
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
 
@@ -197,26 +216,35 @@ are guessing, and a guessed invariant is the one that will fire on honest work.
197
216
 
198
217
  ## About the hooks you were given
199
218
 
200
- They arrive with their tests **in the generator that produced this project**, not
201
- in this repository so by the rule above, as they sit here, they are checks
202
- without tests. That is deliberate and it has a boundary: it holds only while they
203
- are untouched.
219
+ Generator-authored rulebook artifacts rules, hooks, skills, scripts and agent
220
+ specsarrive with their tests **in the generator that produced this project**,
221
+ not in this repository. There is one narrow inherited-snapshot exception: such
222
+ an artifact may cite the generator's upstream tests, which are absent locally,
223
+ only when the pointer says they are absent and `.claude/.rig-manifest.json`
224
+ proves the current artifact's hash matches the installed manifest.
204
225
 
205
- This is the one narrow exception for a generator-authored hook: it may cite the
206
- generator's upstream tests **only while unchanged downstream**, and its hook
207
- header must identify the upstream generator tests as absent locally. That pointer
208
- records the evidence used to author the inherited snapshot; it does not turn the
209
- absent test into a local check.
210
-
211
- **The moment you edit one, its test is yours.** A guard whose behaviour has
212
- changed and whose test lives somewhere else is precisely the "quietly stopped
213
- matching" case this rule names, and nothing here would catch it. The same applies
214
- if you keep a hook whose invariant you have re-scoped.
226
+ A manifest-backed upgrade remains an inherited, generator-owned artifact even
227
+ though the upgrade diff changes its bytes. The exception applies **only while the
228
+ manifest hash matches**. A hash mismatch, missing manifest, or no evidence ends
229
+ the exception and the local test is yours; an owned guard whose behaviour changed
230
+ while its test lives elsewhere is precisely the "quietly stopped matching" case
231
+ this rule names.
215
232
 
216
233
  If a hook matters enough to keep, it is worth ten minutes to copy the shape from
217
234
  `.claude/skills/new-invariant/guard-invariant.example.test.mjs` and pin the
218
235
  behaviour you actually rely on.
219
236
 
237
+ **For hooks, that ownership boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
238
+ reads `.claude/.rig-manifest.json` and asks of every hook in `.claude/hooks/` (and
239
+ `.husky/`, when it exists) whether the project owns it — the bytes differ from
240
+ what the generator installed, or the manifest has no entry — and, if so, whether
241
+ `<hook>.test.mjs` sits beside it. A shipped, unchanged hook is not a finding; an
242
+ owned hook with no neighbour is; a rig with no manifest gets `unknown` for every
243
+ hook that has no test neighbour, never a pass. Exemptions are an explicit list with reasons in
244
+ `.claude/doctor-exemptions.json`, and the report ends with what the script did
245
+ not check. Pinned in the generator's `test/template/doctor.test.ts` — absent in a
246
+ generated rig — › "an owned hook without a test is a FAIL, and the run is STOP".
247
+
220
248
  ## Adding one
221
249
 
222
250
  Use the `new-invariant` skill. It asks what the invariant is (it will not invent
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The decision router — which gate does this change deserve, and what does that
3
3
  * cost.
4
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
4
5
  *
5
6
  * `pr-ship` is the merge-time gate and it always runs the expensive path: the
6
7
  * full suite, then `code-reviewer` on every diff. That is right for a change
@@ -927,12 +928,30 @@ export const route = ({ files, elevatedPaths } = {}) => {
927
928
  };
928
929
  };
929
930
 
931
+ /**
932
+ * The floor each lane sets, and nothing else — what `pr-ship` step 4 launches
933
+ * before any trigger adds to it. Exported as the ONE spelling of that fact:
934
+ * `pr-ship/SKILL.md` restates it in prose, and the generator's
935
+ * test/template/correspondence.test.ts › "the pr-ship fan-out bullets name
936
+ * exactly the floor of each lane, and only known lanes" keeps the two in step
937
+ * in both directions (AR-137). Only the `model` entry has a runtime consumer
938
+ * (`reviewersFor` below, which the router calls); the other two are checked
939
+ * against the prose alone — `route()` launches nothing on those lanes itself.
940
+ * Cost of the check: a floor change touches this function and one bullet, and
941
+ * the bullet must keep its `- \`lane\` → …` shape or the parse fails by name.
942
+ */
943
+ export const reviewersForLane = (lane) => {
944
+ if (lane === 'model') return ['code-reviewer'];
945
+ if (lane === 'fast-path') return ['prose-reviewer'];
946
+ return [];
947
+ };
948
+
930
949
  /**
931
950
  * Who the expensive lane fans out to — `code-reviewer` first, always, and the
932
951
  * conditional gates `pr-ship` already names, decided from the same paths.
933
952
  */
934
953
  const reviewersFor = (files, risks) => {
935
- const reviewers = ['code-reviewer'];
954
+ const reviewers = [...reviewersForLane('model')];
936
955
 
937
956
  let wantsProse = false;
938
957
  for (const file of files) {
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env node
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
+ // doctor — the harness audits itself (AR-5).
4
+ //
5
+ // node .claude/scripts/doctor.mjs # the report, ready to paste
6
+ // node .claude/scripts/doctor.mjs --root <dir> # audit another checkout
7
+ // node .claude/scripts/doctor.mjs --json
8
+ //
9
+ // One question, asked of every hook this project runs: **does the hook the
10
+ // project OWNS have a test beside it?** The three-part pattern in
11
+ // `.claude/rules/invariants.md` — a stated rule, a mechanical check, a test for
12
+ // the check — is decoration with any part missing, and the part a rig loses
13
+ // first is the third: the shipped hooks arrive with their tests in the generator
14
+ // that produced them. A manifest-backed generator upgrade remains inherited;
15
+ // the test becomes the rig's own when the current hash no longer matches the
16
+ // manifest evidence below.
17
+ //
18
+ // Ownership is read from `.claude/.rig-manifest.json`, the install manifest the
19
+ // generator writes (its `files` map is install-relative path → sha256 of the
20
+ // bytes it wrote). It is evidence, and three answers come out of it:
21
+ //
22
+ // - `shipped` — the bytes on disk still hash to the manifest's entry: the
23
+ // hook is the generator's, tested upstream. Not a finding.
24
+ // - `owned` — the hash differs, or the manifest has no entry for the file:
25
+ // authored or edited here, so its test is this project's.
26
+ // - `unknown` — there is no manifest to read (a pre-0.4.0 rig, or the
27
+ // generator's own checkout). A hook with a test beside it still
28
+ // passes — the test is there whoever owns it; one without is
29
+ // reported `unknown`, never as a pass: "could not look" is not
30
+ // "it is fine".
31
+ //
32
+ // The manifest is the only ownership source this script reads. The generator's
33
+ // CLI also carries a hash history of every release for manifest-less rigs; it
34
+ // lives in the CLI, and a rig script that re-implemented it would be a second
35
+ // copy of a table nobody here maintains (`invariants.md`, "one mechanism, one
36
+ // implementation"). A rig without a manifest gets `unknown` on every untested
37
+ // hook and the advice to run `upgrade`, which writes one.
38
+ //
39
+ // A test neighbour is `<hook>.test.mjs` in the same directory — the shape
40
+ // `.claude/skills/new-invariant/guard-invariant.example.test.mjs` prescribes and
41
+ // `node --test` runs. Exemptions are an explicit file list with reasons, in
42
+ // `.claude/doctor-exemptions.json` (`{ "<rel path>": "<reason>" }`): an exempt
43
+ // hook is reported as `exempt` with its reason, an exemption with no reason is a
44
+ // finding, and an exemption naming a file that is not there is a finding too —
45
+ // a list that outlives what it exempts is how a check goes quiet by accident.
46
+ //
47
+ // Scope: every `.mjs` directly in `.claude/hooks/` (not `*.test.mjs`, not
48
+ // `lib/`), and EVERY file directly in `.husky/` when that directory exists — a
49
+ // husky hook is never in the manifest, so it is `owned` whenever ownership can
50
+ // be read at all, and a stray file there (a README, a dotfile) is audited like a
51
+ // hook — the generator's `test/template/doctor.test.ts` › "audits every file in
52
+ // .husky/ as a hook — a stray README there is a finding until it is exempted
53
+ // with a reason" pins it: exempt it with a reason rather than teaching this
54
+ // script which names are not hooks. One entry that cannot be stat'ed (a
55
+ // dangling symlink) is reported by name and the rest of the listing survives —
56
+ // › "a dangling symlink among the hooks is reported by name as unreadable, and
57
+ // never nulls the listing". A `.claude/hooks/` that is missing or unreadable is a FAIL and the
58
+ // run is STOP — a doctor that looked nowhere must never say clean — and so is
59
+ // an exemption file that is present but not readable JSON. When `.husky/` is absent the report says so rather than
60
+ // staying silent about a directory it never looked at — the generator's
61
+ // `test/template/doctor.test.ts` › "names an absent .husky/ instead of staying
62
+ // silent about it" pins the line.
63
+ //
64
+ // 🔴 What this script does NOT check is printed at the end of every report, the
65
+ // same way `preflight.mjs` prints its unchecked items: a script that half-checks
66
+ // is only safe while the boundary is visible.
67
+ import { createHash } from 'node:crypto';
68
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
69
+ import path from 'node:path';
70
+ import { fileURLToPath } from 'node:url';
71
+
72
+ export const MANIFEST_REL = '.claude/.rig-manifest.json';
73
+ export const EXEMPTIONS_REL = '.claude/doctor-exemptions.json';
74
+ export const HOOKS_DIR = '.claude/hooks';
75
+ export const HUSKY_DIR = '.husky';
76
+
77
+ /** What this script cannot decide from the file system alone. */
78
+ export const UNCHECKED = [
79
+ 'that the neighbour test exercises the hook it sits beside — a file named ' +
80
+ '`<hook>.test.mjs` that asserts nothing satisfies this check; run `node --test` on it',
81
+ 'that the hook is wired in .claude/settings.json (or .codex/hooks.json) — an ' +
82
+ 'unwired hook passes its own test and guards nothing',
83
+ 'that a `shipped` hook still matches the rule it enforces — the manifest says ' +
84
+ 'the bytes are the generator’s, not that the generator’s rule is this project’s',
85
+ ];
86
+
87
+ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
88
+
89
+ /**
90
+ * Who owns a hook, from the manifest's evidence.
91
+ *
92
+ * `recorded` is the manifest's hash for the file, `undefined` when the manifest
93
+ * has no entry, and `null` when there is no manifest at all — three inputs that
94
+ * must stay three, because collapsing "no entry" into "no manifest" would read
95
+ * an authored hook as unknowable, and the reverse would read a manifest-less
96
+ * rig's every hook as authored here.
97
+ */
98
+ export const ownershipOf = ({ recorded, actual }) => {
99
+ if (recorded === null) return 'unknown';
100
+ if (typeof recorded === 'string' && recorded === actual) return 'shipped';
101
+ return 'owned';
102
+ };
103
+
104
+ /** The test neighbour's path: `guard-x.mjs` → `guard-x.test.mjs`, `pre-commit` → `pre-commit.test.mjs`. */
105
+ export const neighbourOf = (rel) => {
106
+ const dir = path.posix.dirname(rel);
107
+ const base = path.posix.basename(rel);
108
+ const stem = base.endsWith('.mjs') ? base.slice(0, -'.mjs'.length) : base;
109
+ return path.posix.join(dir, `${stem}.test.mjs`);
110
+ };
111
+
112
+ /** The same ladder `preflight.mjs` uses: any FAIL stops, any unknown cautions. */
113
+ export const verdictOf = (marks) => {
114
+ if (marks.some((mark) => mark === 'FAIL')) return 'STOP';
115
+ if (marks.some((mark) => mark === 'unknown')) return 'CAUTION';
116
+ return 'GO';
117
+ };
118
+
119
+ /**
120
+ * Control bytes stripped before a string from a repo file reaches the terminal
121
+ * (the same concern `queue/core.mjs` › printable has): an exemption reason
122
+ * carrying an escape sequence could repaint the line above it. Printable
123
+ * Unicode stays — a reason is prose.
124
+ */
125
+ // eslint-disable-next-line no-control-regex -- the control range IS the subject of this regex
126
+ export const printable = (text) => String(text).replace(/[\x00-\x1F\x7F-\x9F]/g, '');
127
+
128
+ const reasonOf = (exemptions, rel) => {
129
+ if (!exemptions || typeof exemptions !== 'object' || Array.isArray(exemptions)) return undefined;
130
+ return Object.prototype.hasOwnProperty.call(exemptions, rel) ? exemptions[rel] : undefined;
131
+ };
132
+
133
+ /**
134
+ * The audit, pure: hooks in, marks out. Each hook is `{ rel, ownership,
135
+ * hasTest }`; each result adds `mark` (`pass` | `FAIL` | `unknown` | `exempt`)
136
+ * and a `detail` a reader can act on.
137
+ *
138
+ * Bounded by construction: one pass over the hooks, one over the exemptions.
139
+ */
140
+ export const auditHooks = ({ hooks = [], exemptions = {} } = {}) => {
141
+ const seen = new Set();
142
+ const results = [];
143
+ for (const hook of hooks) {
144
+ const { rel, ownership, hasTest } = hook;
145
+ seen.add(rel);
146
+ const reason = reasonOf(exemptions, rel);
147
+ const exempt = reason !== undefined;
148
+ let mark;
149
+ let detail;
150
+ if (exempt && (typeof reason !== 'string' || reason.trim() === '')) {
151
+ mark = 'FAIL';
152
+ detail = `exempted without a reason in ${EXEMPTIONS_REL} — an exemption is a file AND why`;
153
+ } else if (hasTest) {
154
+ mark = 'pass';
155
+ detail =
156
+ ownership === 'shipped'
157
+ ? `test neighbour ${neighbourOf(rel)} (and the generator’s upstream tests)`
158
+ : `test neighbour ${neighbourOf(rel)}`;
159
+ } else if (ownership === 'shipped') {
160
+ mark = 'pass';
161
+ detail = 'unchanged since install — tested upstream, in the generator that produced it';
162
+ } else if (exempt) {
163
+ mark = 'exempt';
164
+ detail = `no test neighbour; exempt — ${printable(reason.trim())}`;
165
+ } else if (ownership === 'owned') {
166
+ mark = 'FAIL';
167
+ detail =
168
+ `owned here (${'edited or authored in this project'}) and no ${neighbourOf(rel)} — ` +
169
+ 'copy .claude/skills/new-invariant/guard-invariant.example.test.mjs beside it';
170
+ } else {
171
+ mark = 'unknown';
172
+ detail =
173
+ `no ${MANIFEST_REL} to say who owns it, and no ${neighbourOf(rel)} — ` +
174
+ 'not a pass; `upgrade` writes a manifest, or add the test';
175
+ }
176
+ results.push({ rel, ownership, hasTest, mark, detail });
177
+ }
178
+ if (exemptions && typeof exemptions === 'object' && !Array.isArray(exemptions)) {
179
+ for (const rel of Object.keys(exemptions)) {
180
+ if (seen.has(rel)) continue;
181
+ results.push({
182
+ rel: printable(rel),
183
+ ownership: 'absent',
184
+ hasTest: false,
185
+ mark: 'FAIL',
186
+ detail: `stale-exemption: ${EXEMPTIONS_REL} names a file this audit did not find — remove the entry`,
187
+ });
188
+ }
189
+ }
190
+ return { verdict: verdictOf(results.map((r) => r.mark)), hooks: results };
191
+ };
192
+
193
+ // --- the file-system half -----------------------------------------------------
194
+
195
+ const readJson = (file) => {
196
+ try {
197
+ return JSON.parse(readFileSync(file, 'utf8'));
198
+ } catch {
199
+ return null;
200
+ }
201
+ };
202
+
203
+ /** The manifest's `files` map, or `null` when there is nothing trustworthy to read. */
204
+ export const manifestFilesOf = (root) => {
205
+ const parsed = readJson(path.join(root, ...MANIFEST_REL.split('/')));
206
+ const files = parsed?.files;
207
+ if (!files || typeof files !== 'object' || Array.isArray(files)) return null;
208
+ return files;
209
+ };
210
+
211
+ /**
212
+ * The files directly in `dir`: `{ names, unreadable }`, or `null` when the
213
+ * directory itself cannot be listed. One entry that cannot be stat'ed (a dangling
214
+ * symlink) is reported by name, never allowed to null the whole listing.
215
+ */
216
+ const listFiles = (dir) => {
217
+ let entries;
218
+ try {
219
+ entries = readdirSync(dir).sort();
220
+ } catch {
221
+ return null;
222
+ }
223
+ const names = [];
224
+ const unreadable = [];
225
+ for (const name of entries) {
226
+ try {
227
+ if (statSync(path.join(dir, name)).isFile()) names.push(name);
228
+ } catch {
229
+ unreadable.push(name);
230
+ }
231
+ }
232
+ return { names, unreadable };
233
+ };
234
+
235
+ /** A finding the audit itself raises — a scope it could not read. */
236
+ const problem = (rel, detail) => ({ rel, ownership: 'absent', hasTest: false, mark: 'FAIL', detail });
237
+
238
+ /** Every hook in scope, as `{ rel, ownership, hasTest }`, plus the scopes it looked at. */
239
+ export const collectHooks = (root) => {
240
+ const files = manifestFilesOf(root);
241
+ const hooks = [];
242
+ const scopes = [];
243
+ const problems = [];
244
+ const hookDir = listFiles(path.join(root, ...HOOKS_DIR.split('/')));
245
+ scopes.push({ dir: HOOKS_DIR, present: hookDir !== null });
246
+ if (hookDir === null) {
247
+ // Not a rig, or not the directory the caller meant: never a clean report.
248
+ problems.push(problem(HOOKS_DIR, `not found or unreadable under ${root} — nothing was audited`));
249
+ }
250
+ for (const name of hookDir?.names ?? []) {
251
+ if (!name.endsWith('.mjs') || name.endsWith('.test.mjs')) continue;
252
+ hooks.push(`${HOOKS_DIR}/${name}`);
253
+ }
254
+ const husky = listFiles(path.join(root, HUSKY_DIR));
255
+ scopes.push({ dir: HUSKY_DIR, present: husky !== null });
256
+ for (const name of husky?.names ?? []) {
257
+ if (name.endsWith('.test.mjs')) continue;
258
+ hooks.push(`${HUSKY_DIR}/${name}`);
259
+ }
260
+ for (const [dir, listing] of [
261
+ [HOOKS_DIR, hookDir],
262
+ [HUSKY_DIR, husky],
263
+ ]) {
264
+ for (const name of listing?.unreadable ?? []) {
265
+ problems.push(problem(`${dir}/${printable(name)}`, 'cannot be read (a dangling symlink?) — not audited, not a pass'));
266
+ }
267
+ }
268
+ return {
269
+ scopes,
270
+ problems,
271
+ hooks: hooks.map((rel) => {
272
+ const actual = sha256(readFileSync(path.join(root, ...rel.split('/'))));
273
+ const recorded = files === null ? null : files[rel];
274
+ return {
275
+ rel,
276
+ ownership: ownershipOf({ recorded, actual }),
277
+ hasTest: existsSync(path.join(root, ...neighbourOf(rel).split('/'))),
278
+ };
279
+ }),
280
+ };
281
+ };
282
+
283
+ export const report = (root) => {
284
+ const { hooks, scopes, problems } = collectHooks(root);
285
+ // One read, two outcomes: absent is an empty list, present-but-unreadable is a
286
+ // finding. Testing for existence first and reading second would be two truths.
287
+ let exemptions = {};
288
+ let raw = null;
289
+ try {
290
+ raw = readFileSync(path.join(root, ...EXEMPTIONS_REL.split('/')), 'utf8');
291
+ } catch (error) {
292
+ if (error?.code !== 'ENOENT') {
293
+ problems.push(problem(EXEMPTIONS_REL, `present but unreadable — ${error?.code ?? 'read failed'}`));
294
+ }
295
+ }
296
+ if (raw !== null) {
297
+ let parsed;
298
+ try {
299
+ parsed = JSON.parse(raw);
300
+ } catch {
301
+ parsed = null;
302
+ }
303
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) exemptions = parsed;
304
+ else problems.push(problem(EXEMPTIONS_REL, 'present but unreadable — not JSON, or not an object of path → reason'));
305
+ }
306
+ const audited = auditHooks({ hooks, exemptions });
307
+ const all = [...problems, ...audited.hooks];
308
+ const audit = { verdict: verdictOf(all.map((r) => r.mark)), hooks: all };
309
+ const absent = scopes.filter((scope) => !scope.present && scope.dir !== HOOKS_DIR).map((scope) => scope.dir);
310
+ const lines = [
311
+ `**doctor** — verdict: ${audit.verdict}`,
312
+ '',
313
+ // Names come from the file system, reasons from a repo file; both are
314
+ // stripped of control bytes here, once, where they reach the terminal.
315
+ ...audit.hooks.map((hook) => `- ${hook.mark} · ${printable(hook.rel)} — ${printable(hook.detail)}`),
316
+ ...(absent.length > 0
317
+ ? ['', `_Not present, so not audited: ${absent.join(', ')}._`]
318
+ : []),
319
+ '',
320
+ `_Not checked by this script — still yours (${UNCHECKED.length}):_`,
321
+ ...UNCHECKED.map((item) => `- ${item}`),
322
+ ];
323
+ return { ...audit, scopes, unchecked: UNCHECKED, rendered: lines.join('\n') };
324
+ };
325
+
326
+ const invokedDirectly = () => {
327
+ if (!process.argv[1]) return false;
328
+ const real = (p) => {
329
+ try {
330
+ return realpathSync(p);
331
+ } catch {
332
+ return p;
333
+ }
334
+ };
335
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
336
+ };
337
+
338
+ if (invokedDirectly()) {
339
+ const args = process.argv.slice(2);
340
+ const rootIndex = args.indexOf('--root');
341
+ const rootArg = rootIndex === -1 ? null : args[rootIndex + 1];
342
+ if (rootIndex !== -1 && (rootArg === undefined || rootArg.startsWith('--'))) {
343
+ process.stderr.write('doctor: --root needs a directory — auditing the working directory instead would be a guess\n');
344
+ process.exit(1);
345
+ }
346
+ const root = path.resolve(rootArg ?? process.cwd());
347
+ const result = report(root);
348
+ process.stdout.write(
349
+ args.includes('--json')
350
+ ? `${JSON.stringify({ verdict: result.verdict, hooks: result.hooks, scopes: result.scopes, unchecked: result.unchecked }, null, 2)}\n`
351
+ : `${result.rendered}\n`,
352
+ );
353
+ process.exit(result.verdict === 'STOP' ? 1 : 0);
354
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * The environment a child `git` should run with — one implementation, imported.
3
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
4
  *
4
5
  * 🔴 **Why this is not inlined at each call site.** A process started under a
5
6
  * git hook inherits `GIT_DIR` and `GIT_INDEX_FILE`, and any child then answers