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,10 @@
|
|
|
14
14
|
// {
|
|
15
15
|
// id, title, url,
|
|
16
16
|
// state: 'open' | 'in-progress' | 'closed',
|
|
17
|
-
// labels: string[], // informational
|
|
17
|
+
// labels: string[], // informational — see the note below
|
|
18
18
|
// tier: 'normal' | 'elevated',
|
|
19
|
+
// lifecycle: 'keep-core' | 're-scope' | 'obsolete' | null, // AR-144, from labels
|
|
20
|
+
// parked: boolean, // scheduling, orthogonal to lifecycle
|
|
19
21
|
// blockedBy: [{ id, resolved }], // FROM LINKS — see invariant 1
|
|
20
22
|
// blocks: string[], // ids this one unblocks
|
|
21
23
|
// priority: number, // lower is more urgent
|
|
@@ -48,6 +50,14 @@
|
|
|
48
50
|
//
|
|
49
51
|
// `raw` is the adapter's own record of the line or record it parsed. It is
|
|
50
52
|
// deliberately NOT read by this file: it exists for the adapter's writes.
|
|
53
|
+
//
|
|
54
|
+
// **"Labels are informational, never decisive" is a statement about
|
|
55
|
+
// DEPENDENCIES** (invariant 1 below): a `blocked`/`ready` label is a snapshot of
|
|
56
|
+
// a fact the links carry authoritatively. It is not a ban on reading labels at
|
|
57
|
+
// all — `tier`, `trigger`, `triage`, `owner`, and (AR-144) `lifecycle` and
|
|
58
|
+
// `parked` are facts that exist ONLY as a marker, so the marker is the source
|
|
59
|
+
// and the adapter maps it onto a named field. The rule is: a label is never
|
|
60
|
+
// read in place of a source that says the same thing better.
|
|
51
61
|
|
|
52
62
|
/**
|
|
53
63
|
* The operations every adapter provides. A second tracker is an adapter, not a
|
|
@@ -55,12 +65,19 @@
|
|
|
55
65
|
*/
|
|
56
66
|
export const ADAPTER_CONTRACT = [
|
|
57
67
|
'listEligible',
|
|
68
|
+
// One item by id, WITHOUT the closed filter `listEligible` applies: the close
|
|
69
|
+
// point has to see an item somebody already closed (AR-135). Each adapter
|
|
70
|
+
// owns how — the decision is not made above the seam.
|
|
71
|
+
'find',
|
|
58
72
|
'resolveBlockers',
|
|
59
73
|
'claim',
|
|
60
74
|
'close',
|
|
61
75
|
'comment',
|
|
62
76
|
'escalate',
|
|
63
77
|
'proposeTriage',
|
|
78
|
+
// The proposals on file, as `{ id, body }`, so hygiene can read each one's
|
|
79
|
+
// `asOf` and cited paths back (AR-116). Reads only — never a selection input.
|
|
80
|
+
'listProposals',
|
|
64
81
|
];
|
|
65
82
|
|
|
66
83
|
/**
|
|
@@ -80,6 +97,17 @@ export const SKIP_CAUSES = Object.freeze([
|
|
|
80
97
|
'trigger-auto',
|
|
81
98
|
'trigger-human',
|
|
82
99
|
'spacing',
|
|
100
|
+
'owner',
|
|
101
|
+
// AR-144: the lifecycle vocabulary. `re-scope` and `deferred` (the `parked`
|
|
102
|
+
// label) hold takeable work back until a human acts; `obsolete` is out of play
|
|
103
|
+
// until a human closes it with the evidence — see `lifecycleOf` below.
|
|
104
|
+
// 🔴 The cause for the `parked` LABEL is `deferred`, not `parked`: this module
|
|
105
|
+
// already uses "parked" for the out-of-play pile (`partitionSkipped`), and a
|
|
106
|
+
// parked-labelled item is the opposite — held, takeable, waiting on an un-park.
|
|
107
|
+
// One word for two states is how a stop line gets read backwards.
|
|
108
|
+
're-scope',
|
|
109
|
+
'deferred',
|
|
110
|
+
'obsolete',
|
|
83
111
|
]);
|
|
84
112
|
|
|
85
113
|
/**
|
|
@@ -120,8 +148,105 @@ export const HOLDING_CAUSES = Object.freeze([
|
|
|
120
148
|
'spacing',
|
|
121
149
|
'trigger-auto',
|
|
122
150
|
'trigger-human',
|
|
151
|
+
// Another repository's item (AR-132). It clears the way `trigger-human`
|
|
152
|
+
// does — a human moves the item or re-marks it — never by this checkout
|
|
153
|
+
// doing anything, and never by refilling the queue.
|
|
154
|
+
'owner',
|
|
155
|
+
// AR-144: both are real, takeable work waiting on one human act — a rewrite
|
|
156
|
+
// that removes `re-scope`, or an un-park. `obsolete` is deliberately NOT
|
|
157
|
+
// here: it waits on a human CLOSE, so it is out of play, and reporting it as
|
|
158
|
+
// "held" would tell the owner to wait for something that only they can do.
|
|
159
|
+
're-scope',
|
|
160
|
+
'deferred',
|
|
123
161
|
]);
|
|
124
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The repository an item belongs to, read out of its labels: `owner-<name>`,
|
|
165
|
+
* one word for one fact on every tracker. `null` when there is none — and
|
|
166
|
+
* absence means unconditional, exactly as it does for the trigger markers.
|
|
167
|
+
* A bare `owner-` names nobody and is read as no marker.
|
|
168
|
+
*
|
|
169
|
+
* Why (AR-132): AR-129 and AR-130 were Rig Platform items sitting in this
|
|
170
|
+
* repository's project. Both were selected as normal spacers and both escalated
|
|
171
|
+
* PREMISE FALSE at the first premise check, consecutively — a run-level stop,
|
|
172
|
+
* spent on work that was never this checkout's to do.
|
|
173
|
+
*/
|
|
174
|
+
export const ownerOfLabels = (labels) => {
|
|
175
|
+
for (const label of Array.isArray(labels) ? labels : []) {
|
|
176
|
+
if (typeof label === 'string' && label.startsWith('owner-') && label.length > 6) {
|
|
177
|
+
return label.slice(6);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The lifecycle vocabulary (AR-144), closed and ordered from least to most
|
|
185
|
+
* restrictive. It says what the ITEM is, never when it runs:
|
|
186
|
+
*
|
|
187
|
+
* - `keep-core` — the problem and the responsibility are valid for the current
|
|
188
|
+
* project and the item is executable as written. A statement, not a condition.
|
|
189
|
+
* - `re-scope` — the problem is valid but the item is NOT executable literally:
|
|
190
|
+
* a path, mechanism, boundary or acceptance criterion drifted. A short-lived
|
|
191
|
+
* quarantine: a human re-reads the code, rewrites the item, removes the label.
|
|
192
|
+
* The loop surfaces it (hygiene) and never invents the new scope itself.
|
|
193
|
+
* - `obsolete` — the responsibility is gone or fully superseded. A human verdict:
|
|
194
|
+
* the loop never applies it and never closes an item because it believes
|
|
195
|
+
* another mechanism supersedes it. Closing as obsolete needs a comment naming
|
|
196
|
+
* the evidence or the replacement.
|
|
197
|
+
*
|
|
198
|
+
* `parked` is a separate axis — SCHEDULING, not lifecycle: valid work
|
|
199
|
+
* deliberately not active now. `keep-core + parked` is the normal shape of a
|
|
200
|
+
* deferred item and means "still needed, not now"; the hold is the `parked`.
|
|
201
|
+
*
|
|
202
|
+
* 🔴 None of these is inferred. Not from age, not from a key range, not from old
|
|
203
|
+
* terminology, not from `parked`, not from a migration marker such as
|
|
204
|
+
* `legacy-backlog` — which is retired, and which hygiene reports on any open item
|
|
205
|
+
* still carrying it. A label that looks like "old" is no evidence that the work
|
|
206
|
+
* is unnecessary — the owner's migration audit on AR-144 closed nothing as obsolete.
|
|
207
|
+
*/
|
|
208
|
+
export const LIFECYCLE_LABELS = Object.freeze(['keep-core', 're-scope', 'obsolete']);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read the lifecycle and the scheduling flag out of a label list — one
|
|
212
|
+
* function for every tracker adapter, so the semantics live above the seam.
|
|
213
|
+
*
|
|
214
|
+
* Several lifecycle labels on one item is a contradiction hygiene reports
|
|
215
|
+
* (`contradictory-lifecycle-labels`); selection meanwhile takes the MOST
|
|
216
|
+
* restrictive reading, because the permissive one is how an item marked
|
|
217
|
+
* `obsolete` by one hand and `keep-core` by another gets worked.
|
|
218
|
+
*/
|
|
219
|
+
export const lifecycleOf = (labels) => {
|
|
220
|
+
const list = Array.isArray(labels) ? labels : [];
|
|
221
|
+
let lifecycle = null;
|
|
222
|
+
for (const label of LIFECYCLE_LABELS) if (list.includes(label)) lifecycle = label;
|
|
223
|
+
return { lifecycle, parked: list.includes('parked') };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** The lifecycle labels an item carries, for the contradiction check. */
|
|
227
|
+
const lifecycleLabelsOn = (labels) => {
|
|
228
|
+
const list = Array.isArray(labels) ? labels : [];
|
|
229
|
+
return LIFECYCLE_LABELS.filter((label) => list.includes(label));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Why an owned item is not this checkout's, or null when it is (or claims no
|
|
234
|
+
* owner). One predicate for selection and for hygiene, so the two cannot
|
|
235
|
+
* disagree about the same item. A checkout that declares no owner cannot
|
|
236
|
+
* confirm a match, and "could not look" is never "it is fine".
|
|
237
|
+
*/
|
|
238
|
+
const ownerMismatchOf = (ticket, owner) => {
|
|
239
|
+
if (!ticket.owner) return null;
|
|
240
|
+
if (owner === null || owner === undefined || owner === '') {
|
|
241
|
+
return (
|
|
242
|
+
`owned by ${ticket.owner}, and this checkout declares no owner ` +
|
|
243
|
+
'(options.owner in .claude/queue.json) — a match cannot be confirmed'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (ticket.owner === owner) return null;
|
|
247
|
+
return `owned by ${ticket.owner}, and this checkout is ${owner}`;
|
|
248
|
+
};
|
|
249
|
+
|
|
125
250
|
/**
|
|
126
251
|
* Is this item takeable, and if not, why not?
|
|
127
252
|
*
|
|
@@ -130,7 +255,7 @@ export const HOLDING_CAUSES = Object.freeze([
|
|
|
130
255
|
* `cause` tag, so the stop line can say what is holding the queue back without
|
|
131
256
|
* reading the prose back.
|
|
132
257
|
*/
|
|
133
|
-
export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
258
|
+
export const selectionOf = (ticket, { triggersFired = null, owner = null } = {}) => {
|
|
134
259
|
const reasons = [];
|
|
135
260
|
const causes = [];
|
|
136
261
|
const labels = ticket.labels ?? [];
|
|
@@ -181,6 +306,33 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
|
181
306
|
// that claim, and it would be false for the item most likely to carry both:
|
|
182
307
|
// one an owner tightened from auto-gated to human-gated without deleting the
|
|
183
308
|
// old marker, where the silent resolution goes to the LESS restrictive gate.
|
|
309
|
+
// Another repository's item is held, not taken (AR-132). The marker is the
|
|
310
|
+
// adapter's `owner` field; a checkout names itself in `options.owner`.
|
|
311
|
+
const foreign = ownerMismatchOf(ticket, owner);
|
|
312
|
+
if (foreign) reject('owner', `${foreign} — moving or re-marking it is a human act`);
|
|
313
|
+
|
|
314
|
+
// AR-144: the lifecycle vocabulary, read from the adapter's `lifecycle` and
|
|
315
|
+
// `parked` fields (`lifecycleOf`). `keep-core` never rejects — it is a
|
|
316
|
+
// statement that the item is executable, not a condition on taking it.
|
|
317
|
+
if (ticket.lifecycle === 're-scope') {
|
|
318
|
+
reject(
|
|
319
|
+
're-scope',
|
|
320
|
+
're-scope: the problem is valid but the item is not executable as written — ' +
|
|
321
|
+
'a human rewrites it against the current code and removes the label; the ' +
|
|
322
|
+
'loop never invents the new scope',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (ticket.parked === true) {
|
|
326
|
+
reject('deferred', 'parked (deferred): valid work deliberately not active now — a human un-parks it');
|
|
327
|
+
}
|
|
328
|
+
if (ticket.lifecycle === 'obsolete') {
|
|
329
|
+
reject(
|
|
330
|
+
'obsolete',
|
|
331
|
+
'obsolete: a human verdict — a human closes it, with a comment naming the ' +
|
|
332
|
+
'evidence or the replacement; the loop neither takes it nor closes it',
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
184
336
|
if (ticket.trigger === 'human') {
|
|
185
337
|
reject(
|
|
186
338
|
'trigger-human',
|
|
@@ -210,10 +362,54 @@ export const selectionOf = (ticket, { triggersFired = null } = {}) => {
|
|
|
210
362
|
* Reported, never silently corrected: a loop that quietly rewrites the queue's
|
|
211
363
|
* own metadata removes the evidence that the metadata is unreliable.
|
|
212
364
|
*/
|
|
213
|
-
export const hygieneOf = (ticket) => {
|
|
365
|
+
export const hygieneOf = (ticket, { owner = null } = {}) => {
|
|
214
366
|
const labels = ticket.labels ?? [];
|
|
215
367
|
const open = (ticket.blockedBy ?? []).filter((blocker) => !blocker.resolved);
|
|
216
368
|
|
|
369
|
+
const foreign = ownerMismatchOf(ticket, owner);
|
|
370
|
+
if (foreign) {
|
|
371
|
+
return {
|
|
372
|
+
kind: 'owner-mismatch',
|
|
373
|
+
id: ticket.id,
|
|
374
|
+
why: `${foreign} — it sits in this queue but is not this repository's to do`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// AR-144: the retired migration marker, and the lifecycle contradictions.
|
|
379
|
+
// Reported, never corrected: which label is the wrong one is a human call.
|
|
380
|
+
if (labels.includes('legacy-backlog') && ticket.state !== 'closed') {
|
|
381
|
+
return {
|
|
382
|
+
kind: 'stale-legacy-backlog-label',
|
|
383
|
+
id: ticket.id,
|
|
384
|
+
why:
|
|
385
|
+
'still carries legacy-backlog, which is retired — it says nothing about ' +
|
|
386
|
+
'whether the work is needed. Re-mark it keep-core, re-scope or obsolete ' +
|
|
387
|
+
'(and parked if deferred); the loop infers none of those from it',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const lifecycles = lifecycleLabelsOn(labels);
|
|
391
|
+
if (lifecycles.length > 1) {
|
|
392
|
+
return {
|
|
393
|
+
kind: 'contradictory-lifecycle-labels',
|
|
394
|
+
id: ticket.id,
|
|
395
|
+
why:
|
|
396
|
+
`carries ${lifecycles.join(' and ')} at once, and an item has one lifecycle — ` +
|
|
397
|
+
`selection reads the most restrictive (${lifecycleOf(labels).lifecycle}); ` +
|
|
398
|
+
'a human removes the wrong one',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (ticket.lifecycle === 're-scope' && ticket.state !== 'closed') {
|
|
402
|
+
return {
|
|
403
|
+
kind: 're-scope-pending',
|
|
404
|
+
id: ticket.id,
|
|
405
|
+
why:
|
|
406
|
+
'marked re-scope: the problem is valid but the item is not executable as ' +
|
|
407
|
+
'written. It waits on a human rewrite against the current code — a ' +
|
|
408
|
+
'quarantine, not a backlog category, so it is reported until the label ' +
|
|
409
|
+
'comes off',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
217
413
|
if (labels.includes('blocked') && open.length === 0) {
|
|
218
414
|
return {
|
|
219
415
|
kind: 'stale-blocked-label',
|
|
@@ -395,8 +591,10 @@ const clearsSpacing = (lastCompletedTier) =>
|
|
|
395
591
|
* than it should have".
|
|
396
592
|
*
|
|
397
593
|
* Two is the cap because the second round is what verifies the first round's fixes.
|
|
398
|
-
* A third
|
|
399
|
-
*
|
|
594
|
+
* A third is a decision for a human rather than another pass to buy — and the
|
|
595
|
+
* refusal says only that the count is spent, never that the fixes "are not
|
|
596
|
+
* converging": on one branch a granted third round found that round 2's fix had
|
|
597
|
+
* opened the mirror of the bug it closed (AR-115).
|
|
400
598
|
*/
|
|
401
599
|
export const DEFAULT_MAX_GATE_ROUNDS = 2;
|
|
402
600
|
|
|
@@ -442,12 +640,15 @@ export const gateRoundVerdict = (rounds, max = DEFAULT_MAX_GATE_ROUNDS) => {
|
|
|
442
640
|
* items back to back — one unreviewed schema or permissions change is
|
|
443
641
|
* recoverable; a chain of them compounding overnight is not.
|
|
444
642
|
*/
|
|
445
|
-
export const selectNext = (
|
|
643
|
+
export const selectNext = (
|
|
644
|
+
tickets,
|
|
645
|
+
{ lastCompletedTier = null, triggersFired = null, owner = null } = {},
|
|
646
|
+
) => {
|
|
446
647
|
const skipped = [];
|
|
447
648
|
const candidates = [];
|
|
448
649
|
|
|
449
650
|
for (const ticket of tickets) {
|
|
450
|
-
const selection = selectionOf(ticket, { triggersFired });
|
|
651
|
+
const selection = selectionOf(ticket, { triggersFired, owner });
|
|
451
652
|
if (!selection.eligible) {
|
|
452
653
|
skipped.push({
|
|
453
654
|
id: ticket.id,
|
|
@@ -474,6 +675,93 @@ export const selectNext = (tickets, { lastCompletedTier = null, triggersFired =
|
|
|
474
675
|
return { ticket, skipped, candidates: candidates.length };
|
|
475
676
|
};
|
|
476
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Revalidation at SELECT — is the item the run is about to take the item the
|
|
680
|
+
* last take-up saw?
|
|
681
|
+
*
|
|
682
|
+
* The snapshot is the ticket's `updatedAt` marker as recorded at the previous
|
|
683
|
+
* take-up in THIS run (`run-state.mjs` › recordTakeUp). One string compare on
|
|
684
|
+
* the tracker's last-modified field, no second network call — the unchanged
|
|
685
|
+
* case costs nothing. That the field moves on every edit, comment and status
|
|
686
|
+
* change is the tracker's contract, assumed here and not checked.
|
|
687
|
+
*
|
|
688
|
+
* 🔴 **`changed` is three-valued, and `null` is the honest one.** An adapter
|
|
689
|
+
* with no marker (`plan-md`) cannot say "unchanged"; it can only say it did not
|
|
690
|
+
* look. Collapsing that into `false` would report a blind spot as a pass, which
|
|
691
|
+
* is the one thing an evidence log must never do. `true` is reserved for a
|
|
692
|
+
* marker that moved: a first sight (no snapshot yet) is `false` with the baseline
|
|
693
|
+
* recorded, not a change.
|
|
694
|
+
*
|
|
695
|
+
* `action` says what the run does with it: `hold` — re-read the item before
|
|
696
|
+
* acting; `continue` — nothing moved; `unverifiable` — no marker to compare.
|
|
697
|
+
*
|
|
698
|
+
* ⚠ Limit: the marker moves on the run's OWN claim and comments too. The
|
|
699
|
+
* tracker adapters re-record the take-up after each write they make (AR-140),
|
|
700
|
+
* so a move made THROUGH the adapter is not a hold — one made by any other
|
|
701
|
+
* route (a hand-posted comment, a connector) still is. This function cannot
|
|
702
|
+
* tell who moved it; the re-read can, and the `loop` skill records that
|
|
703
|
+
* conclusion as a separate `revalidation-outcome` event.
|
|
704
|
+
*/
|
|
705
|
+
export const revalidationOf = ({ ticket, snapshot = null }) => {
|
|
706
|
+
const to = typeof ticket?.updatedAt === 'string' ? ticket.updatedAt : null;
|
|
707
|
+
const from = typeof snapshot === 'string' ? snapshot : null;
|
|
708
|
+
// One shape at every point (AR-136): `source` is the list of what moved,
|
|
709
|
+
// `action` the same three words BEFORE_PR and BEFORE_CLOSE use, and the two
|
|
710
|
+
// markers sit under `task` — so a reader of the evidence log needs one parser.
|
|
711
|
+
const base = { ticket: ticket?.id ?? null, point: 'SELECT', task: { from, to } };
|
|
712
|
+
if (to === null) return { ...base, changed: null, source: [], action: 'unverifiable' };
|
|
713
|
+
const changed = from !== null && from !== to;
|
|
714
|
+
return {
|
|
715
|
+
...base,
|
|
716
|
+
changed,
|
|
717
|
+
source: changed ? ['task:updatedAt'] : [],
|
|
718
|
+
action: changed ? 'hold' : 'continue',
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Revalidation at BEFORE_PR — the aggregate over two sources, pure.
|
|
724
|
+
*
|
|
725
|
+
* `task` is what {@link revalidationOf} returned for the ticket against the
|
|
726
|
+
* take-up snapshot; `mainChanged` is the list of cited paths the default branch
|
|
727
|
+
* changed since the branch forked (`revalidate.mjs` computes it from git). One
|
|
728
|
+
* source name per finding — `task:updatedAt`, `main:<path>` — so a hold names
|
|
729
|
+
* exactly what moved, never "something changed".
|
|
730
|
+
*
|
|
731
|
+
* `changed` keeps the three values of the SELECT point: `true` when any source
|
|
732
|
+
* moved; `null` when nothing moved but the task could not be checked (no
|
|
733
|
+
* snapshot, no marker, no run) — a blind spot on one side is not a clean pass
|
|
734
|
+
* on both; `false` only when both sides were compared and neither moved.
|
|
735
|
+
*/
|
|
736
|
+
export const beforePrRevalidationOf = ({ ticket, task = { changed: null }, mainChanged = [] }) => {
|
|
737
|
+
const source = [
|
|
738
|
+
...(task?.changed === true ? ['task:updatedAt'] : []),
|
|
739
|
+
...mainChanged.map((path) => `main:${path}`),
|
|
740
|
+
];
|
|
741
|
+
const changed = source.length > 0 ? true : task?.changed === null ? null : false;
|
|
742
|
+
const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
|
|
743
|
+
return { ticket, point: 'BEFORE_PR', changed, source, action };
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Revalidation at BEFORE_CLOSE — the aggregate over the item's marker and its
|
|
748
|
+
* state, pure. `task` is what {@link revalidationOf} returned against the last
|
|
749
|
+
* validation; `state` is the item's neutral state now. At close the item is
|
|
750
|
+
* expected `in-progress`: `closed` means someone else published it, `open`
|
|
751
|
+
* means someone moved it back, and either is a change the close must not
|
|
752
|
+
* paper over. Same three-valued `changed` and the same actions as BEFORE_PR;
|
|
753
|
+
* `task:updatedAt` is named before `task:state`.
|
|
754
|
+
*/
|
|
755
|
+
export const beforeCloseRevalidationOf = ({ ticket, task = { changed: null }, state = null }) => {
|
|
756
|
+
const source = [
|
|
757
|
+
...(task?.changed === true ? ['task:updatedAt'] : []),
|
|
758
|
+
...(state !== 'in-progress' ? ['task:state'] : []),
|
|
759
|
+
];
|
|
760
|
+
const changed = source.length > 0 ? true : task?.changed === null ? null : false;
|
|
761
|
+
const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
|
|
762
|
+
return { ticket, point: 'BEFORE_CLOSE', changed, source, action };
|
|
763
|
+
};
|
|
764
|
+
|
|
477
765
|
/**
|
|
478
766
|
* Split the skipped records into the ones holding takeable work back and the
|
|
479
767
|
* ones that are simply out of play.
|
|
@@ -556,7 +844,12 @@ const parkedNote = (parked) =>
|
|
|
556
844
|
? ''
|
|
557
845
|
: ` A further ${parked.length} item(s) are parked — ${breakdownOf(parked)}. ` +
|
|
558
846
|
'Those are not work this run can take and they wait on a human, never on ' +
|
|
559
|
-
'time
|
|
847
|
+
'time' +
|
|
848
|
+
(parked.includes('obsolete')
|
|
849
|
+
? '; an obsolete item waits on a human close with a comment naming the ' +
|
|
850
|
+
'evidence or the replacement, which the loop never writes'
|
|
851
|
+
: '') +
|
|
852
|
+
'.';
|
|
560
853
|
|
|
561
854
|
/**
|
|
562
855
|
* The trigger remedies, composed from the tags actually present.
|
|
@@ -597,6 +890,35 @@ const triggerNote = (held) => {
|
|
|
597
890
|
);
|
|
598
891
|
};
|
|
599
892
|
|
|
893
|
+
/**
|
|
894
|
+
* The owner remedy (AR-132), present only when the pile carries the tag: an item
|
|
895
|
+
* another repository owns is freed by a human moving or re-marking it — never by
|
|
896
|
+
* waiting, and never by refilling this queue.
|
|
897
|
+
*/
|
|
898
|
+
const ownerNote = (held) =>
|
|
899
|
+
held.includes('owner')
|
|
900
|
+
? ' An item held as owner belongs to another repository (its `owner-<name>` ' +
|
|
901
|
+
"label is not this checkout's `options.owner`): a human moves it to that " +
|
|
902
|
+
"repository's queue or re-marks it; nothing this checkout does frees it."
|
|
903
|
+
: '';
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The lifecycle remedies (AR-144), each present only when its tag is in the pile.
|
|
907
|
+
* Both are human acts on the item itself — neither time nor interleaving frees
|
|
908
|
+
* them, and the loop must not perform either: rewriting a `re-scope` item is
|
|
909
|
+
* authoring its own work, and un-parking is a scheduling decision.
|
|
910
|
+
*/
|
|
911
|
+
const lifecycleNote = (held) =>
|
|
912
|
+
(held.includes('re-scope')
|
|
913
|
+
? ' An item held as re-scope is valid work that is not executable as written: ' +
|
|
914
|
+
'a human rewrites it against the current code and removes the label; the ' +
|
|
915
|
+
'loop never invents the new scope.'
|
|
916
|
+
: '') +
|
|
917
|
+
(held.includes('deferred')
|
|
918
|
+
? ' An item held as deferred carries the parked label — valid work deliberately ' +
|
|
919
|
+
'not active now: a human un-parks it; nothing this run does frees it.'
|
|
920
|
+
: '');
|
|
921
|
+
|
|
600
922
|
/**
|
|
601
923
|
* Should the whole run stop? Checked in severity order, because a regression must
|
|
602
924
|
* not be reported as an empty queue.
|
|
@@ -675,7 +997,7 @@ export const stopConditionOf = ({
|
|
|
675
997
|
'and the two ask for opposite things: an empty queue wants refilling, ' +
|
|
676
998
|
'whereas this one still holds work. Spacing clears when a normal item ' +
|
|
677
999
|
'lands, a blocker when its item closes, in-progress when the other ' +
|
|
678
|
-
`session finishes.${triggerNote(held)} Otherwise the action is to ` +
|
|
1000
|
+
`session finishes.${triggerNote(held) + ownerNote(held) + lifecycleNote(held)} Otherwise the action is to ` +
|
|
679
1001
|
'interleave or to wait, never to refill and never to invent work.',
|
|
680
1002
|
};
|
|
681
1003
|
}
|
|
@@ -737,5 +1059,153 @@ export const validateProposal = (proposal) => {
|
|
|
737
1059
|
'run would prove it worked)',
|
|
738
1060
|
);
|
|
739
1061
|
}
|
|
1062
|
+
checkInferenceAgainstMeasurement(proposal);
|
|
740
1063
|
return proposal;
|
|
741
1064
|
};
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* What a proposal MEASURED and what it INFERRED from that, as two paired
|
|
1068
|
+
* optional fields (AR-142).
|
|
1069
|
+
*
|
|
1070
|
+
* A proposal whose premise was never true had no check at filing, only at
|
|
1071
|
+
* take-up: AR-124 was filed, promoted and claimed before anyone found that its
|
|
1072
|
+
* platform conclusion rested on a probe that had touched one hook. So the two
|
|
1073
|
+
* halves are named separately, and an inference that cites a path the
|
|
1074
|
+
* measurement does not cite is refused here, with both fields and the path
|
|
1075
|
+
* named — the author has to either measure that surface or stop claiming it.
|
|
1076
|
+
*
|
|
1077
|
+
* A "surface" is a cited path, read by `citedPathsOf`: a text scan, so a
|
|
1078
|
+
* surface named indirectly ("the platform") is invisible to this check — it
|
|
1079
|
+
* catches the path-shaped overreach and nothing subtler. Neither field given
|
|
1080
|
+
* files as before; one without the other is refused, because a measurement
|
|
1081
|
+
* without its inference (or the reverse) is exactly the half-formed shape the
|
|
1082
|
+
* pairing exists to make visible.
|
|
1083
|
+
*/
|
|
1084
|
+
const checkInferenceAgainstMeasurement = ({ measured, inferred } = {}) => {
|
|
1085
|
+
const has = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
1086
|
+
if (!has(measured) && !has(inferred)) return;
|
|
1087
|
+
if (!has(measured) || !has(inferred)) {
|
|
1088
|
+
const present = has(measured) ? 'measured' : 'inferred';
|
|
1089
|
+
const absent = has(measured) ? 'inferred' : 'measured';
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
`a proposal that names what it ${present} must also name what it ${absent}: ` +
|
|
1092
|
+
`\`${present}\` is given and \`${absent}\` is not — the two are a pair`,
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const touched = new Set(citedPathsOf(measured));
|
|
1096
|
+
const overreach = citedPathsOf(inferred).filter((path) => !touched.has(path));
|
|
1097
|
+
if (overreach.length > 0) {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`a proposal's inference names a surface its measurement did not touch: ` +
|
|
1100
|
+
`\`inferred\` cites ${overreach.join(', ')}, which \`measured\` does not. ` +
|
|
1101
|
+
'Measure that surface, or keep the inference inside what was measured.',
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* The commit a proposal was measured against, read back out of its body — or
|
|
1108
|
+
* null when the body carries none (AR-116).
|
|
1109
|
+
*
|
|
1110
|
+
* Seven to forty hex characters, after the literal `asOf: `. An absent marker is
|
|
1111
|
+
* `null`, never "HEAD": the hygiene finding for it is "unanswerable", and a
|
|
1112
|
+
* default that quietly said "current" is the failure this field exists to name.
|
|
1113
|
+
*/
|
|
1114
|
+
const AS_OF_IN_BODY = /(?:^|[\s(`"'])asOf: ([0-9a-f]{7,40})(?![0-9a-z])/;
|
|
1115
|
+
|
|
1116
|
+
export const asOfOf = (body) => {
|
|
1117
|
+
const text = typeof body === 'string' ? body : '';
|
|
1118
|
+
const match = AS_OF_IN_BODY.exec(text.slice(0, 20_000));
|
|
1119
|
+
return match ? match[1] : null;
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* The repository paths a proposal's text cites, in order of first mention.
|
|
1124
|
+
*
|
|
1125
|
+
* A path here is a token ending in a source or document extension, with any
|
|
1126
|
+
* directory prefix — `queue/core.mjs`, `.claude/rules/workflow.md`,
|
|
1127
|
+
* `journal/2026-08.md`. It is a text scan, so a path named indirectly ("the
|
|
1128
|
+
* router") is invisible, and a bare word with an extension counts even when it
|
|
1129
|
+
* is not a file — the finding it feeds says "possibly", for exactly this reason.
|
|
1130
|
+
*
|
|
1131
|
+
* Bounded: one pass over at most 20 000 characters, at most 200 matches.
|
|
1132
|
+
*/
|
|
1133
|
+
const PATH_IN_TEXT =
|
|
1134
|
+
/(?<![\w./-])((?:[\w.-]+\/)*[\w-][\w.-]*\.(?:mjs|cjs|js|ts|tsx|md|mdx|json|ya?ml|sh|txt))(?![\w/])/g;
|
|
1135
|
+
|
|
1136
|
+
export const citedPathsOf = (text) => {
|
|
1137
|
+
const source = (typeof text === 'string' ? text : '').slice(0, 20_000);
|
|
1138
|
+
const seen = new Set();
|
|
1139
|
+
let match;
|
|
1140
|
+
while (seen.size < 200 && (match = PATH_IN_TEXT.exec(source)) !== null) {
|
|
1141
|
+
seen.add(match[1]);
|
|
1142
|
+
}
|
|
1143
|
+
PATH_IN_TEXT.lastIndex = 0;
|
|
1144
|
+
return [...seen];
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Has the code a proposal describes moved since the proposal was written?
|
|
1149
|
+
*
|
|
1150
|
+
* Pure: `changedSince` is the list of paths git reports changed between `asOf`
|
|
1151
|
+
* and `head`, or `null` when git could not answer — an unknown commit, a
|
|
1152
|
+
* shallow clone, no checkout at all. The caller runs git; this decides.
|
|
1153
|
+
*
|
|
1154
|
+
* Why it exists: AR-47 and AR-87 both escalated PREMISE FALSE because the merge
|
|
1155
|
+
* that falsified each landed after the proposal was filed, and selection hands
|
|
1156
|
+
* out the oldest proposal first. Nothing on the item said which commit the
|
|
1157
|
+
* finding described, so nothing could say it had been overtaken.
|
|
1158
|
+
*
|
|
1159
|
+
* Every answer short of "clean" is a finding. A proposal without `asOf` is
|
|
1160
|
+
* reported as unanswerable, never as current; one citing no path cannot be
|
|
1161
|
+
* checked by path and says so; a git that could not diff is reported as such.
|
|
1162
|
+
* Silence means: filed against HEAD, or every cited path unchanged since.
|
|
1163
|
+
*/
|
|
1164
|
+
export const overtakenOf = ({ id, asOf = null, citedPaths = [], head = null, changedSince = null }) => {
|
|
1165
|
+
if (!asOf) {
|
|
1166
|
+
return {
|
|
1167
|
+
kind: 'proposal-asof-missing',
|
|
1168
|
+
id,
|
|
1169
|
+
why:
|
|
1170
|
+
'the proposal does not say which commit it was measured against, so whether ' +
|
|
1171
|
+
'the code it describes has moved since cannot be answered — unanswerable, ' +
|
|
1172
|
+
'not clean',
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const paths = Array.isArray(citedPaths) ? citedPaths : [];
|
|
1176
|
+
if (paths.length === 0) {
|
|
1177
|
+
return {
|
|
1178
|
+
kind: 'proposal-cites-no-path',
|
|
1179
|
+
id,
|
|
1180
|
+
why:
|
|
1181
|
+
`measured against ${asOf}, but its text names no repository path, so a change ` +
|
|
1182
|
+
'that overtook it cannot be seen by path — read it before acting on it',
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
const current = typeof head === 'string' && (head.startsWith(asOf) || asOf.startsWith(head));
|
|
1186
|
+
if (current) return null;
|
|
1187
|
+
if (changedSince === null) {
|
|
1188
|
+
return {
|
|
1189
|
+
kind: 'proposal-asof-unanswerable',
|
|
1190
|
+
id,
|
|
1191
|
+
why:
|
|
1192
|
+
`git could not list what changed since ${asOf} — an unknown commit, a shallow ` +
|
|
1193
|
+
'clone, or no checkout — so whether the proposal was overtaken is unanswered',
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
// By suffix on a path boundary, because findings cite the way people write —
|
|
1197
|
+
// `queue/core.mjs` for `.claude/scripts/queue/core.mjs` — and an exact match
|
|
1198
|
+
// would read that citation as clean, the one direction of miss this exists to
|
|
1199
|
+
// prevent. `x/not-core.mjs` is not a match for `core.mjs`.
|
|
1200
|
+
const moved = changedSince.filter((changed) =>
|
|
1201
|
+
paths.some((cited) => changed === cited || changed.endsWith(`/${cited}`)),
|
|
1202
|
+
);
|
|
1203
|
+
if (moved.length === 0) return null;
|
|
1204
|
+
return {
|
|
1205
|
+
kind: 'proposal-possibly-overtaken',
|
|
1206
|
+
id,
|
|
1207
|
+
why:
|
|
1208
|
+
`measured against ${asOf}, and ${moved.join(', ')} changed since — re-read the ` +
|
|
1209
|
+
'proposal against the code before taking it; its premise may have rotted',
|
|
1210
|
+
};
|
|
1211
|
+
};
|