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.
Files changed (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -104,6 +104,26 @@ export const parseElevatedPaths = (markdown) => {
104
104
  );
105
105
  };
106
106
 
107
+ /**
108
+ * A decision record: `docs/decisions/<name>` at any depth, so both the root
109
+ * copy and the vendored template source match.
110
+ *
111
+ * Exported because `decision-router.mjs` needs the same notion, and two regexes
112
+ * for one invariant is the case `invariants.md` legislates against — the copy
113
+ * nobody is looking at is the one that goes wrong.
114
+ *
115
+ * `decisions` is matched as a whole path SEGMENT under a `docs` segment, never
116
+ * as a substring: `docs/decisions-overview.md` is ordinary documentation and
117
+ * must keep the cheap lane. Widening this to a substring would quietly pull
118
+ * every `docs/decisions*` name into the expensive gate.
119
+ *
120
+ * 🔴 Case-SENSITIVE, deliberately, and the caller decides. `normalizePath` in
121
+ * this file does not fold either — a sweep that folded would report a path the
122
+ * repository does not have. `decision-router.mjs` folds before calling this,
123
+ * because there folding can only escalate; here it could only mislabel.
124
+ */
125
+ export const isDecisionRecord = (path) => /(^|\/)docs\/decisions\/[^/]/.test(path);
126
+
107
127
  /**
108
128
  * Paths that provision nothing and configure nothing, whatever directory they sit
109
129
  * in — EXCEPT the rulebook itself. Declaring `.claude/` as elevated was a no-op
@@ -121,13 +141,56 @@ export const parseElevatedPaths = (markdown) => {
121
141
  */
122
142
  const isRulebook = (path) =>
123
143
  path === 'CLAUDE.md' ||
144
+ path === 'AGENTS.md' ||
124
145
  path.endsWith('/CLAUDE.md') ||
146
+ path.endsWith('/AGENTS.md') ||
125
147
  path.startsWith('.claude/') ||
126
- path.includes('/.claude/');
148
+ path.includes('/.claude/') ||
149
+ path.startsWith('.agents/') ||
150
+ path.includes('/.agents/') ||
151
+ path.startsWith('.codex/') ||
152
+ path.includes('/.codex/') ||
153
+ // The rationale extracted out of the rulebook is still rulebook. It left
154
+ // `.claude/` for `docs/decisions/` so that sessions stop paying to load it —
155
+ // not so that it stops being reviewed like the rule it explains.
156
+ isDecisionRecord(path);
157
+
158
+ /**
159
+ * A document, for the sweep's question: *does this need a reviewer?* Both
160
+ * markdown flavours qualify — an `.mdx` page still needs no gate.
161
+ */
162
+ const isDocument = (path) => /\.mdx?$/.test(path);
163
+
164
+ /**
165
+ * A file that **executes nothing**, for the ration's question: *can a merge of
166
+ * this compound into a broken runtime overnight?* (`queue/state.mjs`, and
167
+ * `docs/decisions/spacing-rations-mechanisms.md` for why the ration asks that
168
+ * question at all.)
169
+ *
170
+ * 🔴 **It is `.md` only, and the missing `x` is the whole point.** MDX carries
171
+ * components and imports: it is a program that renders, not a document that is
172
+ * read — which is exactly why `decision-router.mjs` sends `.mdx` down the code
173
+ * lane (`docs/decisions/review-lanes.md`). A ration that called it prose would
174
+ * clear the spacing hold on a file this same rig treats as a program, and it
175
+ * would do so on the permissive side, which is the one direction that costs
176
+ * something.
177
+ *
178
+ * So **this file** carries two markdown predicates on purpose, side by side so
179
+ * they cannot drift apart, answering questions that genuinely differ: *needs no
180
+ * reviewer* is not *executes nothing*.
181
+ *
182
+ * ⚠ That is a statement about this file, **not** about the rig. Other markdown
183
+ * sets exist deliberately and must stay where they are — `decision-router.mjs`
184
+ * keeps its own, and `docs/decisions/review-lanes.md` exists precisely to stop
185
+ * someone consolidating them. Read that record before adding a fourth or
186
+ * "tidying" any of them: each mechanism's question is different, and every
187
+ * consolidation attempted so far would have silently downgraded a real review.
188
+ */
189
+ export const executesNothing = (path) => /\.md$/.test(path);
127
190
 
