create-agent-rig 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +140 -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 +11 -3
- package/templates/agent-os/init/CLAUDE.md +11 -3
- 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 +298 -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 +8 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -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 +28 -0
- 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 +138 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -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 +239 -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 +298 -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 +309 -49
- package/templates/release-ledger.json +9 -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,7 +4,7 @@
|
|
|
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
9
|
//
|
|
10
10
|
// The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
|
|
@@ -14,12 +14,21 @@
|
|
|
14
14
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { basename, dirname, join } from 'node:path';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
asOfOf,
|
|
19
|
+
citedPathsOf,
|
|
20
|
+
hygieneOf,
|
|
21
|
+
overtakenOf,
|
|
22
|
+
revalidationOf,
|
|
23
|
+
selectNext,
|
|
24
|
+
stopConditionOf,
|
|
25
|
+
} from './core.mjs';
|
|
26
|
+
import { changedSinceOf, headShaOf } from './as-of.mjs';
|
|
18
27
|
// One resolver, imported rather than re-derived: writer and reader disagreeing
|
|
19
28
|
// about which checkout they are in is the whole of the worktree defect. It lives
|
|
20
29
|
// apart from `state.mjs` so the read path does not drag the tier computation —
|
|
21
30
|
// and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
|
|
22
|
-
import { mainCheckoutRoot } from './checkout.mjs';
|
|
31
|
+
import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
|
|
23
32
|
|
|
24
33
|
const ADAPTERS = {
|
|
25
34
|
'plan-md': './plan-md.mjs',
|
|
@@ -222,12 +231,20 @@ const parseArgs = (argv) => {
|
|
|
222
231
|
return args;
|
|
223
232
|
};
|
|
224
233
|
|
|
225
|
-
const renderNext = (result, stop) => {
|
|
234
|
+
const renderNext = (result, stop, revalidation = null) => {
|
|
226
235
|
if (stop) {
|
|
227
236
|
const label = stop.kind.replaceAll('-', ' ');
|
|
228
237
|
return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
|
|
229
238
|
}
|
|
230
239
|
const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
|
|
240
|
+
// Only a marker that MOVED earns a line: the unchanged case stays quiet and
|
|
241
|
+
// cheap, and the no-marker case is in the JSON and the event log, not here.
|
|
242
|
+
if (revalidation?.changed === true) {
|
|
243
|
+
lines.push(
|
|
244
|
+
`revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
|
|
245
|
+
`(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
231
248
|
if (result.skipped.length > 0) {
|
|
232
249
|
lines.push('', 'skipped:');
|
|
233
250
|
for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
|
|
@@ -307,6 +324,15 @@ if (invokedDirectly()) {
|
|
|
307
324
|
const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
|
|
308
325
|
verdictFor(0);
|
|
309
326
|
|
|
327
|
+
// 🔴 And refuse a checkout that cannot ship BEFORE counting (AR-141): a
|
|
328
|
+
// dirty tree or an unpushed HEAD means the round — and the fan-out's
|
|
329
|
+
// verdicts — would name a head that never reaches CI. Two rounds were once
|
|
330
|
+
// counted ahead of a commit pre-commit then refused. Thrown, so it lands
|
|
331
|
+
// in the exit-1 path below: not an exhausted cap, nothing counted.
|
|
332
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
333
|
+
const shippable = checkoutIsShippable(gitRoot);
|
|
334
|
+
if (!shippable.ok) throw new Error(`${shippable.why} (nothing was counted)`);
|
|
335
|
+
|
|
310
336
|
const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
|
|
311
337
|
const verdict = verdictFor(rounds);
|
|
312
338
|
|
|
@@ -325,9 +351,9 @@ if (invokedDirectly()) {
|
|
|
325
351
|
`GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
|
|
326
352
|
`${verdict.max}: ${verdict.stop}.\n` +
|
|
327
353
|
' 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
|
-
'
|
|
354
|
+
'with the round count and whatever the last gate reported. Whether those ' +
|
|
355
|
+
'rounds were paying off is the reader\'s judgement — this command measured ' +
|
|
356
|
+
'only the count (AR-115).\n' +
|
|
331
357
|
' Raising the cap to get one more pass on THIS item is the move this ' +
|
|
332
358
|
'refusal exists to prevent.\n',
|
|
333
359
|
);
|
|
@@ -336,12 +362,12 @@ if (invokedDirectly()) {
|
|
|
336
362
|
// 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
|
|
337
363
|
// the rounds are spent — because `pr-ship` acts on it by ending the task. A
|
|
338
364
|
// broken config, an unreadable counter or a detached checkout must not be
|
|
339
|
-
// read as a
|
|
365
|
+
// read as a spent cap.
|
|
340
366
|
process.stderr.write(
|
|
341
367
|
`gate-round could not run: ${error.message}\n` +
|
|
342
368
|
' 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',
|
|
369
|
+
'0 again: a bad config, a bad cap, a detached checkout, a dirty tree or an ' +
|
|
370
|
+
'unpushed HEAD is refused before any round is counted, so retrying costs nothing.\n',
|
|
345
371
|
);
|
|
346
372
|
process.exit(1);
|
|
347
373
|
}
|
|
@@ -366,8 +392,10 @@ if (invokedDirectly()) {
|
|
|
366
392
|
// and must keep working exactly as before.
|
|
367
393
|
let readState;
|
|
368
394
|
let stopInputsOf;
|
|
395
|
+
let recordTakeUp;
|
|
396
|
+
let previousTakeUp;
|
|
369
397
|
try {
|
|
370
|
-
({ readState, stopInputsOf } = await import('../run-state.mjs'));
|
|
398
|
+
({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
|
|
371
399
|
} catch (error) {
|
|
372
400
|
process.stderr.write(
|
|
373
401
|
`run state: ${error.message}\n` +
|
|
@@ -470,12 +498,43 @@ if (invokedDirectly()) {
|
|
|
470
498
|
}
|
|
471
499
|
|
|
472
500
|
if (args.command === 'hygiene') {
|
|
473
|
-
|
|
501
|
+
// The proposals on file are checked too (AR-116): a proposal names the commit
|
|
502
|
+
// it was measured against, and one whose cited paths moved since is reported
|
|
503
|
+
// as possibly overtaken. Git runs here, once per distinct `asOf`, against the
|
|
504
|
+
// project this script belongs to; `core.mjs` only decides.
|
|
505
|
+
const proposals = await adapter.listProposals(optionsWithPlanPath(config.options, configPath));
|
|
506
|
+
// The checkout the proposals describe is the project the config names — an
|
|
507
|
+
// explicit `--config <root>/.claude/queue.json` points at that root — and
|
|
508
|
+
// this script's own project only when the config implies none.
|
|
509
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
510
|
+
const head = headShaOf({ cwd: gitRoot });
|
|
511
|
+
const changedByAsOf = new Map();
|
|
512
|
+
const overtaken = proposals
|
|
513
|
+
.map((proposal) => {
|
|
514
|
+
const asOf = asOfOf(proposal.body);
|
|
515
|
+
if (asOf && !changedByAsOf.has(asOf)) {
|
|
516
|
+
changedByAsOf.set(asOf, changedSinceOf({ cwd: gitRoot, asOf, head: head ?? 'HEAD' }));
|
|
517
|
+
}
|
|
518
|
+
return overtakenOf({
|
|
519
|
+
id: proposal.id,
|
|
520
|
+
asOf,
|
|
521
|
+
citedPaths: citedPathsOf(proposal.body),
|
|
522
|
+
head,
|
|
523
|
+
changedSince: asOf ? changedByAsOf.get(asOf) : null,
|
|
524
|
+
});
|
|
525
|
+
})
|
|
526
|
+
.filter(Boolean);
|
|
527
|
+
const owner = config.options?.owner ?? null;
|
|
528
|
+
const findings = [
|
|
529
|
+
...tickets.map((ticket) => hygieneOf(ticket, { owner })).filter(Boolean),
|
|
530
|
+
...overtaken,
|
|
531
|
+
];
|
|
474
532
|
process.stdout.write(
|
|
475
533
|
args.json
|
|
476
534
|
? `${JSON.stringify({ findings }, null, 2)}\n`
|
|
477
535
|
: findings.length === 0
|
|
478
|
-
? `queue hygiene: ${tickets.length} item(s)
|
|
536
|
+
? `queue hygiene: ${tickets.length} item(s) and ${proposals.length} proposal(s) ` +
|
|
537
|
+
'checked — nothing stale.\n'
|
|
479
538
|
: `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
|
|
480
539
|
);
|
|
481
540
|
process.exit(0);
|
|
@@ -501,6 +560,9 @@ if (invokedDirectly()) {
|
|
|
501
560
|
// impossible to retract, so "not this time" would again require editing the
|
|
502
561
|
// generated file this move exists to get out of.
|
|
503
562
|
triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
|
|
563
|
+
// This checkout's name, for the owner marker (AR-132). Absent means the
|
|
564
|
+
// checkout cannot confirm a match, and an owned item is held.
|
|
565
|
+
owner: config.options?.owner ?? null,
|
|
504
566
|
});
|
|
505
567
|
// The skipped records travel with the count: without them "nothing left" and
|
|
506
568
|
// "everything left is held back" both print as an empty queue, and only one of
|
|
@@ -524,6 +586,31 @@ if (invokedDirectly()) {
|
|
|
524
586
|
// a default derived from `projectRoot` would land the trace inside the very
|
|
525
587
|
// template tree this repository publishes.
|
|
526
588
|
const runDir = process.env.RIG_RUN_DIR;
|
|
589
|
+
// Revalidation at SELECT (AR-133): the selected item against the marker this
|
|
590
|
+
// run recorded at its last take-up. Computed only under a declared run —
|
|
591
|
+
// there is no snapshot to compare against anywhere else — and `null` in the
|
|
592
|
+
// output then, so a reader can tell "not compared" from "compared, unchanged".
|
|
593
|
+
//
|
|
594
|
+
// The baseline is this run's take-up when it has one; otherwise the newest
|
|
595
|
+
// earlier run's (AR-138) — an item taken up yesterday and re-offered today
|
|
596
|
+
// used to compare against nothing and report a first sight. `baseline` says
|
|
597
|
+
// which it was (`this-run` | `previous-run` | null), and `baselineRun` names
|
|
598
|
+
// the earlier run, so the report can tell the three apart. A rig whose
|
|
599
|
+
// run-state module predates `previousTakeUp` keeps the per-run behaviour.
|
|
600
|
+
let revalidation = null;
|
|
601
|
+
if (runDir && result.ticket) {
|
|
602
|
+
const own = runState.takeUps?.[result.ticket.id];
|
|
603
|
+
const prior =
|
|
604
|
+
own === undefined && typeof previousTakeUp === 'function'
|
|
605
|
+
? previousTakeUp(runDir, result.ticket.id)
|
|
606
|
+
: null;
|
|
607
|
+
const snapshot = own ?? prior?.updatedAt ?? null;
|
|
608
|
+
revalidation = {
|
|
609
|
+
...revalidationOf({ ticket: result.ticket, snapshot }),
|
|
610
|
+
baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
|
|
611
|
+
...(prior ? { baselineRun: prior.runDir } : {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
527
614
|
if (runDir) {
|
|
528
615
|
let journal = null;
|
|
529
616
|
try {
|
|
@@ -537,6 +624,27 @@ if (invokedDirectly()) {
|
|
|
537
624
|
// an argument, which is what keeps its records reproducible.
|
|
538
625
|
now: new Date().toISOString(),
|
|
539
626
|
});
|
|
627
|
+
if (revalidation) {
|
|
628
|
+
// The selection decision keeps its place as the run's first record; the
|
|
629
|
+
// evidence log comes next, and only then (below, after the journal) does
|
|
630
|
+
// the baseline move — a snapshot written before its event would leave a
|
|
631
|
+
// crashed run with a marker and no record of what it was compared against.
|
|
632
|
+
if (typeof journal.recordEvent === 'function') {
|
|
633
|
+
journal.recordEvent({
|
|
634
|
+
runDir,
|
|
635
|
+
kind: 'revalidation',
|
|
636
|
+
data: revalidation,
|
|
637
|
+
now: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
// A rig carrying a run journal older than this CLI: the selection
|
|
641
|
+
// stands, the comparison was made, only its record has nowhere to go.
|
|
642
|
+
process.stderr.write(
|
|
643
|
+
'run journal: this journal predates revalidation events, so the ' +
|
|
644
|
+
'revalidation was not recorded.\n',
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
540
648
|
} catch (error) {
|
|
541
649
|
// 🔴 Two failures wearing one face, and treating them alike was a defect
|
|
542
650
|
// this gate caught. The journal asks the module which one this is — never
|
|
@@ -572,9 +680,24 @@ if (invokedDirectly()) {
|
|
|
572
680
|
}
|
|
573
681
|
}
|
|
574
682
|
|
|
683
|
+
if (revalidation) {
|
|
684
|
+
// Its own try, after the journal's: a state file that cannot be written is
|
|
685
|
+
// not the journal failing, and the selection stands either way — the
|
|
686
|
+
// comparison was made and recorded; only the next baseline is lost.
|
|
687
|
+
try {
|
|
688
|
+
recordTakeUp(runDir, { id: result.ticket.id, updatedAt: result.ticket.updatedAt });
|
|
689
|
+
} catch (error) {
|
|
690
|
+
process.stderr.write(
|
|
691
|
+
`run state: the take-up snapshot was NOT recorded in ${runDir} — ${error.message}\n` +
|
|
692
|
+
' the selection below stands; the baseline was not moved — the next revalidation ' +
|
|
693
|
+
'of this item compares against the previous one, if any, or has none.\n',
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
575
698
|
process.stdout.write(
|
|
576
699
|
args.json
|
|
577
|
-
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
|
|
578
|
-
: renderNext(result, stop),
|
|
700
|
+
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
|
|
701
|
+
: renderNext(result, stop, revalidation),
|
|
579
702
|
);
|
|
580
703
|
}
|