create-agent-rig 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/package.json +1 -1
  3. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
  4. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +117 -80
  5. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
  6. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +23 -3
  7. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +65 -7
  8. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +2 -2
  9. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +2 -2
  10. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +109 -0
  11. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  12. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  13. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  14. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  15. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  16. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  17. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  18. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  19. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +41 -19
  20. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  21. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
  22. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  23. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  24. package/templates/agent-os/universal/.claude/settings.json +1 -1
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +117 -80
  26. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
  27. package/templates/agent-os/universal/.codex/hooks.json +1 -1
  28. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  29. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  30. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  31. package/templates/agent-os/universal/layers.json +5 -0
  32. package/templates/hash-history.json +36 -15
  33. package/templates/release-ledger.json +2 -1
@@ -26,8 +26,10 @@
26
26
  import { duplicateOf, fingerprintOf, validateProposal, ownerOfLabels, lifecycleOf } from './core.mjs';
27
27
  import { withAsOf } from './as-of.mjs';
28
28
  import { recordEscalation, recordTakeUp } from '../run-state.mjs';
29
+ import { recordClaimTransition } from '../lib/claim-records.mjs';
29
30
 
30
31
  export const name = 'jira';
32
+ export const claimedState = 'in-progress';
31
33
 
32
34
  /** Jira's own default priority ladder. An unrecognised name sorts last, never first. */
33
35
  const PRIORITY = { highest: 1, high: 2, medium: 3, low: 4, lowest: 5 };
