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.
- package/CHANGELOG.md +170 -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 +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- 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 +330 -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 +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -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 +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- 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 +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -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 +436 -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 +330 -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 +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- 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`,
|
|
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
|
|
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.**
|
|
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
|
|
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
are
|
|
219
|
+
Generator-authored rulebook artifacts — rules, hooks, skills, scripts and agent
|
|
220
|
+
specs — arrive 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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 = ['
|
|
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
|