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.
- package/CHANGELOG.md +134 -0
- package/README.md +57 -9
- package/package.json +9 -2
- package/packages/cli/dist/lib/summary.js +19 -5
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +169 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- 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
|
+
}
|