128
191
  const isInert = (path) =>
129
192
  !isRulebook(path) &&
130
- (/\.mdx?$/.test(path) ||
193
+ (isDocument(path) ||
131
194
  /(^|\/)(test|tests|__tests__)\//.test(path) ||
132
195
  /\.(test|spec)\.[cm]?[jt]sx?$/.test(path));
133
196
 
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ // doctor — the harness audits itself (AR-5).
3
+ //
4
+ // node .claude/scripts/doctor.mjs # the report, ready to paste
5
+ // node .claude/scripts/doctor.mjs --root <dir> # audit another checkout
6
+ // node .claude/scripts/doctor.mjs --json
7
+ //
8
+ // One question, asked of every hook this project runs: **does the hook the
9
+ // project OWNS have a test beside it?** The three-part pattern in
10
+ // `.claude/rules/invariants.md` — a stated rule, a mechanical check, a test for
11
+ // the check — is decoration with any part missing, and the part a rig loses
12
+ // first is the third: the shipped hooks arrive with their tests in the generator
13
+ // that produced them, and the moment one is edited its test is the rig's own.
14
+ //
15
+ // Ownership is read from `.claude/.rig-manifest.json`, the install manifest the
16
+ // generator writes (its `files` map is install-relative path → sha256 of the
17
+ // bytes it wrote). It is evidence, and three answers come out of it:
18
+ //
19
+ // - `shipped` — the bytes on disk still hash to the manifest's entry: the
20
+ // hook is the generator's, tested upstream. Not a finding.
21
+ // - `owned` — the hash differs, or the manifest has no entry for the file:
22
+ // authored or edited here, so its test is this project's.
23
+ // - `unknown` — there is no manifest to read (a pre-0.4.0 rig, or the
24
+ // generator's own checkout). A hook with a test beside it still
25
+ // passes — the test is there whoever owns it; one without is
26
+ // reported `unknown`, never as a pass: "could not look" is not
27
+ // "it is fine".
28
+ //
29
+ // The manifest is the only ownership source this script reads. The generator's
30
+ // CLI also carries a hash history of every release for manifest-less rigs; it
31
+ // lives in the CLI, and a rig script that re-implemented it would be a second
32
+ // copy of a table nobody here maintains (`invariants.md`, "one mechanism, one
33
+ // implementation"). A rig without a manifest gets `unknown` on every untested
34
+ // hook and the advice to run `upgrade`, which writes one.
35
+ //
36
+ // A test neighbour is `<hook>.test.mjs` in the same directory — the shape
37
+ // `.claude/skills/new-invariant/guard-invariant.example.test.mjs` prescribes and
38
+ // `node --test` runs. Exemptions are an explicit file list with reasons, in
39
+ // `.claude/doctor-exemptions.json` (`{ "<rel path>": "<reason>" }`): an exempt
40
+ // hook is reported as `exempt` with its reason, an exemption with no reason is a
41
+ // finding, and an exemption naming a file that is not there is a finding too —
42
+ // a list that outlives what it exempts is how a check goes quiet by accident.
43
+ //
44
+ // Scope: every `.mjs` directly in `.claude/hooks/` (not `*.test.mjs`, not
45
+ // `lib/`), and EVERY file directly in `.husky/` when that directory exists — a
46
+ // husky hook is never in the manifest, so it is `owned` whenever ownership can
47
+ // be read at all, and a stray file there (a README, a dotfile) is audited like a
48
+ // hook — the generator's `test/template/doctor.test.ts` › "audits every file in
49
+ // .husky/ as a hook — a stray README there is a finding until it is exempted
50
+ // with a reason" pins it: exempt it with a reason rather than teaching this
51
+ // script which names are not hooks. One entry that cannot be stat'ed (a
52
+ // dangling symlink) is reported by name and the rest of the listing survives —
53
+ // › "a dangling symlink among the hooks is reported by name as unreadable, and
54
+ // never nulls the listing". A `.claude/hooks/` that is missing or unreadable is a FAIL and the
55
+ // run is STOP — a doctor that looked nowhere must never say clean — and so is
56
+ // an exemption file that is present but not readable JSON. When `.husky/` is absent the report says so rather than
57
+ // staying silent about a directory it never looked at — the generator's
58
+ // `test/template/doctor.test.ts` › "names an absent .husky/ instead of staying
59
+ // silent about it" pins the line.
60
+ //
61
+ // 🔴 What this script does NOT check is printed at the end of every report, the
62
+ // same way `preflight.mjs` prints its unchecked items: a script that half-checks
63
+ // is only safe while the boundary is visible.
64
+ import { createHash } from 'node:crypto';
65
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
66
+ import path from 'node:path';
67
+ import { fileURLToPath } from 'node:url';
68
+
69
+ export const MANIFEST_REL = '.claude/.rig-manifest.json';
70
+ export const EXEMPTIONS_REL = '.claude/doctor-exemptions.json';
71
+ export const HOOKS_DIR = '.claude/hooks';
72
+ export const HUSKY_DIR = '.husky';
73
+
74
+ /** What this script cannot decide from the file system alone. */
75
+ export const UNCHECKED = [
76
+ 'that the neighbour test exercises the hook it sits beside — a file named ' +
77
+ '`<hook>.test.mjs` that asserts nothing satisfies this check; run `node --test` on it',
78
+ 'that the hook is wired in .claude/settings.json (or .codex/hooks.json) — an ' +
79
+ 'unwired hook passes its own test and guards nothing',
80
+ 'that a `shipped` hook still matches the rule it enforces — the manifest says ' +
81
+ 'the bytes are the generator’s, not that the generator’s rule is this project’s',
82
+ ];
83
+
84
+ export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
85
+
86
+ /**
87
+ * Who owns a hook, from the manifest's evidence.
88
+ *
89
+ * `recorded` is the manifest's hash for the file, `undefined` when the manifest
90
+ * has no entry, and `null` when there is no manifest at all — three inputs that
91
+ * must stay three, because collapsing "no entry" into "no manifest" would read
92
+ * an authored hook as unknowable, and the reverse would read a manifest-less
93
+ * rig's every hook as authored here.
94
+ */
95
+ export const ownershipOf = ({ recorded, actual }) => {
96
+ if (recorded === null) return 'unknown';
97
+ if (typeof recorded === 'string' && recorded === actual) return 'shipped';
98
+ return 'owned';
99
+ };
100
+
101
+ /** The test neighbour's path: `guard-x.mjs` → `guard-x.test.mjs`, `pre-commit` → `pre-commit.test.mjs`. */
102
+ export const neighbourOf = (rel) => {
103
+ const dir = path.posix.dirname(rel);
104
+ const base = path.posix.basename(rel);
105
+ const stem = base.endsWith('.mjs') ? base.slice(0, -'.mjs'.length) : base;
106
+ return path.posix.join(dir, `${stem}.test.mjs`);
107
+ };
108
+
109
+ /** The same ladder `preflight.mjs` uses: any FAIL stops, any unknown cautions. */
110
+ export const verdictOf = (marks) => {
111
+ if (marks.some((mark) => mark === 'FAIL')) return 'STOP';
112
+ if (marks.some((mark) => mark === 'unknown')) return 'CAUTION';
113
+ return 'GO';
114
+ };
115
+
116
+ /**
117
+ * Control bytes stripped before a string from a repo file reaches the terminal
118
+ * (the same concern `queue/core.mjs` › printable has): an exemption reason
119
+ * carrying an escape sequence could repaint the line above it. Printable
120
+ * Unicode stays — a reason is prose.
121
+ */
122
+ // eslint-disable-next-line no-control-regex -- the control range IS the subject of this regex
123
+ export const printable = (text) => String(text).replace(/[\x00-\x1F\x7F-\x9F]/g, '');
124
+
125
+ const reasonOf = (exemptions, rel) => {
126
+ if (!exemptions || typeof exemptions !== 'object' || Array.isArray(exemptions)) return undefined;
127
+ return Object.prototype.hasOwnProperty.call(exemptions, rel) ? exemptions[rel] : undefined;
128
+ };
129
+
130
+ /**
131
+ * The audit, pure: hooks in, marks out. Each hook is `{ rel, ownership,
132
+ * hasTest }`; each result adds `mark` (`pass` | `FAIL` | `unknown` | `exempt`)
133
+ * and a `detail` a reader can act on.
134
+ *
135
+ * Bounded by construction: one pass over the hooks, one over the exemptions.
136
+ */
137
+ export const auditHooks = ({ hooks = [], exemptions = {} } = {}) => {
138
+ const seen = new Set();
139
+ const results = [];
140
+ for (const hook of hooks) {
141
+ const { rel, ownership, hasTest } = hook;
142
+ seen.add(rel);
143
+ const reason = reasonOf(exemptions, rel);
144
+ const exempt = reason !== undefined;
145
+ let mark;
146
+ let detail;
147
+ if (exempt && (typeof reason !== 'string' || reason.trim() === '')) {
148
+ mark = 'FAIL';
149
+ detail = `exempted without a reason in ${EXEMPTIONS_REL} — an exemption is a file AND why`;
150
+ } else if (hasTest) {
151
+ mark = 'pass';
152
+ detail =
153
+ ownership === 'shipped'
154
+ ? `test neighbour ${neighbourOf(rel)} (and the generator’s upstream tests)`
155
+ : `test neighbour ${neighbourOf(rel)}`;
156
+ } else if (ownership === 'shipped') {
157
+ mark = 'pass';
158
+ detail = 'unchanged since install — tested upstream, in the generator that produced it';
159
+ } else if (exempt) {
160
+ mark = 'exempt';
161
+ detail = `no test neighbour; exempt — ${printable(reason.trim())}`;
162
+ } else if (ownership === 'owned') {
163
+ mark = 'FAIL';
164
+ detail =
165
+ `owned here (${'edited or authored in this project'}) and no ${neighbourOf(rel)} — ` +
166
+ 'copy .claude/skills/new-invariant/guard-invariant.example.test.mjs beside it';
167
+ } else {
168
+ mark = 'unknown';
169
+ detail =
170
+ `no ${MANIFEST_REL} to say who owns it, and no ${neighbourOf(rel)} — ` +
171
+ 'not a pass; `upgrade` writes a manifest, or add the test';
172
+ }
173
+ results.push({ rel, ownership, hasTest, mark, detail });
174
+ }
175
+ if (exemptions && typeof exemptions === 'object' && !Array.isArray(exemptions)) {
176
+ for (const rel of Object.keys(exemptions)) {
177
+ if (seen.has(rel)) continue;
178
+ results.push({
179
+ rel: printable(rel),
180
+ ownership: 'absent',
181
+ hasTest: false,
182
+ mark: 'FAIL',
183
+ detail: `stale-exemption: ${EXEMPTIONS_REL} names a file this audit did not find — remove the entry`,
184
+ });
185
+ }
186
+ }
187
+ return { verdict: verdictOf(results.map((r) => r.mark)), hooks: results };
188
+ };
189
+
190
+ // --- the file-system half -----------------------------------------------------
191
+
192
+ const readJson = (file) => {
193
+ try {
194
+ return JSON.parse(readFileSync(file, 'utf8'));
195
+ } catch {
196
+ return null;
197
+ }
198
+ };
199
+
200
+ /** The manifest's `files` map, or `null` when there is nothing trustworthy to read. */
201
+ export const manifestFilesOf = (root) => {
202
+ const parsed = readJson(path.join(root, ...MANIFEST_REL.split('/')));
203
+ const files = parsed?.files;
204
+ if (!files || typeof files !== 'object' || Array.isArray(files)) return null;
205
+ return files;
206
+ };
207
+
208
+ /**
209
+ * The files directly in `dir`: `{ names, unreadable }`, or `null` when the
210
+ * directory itself cannot be listed. One entry that cannot be stat'ed (a dangling
211
+ * symlink) is reported by name, never allowed to null the whole listing.
212
+ */
213
+ const listFiles = (dir) => {
214
+ let entries;
215
+ try {
216
+ entries = readdirSync(dir).sort();
217
+ } catch {
218
+ return null;
219
+ }
220
+ const names = [];
221
+ const unreadable = [];
222
+ for (const name of entries) {
223
+ try {
224
+ if (statSync(path.join(dir, name)).isFile()) names.push(name);
225
+ } catch {
226
+ unreadable.push(name);
227
+ }
228
+ }
229
+ return { names, unreadable };
230
+ };
231
+
232
+ /** A finding the audit itself raises — a scope it could not read. */
233
+ const problem = (rel, detail) => ({ rel, ownership: 'absent', hasTest: false, mark: 'FAIL', detail });
234
+
235
+ /** Every hook in scope, as `{ rel, ownership, hasTest }`, plus the scopes it looked at. */
236
+ export const collectHooks = (root) => {
237
+ const files = manifestFilesOf(root);
238
+ const hooks = [];
239
+ const scopes = [];
240
+ const problems = [];
241
+ const hookDir = listFiles(path.join(root, ...HOOKS_DIR.split('/')));
242
+ scopes.push({ dir: HOOKS_DIR, present: hookDir !== null });
243
+ if (hookDir === null) {
244
+ // Not a rig, or not the directory the caller meant: never a clean report.
245
+ problems.push(problem(HOOKS_DIR, `not found or unreadable under ${root} — nothing was audited`));
246
+ }
247
+ for (const name of hookDir?.names ?? []) {
248
+ if (!name.endsWith('.mjs') || name.endsWith('.test.mjs')) continue;
249
+ hooks.push(`${HOOKS_DIR}/${name}`);
250
+ }
251
+ const husky = listFiles(path.join(root, HUSKY_DIR));
252
+ scopes.push({ dir: HUSKY_DIR, present: husky !== null });
253
+ for (const name of husky?.names ?? []) {
254
+ if (name.endsWith('.test.mjs')) continue;
255
+ hooks.push(`${HUSKY_DIR}/${name}`);
256
+ }
257
+ for (const [dir, listing] of [
258
+ [HOOKS_DIR, hookDir],
259
+ [HUSKY_DIR, husky],
260
+ ]) {
261
+ for (const name of listing?.unreadable ?? []) {
262
+ problems.push(problem(`${dir}/${printable(name)}`, 'cannot be read (a dangling symlink?) — not audited, not a pass'));
263
+ }
264
+ }
265
+ return {
266
+ scopes,
267
+ problems,
268
+ hooks: hooks.map((rel) => {
269
+ const actual = sha256(readFileSync(path.join(root, ...rel.split('/'))));
270
+ const recorded = files === null ? null : files[rel];
271
+ return {
272
+ rel,
273
+ ownership: ownershipOf({ recorded, actual }),
274
+ hasTest: existsSync(path.join(root, ...neighbourOf(rel).split('/'))),
275
+ };
276
+ }),
277
+ };
278
+ };
279
+
280
+ export const report = (root) => {
281
+ const { hooks, scopes, problems } = collectHooks(root);
282
+ // One read, two outcomes: absent is an empty list, present-but-unreadable is a
283
+ // finding. Testing for existence first and reading second would be two truths.
284
+ let exemptions = {};
285
+ let raw = null;
286
+ try {
287
+ raw = readFileSync(path.join(root, ...EXEMPTIONS_REL.split('/')), 'utf8');
288
+ } catch (error) {
289
+ if (error?.code !== 'ENOENT') {
290
+ problems.push(problem(EXEMPTIONS_REL, `present but unreadable — ${error?.code ?? 'read failed'}`));
291
+ }
292
+ }
293
+ if (raw !== null) {
294
+ let parsed;
295
+ try {
296
+ parsed = JSON.parse(raw);
297
+ } catch {
298
+ parsed = null;
299
+ }
300
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) exemptions = parsed;
301
+ else problems.push(problem(EXEMPTIONS_REL, 'present but unreadable — not JSON, or not an object of path → reason'));
302
+ }
303
+ const audited = auditHooks({ hooks, exemptions });
304
+ const all = [...problems, ...audited.hooks];
305
+ const audit = { verdict: verdictOf(all.map((r) => r.mark)), hooks: all };
306
+ const absent = scopes.filter((scope) => !scope.present && scope.dir !== HOOKS_DIR).map((scope) => scope.dir);
307
+ const lines = [
308
+ `**doctor** — verdict: ${audit.verdict}`,
309
+ '',
310
+ // Names come from the file system, reasons from a repo file; both are
311
+ // stripped of control bytes here, once, where they reach the terminal.
312
+ ...audit.hooks.map((hook) => `- ${hook.mark} · ${printable(hook.rel)} — ${printable(hook.detail)}`),
313
+ ...(absent.length > 0
314
+ ? ['', `_Not present, so not audited: ${absent.join(', ')}._`]
315
+ : []),
316
+ '',
317
+ `_Not checked by this script — still yours (${UNCHECKED.length}):_`,
318
+ ...UNCHECKED.map((item) => `- ${item}`),
319
+ ];
320
+ return { ...audit, scopes, unchecked: UNCHECKED, rendered: lines.join('\n') };
321
+ };
322
+
323
+ const invokedDirectly = () => {
324
+ if (!process.argv[1]) return false;
325
+ const real = (p) => {
326
+ try {
327
+ return realpathSync(p);
328
+ } catch {
329
+ return p;
330
+ }
331
+ };
332
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
333
+ };
334
+
335
+ if (invokedDirectly()) {
336
+ const args = process.argv.slice(2);
337
+ const rootIndex = args.indexOf('--root');
338
+ const rootArg = rootIndex === -1 ? null : args[rootIndex + 1];
339
+ if (rootIndex !== -1 && (rootArg === undefined || rootArg.startsWith('--'))) {
340
+ process.stderr.write('doctor: --root needs a directory — auditing the working directory instead would be a guess\n');
341
+ process.exit(1);
342
+ }
343
+ const root = path.resolve(rootArg ?? process.cwd());
344
+ const result = report(root);
345
+ process.stdout.write(
346
+ args.includes('--json')
347
+ ? `${JSON.stringify({ verdict: result.verdict, hooks: result.hooks, scopes: result.scopes, unchecked: result.unchecked }, null, 2)}\n`
348
+ : `${result.rendered}\n`,
349
+ );
350
+ process.exit(result.verdict === 'STOP' ? 1 : 0);
351
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The environment a child `git` should run with — one implementation, imported.
3
+ *
4
+ * 🔴 **Why this is not inlined at each call site.** A process started under a
5
+ * git hook inherits `GIT_DIR` and `GIT_INDEX_FILE`, and any child then answers
6
+ * confidently about a repository its caller is not in — `rev-parse` comparing
7
+ * another repo's refs, `fetch` writing into it. This repository has the scar:
8
+ * NOTES.md's `GIT_DIR` incident, 19 junk commits across two branches.
9
+ *
10
+ * ⚠ **Both forms occur, and assuming either one is how this gets skipped.**
11
+ * Measured on git 2.47.1: a hook in the main checkout exports
12
+ * `GIT_INDEX_FILE=.git/index` — relative, so a child that changes directory
13
+ * resolves it against the wrong root. A hook in a **linked worktree** exports
14
+ * an absolute `GIT_DIR=/…/.git/worktrees/<name>`, which redirects the child
15
+ * **whatever its `cwd` is**. So "my child runs in the same directory" is not a
16
+ * reason to skip sanitising, and the worktree case — the one the queue seam
17
+ * exists for — is precisely the absolute one.
18
+ *
19
+ * 🔴 **The list is explicit, and a `GIT_*` prefix sweep is the wrong shape.**
20
+ * Only repository *location* is stripped. `GIT_SSH_COMMAND`, `GIT_ASKPASS` and
21
+ * `GIT_TERMINAL_PROMPT` are the caller's environment and none of our business,
22
+ * and `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_COUNT` are how containers and CI inject
23
+ * `safe.directory` for a checkout owned by another uid — sweep those away and
24
+ * git exits 128 on *dubious ownership*, which a caller that falls back quietly
25
+ * turns straight back into the bug it was guarding against.
26
+ *
27
+ * ⚠ **Limit, stated because it bounds what this buys you:** sanitising here
28
+ * protects only the spawns that call it. A call site that forgets is
29
+ * unprotected and nothing in this module can detect that —
30
+ * `test/template/git-env.test.ts` sweeps for exactly that, and a new git spawn
31
+ * belongs on its list the day it is written.
32
+ */
33
+
34
+ export const GIT_LOCATION_VARS = Object.freeze([
35
+ 'GIT_DIR',
36
+ 'GIT_WORK_TREE',
37
+ 'GIT_INDEX_FILE',
38
+ 'GIT_COMMON_DIR',
39
+ 'GIT_OBJECT_DIRECTORY',
40
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
41
+ 'GIT_NAMESPACE',
42
+ 'GIT_PREFIX',
43
+ ]);
44
+
45
+ export const withoutGitLocation = (env = process.env) => {
46
+ const sanitised = { ...env };
47
+ for (const key of GIT_LOCATION_VARS) delete sanitised[key];
48
+ return sanitised;
49
+ };