create-agent-rig 0.10.1 → 1.0.1
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 +170 -4
- package/README.md +8 -8
- package/package.json +2 -2
- package/packages/cli/dist/commands/upgrade.js +22 -9
- package/packages/cli/dist/index.js +9 -2
- package/packages/cli/dist/integrations/doctor-guards.js +7 -3
- package/templates/agent-os/subagent-routing.json +9 -5
- 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 +5 -1
- package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +142 -14
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +5 -1
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +168 -17
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
- 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 +157 -10
- 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 +2 -2
- package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/AGENTS.md +11 -5
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +1 -1
- 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 +118 -22
- package/templates/release-ledger.json +3 -1
|
@@ -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,11 +94,147 @@ 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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
// Case-folded once at module load, for the same comparison every caller shares
|
|
108
|
+
// — never re-derived per call, and never used anywhere but inside
|
|
109
|
+
// `isWidening` below (`canonicalRulebookPath` folds its own comparison from
|
|
110
|
+
// `RULEBOOK_PREFIX_SEGMENTS` instead).
|
|
111
|
+
const FOLDED_RULEBOOK_PREFIXES = RULEBOOK_PREFIXES.map((prefix) => prefix.toLowerCase());
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The canonical rulebook spelling of a repo-relative path, or `undefined`
|
|
115
|
+
* when it names no rulebook prefix at all. Case-insensitive on the PREFIX
|
|
116
|
+
* match only — NTFS and default APFS resolve a miscased spelling
|
|
117
|
+
* (`.Codex/config.toml`) to the same file as the canonical one, and this
|
|
118
|
+
* comparison is what a directory this checkout has never created yet (or
|
|
119
|
+
* any path on a case-sensitive filesystem) reaches, exactly as spelled by
|
|
120
|
+
* the caller — see the generator's `test/template/guard-rulebook.test.ts`
|
|
121
|
+
* (absent in a generated rig) › "guard-rulebook: blocks a miscased
|
|
122
|
+
* rulebook path even when the guarded directory does not exist on disk yet
|
|
123
|
+
* (RP-215)" › "blocks a Write to .Codex/config.toml when .codex/ is absent
|
|
124
|
+
* from the checkout".
|
|
125
|
+
*
|
|
126
|
+
* RP-215 round 2: the returned spelling replaces only the matched prefix
|
|
127
|
+
* characters with `RULEBOOK_PREFIXES`' own spelling — the remainder of the
|
|
128
|
+
* path (and any allow-list entry) keeps whatever case it was written in.
|
|
129
|
+
* This is the one place a path is re-cased; every comparison downstream
|
|
130
|
+
* (`isAllowed`, the `.claude/queue.board` carve-out in `guard-rulebook.mjs`)
|
|
131
|
+
* takes this canonical value and stays a literal, case-sensitive match —
|
|
132
|
+
* canonicalising the PATH, never folding the ALLOW-LIST, is what keeps a
|
|
133
|
+
* miscased allow entry (`.Claude/`, `.claude/Scripts/`) from ever matching
|
|
134
|
+
* anything, including the one prefix deliberately withheld as an allow root
|
|
135
|
+
* (`.claude/scripts/`, `isWidening`).
|
|
136
|
+
*
|
|
137
|
+
* RP-243: before the fold above, a trailing run of `.`/` ` is stripped from
|
|
138
|
+
* a path component — `.codex./config.toml` folds the same as
|
|
139
|
+
* `.codex/config.toml` — except a component made entirely of dots (`.`,
|
|
140
|
+
* `..`), left untouched so a traversal segment is never collapsed into an
|
|
141
|
+
* empty name. Only the PATH is normalised this way, never an allow-list
|
|
142
|
+
* entry — the same asymmetry as the case fold above.
|
|
143
|
+
*
|
|
144
|
+
* RP-243 round 2: only the components the matched prefix itself spans are
|
|
145
|
+
* normalised — bounded by the longest `RULEBOOK_PREFIXES` entry's own
|
|
146
|
+
* segment count, computed once below — so a component beyond that span
|
|
147
|
+
* keeps its literal spelling and the canonical path returns it raw. See the
|
|
148
|
+
* generator's `test/template/unattended-flag.test.ts` (absent in a
|
|
149
|
+
* generated rig) › "isRulebookPath: a trailing dot or space on a path
|
|
150
|
+
* component is judged the same as the component with it stripped (RP-243)".
|
|
151
|
+
*/
|
|
152
|
+
const stripTrailingDotsAndSpaces = (component) => {
|
|
153
|
+
if (/^\.+$/.test(component)) return component;
|
|
154
|
+
let end = component.length;
|
|
155
|
+
while (end > 0) {
|
|
156
|
+
const code = component.charCodeAt(end - 1);
|
|
157
|
+
if (code !== 46 /* '.' */ && code !== 32 /* ' ' */) break;
|
|
158
|
+
end -= 1;
|
|
159
|
+
}
|
|
160
|
+
return end === component.length ? component : component.slice(0, end);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `RULEBOOK_PREFIXES` split into segments once, at module load — a directory
|
|
165
|
+
* entry (`.claude/hooks/`) drops its trailing slash first, so both forms
|
|
166
|
+
* yield the segment names a path's own `/`-split components are compared
|
|
167
|
+
* against. `directory` records whether the entry itself ended in `/`: a
|
|
168
|
+
* directory entry needs an exact fold-match on every one of its segments
|
|
169
|
+
* plus something after them; a file entry (`CLAUDE.md`, `.rig/revalidation.json`)
|
|
170
|
+
* is matched with a `startsWith` on its last segment — see the generator's
|
|
171
|
+
* `test/template/unattended-flag.test.ts` (absent in a generated rig) ›
|
|
172
|
+
* "isRulebookPath: a path continuing past a matched FILE entry is still a rulebook path (round 3 regression)".
|
|
173
|
+
*/
|
|
174
|
+
const RULEBOOK_PREFIX_SEGMENTS = RULEBOOK_PREFIXES.map((prefix) => {
|
|
175
|
+
const directory = prefix.endsWith('/');
|
|
176
|
+
const segments = (directory ? prefix.slice(0, -1) : prefix).split('/');
|
|
177
|
+
const foldedSegments = segments.map((segment) => segment.toLowerCase());
|
|
178
|
+
return { prefix, directory, foldedSegments, length: segments.length };
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// The most components any single entry's match can ever need to inspect —
|
|
182
|
+
// never re-derived per call, and the only thing that bounds how many of a
|
|
183
|
+
// path's own components `canonicalRulebookPath` normalises.
|
|
184
|
+
const MAX_PREFIX_SEGMENTS = RULEBOOK_PREFIX_SEGMENTS.reduce(
|
|
185
|
+
(max, entry) => Math.max(max, entry.length),
|
|
186
|
+
0,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export const canonicalRulebookPath = (rel) => {
|
|
190
|
+
const components = rel.split('/');
|
|
191
|
+
const headCount = Math.min(components.length, MAX_PREFIX_SEGMENTS);
|
|
192
|
+
const normalisedHead = new Array(headCount);
|
|
193
|
+
const foldedHead = new Array(headCount);
|
|
194
|
+
for (let i = 0; i < headCount; i += 1) {
|
|
195
|
+
normalisedHead[i] = stripTrailingDotsAndSpaces(components[i]);
|
|
196
|
+
foldedHead[i] = normalisedHead[i].toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
for (const entry of RULEBOOK_PREFIX_SEGMENTS) {
|
|
199
|
+
const { directory, foldedSegments, length: segmentCount, prefix } = entry;
|
|
200
|
+
if (components.length < segmentCount) continue;
|
|
201
|
+
let leadingMatches = true;
|
|
202
|
+
for (let i = 0; i < segmentCount - 1; i += 1) {
|
|
203
|
+
if (foldedHead[i] !== foldedSegments[i]) {
|
|
204
|
+
leadingMatches = false;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!leadingMatches) continue;
|
|
209
|
+
const lastFolded = foldedHead[segmentCount - 1];
|
|
210
|
+
const lastSegment = foldedSegments[segmentCount - 1];
|
|
211
|
+
if (directory) {
|
|
212
|
+
if (lastFolded !== lastSegment) continue;
|
|
213
|
+
if (components.length === segmentCount) continue; // no trailing slash, nothing after
|
|
214
|
+
if (components.length === segmentCount + 1 && components[segmentCount] === '') {
|
|
215
|
+
return prefix; // the path itself ends with the literal trailing slash
|
|
216
|
+
}
|
|
217
|
+
return prefix + components.slice(segmentCount).join('/');
|
|
218
|
+
}
|
|
219
|
+
// File entry: the last spanned component is compared folded+normalised,
|
|
220
|
+
// same as every other component this function inspects; anything past
|
|
221
|
+
// it — the rest of that component, and every component after it — is
|
|
222
|
+
// returned exactly as written.
|
|
223
|
+
if (lastFolded === lastSegment) {
|
|
224
|
+
if (components.length === segmentCount) return prefix;
|
|
225
|
+
return `${prefix}/${components.slice(segmentCount).join('/')}`;
|
|
226
|
+
}
|
|
227
|
+
if (lastFolded.startsWith(lastSegment)) {
|
|
228
|
+
const extra = normalisedHead[segmentCount - 1].slice(lastSegment.length);
|
|
229
|
+
if (components.length === segmentCount) return prefix + extra;
|
|
230
|
+
return `${prefix}${extra}/${components.slice(segmentCount).join('/')}`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Is this repo-relative path part of the rulebook? See `canonicalRulebookPath`. */
|
|
237
|
+
export const isRulebookPath = (rel) => canonicalRulebookPath(rel) !== undefined;
|
|
102
238
|
|
|
103
239
|
/**
|
|
104
240
|
* Does this allow entry widen the rulebook? It is unsafe when it is an
|
|
@@ -108,14 +244,25 @@ export const isRulebookPath = (rel) =>
|
|
|
108
244
|
* protected script paths sit under `.claude/scripts/`; a narrower path such as
|
|
109
245
|
* `.claude/scripts/queue/` is an ordinary allow entry and does not widen it.
|
|
110
246
|
* `src/` also does not widen it because the guard judges nothing there.
|
|
247
|
+
*
|
|
248
|
+
* RP-215 round 2: the entry is case-folded once before either comparison —
|
|
249
|
+
* the allow side now folds like the path side (`canonicalRulebookPath`) — so
|
|
250
|
+
* a miscased entry (`.Claude/`, `.claude/Scripts/`) is judged exactly like its
|
|
251
|
+
* canonical spelling instead of slipping through as ordinary, harmless text;
|
|
252
|
+
* see the generator's `test/template/unattended-flag.test.ts` (absent in a
|
|
253
|
+
* generated rig) › "isWidening: a miscased allow entry must be refused
|
|
254
|
+
* exactly like its canonical spelling (RP-215 round 2)".
|
|
111
255
|
*/
|
|
112
|
-
export const isWidening = (entry) =>
|
|
113
|
-
typeof entry !== 'string' ||
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
256
|
+
export const isWidening = (entry) => {
|
|
257
|
+
if (typeof entry !== 'string' || entry === '') return true;
|
|
258
|
+
const folded = entry.toLowerCase();
|
|
259
|
+
return (
|
|
260
|
+
folded === '.agents/' ||
|
|
261
|
+
folded === '.claude/scripts/' ||
|
|
262
|
+
folded === '.codex/' ||
|
|
263
|
+
FOLDED_RULEBOOK_PREFIXES.some((prefix) => prefix !== folded && prefix.startsWith(folded))
|
|
264
|
+
);
|
|
265
|
+
};
|
|
119
266
|
|
|
120
267
|
/**
|
|
121
268
|
* One spelling for one directory — the single canonicaliser this file compares
|
|
@@ -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.
|
|
@@ -420,7 +420,11 @@ who was not reading the code at the time, and everything downstream — the fail
|
|
|
420
420
|
test, the implementation, the reviewer comparing diff to item — inherits its
|
|
421
421
|
claims rather than checking them. On `PREMISE FALSE` the item is escalated (§6),
|
|
422
422
|
not repaired in place: a run that silently re-aims its own task has authored work
|
|
423
|
-
for itself, which is the one thing this loop does not do (§8).
|
|
423
|
+
for itself, which is the one thing this loop does not do (§8). A claim
|
|
424
|
+
`check-premises` cannot settle by reading — a claimed defect or a historical
|
|
425
|
+
finding that needs reproducing on the current default branch, not just re-reading — goes to
|
|
426
|
+
`failure-diagnostician` in claim mode instead, never to an unnamed built-in
|
|
427
|
+
subagent.
|
|
424
428
|
|
|
425
429
|
🔴 **And again at the other end, before `pr-ship`: `check-premises` on the prose the
|
|
426
430
|
task itself wrote** — the rulebook prose the diff touches (the skill defines that set,
|
|
@@ -703,7 +707,23 @@ Both then follow the same three steps:
|
|
|
703
707
|
its clauses: what was *observed* (verbatim errors, not summaries), and the
|
|
704
708
|
single question whose answer unblocks the work. So: what fails, what was tried, the
|
|
705
709
|
current hypothesis, and links to the PR and the failing run where they exist
|
|
706
|
-
— a premise stop has neither, and its citation stands in for both.
|
|
710
|
+
— a premise stop has neither, and its citation stands in for both.
|
|
711
|
+
**For a red check or an unexplained failure, the current hypothesis is the
|
|
712
|
+
diagnostician's parsed verdict**: dispatch `failure-diagnostician` with the
|
|
713
|
+
verbatim failure, save its answer to a file under the run directory, and
|
|
714
|
+
check it —
|
|
715
|
+
|
|
716
|
+
```sh
|
|
717
|
+
node .claude/scripts/verdict.mjs check <report> failure-diagnostician
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
— then carry the parsed verdict (word, `classification`, blockers,
|
|
721
|
+
evidence) as the hypothesis instead of writing the diagnosis from scratch,
|
|
722
|
+
with the verbatim failure output still in the comment beside it, covering
|
|
723
|
+
the "observed" clause the list above already names. A `PREMISE FALSE` or
|
|
724
|
+
exhausted-cap stop keeps the diagnosis the paragraph above already
|
|
725
|
+
describes (what the item claimed, or the round count).
|
|
726
|
+
**Name the outcome
|
|
707
727
|
state in the same comment** — `incomplete` if the diagnosis cannot say **where** it
|
|
708
728
|
stopped (§5: a thin diagnosis that still locates the wall is a `documented-stall`).
|
|
709
729
|
Writing `incomplete` on your own task is uncomfortable and
|
|
@@ -847,29 +867,52 @@ four things, and a proposal missing any of them is not ready to file:
|
|
|
847
867
|
3. the change, concretely enough to diff;
|
|
848
868
|
4. how the next run would prove it worked — the observation that would differ.
|
|
849
869
|
|
|
850
|
-
Filing is the adapter's `proposeTriage`,
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
870
|
+
Filing is the adapter's `proposeTriage`, reached through the one root-safe entry
|
|
871
|
+
point `.claude/scripts/queue/propose.mjs` — never a relative `import()` typed by
|
|
872
|
+
hand, which breaks the moment the session is standing in a subdirectory
|
|
873
|
+
(`ERR_MODULE_NOT_FOUND` from the import, then an `ENOENT` from a cwd-relative
|
|
874
|
+
`PLAN.md` that is not there). The CLI (`index.mjs`) still deliberately does
|
|
875
|
+
**not** expose this — it never writes to the QUEUE (`next`, `list`, `hygiene`
|
|
876
|
+
only), so that no accidental invocation can change what the next run is
|
|
877
|
+
handed. `propose.mjs` resolves its config from its own location, exactly as
|
|
878
|
+
`index.mjs` does, so the proposal lands in the project's real PLAN.md (or
|
|
879
|
+
tracker) and the active board's own options travel with it rather than being
|
|
880
|
+
typed by hand. Write the proposal to a file under the run directory, then run
|
|
881
|
+
the script:
|
|
855
882
|
|
|
856
883
|
```bash
|
|
857
884
|
node --input-type=module -e '
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
885
|
+
const fs = await import("node:fs/promises");
|
|
886
|
+
await fs.writeFile(
|
|
887
|
+
`${process.env.RIG_RUN_DIR}/proposal.json`,
|
|
888
|
+
JSON.stringify({
|
|
889
|
+
finding: "<the journal line it came from>",
|
|
890
|
+
part: "<skill | agent | hook | rule | AGENTS.md | CLAUDE.md | workflow>",
|
|
891
|
+
change: "<concretely enough to diff>",
|
|
892
|
+
proof: "<the observation that would differ next run>",
|
|
893
|
+
// a pair: what the probe touched, and what is concluded from it. The
|
|
894
|
+
// mechanism accepts a proposal without them; this procedure does not.
|
|
895
|
+
measured: "<the paths the probe actually exercised>",
|
|
896
|
+
inferred: "<the conclusion, citing only surfaces named in measured>",
|
|
897
|
+
}),
|
|
898
|
+
);
|
|
870
899
|
'
|
|
900
|
+
# Root-anchored so the same command works whether the session is standing
|
|
901
|
+
# at the repo root or in a subdirectory. Pinned in the generator's
|
|
902
|
+
# test/template/loop-report-file.test.ts (absent in a generated rig) ›
|
|
903
|
+
# "files when the documented command line runs, unmodified, from a project
|
|
904
|
+
# subdirectory".
|
|
905
|
+
node "$(git rev-parse --show-toplevel)/.claude/scripts/queue/propose.mjs" --file "$RIG_RUN_DIR/proposal.json"
|
|
871
906
|
```
|
|
872
907
|
|
|
908
|
+
The result prints as one JSON line on stdout, and — because `RIG_RUN_DIR` is
|
|
909
|
+
declared — the same result is also recorded as a `proposal` event in the run
|
|
910
|
+
journal, so a failed filing is journalled as a failure instead of silently
|
|
911
|
+
going nowhere. Pinned in the generator's `test/template/queue-propose.test.ts`
|
|
912
|
+
(absent in a generated rig) › "files a proposal with a multiline finding from
|
|
913
|
+
a project subdirectory, into the project-root PLAN.md" and › "journals a
|
|
914
|
+
proposal event with ok: true on a successful filing under RIG_RUN_DIR".
|
|
915
|
+
|
|
873
916
|
A proposal missing any of the four parts is refused rather than filed half-formed.
|
|
874
917
|
|
|
875
918
|
**A finding can say what it measured and what it inferred, as two paired fields.**
|
|
@@ -920,9 +963,9 @@ rather than a step in the procedure: `plan-md` returns it when the plan file has
|
|
|
920
963
|
no `## Operator queue` heading, because a proposal then has nowhere to land that
|
|
921
964
|
the selection query cannot reach. Add the heading — never the Agent queue.
|
|
922
965
|
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
966
|
+
`jira` still requires `options.project`, and still throws rather than filing
|
|
967
|
+
without it — loudly, so nothing is lost — and there is no
|
|
968
|
+
second argument left to hand-copy.
|
|
926
969
|
|
|
927
970
|
🔴 **The loop proposes; the owner patches.** Self-applying a change to its own
|
|
928
971
|
rulebook is how an unattended run drifts irreversibly, and it collides head-on
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-slices
|
|
3
|
+
description: Use when a task cannot be verified as one reviewable PR and splits into slices that are each independently verifiable on their own. Ships only with the opt-in workflow layer.
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Write, Edit
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Splitting a task into slices
|
|
8
|
+
|
|
9
|
+
This applies when a task's change cannot be reviewed and verified as one PR,
|
|
10
|
+
and decomposes into slices that are each independently verifiable — each
|
|
11
|
+
slice stands on its own claim about behaviour, checked by its own test.
|
|
12
|
+
|
|
13
|
+
Do not trigger it mechanically based on file or module count: a change
|
|
14
|
+
that touches many files but makes one verifiable claim stays one PR, and a
|
|
15
|
+
change touching few files but making several independent claims still
|
|
16
|
+
splits.
|
|
17
|
+
|
|
18
|
+
There is no planner role dispatched for this — the session writes the slice
|
|
19
|
+
plan directly. For each slice, record:
|
|
20
|
+
|
|
21
|
+
- the outcome the slice delivers, stated as a claim a test can check;
|
|
22
|
+
- its own failing test, written by `test-writer` in the ordinary Red step;
|
|
23
|
+
- the elevated paths it touches, if any (`AGENTS.md`'s elevated-paths block);
|
|
24
|
+
- where it sits in the slice order, and what it depends on.
|
|
25
|
+
|
|
26
|
+
Each slice then ships as its own PR through the ordinary flow —
|
|
27
|
+
`.claude/rules/workflow.md` has the TDD cycle and the PR flow in full; this
|
|
28
|
+
skill does not restate them.
|
|
29
|
+
|
|
30
|
+
This skill ships only with the opt-in workflow layer.
|
|
@@ -39,7 +39,7 @@ blockers.
|
|
|
39
39
|
`test/template/gate-rounds.test.ts` — absent in a generated rig — ›
|
|
40
40
|
"refuses to count a round on a dirty tree, and counts nothing".
|
|
41
41
|
|
|
42
|
-
The cap is **
|
|
42
|
+
The cap is **3 by default**, and no shipped `.claude/queue.json` carries the key
|
|
43
43
|
— the default lives in `core.mjs` as `DEFAULT_MAX_GATE_ROUNDS`. A project that
|
|
44
44
|
wants a different cap sets `options.maxGateRounds` there, which in a rig whose
|
|
45
45
|
`queue.json` is composed means changing what composes it, not editing the file.
|