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.
Files changed (42) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +8 -8
  3. package/package.json +2 -2
  4. package/packages/cli/dist/commands/upgrade.js +69 -31
  5. package/packages/cli/dist/index.js +15 -2
  6. package/templates/agent-os/subagent-routing.json +4 -0
  7. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +32 -3
  8. package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
  9. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
  10. package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
  11. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
  12. package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
  13. package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
  14. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +4 -0
  15. package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
  16. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +18 -5
  17. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +21 -6
  18. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +13 -3
  19. package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
  20. package/templates/agent-os/universal/.claude/rules/workflow.md +14 -2
  21. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
  23. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
  24. package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
  25. package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
  26. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
  27. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +8 -0
  28. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +32 -3
  29. package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
  31. package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
  32. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
  33. package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
  34. package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
  35. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  36. package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
  37. package/templates/agent-os/universal/AGENTS.md +6 -4
  38. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
  39. package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
  40. package/templates/agent-os/universal/layers.json +12 -0
  41. package/templates/hash-history.json +112 -39
  42. 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
- const raw =
157
- issues ?? ghJson(['issue', 'list', '--state', 'all', '--limit', String(limit), '--json', FIELDS]);
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
- return raw
161
- .filter((issue) => String(issue.state ?? '').toUpperCase() !== 'CLOSED')
162
- .map((issue) => {
163
- const ticket = toTicket(issue, states);
164
- return { ...ticket, blocks: blocks[ticket.id] ?? [] };
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
- /** The proposals on file, as `{ id, body }` — every `triage`-labelled issue. */
297
- export const listProposals = ({ existing = null } = {}) =>
298
- (
299
- existing ??
300
- ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
301
- ).map((issue) => ({ id: String(issue.number), body: issue.body }));
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
- const { events } = readRun({ runDir: join(runsDir, run) });
128
- return { run, events };
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.