create-agent-rig 0.2.0 โ†’ 0.3.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 (33) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +57 -9
  3. package/package.json +9 -2
  4. package/packages/cli/dist/lib/summary.js +19 -5
  5. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  6. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  7. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  8. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  9. package/templates/agent-os/universal/.claude/queue.json +3 -0
  10. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  11. package/templates/agent-os/universal/.claude/rules/invariants.md +169 -0
  12. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  13. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  14. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  15. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  16. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  17. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  18. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  19. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  20. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  21. package/templates/agent-os/universal/.claude/settings.json +4 -0
  22. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
  23. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  27. package/templates/agent-os/universal/CLAUDE.md +57 -7
  28. package/templates/agent-os/universal/PLAN.md +28 -2
  29. package/templates/agent-os/universal/layers.json +20 -1
  30. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  31. package/templates/skeleton/aws-serverless/gitignore +8 -0
  32. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  33. package/templates/skeleton/node-service/gitignore +8 -0
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ // The one finding a run cannot raise about itself.
3
+ //
4
+ // A change that touches an elevated-tier path must go through a reviewer gate
5
+ // (.claude/rules/autonomy.md, "Tier 2"; .claude/rules/workflow.md, "PR flow").
6
+ // When a run continues past that gate, the miss is invisible by construction:
7
+ // **the run that missed the gate is exactly the run that will not report it.**
8
+ // A run that had known was a run that would have run the gate.
9
+ //
10
+ // So this sweep runs OUTSIDE any run, over merged PRs, reading only merged
11
+ // artifacts. It costs a run nothing and cannot be skipped by one.
12
+ //
13
+ // node .claude/scripts/detect-missed-gate.mjs # last 7 days
14
+ // node .claude/scripts/detect-missed-gate.mjs --since 2026-07-01 --json
15
+ // node .claude/scripts/detect-missed-gate.mjs --input prs.json # offline
16
+ // node .claude/scripts/detect-missed-gate.mjs --epoch 2026-07-01 # ignore older merges
17
+ //
18
+ // ๐Ÿ”ด **Never invoke it as a step inside a run.** A check the run performs on
19
+ // itself is a check the run in a hurry skips โ€” running it inside would give back
20
+ // exactly the property that makes it worth having. A scheduled job, a weekly
21
+ // habit, or a human is the right caller.
22
+ //
23
+ // It always exits 0 unless it could not run at all: findings are the output, not
24
+ // the status. A non-zero exit would make a sweep that FOUND something look like
25
+ // a sweep that BROKE.
26
+ import { execFileSync } from 'node:child_process';
27
+ import { readFileSync, readdirSync, realpathSync } from 'node:fs';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { dirname, join } from 'node:path';
30
+
31
+ /**
32
+ * Read every layer's declaration and union them.
33
+ *
34
+ * The set is never duplicated into this file: a list here plus a list in the docs
35
+ * needs a drift check to stay honest, and a drifted detector quietly stops
36
+ * detecting. Reading the declaration removes that failure mode instead of
37
+ * monitoring it.
38
+ *
39
+ * `CLAUDE.md` carries the project's own paths; each stack layer's rule file
40
+ * carries the ones that only exist in that shape (`infra/` comes from the
41
+ * infrastructure layer, and a project without one must not declare it). Seeding
42
+ * every path in one place would declare directories that do not exist in half the
43
+ * targets โ€” and a gate declared over a missing directory reports "clean" while
44
+ * looking nowhere.
45
+ */
46
+ export const readDeclaredPaths = (projectRoot, { readFile = readFileSync, listDir = null } = {}) => {
47
+ const sources = [join(projectRoot, 'CLAUDE.md')];
48
+ try {
49
+ const rulesDir = join(projectRoot, '.claude', 'rules');
50
+ const entries = listDir ? listDir(rulesDir) : readdirSync(rulesDir);
51
+ for (const entry of entries) {
52
+ if (entry.endsWith('.md')) sources.push(join(rulesDir, entry));
53
+ }
54
+ } catch {
55
+ // no rules directory โ€” CLAUDE.md alone then
56
+ }
57
+
58
+ const declared = [];
59
+ let found = false;
60
+ for (const source of sources) {
61
+ let parsed;
62
+ try {
63
+ parsed = parseElevatedPaths(readFile(source, 'utf8'));
64
+ } catch {
65
+ continue; // an unreadable source is not a declaration
66
+ }
67
+ if (parsed) {
68
+ found = true;
69
+ declared.push(...parsed);
70
+ }
71
+ }
72
+ // null, not [], when nothing declared anything: `sweep` reports that as its own
73
+ // finding rather than as "no findings".
74
+ return found ? [...new Set(declared)] : null;
75
+ };
76
+
77
+ /**
78
+ * Normalise a path so both sides of the comparison agree.
79
+ *
80
+ * `./infra/x.ts`, `/infra/x.ts` and `infra//x.ts` all name the same file as
81
+ * `infra/x.ts`; comparing raw strings meant each of those slipped the gate
82
+ * silently. Case is deliberately preserved โ€” paths are case-sensitive on the
83
+ * systems this runs on, and folding case would create false positives.
84
+ */
85
+ export const normalizePath = (path) =>
86
+ String(path ?? '')
87
+ .replace(/\\/g, '/')
88
+ .replace(/\/{2,}/g, '/')
89
+ .replace(/^\.\//, '')
90
+ .replace(/^\//, '');
91
+
92
+ export const parseElevatedPaths = (markdown) => {
93
+ // `\r?` so a file with CRLF line endings is not invisible: the block used to be
94
+ // undetectable there, and with another source also declaring, the loss was
95
+ // silent rather than reported as a blind sweep.
96
+ const blocks = [...String(markdown ?? '').matchAll(/```elevated-paths\r?\n([\s\S]*?)```/g)];
97
+ if (blocks.length === 0) return null;
98
+ return blocks.flatMap((block) =>
99
+ block[1]
100
+ .split('\n')
101
+ // an inline comment after the path is a comment, not part of the path
102
+ .map((line) => normalizePath(line.replace(/\s+#.*$/, '').trim()))
103
+ .filter((line) => line && !line.startsWith('#')),
104
+ );
105
+ };
106
+
107
+ /**
108
+ * Paths that provision nothing and configure nothing, whatever directory they sit
109
+ * in โ€” EXCEPT the rulebook itself. Declaring `.claude/` as elevated was a no-op
110
+ * for every `.md` under it, so a merged PR rewriting the autonomy tiers or the
111
+ * Never list passed the gate meant to catch exactly that.
112
+ */
113
+ const isRulebook = (path) => path === 'CLAUDE.md' || path.startsWith('.claude/');
114
+
115
+ const isInert = (path) =>
116
+ !isRulebook(path) &&
117
+ (/\.mdx?$/.test(path) ||
118
+ /(^|\/)(test|tests|__tests__)\//.test(path) ||
119
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(path));
120
+
121
+ /** Coerce whatever the host returned into a path string, never throwing. */
122
+ const pathOf = (file) => {
123
+ if (typeof file === 'string') return file;
124
+ const path = file?.path ?? file?.filename ?? '';
125
+ return typeof path === 'string' ? path : '';
126
+ };
127
+
128
+ /**
129
+ * The elevated-tier files among `files`; empty means the change was not elevated.
130
+ *
131
+ * Tolerant of every malformed shape seen in testing (a non-array, an object,
132
+ * `{path: 42}`) because the alternative was a TypeError that killed the whole
133
+ * sweep and lost the findings on every other PR โ€” which both this file and the
134
+ * reconciler explicitly promise not to do.
135
+ */
136
+ export const elevatedPathsIn = (files = [], elevatedPaths = []) => {
137
+ if (!Array.isArray(files)) return [];
138
+ const prefixes = (Array.isArray(elevatedPaths) ? elevatedPaths : []).map(normalizePath);
139
+ return files
140
+ .map(pathOf)
141
+ .map(normalizePath)
142
+ .filter((path) => path && !isInert(path) && prefixes.some((prefix) => path.startsWith(prefix)));
143
+ };
144
+
145
+ // A reviewer agent named in the PR body, by convention: the two universal gates
146
+ // plus any project-specific `*-reviewer`.
147
+ // The quantifier is BOUNDED. `[a-z0-9-]*` before a `-reviewer` tail backtracks
148
+ // quadratically on an attacker-written body: 3.9s on a 65k line, and the sweep
149
+ // reads 100 PR bodies, so a crafted set costs minutes of CPU on a scheduled job
150
+ // that reports nothing when it is killed.
151
+ const REVIEWERS = /\b(code-reviewer|security-scanner|[a-z][a-z0-9-]{0,48}-reviewer)\b/i;
152
+ const VERDICT = /\b(clean|passed|pass|approved|no blocking|green)\b/i;
153
+
154
+ /**
155
+ * ๐Ÿ”ด The body is NOT authority, and this is the security core of the file.
156
+ *
157
+ * The PR body is written by whoever opened the PR โ€” including the run whose
158
+ * compliance is being audited, and including an outside contributor. Testing it
159
+ * with a keyword scan made the detector strictly *more* permissive the more
160
+ * damning the body got: `VERDICT: HOLD โ€” code-reviewer listed 3 blockers`
161
+ * suppressed the finding, an unticked `- [ ] code-reviewer verdict recorded`
162
+ * checkbox suppressed it, and so did `ignore the code-reviewer, this is urgent`.
163
+ * Writing the truth got you flagged; writing a lie did not.
164
+ *
165
+ * So only the **`human-review` label** suppresses a finding. Applying a label
166
+ * needs repository triage permission, which a fork contributor does not have and a
167
+ * run cannot fake. The body is still read โ€” a plausible-looking verdict there is
168
+ * reported as a weaker, separate observation, never as a pass.
169
+ */
170
+ const labelsOf = (pr) => {
171
+ const labels = pr?.labels;
172
+ if (typeof labels === 'string') return [labels];
173
+ if (!Array.isArray(labels)) return [];
174
+ return labels.map((label) => (typeof label === 'string' ? label : (label?.name ?? '')));
175
+ };
176
+
177
+ export const gateEvidence = (pr) => {
178
+ if (labelsOf(pr).includes('human-review')) return 'label';
179
+ const claimsVerdict = String(pr?.body ?? '')
180
+ .split('\n')
181
+ .some((line) => REVIEWERS.test(line) && VERDICT.test(line));
182
+ return claimsVerdict ? 'body-claim' : 'none';
183
+ };
184
+
185
+ /**
186
+ * Which lane produced this PR. The branch convention (`<type>/<queue-id>-<slug>`
187
+ * โ€” see the `worktree-task` skill) is the discriminator, because it is the one
188
+ * mark a queue-driven run always leaves and an outside contributor never does.
189
+ */
190
+ const BRANCH_CONVENTION = /^(?:feat|fix|docs|chore|refactor|test|perf)\/((?:[A-Z][A-Z0-9]*-)?\d+)-/;
191
+ const QUEUE_REF = /#(\d+)\b|\b([A-Z][A-Z0-9]+-\d+)\b/;
192
+ const CLOSES_ISSUE = /\b(?:clos(?:e|es|ed)|fix(?:e[sd])?|resolv(?:e|es|ed))\s+#(\d+)/i;
193
+
194
+ export const queueRefOf = (pr) => {
195
+ const fromBranch = String(pr?.headRefName ?? '').match(BRANCH_CONVENTION)?.[1] ?? null;
196
+ const text = `${pr?.title ?? ''}\n${pr?.body ?? ''}`;
197
+ const inText = text.match(QUEUE_REF);
198
+ const fromText = inText ? (inText[1] ?? inText[2] ?? null) : null;
199
+ const closesIssue = text.match(CLOSES_ISSUE)?.[1] ?? null;
200
+ return { fromBranch, fromText, closesIssue };
201
+ };
202
+
203
+ export const laneOf = (pr) => {
204
+ const { fromBranch, fromText, closesIssue } = queueRefOf(pr);
205
+ // A queue-driven PR whose description lost the reference is still recognisably
206
+ // queue-driven. Counting it as external would corrupt the accounting AND hide
207
+ // the broken convention โ€” so the lane stays `queue` and the gap is a finding.
208
+ if (fromBranch) {
209
+ return {
210
+ lane: 'queue',
211
+ queueRef: fromBranch,
212
+ closesIssue,
213
+ finding: fromText === null ? 'queue-ref-missing-from-description' : null,
214
+ };
215
+ }
216
+ if (closesIssue) return { lane: 'external', queueRef: null, closesIssue, finding: null };
217
+ // No reference anywhere and nothing closed: owner-directed work outside both
218
+ // lanes. Recorded, not flagged โ€” it is legitimate.
219
+ return { lane: 'owner-directed', queueRef: null, closesIssue: null, finding: null };
220
+ };
221
+
222
+ /**
223
+ * One merged PR โ†’ a `missed-gate` finding, or null. Three tests in order: merged
224
+ * after the epoch, touched an elevated path, no recorded gate.
225
+ */
226
+ export const classifyPr = (pr, { elevatedPaths = [], epoch = null } = {}) => {
227
+ if (!pr || typeof pr !== 'object') return null;
228
+ if (!pr.mergedAt) return null;
229
+ if (epoch && new Date(pr.mergedAt) < new Date(epoch)) return null;
230
+
231
+ // A file list that is absent is NOT an empty one. `files: null` used to read as
232
+ // "touched nothing elevated" and pass silently โ€” so a schema change, a truncated
233
+ // response or a hand-exported fixture turned the sweep into a rubber stamp.
234
+ // `gh pr list --json files` uses GraphQL `files(first: 100)` and silently
235
+ // returns only the first 100, with no truncation marker. A PR padded past that
236
+ // hides its elevated file OUTSIDE the window, and the sweep reads the empty
237
+ // result as "touched nothing elevated" โ€” the silent variant of the failure this
238
+ // file exists to prevent. `changedFiles` comes back on the same call and is the
239
+ // ground truth.
240
+ const truncated =
241
+ Array.isArray(pr.files) &&
242
+ typeof pr.changedFiles === 'number' &&
243
+ pr.files.length < pr.changedFiles;
244
+
245
+ if (pr.files === undefined || pr.files === null || truncated) {
246
+ return {
247
+ kind: 'unknown-file-list',
248
+ pr: pr.number,
249
+ title: pr.title,
250
+ url: pr.url,
251
+ mergedAt: pr.mergedAt,
252
+ lane: laneOf(pr).lane,
253
+ elevatedFiles: [],
254
+ why: truncated
255
+ ? `the host returned ${pr.files.length} of ${pr.changedFiles} changed files, so ` +
256
+ 'this sweep could not see the whole diff. That is an unknown, not a pass โ€” ' +
257
+ 'check this PR by hand.'
258
+ : 'the merged PR carries no file list, so this sweep could not tell whether ' +
259
+ 'it crossed an elevated path. That is an unknown, not a pass โ€” re-fetch it ' +
260
+ 'with `--json files` or check the PR by hand.',
261
+ };
262
+ }
263
+
264
+ const elevatedFiles = elevatedPathsIn(pr.files, elevatedPaths);
265
+ if (elevatedFiles.length === 0) return null;
266
+
267
+ const evidence = gateEvidence(pr);
268
+ if (evidence === 'label') return null;
269
+
270
+ const { lane, queueRef } = laneOf(pr);
271
+ return {
272
+ kind: 'missed-gate',
273
+ pr: pr.number,
274
+ title: pr.title,
275
+ url: pr.url,
276
+ mergedAt: pr.mergedAt,
277
+ lane,
278
+ queueRef,
279
+ evidence,
280
+ elevatedFiles,
281
+ why:
282
+ evidence === 'body-claim'
283
+ ? `merged touching ${elevatedFiles.length} elevated-tier path(s). The body ` +
284
+ 'claims a reviewer verdict, but the body is written by the author โ€” it is ' +
285
+ 'not verifiable after the fact. Only the human-review label, which needs ' +
286
+ 'repository permission, records the gate. Confirm the gate ran and label it.'
287
+ : `merged touching ${elevatedFiles.length} elevated-tier path(s) with ` +
288
+ 'no human-review label and no reviewer verdict recorded anywhere',
289
+ };
290
+ };
291
+
292
+ /**
293
+ * A sweep over merged PRs.
294
+ *
295
+ * Escalation grows with the second miss: the first is commented and journaled;
296
+ * a second inside the same window opens an escalation issue, because two misses
297
+ * is a hole in the gate logic rather than one slip.
298
+ */
299
+ export const sweep = ({ prs = [], elevatedPaths = [], epoch = null } = {}) => {
300
+ const findings = [];
301
+
302
+ // No declaration is not "nothing to find" โ€” it is a blind sweep, and a blind
303
+ // sweep reporting "no findings" is the most misleading output this tool has.
304
+ if (elevatedPaths === null || elevatedPaths.length === 0) {
305
+ findings.push({
306
+ kind: 'no-elevated-paths-declared',
307
+ why:
308
+ 'CLAUDE.md declares no `elevated-paths` block, so this sweep cannot tell ' +
309
+ 'an elevated merge from an ordinary one. Until it does, "no findings" ' +
310
+ 'means "did not look".',
311
+ actions: ['journal-line', 'escalation-issue'],
312
+ });
313
+ return { findings, sweptPrs: prs.length, epoch };
314
+ }
315
+
316
+ // A non-array input is a caller mistake, not forty clean PRs.
317
+ const rows = Array.isArray(prs) ? prs : [];
318
+
319
+ let misses = 0;
320
+ for (const pr of rows) {
321
+ let finding;
322
+ try {
323
+ finding = classifyPr(pr, { elevatedPaths, epoch });
324
+ } catch {
325
+ // One unparseable row must not cost the findings on every other row.
326
+ findings.push({
327
+ kind: 'unreadable-record',
328
+ pr: pr?.number ?? null,
329
+ why: 'this merged PR could not be read, so it was not checked. That is an unknown, not a pass.',
330
+ actions: ['journal-line'],
331
+ });
332
+ continue;
333
+ }
334
+ if (!finding) continue;
335
+ const actions = [
336
+ finding.lane === 'queue' ? 'comment-on-queue-item' : 'comment-on-pr',
337
+ 'journal-line',
338
+ ];
339
+ if (finding.kind === 'missed-gate') {
340
+ if (misses >= 1) actions.push('escalation-issue');
341
+ misses += 1;
342
+ }
343
+ findings.push({ ...finding, actions });
344
+ }
345
+
346
+ return { findings, sweptPrs: rows.length, epoch };
347
+ };
348
+
349
+ // --- CLI -----------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Read an offline fixture, or say plainly why it could not be read.
353
+ *
354
+ * A raw SyntaxError stack, or a silent fall-through to the live repo when
355
+ * `--input` was given without a value, are both worse than a one-line diagnosis:
356
+ * this tool's whole value is that "could not look" never renders as "clean".
357
+ */
358
+ const readInput = (file, label) => {
359
+ let parsed;
360
+ try {
361
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
362
+ } catch (error) {
363
+ process.stderr.write(
364
+ `${label}: could not read ${file} as JSON โ€” nothing was checked. ` +
365
+ `${String(error?.message ?? error).split('\n')[0]}\n`,
366
+ );
367
+ process.exit(1);
368
+ }
369
+ if (!Array.isArray(parsed)) {
370
+ process.stderr.write(
371
+ `${label}: ${file} does not contain a JSON array of merged PRs, so nothing ` +
372
+ 'was checked. Expected the shape `gh pr list --json โ€ฆ` produces.\n',
373
+ );
374
+ process.exit(1);
375
+ }
376
+ return parsed;
377
+ };
378
+
379
+ const parseArgs = (argv) => {
380
+ const args = { json: false, since: null, epoch: null, input: null };
381
+ for (let i = 0; i < argv.length; i += 1) {
382
+ const flag = argv[i];
383
+ if (flag === '--json') args.json = true;
384
+ else if (flag === '--since') args.since = argv[++i];
385
+ else if (flag === '--epoch') args.epoch = argv[++i];
386
+ else if (flag === '--input') args.input = argv[++i];
387
+ }
388
+ return args;
389
+ };
390
+
391
+ const daysAgo = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
392
+
393
+ /**
394
+ * Findings are the output, not the status โ€” but that cuts both ways: a sweep that
395
+ * could not reach the API must be unmistakably different from a clean sweep. So
396
+ * this is the one path that exits non-zero, and it says why in one line rather
397
+ * than dumping a stack.
398
+ */
399
+ const fetchMergedPrs = (since) => {
400
+ try {
401
+ return JSON.parse(
402
+ execFileSync(
403
+ 'gh',
404
+ [
405
+ 'pr',
406
+ 'list',
407
+ '--state',
408
+ 'merged',
409
+ '--limit',
410
+ '100',
411
+ '--search',
412
+ `merged:>=${since}`,
413
+ '--json',
414
+ 'number,title,body,headRefName,mergedAt,url,labels,files,changedFiles',
415
+ ],
416
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
417
+ ),
418
+ );
419
+ } catch (error) {
420
+ process.stderr.write(
421
+ 'missed-gate sweep: could not list merged PRs โ€” the `gh` CLI is missing, ' +
422
+ 'unauthenticated, or the API is unreachable. This is NOT a clean sweep; ' +
423
+ 'nothing was checked. Use --input <file> to sweep an exported list offline.\n' +
424
+ ` ${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]}\n`,
425
+ );
426
+ process.exit(1);
427
+ }
428
+ };
429
+
430
+ export const render = (result) => {
431
+ if (result.findings.length === 0) {
432
+ return (
433
+ `missed-gate sweep: ${result.sweptPrs} merged PR(s) swept โ€” no findings` +
434
+ `${result.epoch ? ` (epoch ${result.epoch})` : ''}.\n`
435
+ );
436
+ }
437
+ const lines = [
438
+ `missed-gate sweep: ${result.findings.length} finding(s) over ${result.sweptPrs} merged PR(s).`,
439
+ '',
440
+ ];
441
+ for (const f of result.findings) {
442
+ if (f.kind === 'no-elevated-paths-declared') {
443
+ lines.push(` [${f.kind}] ${f.why}`);
444
+ } else {
445
+ lines.push(
446
+ ` [missed-gate] #${f.pr} (${f.lane}${f.queueRef ? `, ${f.queueRef}` : ''}) โ€” ${f.title}`,
447
+ );
448
+ lines.push(` elevated files: ${f.elevatedFiles.join(', ')}`);
449
+ lines.push(` ${f.why}`);
450
+ }
451
+ lines.push(` actions: ${f.actions.join(' โ†’ ')}`);
452
+ lines.push('');
453
+ }
454
+ return lines.join('\n');
455
+ };
456
+
457
+ /**
458
+ * Was this file invoked directly?
459
+ *
460
+ * Compared by REALPATH on both sides: ESM resolves `import.meta.url` through
461
+ * symlinks while `process.argv[1]` keeps the path as typed, so a project living
462
+ * under a symlinked directory (a macOS temp dir, a symlinked home, a checkout
463
+ * behind a link) would fail a naive equality check โ€” and the script would exit 0
464
+ * having printed nothing, which reads exactly like "no findings".
465
+ */
466
+ const invokedDirectly = () => {
467
+ if (!process.argv[1]) return false;
468
+ const real = (p) => {
469
+ try {
470
+ return realpathSync(p);
471
+ } catch {
472
+ return p;
473
+ }
474
+ };
475
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
476
+ };
477
+
478
+ if (invokedDirectly()) {
479
+ const args = parseArgs(process.argv.slice(2));
480
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
481
+ const prs = args.input
482
+ ? readInput(args.input, 'missed-gate sweep')
483
+ : fetchMergedPrs(args.since ?? daysAgo(7));
484
+ // No declaration anywhere reads as a blind sweep, and `sweep` reports that as
485
+ // its own finding rather than as "no findings".
486
+ const elevatedPaths = readDeclaredPaths(projectRoot);
487
+ const result = sweep({ prs, elevatedPaths, epoch: args.epoch });
488
+ process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : render(result));
489
+ }
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ // Preflight for an unattended run โ€” walked ONCE, before the first task.
3
+ //
4
+ // node .claude/scripts/preflight.mjs # the block, ready to paste
5
+ // node .claude/scripts/preflight.mjs --json
6
+ //
7
+ // Every scripted item below has already cost a run somewhere: they are cheap
8
+ // before task #1 and expensive at turn 40.
9
+ //
10
+ // ๐Ÿ”ด **It prints the items it did NOT check, every time.** That is the whole
11
+ // reason it is safe to script half a checklist. The honest objection to a partial
12
+ // script โ€” "a script that half-checks is worse than a list the run actually
13
+ // reads" โ€” is true exactly while the boundary is invisible. A silent script would
14
+ // let a GO on three items read as a pass on six.
15
+ //
16
+ // ๐Ÿ”ด **`unknown` never becomes `pass`.** A probe that could not run tells you
17
+ // nothing, and "I could not look" recorded as "it is fine" is the failure this
18
+ // checklist exists to prevent.
19
+ import { execFileSync } from 'node:child_process';
20
+ import { realpathSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ // One implementation of the brake, shared with the hook that enforces it. This
23
+ // file used to carry its own `process.env.AGENT_LOOP_STOP || <default>`, which is
24
+ // the replace-not-add bug โ€” fixed in the hook and left open here for a full review
25
+ // cycle, because preflight is the only scripted brake check and had no test.
26
+ import { brakeIsOn } from './stop-flag.mjs';
27
+
28
+ /** The items this script cannot check: judgement, or a call worth more than it saves. */
29
+ export const UNCHECKED = [
30
+ 'the queue is reachable through its adapter (`node .claude/scripts/queue/index.mjs next`)',
31
+ 'no stray worktree from a dead session that this run might mistake for its own',
32
+ 'a budget is declared for this run, and it is written down somewhere the run can re-read',
33
+ ];
34
+
35
+ const run = (command, args) =>
36
+ execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
37
+
38
+ /** The kill switch must be absent before a run starts. */
39
+ export const checkKillSwitch = () => {
40
+ const armed = brakeIsOn();
41
+ return armed
42
+ ? { ok: false, detail: `kill switch is SET (${armed}) โ€” do not start; deal with the cause` }
43
+ : { ok: true, detail: 'absent' };
44
+ };
45
+
46
+ /**
47
+ * The local default branch must match the remote.
48
+ *
49
+ * `fetch` is not `pull`, and a stale working tree reads as current โ€” this exact
50
+ * confusion has produced confidently-wrong runtime diagnoses more than once.
51
+ */
52
+ export const checkDefaultBranchFresh = () => {
53
+ try {
54
+ const branch =
55
+ run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
56
+ .split('/')
57
+ .pop() || 'main';
58
+ run('git', ['fetch', '--quiet', 'origin', branch]);
59
+ const local = run('git', ['rev-parse', branch]);
60
+ const remote = run('git', ['rev-parse', `origin/${branch}`]);
61
+ return local === remote
62
+ ? { ok: true, detail: `${branch} == origin/${branch}` }
63
+ : {
64
+ // `stale`, not `unknown`: the probe RAN and produced a definite answer.
65
+ // Reporting a known-stale branch as "could not look" collapsed the two
66
+ // states this file exists to keep apart.
67
+ ok: 'stale',
68
+ detail: `local ${branch} differs from origin/${branch} โ€” pull before starting`,
69
+ };
70
+ } catch (error) {
71
+ return { ok: 'unknown', detail: `could not compare: ${String(error.message ?? error).split('\n')[0]}` };
72
+ }
73
+ };
74
+
75
+ /** The last deploy must have concluded successfully โ€” never start on a broken runtime. */
76
+ export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
77
+ try {
78
+ const runs = JSON.parse(
79
+ run('gh', ['run', 'list', '--workflow', workflow, '--limit', '1', '--json', 'conclusion,url']),
80
+ );
81
+ if (runs.length === 0) return { ok: 'unknown', detail: 'no deploy run found yet' };
82
+ const [last] = runs;
83
+ return last.conclusion === 'success'
84
+ ? { ok: true, detail: `last deploy succeeded (${last.url})` }
85
+ : { ok: false, detail: `last deploy concluded ${last.conclusion} (${last.url})` };
86
+ } catch (error) {
87
+ return {
88
+ ok: 'unknown',
89
+ detail: `could not read deploy history: ${String(error.message ?? error).split('\n')[0]}`,
90
+ };
91
+ }
92
+ };
93
+
94
+ /**
95
+ * STOP on any hard failure; CAUTION on anything that is not a clean pass; GO only
96
+ * when every scripted item genuinely passed. The three unscripted items are still
97
+ * the reader's.
98
+ *
99
+ * `stale` and `unknown` both give CAUTION but are never merged into one word:
100
+ * "I looked and it is stale" is actionable, "I could not look" is not, and neither
101
+ * ever becomes a pass.
102
+ */
103
+ export const verdictOf = (checks) => {
104
+ const values = Object.values(checks);
105
+ if (values.some((check) => check?.ok === false)) return 'STOP';
106
+ if (values.some((check) => check?.ok !== true)) return 'CAUTION';
107
+ return 'GO';
108
+ };
109
+
110
+ export const report = (checks, { unchecked = UNCHECKED } = {}) => {
111
+ const verdict = verdictOf(checks);
112
+ const mark = (ok) =>
113
+ ok === true ? 'pass' : ok === false ? 'FAIL' : ok === 'stale' ? 'stale' : 'unknown';
114
+ const lines = [
115
+ `**preflight** โ€” verdict: ${verdict}`,
116
+ '',
117
+ ...Object.entries(checks).map(([key, check]) => `- ${mark(check?.ok)} ยท ${key} โ€” ${check?.detail ?? ''}`),
118
+ '',
119
+ `_Not checked by this script โ€” still yours (${unchecked.length}):_`,
120
+ ...unchecked.map((item) => `- ${item}`),
121
+ '',
122
+ '_An item skipped twice is the signal to script it or drop it: a checklist',
123
+ "nobody completes decays into one nobody reads._",
124
+ ];
125
+ return { verdict, checks, unchecked, rendered: lines.join('\n') };
126
+ };
127
+
128
+ /**
129
+ * Was this file invoked directly?
130
+ *
131
+ * Compared by REALPATH on both sides: ESM resolves `import.meta.url` through
132
+ * symlinks while `process.argv[1]` keeps the path as typed, so a project living
133
+ * under a symlinked directory (a macOS temp dir, a symlinked home, a checkout
134
+ * behind a link) would fail a naive equality check โ€” and the script would exit 0
135
+ * having printed nothing, which reads exactly like "no findings".
136
+ */
137
+ const invokedDirectly = () => {
138
+ if (!process.argv[1]) return false;
139
+ const real = (p) => {
140
+ try {
141
+ return realpathSync(p);
142
+ } catch {
143
+ return p;
144
+ }
145
+ };
146
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
147
+ };
148
+
149
+ if (invokedDirectly()) {
150
+ const checks = {
151
+ killSwitch: checkKillSwitch(),
152
+ defaultBranchFresh: checkDefaultBranchFresh(),
153
+ lastDeploy: checkLastDeploy(),
154
+ };
155
+ const result = report(checks);
156
+ process.stdout.write(
157
+ process.argv.includes('--json')
158
+ ? `${JSON.stringify(result, null, 2)}\n`
159
+ : `${result.rendered}\n`,
160
+ );
161
+ }