create-agent-rig 0.5.0 → 0.6.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 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// be a snapshot that nothing updates when the blocker lands; this cannot go stale,
|
|
15
15
|
// because it is re-resolved from the blockers themselves on every selection.
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
|
-
import { duplicateOf, fingerprintOf, validateProposal } from './core.mjs';
|
|
18
|
-
import {
|
|
17
|
+
import { duplicateOf, fingerprintOf, lifecycleOf, ownerOfLabels, validateProposal } from './core.mjs';
|
|
18
|
+
import { withAsOf } from './as-of.mjs';
|
|
19
|
+
import { recordEscalation, recordTakeUp } from '../run-state.mjs';
|
|
19
20
|
|
|
20
21
|
export const name = 'github-issues';
|
|
21
22
|
|
|
@@ -75,6 +76,11 @@ export const toTicket = (issue, states = {}) => {
|
|
|
75
76
|
blocks: [],
|
|
76
77
|
priority: priorityLabel ? Number(priorityLabel[1]) : 999,
|
|
77
78
|
createdAt: issue.createdAt ?? null,
|
|
79
|
+
// The take-up marker for revalidation at SELECT: the tracker's own
|
|
80
|
+
// last-modified field, whose contract (moves on edits, comments and state
|
|
81
|
+
// changes) is assumed and not checked here. `null` when the listing did not
|
|
82
|
+
// carry it.
|
|
83
|
+
updatedAt: issue.updatedAt ?? null,
|
|
78
84
|
// The body travels on the neutral shape so the hygiene checks live in one
|
|
79
85
|
// place (core.mjs) instead of once per adapter. This adapter also parses it
|
|
80
86
|
// internally for blocker links — the two readings are independent on
|
|
@@ -87,6 +93,10 @@ export const toTicket = (issue, states = {}) => {
|
|
|
87
93
|
: labels.includes('trigger-human')
|
|
88
94
|
? 'human'
|
|
89
95
|
: null,
|
|
96
|
+
// The repository this item belongs to (AR-132): `owner-<name>`, or null.
|
|
97
|
+
owner: ownerOfLabels(labels),
|
|
98
|
+
// The lifecycle and the scheduling flag (AR-144), read above the seam.
|
|
99
|
+
...lifecycleOf(labels),
|
|
90
100
|
};
|
|
91
101
|
};
|
|
92
102
|
|
|
@@ -120,7 +130,7 @@ const ghText = (args) =>
|
|
|
120
130
|
|
|
121
131
|
const ghJson = (args) => JSON.parse(ghText(args));
|
|
122
132
|
|
|
123
|
-
const FIELDS = 'number,title,body,state,labels,url,createdAt';
|
|
133
|
+
const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt';
|
|
124
134
|
|
|
125
135
|
// --- the adapter contract ------------------------------------------------------
|
|
126
136
|
|
|
@@ -146,21 +156,63 @@ export const listEligible = ({ limit = 100, issues = null } = {}) => {
|
|
|
146
156
|
export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) => !b.resolved);
|
|
147
157
|
|
|
148
158
|
/** `To Do → In Progress` before the first file is edited, not when the PR opens. */
|
|
159
|
+
/**
|
|
160
|
+
* Re-record the item's marker after a write of this adapter's own (AR-140) —
|
|
161
|
+
* the same rule and the same limit as `jira.mjs` › rebaseline: only writes
|
|
162
|
+
* made through this adapter re-baseline, and a read-back that fails is
|
|
163
|
+
* announced, never thrown.
|
|
164
|
+
*/
|
|
165
|
+
const rebaseline = (ticket) => {
|
|
166
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
167
|
+
if (!runDir) return;
|
|
168
|
+
try {
|
|
169
|
+
const after = ghJson(['issue', 'view', ticket.id, '--json', 'updatedAt']);
|
|
170
|
+
recordTakeUp(runDir, { id: ticket.id, updatedAt: after?.updatedAt ?? null });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`#${ticket.id}: the write landed, but its marker was NOT re-recorded in ${runDir} — ` +
|
|
174
|
+
`${error.message}\n`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
149
179
|
export const claim = (ticket) => {
|
|
150
180
|
ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
|
|
181
|
+
rebaseline(ticket);
|
|
151
182
|
return { ok: true };
|
|
152
183
|
};
|
|
153
184
|
|
|
185
|
+
/**
|
|
186
|
+
* One issue by number, closed included — `gh issue view` sees every state,
|
|
187
|
+
* where `listEligible` drops CLOSED for selection's sake. The offline `issues`
|
|
188
|
+
* seam is honoured. `blocks` is empty here: the cross-index needs the whole
|
|
189
|
+
* list, and a single view does not carry it.
|
|
190
|
+
*/
|
|
191
|
+
export const find = (id, { issues = null } = {}) => {
|
|
192
|
+
const raw = issues
|
|
193
|
+
? (issues.find((issue) => String(issue.number) === String(id)) ?? null)
|
|
194
|
+
: ghJson(['issue', 'view', String(id), '--json', FIELDS]);
|
|
195
|
+
return raw ? toTicket(raw, {}) : null;
|
|
196
|
+
};
|
|
197
|
+
|
|
154
198
|
export const close = (ticket, { prUrl = null } = {}) => {
|
|
155
199
|
const note = prUrl ? `Landed in ${prUrl}.` : 'Closed by the run.';
|
|
156
200
|
ghText(['issue', 'comment', ticket.id, '--body', note]);
|
|
157
201
|
ghText(['issue', 'close', ticket.id]);
|
|
158
|
-
|
|
159
|
-
|
|
202
|
+
// Read back, never inferred: `gh issue close` exits 0 on an issue that was
|
|
203
|
+
// already closed, or that a workflow reopened a moment later (AR-135). The
|
|
204
|
+
// claim label goes only once the read-back says CLOSED — a close that did
|
|
205
|
+
// not land leaves the item claimed, exactly as it was found.
|
|
206
|
+
const after = ghJson(['issue', 'view', ticket.id, '--json', 'state']);
|
|
207
|
+
const transitioned = String(after?.state ?? '').toUpperCase() === 'CLOSED';
|
|
208
|
+
if (transitioned) ghText(['issue', 'edit', ticket.id, '--remove-label', 'in-progress']);
|
|
209
|
+
rebaseline(ticket);
|
|
210
|
+
return { ok: true, transitioned };
|
|
160
211
|
};
|
|
161
212
|
|
|
162
213
|
export const comment = (ticket, body) => {
|
|
163
214
|
ghText(['issue', 'comment', ticket.id, '--body', body]);
|
|
215
|
+
rebaseline(ticket);
|
|
164
216
|
return { ok: true };
|
|
165
217
|
};
|
|
166
218
|
|
|
@@ -172,6 +224,7 @@ export const comment = (ticket, body) => {
|
|
|
172
224
|
export const escalate = (ticket, diagnosis, { env = process.env } = {}) => {
|
|
173
225
|
ghText(['issue', 'comment', ticket.id, '--body', diagnosis]);
|
|
174
226
|
ghText(['issue', 'edit', ticket.id, '--add-label', 'escalated']);
|
|
227
|
+
rebaseline(ticket);
|
|
175
228
|
// Counted through the one recorder, never a counter of this adapter's own —
|
|
176
229
|
// "twice in a row" has to mean the same thing on every tracker.
|
|
177
230
|
recordEscalation(env.RIG_RUN_DIR);
|
|
@@ -196,8 +249,10 @@ export const triageItemFor = (proposal) => {
|
|
|
196
249
|
`- **part to change** — ${proposal.part}`,
|
|
197
250
|
`- **proposed change** — ${proposal.change}`,
|
|
198
251
|
`- **how the next run proves it** — ${proposal.proof}`,
|
|
252
|
+
...(proposal.measured ? [`- **measured** — ${proposal.measured}`, `- **inferred** — ${proposal.inferred}`] : []),
|
|
199
253
|
'',
|
|
200
254
|
`fingerprint: ${fingerprint}`,
|
|
255
|
+
...(proposal.asOf ? [`asOf: ${proposal.asOf}`] : []),
|
|
201
256
|
'',
|
|
202
257
|
'The loop proposes; the owner patches. Self-applying a change to its own',
|
|
203
258
|
'rulebook is how an unattended run drifts irreversibly — and it collides with',
|
|
@@ -216,15 +271,17 @@ export const triageItemFor = (proposal) => {
|
|
|
216
271
|
* hand out nothing — "queue empty" and "nothing selectable";
|
|
217
272
|
* twenty such stops must produce one proposal with a count of twenty.
|
|
218
273
|
*/
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
274
|
+
/** The proposals on file, as `{ id, body }` — every `triage`-labelled issue. */
|
|
275
|
+
export const listProposals = ({ existing = null } = {}) =>
|
|
276
|
+
(
|
|
222
277
|
existing ??
|
|
223
|
-
ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
);
|
|
278
|
+
ghJson(['issue', 'list', '--label', 'triage', '--state', 'all', '--limit', '100', '--json', FIELDS])
|
|
279
|
+
).map((issue) => ({ id: String(issue.number), body: issue.body }));
|
|
280
|
+
|
|
281
|
+
export const proposeTriage = (rawProposal, { existing = null } = {}) => {
|
|
282
|
+
const proposal = withAsOf(rawProposal);
|
|
283
|
+
const item = triageItemFor(proposal);
|
|
284
|
+
const duplicate = duplicateOf(item, listProposals({ existing }));
|
|
228
285
|
|
|
229
286
|
if (duplicate) {
|
|
230
287
|
ghText([
|
|
@@ -237,6 +294,23 @@ export const proposeTriage = (proposal, { existing = null } = {}) => {
|
|
|
237
294
|
return { ok: true, incremented: String(duplicate.number), item };
|
|
238
295
|
}
|
|
239
296
|
|
|
240
|
-
ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
|
|
241
|
-
|
|
297
|
+
const url = ghText(['issue', 'create', '--title', item.title, '--body', item.body, '--label', 'triage']);
|
|
298
|
+
// The proposal's own baseline (AR-138), as in jira.mjs: `gh issue create`
|
|
299
|
+
// prints the new issue's URL, whose last segment is its number; the marker
|
|
300
|
+
// is read back with `gh issue view`. No run directory → nothing recorded.
|
|
301
|
+
// Best-effort for the same reason as there: the issue exists by now, and a
|
|
302
|
+
// throw would make the caller file it again.
|
|
303
|
+
const id = /\/(\d+)\s*$/.exec(String(url ?? ''))?.[1] ?? null;
|
|
304
|
+
if (id && process.env.RIG_RUN_DIR) {
|
|
305
|
+
try {
|
|
306
|
+
const after = ghJson(['issue', 'view', id, '--json', 'updatedAt']);
|
|
307
|
+
recordTakeUp(process.env.RIG_RUN_DIR, { id, updatedAt: after?.updatedAt ?? null });
|
|
308
|
+
} catch (error) {
|
|
309
|
+
process.stderr.write(
|
|
310
|
+
`proposeTriage: #${id} is filed, but its baseline was NOT recorded in ` +
|
|
311
|
+
`${process.env.RIG_RUN_DIR} — ${error.message}\n`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, filed: item.title, id, item };
|
|
242
316
|
};
|
|
@@ -4,22 +4,32 @@
|
|
|
4
4
|
// node .claude/scripts/queue/index.mjs next # the item to take, and why
|
|
5
5
|
// node .claude/scripts/queue/index.mjs next --json
|
|
6
6
|
// node .claude/scripts/queue/index.mjs list # every item, with skip reasons
|
|
7
|
-
// node .claude/scripts/queue/index.mjs hygiene # stale labels
|
|
7
|
+
// node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, overtaken proposals
|
|
8
8
|
// node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
|
|
9
|
+
// node .claude/scripts/queue/index.mjs board [<name>] # the active board; or switch this checkout to <name>
|
|
9
10
|
//
|
|
10
11
|
// The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
|
|
11
12
|
// defaults to `plan-md`, which is the only adapter that works in a freshly
|
|
12
13
|
// generated project. An unknown adapter is a hard error, never a fallback: a loop
|
|
13
14
|
// that silently reads the wrong queue is worse than one that refuses to start.
|
|
14
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
16
17
|
import { basename, dirname, join } from 'node:path';
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
asOfOf,
|
|
20
|
+
citedPathsOf,
|
|
21
|
+
hygieneOf,
|
|
22
|
+
overtakenOf,
|
|
23
|
+
revalidationOf,
|
|
24
|
+
selectNext,
|
|
25
|
+
stopConditionOf,
|
|
26
|
+
} from './core.mjs';
|
|
27
|
+
import { changedSinceOf, headShaOf } from './as-of.mjs';
|
|
18
28
|
// One resolver, imported rather than re-derived: writer and reader disagreeing
|
|
19
29
|
// about which checkout they are in is the whole of the worktree defect. It lives
|
|
20
30
|
// apart from `state.mjs` so the read path does not drag the tier computation —
|
|
21
31
|
// and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
|
|
22
|
-
import { mainCheckoutRoot } from './checkout.mjs';
|
|
32
|
+
import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
|
|
23
33
|
|
|
24
34
|
const ADAPTERS = {
|
|
25
35
|
'plan-md': './plan-md.mjs',
|
|
@@ -39,7 +49,7 @@ export const resolveAdapter = async (adapterName) => {
|
|
|
39
49
|
return import(new URL(modulePath, import.meta.url).href);
|
|
40
50
|
};
|
|
41
51
|
|
|
42
|
-
export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
|
|
52
|
+
export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round', 'board'];
|
|
43
53
|
|
|
44
54
|
/**
|
|
45
55
|
* A missing config is the normal state of a fresh project. A config that exists
|
|
@@ -54,8 +64,9 @@ export const loadConfig = (configPath) => {
|
|
|
54
64
|
} catch {
|
|
55
65
|
return {};
|
|
56
66
|
}
|
|
67
|
+
let parsed;
|
|
57
68
|
try {
|
|
58
|
-
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
59
70
|
} catch (error) {
|
|
60
71
|
throw new Error(
|
|
61
72
|
`${configPath} exists but is not valid JSON, so the configured queue cannot be ` +
|
|
@@ -64,6 +75,83 @@ export const loadConfig = (configPath) => {
|
|
|
64
75
|
{ cause: error },
|
|
65
76
|
);
|
|
66
77
|
}
|
|
78
|
+
return resolveBoard(parsed, configPath);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The selector that travels with a config: `<name>.json` → `<name>.board`.
|
|
83
|
+
*
|
|
84
|
+
* A plain-text file holding one board name. Same class as the state file — a
|
|
85
|
+
* per-checkout runtime value that must never be committed, because the config it
|
|
86
|
+
* sits beside is composed and tracked. Derived from the config path for the same
|
|
87
|
+
* reason `statePathFor` is: a run pointed at a temp config must not switch on
|
|
88
|
+
* this checkout's real selector.
|
|
89
|
+
*/
|
|
90
|
+
export const boardPathFor = (configPath) => configPath.replace(/(\.json)?$/, '.board');
|
|
91
|
+
|
|
92
|
+
const isTerminalControl = (char) => {
|
|
93
|
+
const code = char.codePointAt(0);
|
|
94
|
+
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const assertSafeBoardName = (name, source) => {
|
|
98
|
+
if (typeof name === 'string' && [...name].some(isTerminalControl)) {
|
|
99
|
+
throw new Error(`${source}: board names must not contain terminal control characters.`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const boardNamesOf = (boards, configPath) => {
|
|
104
|
+
const names = Object.keys(boards);
|
|
105
|
+
for (const name of names) assertSafeBoardName(name, configPath);
|
|
106
|
+
return names;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A config may declare several boards and one default:
|
|
111
|
+
*
|
|
112
|
+
* { "adapter": "jira", "board": "AR",
|
|
113
|
+
* "boards": { "AR": { "project": "AR", "owner": "x" }, "RP": { … } },
|
|
114
|
+
* "options": { "maxGateRounds": 3 } }
|
|
115
|
+
*
|
|
116
|
+
* The active board is the selector file if present, else `board`; its entry is
|
|
117
|
+
* laid over `options`, so a key every board shares stays in `options` and only
|
|
118
|
+
* what differs is per board. A config with no `boards` is returned exactly as it
|
|
119
|
+
* was. A name nobody declared — in the selector or as the default — is refused,
|
|
120
|
+
* never read as "no board": the loop would otherwise run on the shared options
|
|
121
|
+
* alone, and for `jira` that is a different (or no) project.
|
|
122
|
+
*/
|
|
123
|
+
export const resolveBoard = (config, configPath) => {
|
|
124
|
+
if (config?.boards === undefined) return config;
|
|
125
|
+
const boards = config.boards;
|
|
126
|
+
if (boards === null || typeof boards !== 'object' || Array.isArray(boards)) {
|
|
127
|
+
throw new Error(`${configPath}: "boards" must be an object of <name> → options.`);
|
|
128
|
+
}
|
|
129
|
+
const known = boardNamesOf(boards, configPath);
|
|
130
|
+
let selected = null;
|
|
131
|
+
let source = 'the "board" key';
|
|
132
|
+
try {
|
|
133
|
+
selected = readFileSync(boardPathFor(configPath), 'utf8').trim();
|
|
134
|
+
source = boardPathFor(configPath);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
137
|
+
}
|
|
138
|
+
// A selector that exists but is empty is refused, not read as "no selector":
|
|
139
|
+
// a truncated write would otherwise switch the run to the default board while
|
|
140
|
+
// the file still looks like a choice somebody made.
|
|
141
|
+
const active = selected === null ? config.board : selected;
|
|
142
|
+
assertSafeBoardName(active, source);
|
|
143
|
+
if (!active || !known.includes(active)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${source} names board ${JSON.stringify(active ?? null)}, which ${configPath} does not ` +
|
|
146
|
+
`declare. Declared boards: ${known.join(', ')}. Refusing rather than running on the ` +
|
|
147
|
+
'shared options alone — that would be a different queue than the one configured.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const entry = boards[active];
|
|
151
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
152
|
+
throw new Error(`${configPath}: boards.${active} must be an object of adapter options.`);
|
|
153
|
+
}
|
|
154
|
+
return { ...config, board: active, options: { ...(config.options ?? {}), ...entry } };
|
|
67
155
|
};
|
|
68
156
|
|
|
69
157
|
/**
|
|
@@ -213,21 +301,30 @@ export const loadState = (statePath) => {
|
|
|
213
301
|
};
|
|
214
302
|
|
|
215
303
|
const parseArgs = (argv) => {
|
|
216
|
-
const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
|
|
304
|
+
const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null, name: null };
|
|
217
305
|
for (let i = 1; i < argv.length; i += 1) {
|
|
218
306
|
if (argv[i] === '--json') args.json = true;
|
|
219
307
|
else if (argv[i] === '--config') args.config = argv[++i];
|
|
220
308
|
else if (argv[i] === '--branch') args.branch = argv[++i];
|
|
309
|
+
else if (!argv[i].startsWith('--') && args.name === null) args.name = argv[i];
|
|
221
310
|
}
|
|
222
311
|
return args;
|
|
223
312
|
};
|
|
224
313
|
|
|
225
|
-
const renderNext = (result, stop) => {
|
|
314
|
+
const renderNext = (result, stop, revalidation = null) => {
|
|
226
315
|
if (stop) {
|
|
227
316
|
const label = stop.kind.replaceAll('-', ' ');
|
|
228
317
|
return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
|
|
229
318
|
}
|
|
230
319
|
const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
|
|
320
|
+
// Only a marker that MOVED earns a line: the unchanged case stays quiet and
|
|
321
|
+
// cheap, and the no-marker case is in the JSON and the event log, not here.
|
|
322
|
+
if (revalidation?.changed === true) {
|
|
323
|
+
lines.push(
|
|
324
|
+
`revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
|
|
325
|
+
`(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
231
328
|
if (result.skipped.length > 0) {
|
|
232
329
|
lines.push('', 'skipped:');
|
|
233
330
|
for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
|
|
@@ -271,6 +368,66 @@ if (invokedDirectly()) {
|
|
|
271
368
|
process.exit(1);
|
|
272
369
|
}
|
|
273
370
|
|
|
371
|
+
// `board` is local too: it reads the config and writes the selector beside it,
|
|
372
|
+
// and never touches the tracker. Switching is refused on a config that declares
|
|
373
|
+
// no boards — the selector would then be a file nothing reads.
|
|
374
|
+
if (args.command === 'board') {
|
|
375
|
+
try {
|
|
376
|
+
// Resolve before deriving either the checkout root or the selector path.
|
|
377
|
+
// Otherwise a symlinked `.claude` lets the caller authorize one checkout
|
|
378
|
+
// while the write follows the link into another unattended checkout.
|
|
379
|
+
const configPath = realpathSync(args.config ?? join(projectRoot, '.claude', 'queue.json'));
|
|
380
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
381
|
+
if (raw?.boards === undefined) {
|
|
382
|
+
throw new Error(`${configPath} declares no boards, so there is nothing to switch between.`);
|
|
383
|
+
}
|
|
384
|
+
if (raw.boards === null || typeof raw.boards !== 'object' || Array.isArray(raw.boards)) {
|
|
385
|
+
throw new Error(`${configPath}: "boards" must be an object of <name> → options. Nothing was written.`);
|
|
386
|
+
}
|
|
387
|
+
const boardNames = boardNamesOf(raw.boards, configPath);
|
|
388
|
+
if (args.name !== null) {
|
|
389
|
+
assertSafeBoardName(args.name, 'the requested board');
|
|
390
|
+
if (!boardNames.includes(args.name)) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`${JSON.stringify(args.name)} is not a declared board. Declared: ` +
|
|
393
|
+
`${boardNames.join(', ')}. Nothing was written.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
// Loaded only on the mutation path. `next`, `list` and `hygiene` also
|
|
397
|
+
// run in partial-install diagnostics where this sibling is deliberately
|
|
398
|
+
// absent; a static import would replace their own actionable refusal
|
|
399
|
+
// with ERR_MODULE_NOT_FOUND before the command could start.
|
|
400
|
+
const { readUnattended } = await import('../unattended-flag.mjs');
|
|
401
|
+
const callerRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
402
|
+
const targetRoot = projectRootOfConfig(configPath) ?? callerRoot;
|
|
403
|
+
const guardedRoots = [...new Set([callerRoot, targetRoot])];
|
|
404
|
+
const unattended = guardedRoots
|
|
405
|
+
.map((root) => ({ root, mode: readUnattended({ ...process.env, CLAUDE_PROJECT_DIR: root }) }))
|
|
406
|
+
.find(({ mode }) => mode.on);
|
|
407
|
+
if (unattended) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`board switching is refused while checkout ${unattended.root} is unattended. ` +
|
|
410
|
+
'The read-only `board` report remains available; disarm the loop before re-aiming its queue.',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
writeFileSync(boardPathFor(configPath), `${args.name}\n`);
|
|
414
|
+
}
|
|
415
|
+
const config = loadConfig(configPath);
|
|
416
|
+
const report = { board: config.board, boards: boardNames, options: config.options };
|
|
417
|
+
process.stdout.write(
|
|
418
|
+
args.json
|
|
419
|
+
? `${JSON.stringify(report)}\n`
|
|
420
|
+
: `board: ${report.board}${args.name !== null ? ' (switched)' : ''}\n` +
|
|
421
|
+
` declared: ${report.boards.join(', ')}\n` +
|
|
422
|
+
` selector: ${boardPathFor(configPath)}\n`,
|
|
423
|
+
);
|
|
424
|
+
process.exit(0);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
process.stderr.write(`board could not run: ${error.message}\n`);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
274
431
|
// 🔴 **`gate-round` returns before the tracker is ever touched, and that is
|
|
275
432
|
// load-bearing rather than an optimisation.** `pr-ship` calls this once per
|
|
276
433
|
// round, and every other command below reaches the adapter — so routing this one
|
|
@@ -307,6 +464,15 @@ if (invokedDirectly()) {
|
|
|
307
464
|
const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
|
|
308
465
|
verdictFor(0);
|
|
309
466
|
|
|
467
|
+
// 🔴 And refuse a checkout that cannot ship BEFORE counting (AR-141): a
|
|
468
|
+
// dirty tree or an unpushed HEAD means the round — and the fan-out's
|
|
469
|
+
// verdicts — would name a head that never reaches CI. Two rounds were once
|
|
470
|
+
// counted ahead of a commit pre-commit then refused. Thrown, so it lands
|
|
471
|
+
// in the exit-1 path below: not an exhausted cap, nothing counted.
|
|
472
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
473
|
+
const shippable = checkoutIsShippable(gitRoot);
|
|
474
|
+
if (!shippable.ok) throw new Error(`${shippable.why} (nothing was counted)`);
|
|
475
|
+
|
|
310
476
|
const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
|
|
311
477
|
const verdict = verdictFor(rounds);
|
|
312
478
|
|
|
@@ -325,9 +491,9 @@ if (invokedDirectly()) {
|
|
|
325
491
|
`GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
|
|
326
492
|
`${verdict.max}: ${verdict.stop}.\n` +
|
|
327
493
|
' Do not run another round. The item stops here and goes back to a human ' +
|
|
328
|
-
'with the round count and whatever the last gate reported
|
|
329
|
-
'
|
|
330
|
-
'
|
|
494
|
+
'with the round count and whatever the last gate reported. Whether those ' +
|
|
495
|
+
'rounds were paying off is the reader\'s judgement — this command measured ' +
|
|
496
|
+
'only the count (AR-115).\n' +
|
|
331
497
|
' Raising the cap to get one more pass on THIS item is the move this ' +
|
|
332
498
|
'refusal exists to prevent.\n',
|
|
333
499
|
);
|
|
@@ -336,12 +502,12 @@ if (invokedDirectly()) {
|
|
|
336
502
|
// 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
|
|
337
503
|
// the rounds are spent — because `pr-ship` acts on it by ending the task. A
|
|
338
504
|
// broken config, an unreadable counter or a detached checkout must not be
|
|
339
|
-
// read as a
|
|
505
|
+
// read as a spent cap.
|
|
340
506
|
process.stderr.write(
|
|
341
507
|
`gate-round could not run: ${error.message}\n` +
|
|
342
508
|
' This is NOT an exhausted cap (that is exit 2). Fix the cause and run step ' +
|
|
343
|
-
'0 again: a bad config, a bad cap
|
|
344
|
-
'any round is counted, so retrying costs nothing.\n',
|
|
509
|
+
'0 again: a bad config, a bad cap, a detached checkout, a dirty tree or an ' +
|
|
510
|
+
'unpushed HEAD is refused before any round is counted, so retrying costs nothing.\n',
|
|
345
511
|
);
|
|
346
512
|
process.exit(1);
|
|
347
513
|
}
|
|
@@ -366,8 +532,10 @@ if (invokedDirectly()) {
|
|
|
366
532
|
// and must keep working exactly as before.
|
|
367
533
|
let readState;
|
|
368
534
|
let stopInputsOf;
|
|
535
|
+
let recordTakeUp;
|
|
536
|
+
let previousTakeUp;
|
|
369
537
|
try {
|
|
370
|
-
({ readState, stopInputsOf } = await import('../run-state.mjs'));
|
|
538
|
+
({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
|
|
371
539
|
} catch (error) {
|
|
372
540
|
process.stderr.write(
|
|
373
541
|
`run state: ${error.message}\n` +
|
|
@@ -470,12 +638,43 @@ if (invokedDirectly()) {
|
|
|
470
638
|
}
|
|
471
639
|
|
|
472
640
|
if (args.command === 'hygiene') {
|
|
473
|
-
|
|
641
|
+
// The proposals on file are checked too (AR-116): a proposal names the commit
|
|
642
|
+
// it was measured against, and one whose cited paths moved since is reported
|
|
643
|
+
// as possibly overtaken. Git runs here, once per distinct `asOf`, against the
|
|
644
|
+
// project this script belongs to; `core.mjs` only decides.
|
|
645
|
+
const proposals = await adapter.listProposals(optionsWithPlanPath(config.options, configPath));
|
|
646
|
+
// The checkout the proposals describe is the project the config names — an
|
|
647
|
+
// explicit `--config <root>/.claude/queue.json` points at that root — and
|
|
648
|
+
// this script's own project only when the config implies none.
|
|
649
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
650
|
+
const head = headShaOf({ cwd: gitRoot });
|
|
651
|
+
const changedByAsOf = new Map();
|
|
652
|
+
const overtaken = proposals
|
|
653
|
+
.map((proposal) => {
|
|
654
|
+
const asOf = asOfOf(proposal.body);
|
|
655
|
+
if (asOf && !changedByAsOf.has(asOf)) {
|
|
656
|
+
changedByAsOf.set(asOf, changedSinceOf({ cwd: gitRoot, asOf, head: head ?? 'HEAD' }));
|
|
657
|
+
}
|
|
658
|
+
return overtakenOf({
|
|
659
|
+
id: proposal.id,
|
|
660
|
+
asOf,
|
|
661
|
+
citedPaths: citedPathsOf(proposal.body),
|
|
662
|
+
head,
|
|
663
|
+
changedSince: asOf ? changedByAsOf.get(asOf) : null,
|
|
664
|
+
});
|
|
665
|
+
})
|
|
666
|
+
.filter(Boolean);
|
|
667
|
+
const owner = config.options?.owner ?? null;
|
|
668
|
+
const findings = [
|
|
669
|
+
...tickets.map((ticket) => hygieneOf(ticket, { owner })).filter(Boolean),
|
|
670
|
+
...overtaken,
|
|
671
|
+
];
|
|
474
672
|
process.stdout.write(
|
|
475
673
|
args.json
|
|
476
674
|
? `${JSON.stringify({ findings }, null, 2)}\n`
|
|
477
675
|
: findings.length === 0
|
|
478
|
-
? `queue hygiene: ${tickets.length} item(s)
|
|
676
|
+
? `queue hygiene: ${tickets.length} item(s) and ${proposals.length} proposal(s) ` +
|
|
677
|
+
'checked — nothing stale.\n'
|
|
479
678
|
: `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
|
|
480
679
|
);
|
|
481
680
|
process.exit(0);
|
|
@@ -501,6 +700,9 @@ if (invokedDirectly()) {
|
|
|
501
700
|
// impossible to retract, so "not this time" would again require editing the
|
|
502
701
|
// generated file this move exists to get out of.
|
|
503
702
|
triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
|
|
703
|
+
// This checkout's name, for the owner marker (AR-132). Absent means the
|
|
704
|
+
// checkout cannot confirm a match, and an owned item is held.
|
|
705
|
+
owner: config.options?.owner ?? null,
|
|
504
706
|
});
|
|
505
707
|
// The skipped records travel with the count: without them "nothing left" and
|
|
506
708
|
// "everything left is held back" both print as an empty queue, and only one of
|
|
@@ -524,6 +726,31 @@ if (invokedDirectly()) {
|
|
|
524
726
|
// a default derived from `projectRoot` would land the trace inside the very
|
|
525
727
|
// template tree this repository publishes.
|
|
526
728
|
const runDir = process.env.RIG_RUN_DIR;
|
|
729
|
+
// Revalidation at SELECT (AR-133): the selected item against the marker this
|
|
730
|
+
// run recorded at its last take-up. Computed only under a declared run —
|
|
731
|
+
// there is no snapshot to compare against anywhere else — and `null` in the
|
|
732
|
+
// output then, so a reader can tell "not compared" from "compared, unchanged".
|
|
733
|
+
//
|
|
734
|
+
// The baseline is this run's take-up when it has one; otherwise the newest
|
|
735
|
+
// earlier run's (AR-138) — an item taken up yesterday and re-offered today
|
|
736
|
+
// used to compare against nothing and report a first sight. `baseline` says
|
|
737
|
+
// which it was (`this-run` | `previous-run` | null), and `baselineRun` names
|
|
738
|
+
// the earlier run, so the report can tell the three apart. A rig whose
|
|
739
|
+
// run-state module predates `previousTakeUp` keeps the per-run behaviour.
|
|
740
|
+
let revalidation = null;
|
|
741
|
+
if (runDir && result.ticket) {
|
|
742
|
+
const own = runState.takeUps?.[result.ticket.id];
|
|
743
|
+
const prior =
|
|
744
|
+
own === undefined && typeof previousTakeUp === 'function'
|
|
745
|
+
? previousTakeUp(runDir, result.ticket.id)
|
|
746
|
+
: null;
|
|
747
|
+
const snapshot = own ?? prior?.updatedAt ?? null;
|
|
748
|
+
revalidation = {
|
|
749
|
+
...revalidationOf({ ticket: result.ticket, snapshot }),
|
|
750
|
+
baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
|
|
751
|
+
...(prior ? { baselineRun: prior.runDir } : {}),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
527
754
|
if (runDir) {
|
|
528
755
|
let journal = null;
|
|
529
756
|
try {
|
|
@@ -537,6 +764,27 @@ if (invokedDirectly()) {
|
|
|
537
764
|
// an argument, which is what keeps its records reproducible.
|
|
538
765
|
now: new Date().toISOString(),
|
|
539
766
|
});
|
|
767
|
+
if (revalidation) {
|
|
768
|
+
// The selection decision keeps its place as the run's first record; the
|
|
769
|
+
// evidence log comes next, and only then (below, after the journal) does
|
|
770
|
+
// the baseline move — a snapshot written before its event would leave a
|
|
771
|
+
// crashed run with a marker and no record of what it was compared against.
|
|
772
|
+
if (typeof journal.recordEvent === 'function') {
|
|
773
|
+
journal.recordEvent({
|
|
774
|
+
runDir,
|
|
775
|
+
kind: 'revalidation',
|
|
776
|
+
data: revalidation,
|
|
777
|
+
now: new Date().toISOString(),
|
|
778
|
+
});
|
|
779
|
+
} else {
|
|
780
|
+
// A rig carrying a run journal older than this CLI: the selection
|
|
781
|
+
// stands, the comparison was made, only its record has nowhere to go.
|
|
782
|
+
process.stderr.write(
|
|
783
|
+
'run journal: this journal predates revalidation events, so the ' +
|
|
784
|
+
'revalidation was not recorded.\n',
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
540
788
|
} catch (error) {
|
|
541
789
|
// 🔴 Two failures wearing one face, and treating them alike was a defect
|
|
542
790
|
// this gate caught. The journal asks the module which one this is — never
|
|
@@ -572,9 +820,24 @@ if (invokedDirectly()) {
|
|
|
572
820
|
}
|
|
573
821
|
}
|
|
574
822
|
|
|
823
|
+
if (revalidation) {
|
|
824
|
+
// Its own try, after the journal's: a state file that cannot be written is
|
|
825
|
+
// not the journal failing, and the selection stands either way — the
|
|
826
|
+
// comparison was made and recorded; only the next baseline is lost.
|
|
827
|
+
try {
|
|
828
|
+
recordTakeUp(runDir, { id: result.ticket.id, updatedAt: result.ticket.updatedAt });
|
|
829
|
+
} catch (error) {
|
|
830
|
+
process.stderr.write(
|
|
831
|
+
`run state: the take-up snapshot was NOT recorded in ${runDir} — ${error.message}\n` +
|
|
832
|
+
' the selection below stands; the baseline was not moved — the next revalidation ' +
|
|
833
|
+
'of this item compares against the previous one, if any, or has none.\n',
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
575
838
|
process.stdout.write(
|
|
576
839
|
args.json
|
|
577
|
-
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
|
|
578
|
-
: renderNext(result, stop),
|
|
840
|
+
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
|
|
841
|
+
: renderNext(result, stop, revalidation),
|
|
579
842
|
);
|
|
580
843
|
}
|