create-agent-rig 0.10.0 → 1.0.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 +155 -0
- package/README.md +8 -8
- package/package.json +2 -2
- package/packages/cli/dist/commands/upgrade.js +69 -31
- package/packages/cli/dist/index.js +15 -2
- package/templates/agent-os/subagent-routing.json +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +32 -3
- package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +4 -0
- package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +18 -5
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +21 -6
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +14 -2
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
- package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
- package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +8 -0
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +32 -3
- package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
- package/templates/agent-os/universal/AGENTS.md +6 -4
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
- package/templates/agent-os/universal/layers.json +12 -0
- package/templates/hash-history.json +112 -39
- package/templates/release-ledger.json +3 -1
|
@@ -144,6 +144,21 @@ const ghJson = (args) => JSON.parse(ghText(args));
|
|
|
144
144
|
|
|
145
145
|
const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments';
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A `--state` (or triage) window that came back exactly at its cap: older
|
|
149
|
+
* items may have been left unread, and a window this shape cannot tell the
|
|
150
|
+
* difference from a repository that happens to have exactly `limit` items.
|
|
151
|
+
* See queue-github-pagination.test.ts (absent in a generated rig) ›
|
|
152
|
+
* "a --state %s window that comes back exactly at the limit is announced on
|
|
153
|
+
* stderr" and › "a triage window that comes back exactly at the cap (100) is
|
|
154
|
+
* announced on stderr".
|
|
155
|
+
*/
|
|
156
|
+
const announceCap = (label, limit) => {
|
|
157
|
+
process.stderr.write(
|
|
158
|
+
`github-issues: ${label} window capped at ${limit} issues — older ${label} items may be missing; raise limit\n`,
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
147
162
|
// --- the adapter contract ------------------------------------------------------
|
|
148
163
|
|
|
149
164
|
/**
|
|
@@ -151,18 +166,42 @@ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments'
|
|
|
151
166
|
*
|
|
152
167
|
* Deliberately queries fresh on every call and never caches: the queue changes as
|
|
153
168
|
* the loop itself closes items and unblocks their dependents.
|
|
169
|
+
*
|
|
170
|
+
* Open and closed issues are read as two separate `--state` windows rather
|
|
171
|
+
* than one shared `--state all` window: a shared window lets closed history
|
|
172
|
+
* push an older open issue out of it, which used to be silent. See
|
|
173
|
+
* queue-github-pagination.test.ts (absent in a generated rig) › "keeps an
|
|
174
|
+
* older OPEN issue even when 100 CLOSED issues would fill a shared window".
|
|
154
175
|
*/
|
|
155
176
|
export const listEligible = ({ limit = 100, issues = null } = {}) => {
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
let raw;
|
|
178
|
+
let openIssues = null;
|
|
179
|
+
if (issues) {
|
|
180
|
+
raw = issues;
|
|
181
|
+
} else {
|
|
182
|
+
openIssues = ghJson(['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', FIELDS]);
|
|
183
|
+
if (openIssues.length === limit) announceCap('open', limit);
|
|
184
|
+
const closedIssues = ghJson([
|
|
185
|
+
'issue',
|
|
186
|
+
'list',
|
|
187
|
+
'--state',
|
|
188
|
+
'closed',
|
|
189
|
+
'--limit',
|
|
190
|
+
String(limit),
|
|
191
|
+
'--json',
|
|
192
|
+
FIELDS,
|
|
193
|
+
]);
|
|
194
|
+
if (closedIssues.length === limit) announceCap('closed', limit);
|
|
195
|
+
raw = [...openIssues, ...closedIssues];
|
|
196
|
+
}
|
|
158
197
|
const states = Object.fromEntries(raw.map((issue) => [String(issue.number), issue.state]));
|
|
159
198
|
const blocks = blocksIndex(raw);
|
|
160
|
-
|
|
161
|
-
.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED')
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
const eligible =
|
|
200
|
+
openIssues ?? raw.filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED');
|
|
201
|
+
return eligible.map((issue) => {
|
|
202
|
+
const ticket = toTicket(issue, states);
|
|
203
|
+
return { ...ticket, blocks: blocks[ticket.id] ?? [] };
|
|
204
|
+
});
|
|
166
205
|
};
|
|
167
206
|
|
|
168
207
|
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
@@ -293,12 +332,30 @@ export const triageItemFor = (proposal) => {
|
|
|
293
332
|
* hand out nothing — "queue empty" and "nothing selectable";
|
|
294
333
|
* twenty such stops must produce one proposal with a count of twenty.
|
|
295
334
|
*/
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
335
|
+
/**
|
|
336
|
+
* The proposals on file, as `{ id, body }` — every `triage`-labelled issue.
|
|
337
|
+
* A window that comes back exactly at its cap is announced on stderr, same
|
|
338
|
+
* as `listEligible`'s.
|
|
339
|
+
*/
|
|
340
|
+
export const listProposals = ({ existing = null, limit = 100 } = {}) => {
|
|
341
|
+
let raw = existing;
|
|
342
|
+
if (!raw) {
|
|
343
|
+
raw = ghJson([
|
|
344
|
+
'issue',
|
|
345
|
+
'list',
|
|
346
|
+
'--label',
|
|
347
|
+
'triage',
|
|
348
|
+
'--state',
|
|
349
|
+
'all',
|
|
350
|
+
'--limit',
|
|
351
|
+
String(limit),
|
|
352
|
+
'--json',
|
|
353
|
+
FIELDS,
|
|
354
|
+
]);
|
|
355
|
+
if (raw.length === limit) announceCap('triage', limit);
|
|
356
|
+
}
|
|
357
|
+
return raw.map((issue) => ({ id: String(issue.number), body: issue.body }));
|
|
358
|
+
};
|
|
302
359
|
|
|
303
360
|
export const proposeTriage = (rawProposal, { existing = null } = {}) => {
|
|
304
361
|
const proposal = withAsOf(rawProposal);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The one repo-root-safe entry point for filing an improvement-triage
|
|
3
|
+
// proposal (RP-209). It resolves its config, and through it the active
|
|
4
|
+
// board's adapter and plan path, from its OWN location — exactly like
|
|
5
|
+
// `index.mjs`'s `projectRoot` — so a session standing in a subdirectory
|
|
6
|
+
// files into the project's real PLAN.md rather than a cwd-relative one
|
|
7
|
+
// that happens not to exist there.
|
|
8
|
+
//
|
|
9
|
+
// node .claude/scripts/queue/propose.mjs --file <proposal.json>
|
|
10
|
+
// node .claude/scripts/queue/propose.mjs --file - # stdin
|
|
11
|
+
// node .claude/scripts/queue/propose.mjs --file <path> --config <queue.json>
|
|
12
|
+
//
|
|
13
|
+
// The proposal object is whatever the active adapter's `proposeTriage`
|
|
14
|
+
// already accepts. The result prints as one JSON line on stdout; the
|
|
15
|
+
// process exits 0 only when `ok === true`. When `RIG_RUN_DIR` is declared,
|
|
16
|
+
// one `proposal` event is recorded in the run journal either way, so a
|
|
17
|
+
// failed filing is journalled as a failure rather than going nowhere
|
|
18
|
+
// silently.
|
|
19
|
+
//
|
|
20
|
+
// See the generator's test/template/queue-propose.test.ts (absent in a
|
|
21
|
+
// generated rig).
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './index.mjs';
|
|
26
|
+
|
|
27
|
+
const parseArgs = (argv) => {
|
|
28
|
+
const args = { file: null, config: null };
|
|
29
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
30
|
+
if (argv[i] === '--file') args.file = argv[++i];
|
|
31
|
+
else if (argv[i] === '--config') args.config = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
return args;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readStdin = () =>
|
|
37
|
+
new Promise((resolve, reject) => {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
40
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
41
|
+
process.stdin.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const readProposalRaw = (file) => (file === '-' ? readStdin() : Promise.resolve(readFileSync(file, 'utf8')));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Small, deliberately: the journal is a trace of the filing decision, not a
|
|
48
|
+
* second copy of the proposal or of the adapter's whole response.
|
|
49
|
+
*/
|
|
50
|
+
const journalDataFor = (result, reason) => {
|
|
51
|
+
const data = { ok: result?.ok === true };
|
|
52
|
+
if (result?.item?.fingerprint !== undefined) data.id = result.item.fingerprint;
|
|
53
|
+
if (result?.filed !== undefined) data.filed = result.filed;
|
|
54
|
+
if (result?.incremented !== undefined) data.incremented = result.incremented;
|
|
55
|
+
if (reason !== undefined) data.reason = reason;
|
|
56
|
+
return data;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const main = async () => {
|
|
60
|
+
const args = parseArgs(process.argv.slice(2));
|
|
61
|
+
if (!args.file) {
|
|
62
|
+
process.stderr.write('propose: --file <proposal.json> is required (or --file - for stdin).\n');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = await readProposalRaw(args.file);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
process.stderr.write(`propose: could not read ${args.file}: ${error.message}\n`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let proposal;
|
|
75
|
+
try {
|
|
76
|
+
proposal = JSON.parse(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
process.stderr.write(`propose: ${args.file} is not valid JSON: ${error.message}\n`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Resolved against this file's own URL, not the cwd — the same rule
|
|
83
|
+
// `index.mjs` follows, for the same reason: the CLI runs from the project
|
|
84
|
+
// root, from a worktree, and from a subdirectory the session happens to be
|
|
85
|
+
// standing in.
|
|
86
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
87
|
+
const projectRoot = join(scriptDir, '..', '..', '..');
|
|
88
|
+
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
89
|
+
|
|
90
|
+
let result;
|
|
91
|
+
let reason;
|
|
92
|
+
try {
|
|
93
|
+
const config = loadConfig(configPath);
|
|
94
|
+
const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
95
|
+
const options = optionsWithPlanPath(config.options, configPath);
|
|
96
|
+
result = await adapter.proposeTriage(proposal, options);
|
|
97
|
+
if (result?.ok !== true) reason = result?.why ?? 'proposeTriage returned ok: false';
|
|
98
|
+
} catch (error) {
|
|
99
|
+
reason = error.message ?? String(error);
|
|
100
|
+
result = { ok: false, reason };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const exitCode = result?.ok === true ? 0 : 1;
|
|
104
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
105
|
+
if (exitCode !== 0) process.stderr.write(`propose: ${reason}\n`);
|
|
106
|
+
|
|
107
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
108
|
+
if (runDir) {
|
|
109
|
+
let journal = null;
|
|
110
|
+
try {
|
|
111
|
+
journal = await import('../run-journal.mjs');
|
|
112
|
+
journal.recordEvent({
|
|
113
|
+
runDir,
|
|
114
|
+
kind: 'proposal',
|
|
115
|
+
data: journalDataFor(result, reason),
|
|
116
|
+
now: new Date().toISOString(),
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const classify = journal?.isTraceExhausted;
|
|
120
|
+
if (typeof classify === 'function' && classify(error)) {
|
|
121
|
+
// The trace is over; the filing already happened and stands. Loud on
|
|
122
|
+
// stderr, exit code stays whatever the filing decided — mirrors the
|
|
123
|
+
// pattern in `index.mjs` and the `loop` skill's own journal section.
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`run journal: ${error.message}\n` +
|
|
126
|
+
` the proposal result above was NOT recorded in ${runDir}. This run's ` +
|
|
127
|
+
"trace ends here; the filing above stands.\n",
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
process.stderr.write(`run journal: ${error.message}\n`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.exit(exitCode);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
main();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* release-evidence.mjs — read-only, deterministic. Turns existing run-journal
|
|
4
|
+
* evidence (gate-blocker patterns, filed triage proposals) into a
|
|
5
|
+
* REPEATED_PAIN or GATHER_MORE_EVIDENCE verdict, for the `release-propose`
|
|
6
|
+
* skill to read (RP-203). It files nothing itself — see
|
|
7
|
+
* test/template/release-evidence.test.ts (absent in a generated rig).
|
|
8
|
+
*
|
|
9
|
+
* node .claude/scripts/release-evidence.mjs --since <ISO> [--runs <dir>] [--json]
|
|
10
|
+
*
|
|
11
|
+
* Reads `<runs>/<run-id>/` the same way `revalidation-report.mjs` does — this
|
|
12
|
+
* script reuses that script's `readRuns` (default `--runs` also resolves
|
|
13
|
+
* through `queue/checkout.mjs`'s `mainCheckoutRoot`, the same main-checkout
|
|
14
|
+
* rule) and `run-journal.mjs`'s `readRun`. A run `readRun` refuses is counted
|
|
15
|
+
* under `runs.skipped`, with why, never dropped silently.
|
|
16
|
+
*
|
|
17
|
+
* Grouping keys: `gate-blocker|<gate>|<norm(rule)>` for each blocker on a
|
|
18
|
+
* `decisions.jsonl` record; `proposal|<data.id>` for each `events.jsonl`
|
|
19
|
+
* record with `kind === 'proposal'` and `data.ok === true`. A group is
|
|
20
|
+
* `repeated` only when its records come from at least `REPEATED_MIN_RUNS`
|
|
21
|
+
* distinct run directories — any number of records inside one run is one
|
|
22
|
+
* anecdote.
|
|
23
|
+
*
|
|
24
|
+
* Limits: grouping is lexical (exact gate plus a normalised blocker rule, or
|
|
25
|
+
* a proposal fingerprint) — merging rules that mean the same thing but read
|
|
26
|
+
* differently is inference this script does not attempt. Run independence is
|
|
27
|
+
* assumed, not proven: one PR can span more than one run directory, which
|
|
28
|
+
* this script has no way to detect and would then double-count as two.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { realpathSync } from 'node:fs';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { mainCheckoutRoot } from './queue/checkout.mjs';
|
|
35
|
+
import { readRuns } from './revalidation-report.mjs';
|
|
36
|
+
|
|
37
|
+
export const REPEATED_MIN_RUNS = 2;
|
|
38
|
+
|
|
39
|
+
const norm = (rule) => rule.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
40
|
+
|
|
41
|
+
/** The report over already-read runs — pure, so the grouping is testable alone. */
|
|
42
|
+
export const evidenceOf = ({ runs, since }) => {
|
|
43
|
+
const sinceMs = Date.parse(since);
|
|
44
|
+
const read = [];
|
|
45
|
+
const skipped = [];
|
|
46
|
+
const groups = new Map();
|
|
47
|
+
|
|
48
|
+
const addRecord = (key, source, gate, label, run, at, pointer) => {
|
|
49
|
+
let group = groups.get(key);
|
|
50
|
+
if (!group) {
|
|
51
|
+
group = { key, source, gate, label, records: 0, runsSeen: new Set(), firstAt: at, lastAt: at, pointers: [] };
|
|
52
|
+
groups.set(key, group);
|
|
53
|
+
}
|
|
54
|
+
group.records += 1;
|
|
55
|
+
group.runsSeen.add(run);
|
|
56
|
+
if (Date.parse(at) < Date.parse(group.firstAt)) group.firstAt = at;
|
|
57
|
+
if (Date.parse(at) > Date.parse(group.lastAt)) group.lastAt = at;
|
|
58
|
+
group.pointers.push(pointer);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const entry of runs) {
|
|
62
|
+
if (entry.error) {
|
|
63
|
+
skipped.push({ run: entry.run, why: entry.error });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
read.push(entry.run);
|
|
67
|
+
|
|
68
|
+
for (const record of entry.decisions ?? []) {
|
|
69
|
+
if (!(Date.parse(record.at) >= sinceMs)) continue;
|
|
70
|
+
for (const blockerItem of record.blockers ?? []) {
|
|
71
|
+
const rule = blockerItem?.rule;
|
|
72
|
+
if (typeof rule !== 'string' || rule.trim() === '') continue;
|
|
73
|
+
const gate = record.gate ?? 'unknown';
|
|
74
|
+
const label = norm(rule);
|
|
75
|
+
addRecord(`gate-blocker|${gate}|${label}`, 'gate-blocker', gate, label, entry.run, record.at, {
|
|
76
|
+
run: entry.run,
|
|
77
|
+
file: 'decisions.jsonl',
|
|
78
|
+
seq: record.seq,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const event of entry.events ?? []) {
|
|
84
|
+
if (event.kind !== 'proposal') continue;
|
|
85
|
+
if (event.data?.ok !== true) continue;
|
|
86
|
+
const id = event.data?.id;
|
|
87
|
+
if (id === undefined || id === null) continue;
|
|
88
|
+
if (!(Date.parse(event.at) >= sinceMs)) continue;
|
|
89
|
+
addRecord(`proposal|${id}`, 'proposal', null, String(id), entry.run, event.at, {
|
|
90
|
+
run: entry.run,
|
|
91
|
+
file: 'events.jsonl',
|
|
92
|
+
seq: event.seq,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const groupList = [...groups.values()].map((group) => ({
|
|
98
|
+
key: group.key,
|
|
99
|
+
source: group.source,
|
|
100
|
+
gate: group.gate,
|
|
101
|
+
label: group.label,
|
|
102
|
+
records: group.records,
|
|
103
|
+
runs: group.runsSeen.size,
|
|
104
|
+
repeated: group.runsSeen.size >= REPEATED_MIN_RUNS,
|
|
105
|
+
firstAt: group.firstAt,
|
|
106
|
+
lastAt: group.lastAt,
|
|
107
|
+
pointers: group.pointers,
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
groupList.sort((a, b) => {
|
|
111
|
+
if (a.repeated !== b.repeated) return a.repeated ? -1 : 1;
|
|
112
|
+
if (a.runs !== b.runs) return b.runs - a.runs;
|
|
113
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const verdict = groupList.some((group) => group.repeated) ? 'REPEATED_PAIN' : 'GATHER_MORE_EVIDENCE';
|
|
117
|
+
const why =
|
|
118
|
+
verdict === 'REPEATED_PAIN'
|
|
119
|
+
? `at least one group recurred across ${REPEATED_MIN_RUNS}+ distinct run directories`
|
|
120
|
+
: 'no group recurred across distinct run directories since the window opened; an anecdote is not evidence';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
since,
|
|
125
|
+
rule: {
|
|
126
|
+
repeatedMinRuns: REPEATED_MIN_RUNS,
|
|
127
|
+
unit: 'distinct run directory',
|
|
128
|
+
grouping: 'exact gate + normalised blocker rule; proposal fingerprint',
|
|
129
|
+
},
|
|
130
|
+
runs: { read: read.length, skipped },
|
|
131
|
+
groups: groupList,
|
|
132
|
+
verdict,
|
|
133
|
+
why,
|
|
134
|
+
limits: [
|
|
135
|
+
'grouping is lexical; merging similar rules is inference',
|
|
136
|
+
'run independence is assumed, not proven (one PR can span runs)',
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const parseArgs = (argv) => {
|
|
142
|
+
const args = { since: null, runs: null, json: false, bad: null };
|
|
143
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
144
|
+
const arg = argv[i];
|
|
145
|
+
if (arg === '--json') args.json = true;
|
|
146
|
+
else if (arg === '--since') args.since = argv[++i] ?? null;
|
|
147
|
+
else if (arg === '--runs') args.runs = argv[++i] ?? null;
|
|
148
|
+
else if (args.bad === null) args.bad = arg;
|
|
149
|
+
}
|
|
150
|
+
return args;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const invokedDirectly = () => {
|
|
154
|
+
if (!process.argv[1]) return false;
|
|
155
|
+
const real = (p) => {
|
|
156
|
+
try {
|
|
157
|
+
return realpathSync(p);
|
|
158
|
+
} catch {
|
|
159
|
+
return p;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (invokedDirectly()) {
|
|
166
|
+
const args = parseArgs(process.argv.slice(2));
|
|
167
|
+
const refuse = (message) => {
|
|
168
|
+
process.stderr.write(`${message}\n`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
};
|
|
171
|
+
if (args.bad !== null) refuse(`unrecognised argument: ${args.bad}`);
|
|
172
|
+
if (!args.since || Number.isNaN(Date.parse(args.since))) {
|
|
173
|
+
refuse(`--since needs an ISO date (got ${args.since ?? '(none)'}); a report with no window reports nothing honest.`);
|
|
174
|
+
}
|
|
175
|
+
const scriptsDir = dirname(fileURLToPath(import.meta.url));
|
|
176
|
+
const runsDir = args.runs ?? join(mainCheckoutRoot(join(scriptsDir, '..', '..')), '.claude', 'runs');
|
|
177
|
+
let runs;
|
|
178
|
+
try {
|
|
179
|
+
runs = readRuns(runsDir);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
refuse(error.message);
|
|
182
|
+
}
|
|
183
|
+
const evidence = evidenceOf({ runs, since: new Date(args.since).toISOString() });
|
|
184
|
+
// Always JSON: the one output shape `release-propose` reads. `--json` is
|
|
185
|
+
// accepted (every call site may pass it) but does not change the shape —
|
|
186
|
+
// there is no separate human-summary mode to opt out of.
|
|
187
|
+
process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`);
|
|
188
|
+
}
|
|
@@ -124,8 +124,10 @@ export const readRuns = (runsDir) => {
|
|
|
124
124
|
}
|
|
125
125
|
return names.sort().map((run) => {
|
|
126
126
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// `decisions` rides along unused here — added for release-evidence.mjs
|
|
128
|
+
// (RP-203), which reuses this same reader for its own grouping.
|
|
129
|
+
const { decisions, events } = readRun({ runDir: join(runsDir, run) });
|
|
130
|
+
return { run, decisions, events };
|
|
129
131
|
} catch (error) {
|
|
130
132
|
return { run, error: String(error?.message ?? error) };
|
|
131
133
|
}
|
|
@@ -94,6 +94,14 @@ export const RULEBOOK_PREFIXES = Object.freeze([
|
|
|
94
94
|
'.codex/',
|
|
95
95
|
'AGENTS.md',
|
|
96
96
|
'CLAUDE.md',
|
|
97
|
+
// the detection contract preflight and claim-records both read: it decides
|
|
98
|
+
// whether preflight STOPs and what the scope fingerprint watches, so an
|
|
99
|
+
// unattended run does not rewrite what its own revalidation checks against
|
|
100
|
+
// — outside its item's allow-list, like every other entry here and unlike
|
|
101
|
+
// `.claude/queue.board`, which `guard-rulebook` refuses even when the
|
|
102
|
+
// allow-list names it. The exact file, never `.rig/` — a SELECT still needs to
|
|
103
|
+
// write its own baseline under `.rig/claims/`, which stays unlisted here.
|
|
104
|
+
'.rig/revalidation.json',
|
|
97
105
|
]);
|
|
98
106
|
|
|
99
107
|
/** Is this repo-relative path part of the rulebook? */
|
|
@@ -91,13 +91,40 @@ At the **second** entry point this inverts for one case: a test is exactly what
|
|
|
91
91
|
a behaviour claim, so reading it is the point. The rule above is about not letting a
|
|
92
92
|
test's *name* stand in for what the code does; §4 says which artifacts count.
|
|
93
93
|
|
|
94
|
+
### External premises
|
|
95
|
+
|
|
96
|
+
A claim about something this repository does not contain — an external API, a
|
|
97
|
+
CLI, a library — is a premise like any other, and §2 decides whether it is
|
|
98
|
+
load-bearing. "The provider accepts a `--json` flag" or "the SDK retries on 429"
|
|
99
|
+
changes what gets built if it is false. The code here cannot settle it, so this
|
|
100
|
+
is the one case where documentation is the evidence, and it is recorded in four
|
|
101
|
+
parts:
|
|
102
|
+
|
|
103
|
+
- **version** — the exact version this project uses (the lockfile, the installed
|
|
104
|
+
binary's own version output), not "latest";
|
|
105
|
+
- **source** — an authoritative one for that version: the vendor's reference
|
|
106
|
+
documentation, its changelog, or the tool's own `--help`;
|
|
107
|
+
- **date** — when you read it, because documentation changes under a fixed URL;
|
|
108
|
+
- **quote** or pointer — the sentence that says it, short enough to re-check, or
|
|
109
|
+
the exact section it sits in.
|
|
110
|
+
|
|
111
|
+
A complete record that supports the claim lets it hold. One that contradicts it
|
|
112
|
+
is `PREMISE FALSE`, and the work stops exactly as it does for a claim the code
|
|
113
|
+
contradicts. The four parts go in the report's `evidence`, and in the blocker's
|
|
114
|
+
`note` when there is one.
|
|
115
|
+
|
|
116
|
+
A claim missing any of the four stays `UNVERIFIABLE`: being widely believed, or
|
|
117
|
+
true of an earlier version, does not promote it to a fact. This skill adds no
|
|
118
|
+
network tooling — where the session cannot reach the source, the claim is
|
|
119
|
+
`UNVERIFIABLE` and travels as a labelled assumption, as §4 says.
|
|
120
|
+
|
|
94
121
|
## 4. The verdict
|
|
95
122
|
|
|
96
123
|
| Verdict | When | What happens next |
|
|
97
124
|
| --- | --- | --- |
|
|
98
125
|
| `PREMISES HOLD` | every load-bearing claim checked out, or there were none | proceed to the Red step |
|
|
99
|
-
| `PREMISE FALSE` | a load-bearing claim is contradicted by the code | **stop and report** |
|
|
100
|
-
| `UNVERIFIABLE` | a load-bearing claim could not be decided from the code | report it as unverifiable, name what would decide it, and proceed only under a **labelled assumption** |
|
|
126
|
+
| `PREMISE FALSE` | a load-bearing claim is contradicted by the code — or, for an external premise, by its four-part record | **stop and report** |
|
|
127
|
+
| `UNVERIFIABLE` | a load-bearing claim could not be decided from the code — or, for an external premise, has no complete four-part record | report it as unverifiable, name what would decide it, and proceed only under a **labelled assumption** |
|
|
101
128
|
| `UNMEASURED` | **second entry point only:** a sentence you wrote asserts behaviour, and nothing you can point at backs it | **delete the sentence, or turn it into a pointer to the test that proves it** — before the gate |
|
|
102
129
|
|
|
103
130
|
🔴 **The edit belongs to the calling session, not to this skill.** It reports; the
|
|
@@ -223,7 +250,9 @@ is invisible to every gate downstream.
|
|
|
223
250
|
- **It reads the code, so it only catches what the code can contradict.** A claim
|
|
224
251
|
about runtime behaviour ("this times out in production"), about intent, or
|
|
225
252
|
about a system this repository does not contain is `UNVERIFIABLE` here, not
|
|
226
|
-
false — say so rather than guessing.
|
|
253
|
+
false — say so rather than guessing. For an external API, CLI or library the
|
|
254
|
+
way out is the four-part record "External premises" asks for, which can make
|
|
255
|
+
the claim hold or prove it false.
|
|
227
256
|
- **Each entry point is one pass, at its own end of the task.** A premise that goes
|
|
228
257
|
false *between* them — a merge lands, a dependency moves — is a staleness stop rule
|
|
229
258
|
(`.claude/rules/autonomy.md`), not this skill. Neither pass watches the other's
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: diagnose
|
|
3
|
+
description: Use when a check is red or a run crashed and the cause is not obvious, or when a claimed defect or historical finding needs confirming before work is planned on it.
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Bash, Task
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Diagnose before you fix
|
|
8
|
+
|
|
9
|
+
Stop guessing. A red check or a crashed run is never a thing to retry until
|
|
10
|
+
it goes green — that stop rule is already stated in
|
|
11
|
+
`.claude/rules/autonomy.md` ("Flaky ≠ retry"); this skill does not restate
|
|
12
|
+
it.
|
|
13
|
+
|
|
14
|
+
## Hand it to `failure-diagnostician`
|
|
15
|
+
|
|
16
|
+
Give the agent what it needs to reproduce, verbatim:
|
|
17
|
+
|
|
18
|
+
- for a failure — the exact failure output, the command that produced it,
|
|
19
|
+
and the commit or branch where it failed;
|
|
20
|
+
- for a claim — the claim's own text, and where it came from (a queue item,
|
|
21
|
+
a review comment, a prior finding).
|
|
22
|
+
|
|
23
|
+
Dispatch `failure-diagnostician`. Its method is its own —
|
|
24
|
+
`.claude/agents/failure-diagnostician.md` — not repeated here.
|
|
25
|
+
|
|
26
|
+
## Check the answer, then act on the word
|
|
27
|
+
|
|
28
|
+
Save its report to a file and run exactly:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
node .claude/scripts/verdict.mjs check <report> failure-diagnostician
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Exit 1 means it did not answer — that is no diagnosis, not a word to act on.
|
|
35
|
+
|
|
36
|
+
| Verdict | Action |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `ROOT_CAUSE` / `STILL_LIVE` | the failing test first, through `test-writer` — the Red step in `.claude/rules/workflow.md` |
|
|
39
|
+
| `INCONCLUSIVE` / `INSUFFICIENT_EVIDENCE` | stop; escalate in the format `.claude/rules/autonomy.md` ("Escalation format") sets, carrying the verdict's blockers as what would decide the question |
|
|
40
|
+
| `ALREADY_FIXED` / `OBSOLETE` | close the item, citing the verdict's `evidence` |
|
|
41
|
+
|
|
42
|
+
This is a Core skill: it dispatches no opt-in-workflow-layer machinery, and
|
|
43
|
+
routes on the word alone.
|