create-agent-rig 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -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
|
-
(
|
|
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
|
+
};
|