create-agent-rig 0.6.1 → 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 (39) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/package.json +3 -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 +122 -81
  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 +24 -8
  7. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +3 -6
  8. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +67 -12
  9. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +5 -9
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +20 -11
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +5 -9
  13. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  14. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +164 -0
  15. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  16. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  17. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +61 -27
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  25. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
  26. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  27. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  28. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +33 -19
  29. package/templates/agent-os/universal/.claude/settings.json +1 -1
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +122 -81
  31. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
  32. package/templates/agent-os/universal/.codex/hooks.json +17 -17
  33. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  34. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  35. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  36. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  37. package/templates/agent-os/universal/layers.json +6 -0
  38. package/templates/hash-history.json +71 -28
  39. package/templates/release-ledger.json +3 -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'
@@ -286,22 +302,32 @@ const retryDelayMs = (response, attempt) => {
286
302
  * - a timeout (`timeoutMs`, default 20 s) through an AbortController — a stalled
287
303
  * connection used to block selection forever, and a loop that cannot read its
288
304
  * queue must stop, not hang;
289
- * - a retry on 429/502/503/504, at most `MAX_ATTEMPTS`, honouring `Retry-After`;
290
- * 401/403/404 and every other status fail at once — a bad credential is not
291
- * transient, and retrying it only delays the diagnosis;
305
+ * - safe reads retry 429/502/503/504 at most `MAX_ATTEMPTS`, honouring
306
+ * `Retry-After`; mutating calls do not retry an ambiguous response, because
307
+ * Jira may have applied the write before a proxy returned the error. The
308
+ * search endpoint opts in explicitly: it is a POST at the transport layer and
309
+ * a read at the operation layer;
292
310
  * - `retry.sleep` injectable, so a test measures the delay it would have waited
293
311
  * instead of waiting it.
294
312
  *
295
313
  * Pinned in the generator's `test/template/queue-jira.test.ts` (absent in a
296
314
  * generated rig) › "hands fetch an AbortSignal", › "rejects naming the timeout
297
- * and the route when fetch never resolves", › "a 429 followed by a 200 yields
298
- * the 200 body", › "sleeps for the Retry-After the 429 carried, in
315
+ * and the route when fetch never resolves", › "retries a semantically read-only
316
+ * search POST after a 429", › "keeps bounded retry for a safe issue GET", ›
317
+ * "does not retry %s", › "sleeps for the Retry-After the 429 carried, in
299
318
  * milliseconds", › "gives up after four consecutive 503s, naming the status and
300
319
  * the attempts" and › "does not retry a 401 — a bad credential is not transient".
301
320
  */
302
321
  const request = async (
303
322
  route,
304
- { method = 'GET', body = null, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, retry = {} } = {},
323
+ {
324
+ method = 'GET',
325
+ body = null,
326
+ env = process.env,
327
+ timeoutMs = DEFAULT_TIMEOUT_MS,
328
+ retry = {},
329
+ retryTransient = method === 'GET',
330
+ } = {},
305
331
  ) => {
306
332
  const { baseUrl, email, token } = requireCredentials(env);
307
333
  const sleep = retry.sleep ?? defaultSleep;
@@ -334,13 +360,14 @@ const request = async (
334
360
  clearTimeout(timer);
335
361
  }
336
362
  if (response.ok) return payload;
337
- if (TRANSIENT.has(response.status) && attempt < MAX_ATTEMPTS) {
363
+ const retryable = retryTransient && TRANSIENT.has(response.status);
364
+ if (retryable && attempt < MAX_ATTEMPTS) {
338
365
  await sleep(retryDelayMs(response, attempt));
339
366
  continue;
340
367
  }
341
368
  // The status alone; never echo the response body, which can carry the token
342
369
  // back in an error envelope.
343
- const attempts = TRANSIENT.has(response.status) ? ` after ${attempt} attempts` : '';
370
+ const attempts = retryable ? ` after ${attempt} attempts` : '';
344
371
  throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}${attempts}`);
345
372
  }
346
373
  };
@@ -363,6 +390,7 @@ const FIELDS = [
363
390
  'updated',
364
391
  'issuelinks',
365
392
  'description',
393
+ 'comment',
366
394
  ];
367
395
 
368
396
  // --- the adapter contract ------------------------------------------------------
@@ -443,6 +471,7 @@ export const search = async ({
443
471
  pages += 1;
444
472
  const page = await request('/rest/api/3/search/jql', {
445
473
  method: 'POST',
474
+ retryTransient: true,
446
475
  body: {
447
476
  jql: query,
448
477
  maxResults: limit,
@@ -517,17 +546,9 @@ export const resolveBlockers = (ticket) => (ticket.blockedBy ?? []).filter((b) =
517
546
  /**
518
547
  * Re-record the item's marker after a write of this adapter's own (AR-140).
519
548
  *
520
- * Every write here — a claim, a comment, a close, an escalation — moves the
521
- * tracker's `updated`, and the next revalidation compared against the take-up
522
- * from before it — the generator's journal records one run whose every
523
- * BEFORE_PR catch was a hold on its own comment (`revalidation-report.mjs`
524
- * over that run). So the marker is read back after the write
525
- * and recorded as the take-up in the declared run; a hold that still fires is
526
- * a move by something other than this adapter.
527
- *
528
- * ⚠ Limit: only writes made THROUGH this adapter re-baseline. A comment the
529
- * session posts by another route — a REST call by hand, a connector — moves
530
- * 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.
531
552
  *
532
553
  * Best-effort, like `proposeTriage`'s baseline: the write has landed by now,
533
554
  * and a read-back the tracker refused or a stale run directory is announced on
@@ -560,7 +581,10 @@ const rebaseline = async (ticket, env) => {
560
581
  recordMarker(ticket, updatedAt, env);
561
582
  };
562
583
 
563
- 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
+ ) => {
564
588
  if (!transitionId) {
565
589
  const available = await request(`/rest/api/3/issue/${ticket.id}/transitions`, { env });
566
590
  const target = available.transitions.find(
@@ -579,8 +603,18 @@ export const claim = async (ticket, { transitionId = null, env = process.env } =
579
603
  body: { transition: { id: transitionId } },
580
604
  env,
581
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
+ }
582
616
  await rebaseline(ticket, env);
583
- return { ok: true };
617
+ return { ok: true, workflowClaimRecorded };
584
618
  };
585
619
 
586
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
  }