@@ -76,6 +78,14 @@ export const toTicket = (issue) => {
76
78
  const labels = fields.labels ?? [];
77
79
  const links = fields.issuelinks ?? [];
78
80
  const category = statusCategory(fields);
81
+ const comments = Array.isArray(fields.comment?.comments) ? fields.comment.comments : [];
82
+ const commentaryIds = comments
83
+ .map((comment) => comment?.id)
84
+ .filter((id) => id !== undefined && id !== null)
85
+ .map(String);
86
+ const commentaryCount = Number.isInteger(fields.comment?.total)
87
+ ? fields.comment.total
88
+ : comments.length;
79
89
 
80
90
  // 🔴 INVARIANT 1: the dependency is the LINK, and the blocker's own status
81
91
  // decides. A `blocked` label is a snapshot nobody updates when the blocker
@@ -116,16 +126,22 @@ export const toTicket = (issue) => {
116
126
  ? Number(fields.priority?.id)
117
127
  : 999),
118
128
  createdAt: toIso(fields.created),
119
- // The take-up marker for revalidation at SELECT (`core.mjs` › revalidationOf):
120
- // the tracker's own last-modified field. That it moves on every status
121
- // change, edit and comment is Jira's contract, assumed and not checked
122
- // here. `null` when the search did not carry it — never `''`, which would
123
- // compare equal to itself and read as "unchanged" where the truth is "not
124
- // looked".
129
+ // Compatibility evidence only: Jira's last-modified field is retained in
130
+ // `takeUps`, but content-blind claim fingerprints decide drift.
125
131
  updatedAt: toIso(fields.updated),
126
132
  // Flattened from the document description — the same text this adapter
127
133
  // already reads internally, now visible to the shared hygiene checks.
128
134
  body: descriptionTextOf(issue) || null,
135
+ commentary: {
136
+ count: commentaryCount,
137
+ ids: commentaryIds,
138
+ // Jira may return only the first page while still declaring the total.
139
+ // A partial set cannot truthfully fingerprint commentary; the shared
140
+ // claim resolver turns this explicit false into UNVERIFIABLE.
141
+ complete:
142
+ commentaryIds.length === commentaryCount &&
143
+ new Set(commentaryIds).size === commentaryIds.length,
144
+ },
129
145
  triage: labels.includes('triage'),
130
146
  trigger: labels.includes('trigger-auto')
131
147
  ? 'auto'
@@ -374,6 +390,7 @@ const FIELDS = [
374
390
  'updated',
375
391
  'issuelinks',
376
392
  'description',
393
+ 'comment',
377
394
  ];
378
395
 
379
396
  // --- the adapter contract ------------------------------------------------------
@@ -529,17 +546,9 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
529
546
  /**
530
547
  * Re-record the item's marker after a write of this adapter's own (AR-140).
531
548
  *
532
- * Every write here a claim, a comment, a close, an escalation moves the
533
- * tracker's `updated`, and the next revalidation compared against the take-up
534
- * from before it the generator's journal records one run whose every
535
- * BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
536
- * over that run). So the marker is read back after the write
537
- * and recorded as the take-up in the declared run; a hold that still fires is
538
- * a move by something other than this adapter.
539
- *
540
- * ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
541
- * session posts by another route — a REST call by hand, a connector — moves
542
- * the marker like anyone else's, and the next check holds on it.
549
+ * Every write here moves Jira's `updated`, so it is read back and retained for
550
+ * attribution and compatibility. It does not re-baseline `.rig/claims/` and
551
+ * cannot produce or clear a drift decision.
543
552
  *
544
553
  * Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
545
554
  * and a read-back the tracker refused or a stale run directory is announced on
@@ -572,7 +581,10 @@ const rebaseline = async (ticket, env) => {
572
581
  recordMarker(ticket, updatedAt, env);
573
582
  };
574
583
 
575
- export const claim = async (ticket, { transitionId = null, env = process.env } = {}) => {
584
+ export const claim = async (
585
+ ticket,
586
+ { transitionId = null, env = process.env, projectRoot = process.cwd() } = {},
587
+ ) => {
576
588
  if (!transitionId) {
577
589
  const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
578
590
  const target = available.transitions.find(
@@ -591,8 +603,18 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
591
603
  body: { transition: { id: transitionId } },
592
604
  env,
593
605
  });
606
+ let workflowClaimRecorded = false;
607
+ try {
608
+ workflowClaimRecorded =
609
+ recordClaimTransition({ projectRoot, ticket, claimedState }) !== null;
610
+ } catch (error) {
611
+ process.stderr.write(
612
+ `${ticket.id}: the workflow claim landed, but its durable acknowledgement was NOT recorded — ` +
613
+ `${error.message}\n`,
614
+ );
615
+ }
594
616
  await rebaseline(ticket, env);
595
- return { ok: true };
617
+ return { ok: true, workflowClaimRecorded };
596
618
  };
597
619
 
598
620
  export const comment = async (ticket, body, { env = process.env } = {}) => {
@@ -17,6 +17,7 @@ import { withAsOf } from './as-of.mjs';
17
17
  import { recordEscalation } from '../run-state.mjs';
18
18
 
19
19
  export const name = 'plan-md';
20
+ export const claimedState = 'open';
20
21
 
21
22
  const AGENT_QUEUE = /^##\s+Agent queue\s*$/i;
22
23
  const OPERATOR_QUEUE = /^##\s+Operator queue\s*$/i;
@@ -129,9 +130,10 @@ export const parsePlan = (plan) => {
129
130
  blocks: [],
130
131
  priority: items.length,
131
132
  createdAt: null,
132
- // A flat list carries no marker at all, so revalidation at SELECT records
133
- // `changed: null` for it — a blind spot, never an "unchanged".
133
+ // A flat list carries no compatibility marker; claim fingerprints still
134
+ // provide the authoritative revalidation baseline.
134
135
  updatedAt: null,
136
+ commentary: { count: 0, ids: [] },
135
137
  triage: MARKERS.triage.test(raw),
136
138
  trigger: MARKERS.triggerAuto.test(raw)
137
139
  ? 'auto'
@@ -6,13 +6,22 @@
6
6
  *
7
7
  * node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
8
8
  *
9
- * Two sources, compared and named separately, because a hold that cannot say
10
- * WHAT moved sends the run to re-read everything:
9
+ * One existing checkpoint chain, with one authoritative durable baseline:
11
10
  *
12
- * - `task:updatedAt` the item's marker now, read through the queue adapter,
13
- * against the take-up snapshot `queue/index.mjs next` recorded in the run's
14
- * `state.json` (`takeUps`, AR-133). No snapshot, no marker or no run → that
15
- * source is `null`: not looked, never "unchanged".
11
+ * - `.rig/claims/<ticket>.json` carries versioned, content-blind `scope` and
12
+ * `commentary` fingerprint sets. Scope is authoritative at BEFORE_PR;
13
+ * commentary is observed but does not hold until BEFORE_CLOSE. Missing,
14
+ * untracked or unreadable claim state is `UNVERIFIABLE` and exits 2. So is a
15
+ * tracker whose adapter this script cannot READ (RP-64) — that one means the
16
+ * question was never put, rather than that the claim record is unreadable.
17
+ * ⚠ Reads, precisely: the queue CONFIG failing to resolve at all — an unknown
18
+ * adapter name, a malformed `queue.json` — is exit 1, the refusal path, not a
19
+ * hold, and it is the operator's to fix rather than a claim waiting on a
20
+ * tracker. Pinned in the generator's
21
+ * `test/template/revalidate-adapter.test.ts` — absent in a generated rig —
22
+ * › "refuses an adapter name it cannot resolve with a readable message, not a
23
+ * stack trace" and › "refuses a queue config that is not valid JSON with a
24
+ * readable message, not a stack trace".
16
25
  * - `main:<path>` — what the default branch changed since this branch forked
17
26
  * (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
18
27
  * paths. Cited is a labelled assumption, not a recorded fact: the paths the
@@ -20,33 +29,34 @@
20
29
  * record in this run's journal — the files the run said its premises rest
21
30
  * on. An unrelated change on the default branch does not hold.
22
31
  *
23
- * At BEFORE_CLOSE (AR-135) there is no git and no main: the sources are
24
- * `task:updatedAt` against the LAST VALIDATION the `task.to` of this run's
25
- * latest `revalidation` event for the item, falling back to the take-up
26
- * snapshot and `task:state`, which is expected `in-progress` at close and
27
- * is a change when someone closed the item or moved it back. The item comes
32
+ * `updatedAt` and `takeUps` are still projected into `task` as compatibility
33
+ * evidence, but never contribute a source or action.
34
+ *
35
+ * At BEFORE_CLOSE (AR-135) there is no main comparison: both claim fingerprint
36
+ * sets are authoritative. Workflow state is already inside `claim:scope`,
37
+ * normalised to the adapter's expected claimed state, so an expected claim
38
+ * transition stays current while a close or rollback moves scope. The item comes
28
39
  * from the adapter's `find`, which sees closed items where `listEligible`
29
40
  * drops them; one the tracker no longer offers at all reads `missing`, and
30
- * either holds on `task:state` (revalidate.test.ts › "holds on task:state when
31
- * someone already closed the item", › "holds on task:state when the tracker no
32
- * longer offers the item"). The result lists the item's dependants (`blocks`)
41
+ * holds on `claim:scope` (revalidate.test.ts › "holds on claim:scope when
42
+ * someone already closed the item", › "holds on claim:scope when the item was
43
+ * moved back to open"). The result lists the item's dependants (`blocks`)
33
44
  * and re-reads each one's state through the same `find` (revalidate.test.ts ›
34
45
  * "re-reads each dependant's state, and names one the tracker no longer
35
46
  * offers") for the loop's write-back.
36
47
  *
37
- * The aggregates are `queue/core.mjs` › beforePrRevalidationOf and
38
- * beforeCloseRevalidationOf; this file is the I/O around them.
39
- *
40
48
  * `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
41
49
  * (AR-136) is the second half of the evidence: after the re-read, it appends a
42
50
  * `revalidation-outcome` record whose `answers` is the seq of the latest
43
51
  * `revalidation` for that ticket and point in this run — the join a report
44
52
  * needs, made by the writer rather than guessed by the reader. It refuses
45
53
  * without a run, without a matching revalidation, and with any word but
46
- * `true`/`false`, and writes nothing then. Exit 2 on `hold`, 0 on `continue` and `unverifiable`, 1 when
47
- * the arguments cannot be acted on (unknown point, no ticket, a base that is
48
- * not a revision) and then nothing is journalled, because a refusal is not
49
- * an answer.
54
+ * `true`/`false`, and writes nothing then. The typed resolution names the
55
+ * stable detection id and clears only the matching run-level hold. Exit 2 on
56
+ * `hold` or `unverifiable`, 0 on `continue`, and 1 when the call cannot be
57
+ * acted on (unknown point, no ticket, a base that is not a revision — or, on
58
+ * the paths that reach it, a queue config that does not resolve) — and then
59
+ * nothing is journalled, because a refusal is not an answer.
50
60
  *
51
61
  * ⚠ It reads `<base>` as it is in this checkout and never updates the remote
52
62
  * ref itself; `pr-ship` step 1 does that before calling this. A stale ref
@@ -61,10 +71,18 @@ import { dirname, join } from 'node:path';
61
71
  import { fileURLToPath } from 'node:url';
62
72
  import { withoutGitLocation } from './git-env.mjs';
63
73
  import { readRun, recordEvent } from './run-journal.mjs';
64
- import { readState } from './run-state.mjs';
74
+ import { clearRevalidationHold, readState, recordRevalidationHold } from './run-state.mjs';
65
75
  import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
66
- import { beforeCloseRevalidationOf, beforePrRevalidationOf, revalidationOf } from './queue/core.mjs';
76
+ import { takeUpEvidenceOf } from './queue/core.mjs';
67
77
  import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
78
+ import { projectRootOfConfig } from './queue/index.mjs';
79
+ import {
80
+ revalidateClaim,
81
+ targetShaOf,
82
+ unverifiableResult,
83
+ withAdditionalDrift,
84
+ } from './lib/claim-records.mjs';
85
+ import { DEFAULT_SCAN_LIMIT, findSecretValues } from './lib/secrets.mjs';
68
86
 
69
87
  // Derived from the one source, never restated here (AR-137).
70
88
  export const POINTS = REVALIDATES;
@@ -132,7 +150,7 @@ const citedByPremises = (runDir) => {
132
150
  .filter((file) => typeof file === 'string' && file !== '');
133
151
  };
134
152
 
135
- /** The last validation's marker for this item: the latest revalidation event, any point. */
153
+ /** The last compatibility marker observed for this item, at any revalidation point. */
136
154
  const lastValidationOf = (runDir, id) => {
137
155
  if (!runDir) return null;
138
156
  const { events } = readRun({ runDir });
@@ -155,6 +173,150 @@ const invokedDirectly = () => {
155
173
  return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
156
174
  };
157
175
 
176
+ /**
177
+ * The adapter's own message, unless it carries something credential-shaped.
178
+ *
179
+ * The messages this is written for name environment VARIABLES rather than their
180
+ * values, and naming them is exactly what the caller acts on. But an adapter is
181
+ * free to put a URL or a response body into a message, and this text is
182
+ * published to stdout and into a verdict a run journals — neither of which
183
+ * `guard-secret-file` or `validate-no-secrets` can see — so it is checked
184
+ * against the one credential vocabulary this repository has before it goes
185
+ * anywhere.
186
+ *
187
+ * ⚠ **It is defence in depth, not a general redacter.** Measured on the shapes
188
+ * an adapter could plausibly produce: an Atlassian token value, an opaque token
189
+ * after a credential keyword, and a token in a query string are caught by
190
+ * `findSecretValues`; `Authorization: Bearer <opaque>` and `Authorization: Basic
191
+ * <base64>` are NOT, and no shape outside the vocabulary is.
192
+ *
193
+ * 🔴 **URL userinfo is matched HERE rather than left to the vocabulary, because
194
+ * it is reachable through the adapter this repository configures.** `jira.mjs`
195
+ * builds its own errors from method, route and status — but its network arm
196
+ * re-raises the underlying error untouched, and `requireCredentials` accepts any
197
+ * `JIRA_BASE_URL` that begins with `https://`, userinfo included. Undici then
198
+ * throws "Request cannot be constructed from a URL that includes credentials:
199
+ * https://user:<password>@host/…". `findSecretValues` does not see that shape,
200
+ * and unlike the pre-RP-64 crash — which put it on stderr — this path PERSISTS
201
+ * it into the run journal. So the reason is withheld on userinfo as well.
202
+ *
203
+ * An earlier version of this comment argued the blind spots were acceptable
204
+ * because no adapter here produces them. That was false, and resting a safety
205
+ * property on a claim about every present and future adapter is the wrong shape
206
+ * of argument regardless.
207
+ *
208
+ * All-or-nothing on purpose: `findSecretValues` never returns the matched text,
209
+ * so redacting in place would need a second matcher, and a partial redacter is
210
+ * where redacters leak.
211
+ *
212
+ * Exported so the control itself is testable rather than only reachable through
213
+ * a subprocess. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
214
+ * — absent in a generated rig — › "publishes a message that names only environment variables"
215
+ * and › "withholds a message carrying a credential-shaped value".
216
+ */
217
+ /**
218
+ * Userinfo present in a URL at all — `//<anything but a slash or space>@host`.
219
+ * One forward pass, one negated bounded class, so it cannot backtrack.
220
+ *
221
+ * 🔴 It matches the CLASS, not a list of spellings, and that is the whole
222
+ * lesson of how it got here. It first required `user:pass@`, which published
223
+ * `//<token>@host`. Widened to make the password optional, it published
224
+ * `//:<token>@host` — the shape `https://${JIRA_EMAIL}:${JIRA_API_TOKEN}@host`
225
+ * degrades to when the first variable is unset, so the likeliest accident of
226
+ * the three. Two rounds of enumerating forms; the invariant was always "there
227
+ * is userinfo here", and it is shorter than any enumeration of it.
228
+ *
229
+ * The class excludes `/` and whitespace, which is what keeps an ordinary URL,
230
+ * a bare email address in prose, and a registry path carrying an `@scope`
231
+ * published. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
232
+ * — absent in a generated rig — › "withholds a URL whose userinfo is %s — every
233
+ * shape, not the ones enumerated so far" and › "still publishes a message
234
+ * carrying %s", which are tables rather than cases so a future narrowing that
235
+ * handles the known spellings and reopens the class goes red.
236
+ */
237
+ const URL_USERINFO = /\/\/[^\s/@]*@/;
238
+
239
+ export const safeReason = (text) => {
240
+ // Scan and publish the SAME prefix: findSecretValues reads at most
241
+ // DEFAULT_SCAN_LIMIT, and publishing more than was scanned would ship the
242
+ // unscanned tail verbatim.
243
+ const scanned = String(text ?? '').slice(0, DEFAULT_SCAN_LIMIT);
244
+ return findSecretValues(scanned).length === 0 && !URL_USERINFO.test(scanned)
245
+ ? scanned
246
+ : 'the queue adapter could not be read; its message is withheld because it carries a credential-shaped value';
247
+ };
248
+
249
+ /**
250
+ * The revalidation boundary for a tracker that cannot be read (RP-64).
251
+ *
252
+ * 🔴 It answers `UNVERIFIABLE` and exits 2 — the same hold path a real drift
253
+ * takes, and never 0. "The adapter was unreachable" is not evidence that the
254
+ * branch is still the branch the run took up, and a caller that read it as a
255
+ * pass would carry an unchecked claim into a PR. A drift the adapter DID report
256
+ * still comes back as `hold`, and an unchanged claim still as `continue`;
257
+ * this only replaces the crash. Pinned in the generator's
258
+ * `test/template/revalidate-adapter.test.ts` — absent in a generated rig — ›
259
+ * "holds the same way at BEFORE_CLOSE, which reads the adapter through a different call"
260
+ * and › "still refuses an unusable invocation as before — this did not swallow argument errors".
261
+ *
262
+ * The detection `identity` names the point and the operation and NOT the
263
+ * message, so it is stable across retries of the same outage — which also means
264
+ * two unrelated failures at one operation share an id, and one `outcome`
265
+ * answers both.
266
+ */
267
+ const answerUnverifiable = ({ runDir, ticket, point, json }, operation, cause) => {
268
+ const result = unverifiableResult({
269
+ ticket: { id: ticket },
270
+ point,
271
+ reason: safeReason(
272
+ `the queue adapter could not be read (${operation}): ${cause?.message ?? cause}`,
273
+ ),
274
+ identity: `adapter-unreadable:${operation}`,
275
+ });
276
+ if (runDir) {
277
+ // Announce, never throw: a stale or unwritable RIG_RUN_DIR made these throw
278
+ // INSIDE the catch that was handling the adapter failure, and the run ended
279
+ // on the Node stack trace this whole change exists to remove.
280
+ try {
281
+ recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
282
+ recordRevalidationHold(runDir, result);
283
+ } catch (error) {
284
+ process.stderr.write(
285
+ `could not journal this revalidation into ${runDir}: ${error?.message ?? error}\n`,
286
+ );
287
+ }
288
+ }
289
+ if (json) {
290
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
291
+ } else {
292
+ process.stdout.write(
293
+ `revalidate ${point}: ${ticket} unverifiable — the queue adapter could not be read (${operation})\n`,
294
+ );
295
+ process.stdout.write(` ${result.evidence.error}\n`);
296
+ process.stdout.write(
297
+ ' this is NOT a pass: nothing about the claim was observed. Fix the adapter and run it again.\n',
298
+ );
299
+ }
300
+ process.exit(2);
301
+ };
302
+
303
+ /** Every adapter call in this script goes through here, or it can still crash. */
304
+ const readAdapter = async (operation, read, context) => {
305
+ try {
306
+ return await read();
307
+ } catch (error) {
308
+ answerUnverifiable(context, operation, error);
309
+ }
310
+ // Reached only if answerUnverifiable failed to exit. Throwing rather than
311
+ // returning null keeps a null ticket from reaching revalidateClaim and
312
+ // resolving to `continue` — the silent pass this whole change forbids.
313
+ //
314
+ // Outside the catch on purpose, and it carries no `cause`: the caught error
315
+ // is the raw adapter message, the one thing the frame above exists to
316
+ // withhold, and Node's uncaught printer walks a cause chain.
317
+ throw new Error('unreachable: answerUnverifiable did not exit');
318
+ };
319
+
158
320
  const refuse = (message) => {
159
321
  process.stderr.write(`${message}\n`);
160
322
  process.exit(1);
@@ -191,18 +353,26 @@ if (invokedDirectly()) {
191
353
  if (!target) {
192
354
  refuse(`no revalidation of ${args.ticket} at ${args.point} in ${runDir} for this outcome to answer.`);
193
355
  }
356
+ const now = new Date().toISOString();
357
+ const actionRequired = args.actionChanged === 'true';
194
358
  const record = recordEvent({
195
359
  runDir,
196
360
  kind: 'revalidation-outcome',
197
361
  data: {
362
+ detectionId: target.data?.id,
363
+ action: actionRequired ? 'semantic decision' : 'continue',
364
+ actionRequired,
365
+ driftOrigin: 'unknown',
366
+ resolvedAt: now,
198
367
  ticket: args.ticket,
199
368
  point: args.point,
200
- actionChanged: args.actionChanged === 'true',
369
+ actionChanged: actionRequired,
201
370
  note: args.note,
202
371
  answers: target.seq,
203
372
  },
204
- now: new Date().toISOString(),
373
+ now,
205
374
  });
375
+ clearRevalidationHold(runDir, target.data?.id);
206
376
  process.stdout.write(
207
377
  args.json
208
378
  ? `${JSON.stringify(record, null, 2)}\n`
@@ -213,16 +383,35 @@ if (invokedDirectly()) {
213
383
 
214
384
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
215
385
  const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
216
- const config = loadConfig(configPath);
217
- const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
386
+ // 🔴 The queue CONFIG, not the tracker behind it. `readAdapter` covers every
387
+ // adapter CALL, but resolving the config sat outside it, so an unknown
388
+ // adapter name or a malformed `queue.json` still crashed with the raw Node
389
+ // stack trace this script exists to remove — and the `[cause]` chain of the
390
+ // malformed case printed the parse error underneath it.
391
+ //
392
+ // It stays exit 1 rather than becoming `UNVERIFIABLE`: a config the operator
393
+ // has to fix is the command refusing, not a claim held pending a tracker
394
+ // that might come back. `refuse` is the path this file already uses for that.
395
+ let config;
396
+ let adapter;
397
+ try {
398
+ config = loadConfig(configPath);
399
+ adapter = await resolveAdapter(config.adapter ?? 'plan-md');
400
+ } catch (error) {
401
+ refuse(
402
+ `the queue configuration at ${configPath} could not be resolved: ` +
403
+ safeReason(error?.message ?? String(error)),
404
+ );
405
+ }
218
406
  const options = optionsWithPlanPath(config.options, configPath);
407
+ const claimRoot = projectRootOfConfig(configPath) ?? projectRoot;
219
408
 
220
409
  if (args.point === 'BEFORE_CLOSE') {
221
- const ticket = await adapter.find(args.ticket, options);
410
+ const context = { runDir, ticket: args.ticket, point: args.point, json: args.json };
411
+ const ticket = await readAdapter('find', () => adapter.find(args.ticket, options), context);
222
412
  const takeUp = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
223
- // The NEWER of the two, not the last validation first (AR-140): an adapter
224
- // re-records the take-up after each write of its own, and a comment posted
225
- // after BEFORE_PR would otherwise hold this close on the run's own move.
413
+ // Preserve the newest compatibility marker as evidence. This comparison
414
+ // never decides drift; the durable claim below is the authority.
226
415
  // ISO strings compare as text; a missing side yields to the other.
227
416
  const lastValidation = lastValidationOf(runDir, args.ticket);
228
417
  const baseline =
@@ -233,7 +422,7 @@ if (invokedDirectly()) {
233
422
  : (lastValidation ?? takeUp);
234
423
  const task =
235
424
  ticket && baseline !== null
236
- ? revalidationOf({ ticket, snapshot: baseline })
425
+ ? takeUpEvidenceOf({ ticket, snapshot: baseline })
237
426
  : { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
238
427
  // Not found is not "in progress": the tracker no longer offers the item.
239
428
  const actual = ticket ? ticket.state : 'missing';
@@ -243,18 +432,33 @@ if (invokedDirectly()) {
243
432
  const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
244
433
  const dependantState = {};
245
434
  for (const dependant of dependants) {
246
- dependantState[dependant] = (await adapter.find(dependant, options))?.state ?? 'missing';
435
+ dependantState[dependant] =
436
+ (await readAdapter('find dependant', () => adapter.find(dependant, options), context))
437
+ ?.state ?? 'missing';
247
438
  }
248
- const aggregate = beforeCloseRevalidationOf({ ticket: args.ticket, task, state: actual });
439
+ const claim = revalidateClaim({
440
+ projectRoot: claimRoot,
441
+ ticket: ticket ?? { id: args.ticket },
442
+ point: 'BEFORE_CLOSE',
443
+ claimedState: adapter.claimedState,
444
+ // Close has no caller-selected comparison base. Resolve the same default
445
+ // target SELECT pinned, so a missing `origin/master` cannot turn an
446
+ // otherwise current local rig into claim:scope drift.
447
+ targetSha: targetShaOf(claimRoot),
448
+ });
249
449
  const result = {
250
- ...aggregate,
450
+ ...claim,
451
+ observedAt: new Date().toISOString(),
251
452
  task: { changed: task.changed, from: task.task.from, to: task.task.to },
252
- state: { expected: 'in-progress', actual },
453
+ state: { expected: adapter.claimedState, actual },
253
454
  dependants,
254
455
  dependantState,
255
456
  };
256
457
  if (runDir) {
257
458
  recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
459
+ if (result.action === 'hold' || result.action === 'unverifiable') {
460
+ recordRevalidationHold(runDir, result);
461
+ }
258
462
  }
259
463
  if (args.json) {
260
464
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -265,7 +469,7 @@ if (invokedDirectly()) {
265
469
  process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
266
470
  }
267
471
  }
268
- process.exit(result.action === 'hold' ? 2 : 0);
472
+ process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
269
473
  }
270
474
 
271
475
  let mergeBase;
@@ -275,33 +479,49 @@ if (invokedDirectly()) {
275
479
  refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
276
480
  }
277
481
 
278
- const tickets = await adapter.listEligible(options);
482
+ const tickets = await readAdapter('listEligible', () => adapter.listEligible(options), {
483
+ runDir,
484
+ ticket: args.ticket,
485
+ point: args.point,
486
+ json: args.json,
487
+ });
279
488
  const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
280
489
 
281
490
  const snapshot = runDir ? (readState(runDir).takeUps?.[args.ticket] ?? null) : null;
282
- // At SELECT a missing snapshot is a first sight only when no earlier run
283
- // took the item up either `queue/index.mjs` asks `previousTakeUp` (AR-138);
284
- // here the question is this run's own take-up, which SELECT wrote — and it
285
- // becomes the baseline;
286
- // here it is a comparison that cannot be made — the run never recorded a
287
- // take-up for this item, so `null`, not the SELECT point's `false`.
491
+ // BEFORE_PR reports this run's marker snapshot as compatibility evidence.
492
+ // A missing marker is evidence that cannot be compared, but it does not make
493
+ // the authoritative claim unverifiable; `revalidateClaim` decides that.
288
494
  const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
289
- const task = ticket && snapshot !== null ? revalidationOf({ ticket, snapshot }) : unverifiable;
495
+ const task = ticket && snapshot !== null ? takeUpEvidenceOf({ ticket, snapshot }) : unverifiable;
290
496
 
291
497
  const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
292
498
  const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
293
499
  const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
294
500
  const mainChanged = mainPaths.filter((path) => cited.includes(path));
295
501
 
296
- const aggregate = beforePrRevalidationOf({ ticket: args.ticket, task, mainChanged });
502
+ const claim = revalidateClaim({
503
+ projectRoot: claimRoot,
504
+ ticket: ticket ?? { id: args.ticket },
505
+ point: 'BEFORE_PR',
506
+ claimedState: adapter.claimedState,
507
+ targetSha: targetShaOf(claimRoot, args.base),
508
+ });
509
+ const aggregate = withAdditionalDrift(
510
+ claim,
511
+ mainChanged.map((path) => `main:${path}`),
512
+ );
297
513
  const result = {
298
514
  ...aggregate,
515
+ observedAt: new Date().toISOString(),
299
516
  task: { changed: task.changed, from: task.task.from, to: task.task.to },
300
517
  main: { base: args.base, mergeBase, cited, changed: mainChanged },
301
518
  };
302
519
 
303
520
  if (runDir) {
304
521
  recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
522
+ if (result.action === 'hold' || result.action === 'unverifiable') {
523
+ recordRevalidationHold(runDir, result);
524
+ }
305
525
  }
306
526
 
307
527
  if (args.json) {
@@ -313,5 +533,5 @@ if (invokedDirectly()) {
313
533
  process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
314
534
  }
315
535
  }
316
- process.exit(result.action === 'hold' ? 2 : 0);
536
+ process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
317
537
  }