create-agent-rig 0.6.2 → 0.7.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +180 -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 +84 -16
  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 +640 -59
  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 +84 -16
  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 +104 -29
  33. package/templates/release-ledger.json +3 -1
@@ -5,14 +5,79 @@
5
5
  * run took up?
6
6
  *
7
7
  * node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <id> [--base origin/master] [--config <queue.json>] [--json]
8
+ * node .claude/scripts/revalidate.mjs --point BEFORE_PR --owner-directed [--base origin/master] [--json]
8
9
  *
9
- * Two sources, compared and named separately, because a hold that cannot say
10
- * WHAT moved sends the run to re-read everything:
10
+ * BEFORE_PR has two mutually exclusive modes and neither is inferred (RP-94):
11
+ * `--ticket` for a branch that is an item's take-up, `--owner-directed` for
12
+ * owner-directed work or a hotfix that has no item — the path `pr-ship` step 4
13
+ * already named while step 1 could not execute it. Passing both, or neither,
14
+ * is exit 1 — owner-directed-revalidation.test.ts › "refuses both modes at
15
+ * once: exit 1, stderr only, nothing journaled" and › "refuses neither mode:
16
+ * exit 1, and the message names both ways forward".
11
17
  *
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".
18
+ * Owner-directed mode runs the `main:<path>` comparison below and nothing
19
+ * else. It resolves no queue config, so no tracker or adapter is reached and
20
+ * no credential is needed owner-directed-revalidation.test.ts "needs no
21
+ * tracker credentials: an adapter name that cannot resolve is never reached".
22
+ * A `hold` there is the same exit 2 as the ticketed path's — › "HOLDs when the
23
+ * default branch moved under a path the branch touches".
24
+ *
25
+ * FOUR refusals keep it from becoming a way around the claim chain — exit 1,
26
+ * nothing journalled, each with its own test in that file:
27
+ *
28
+ * - an unresolved `revalidationHold` in this run's state, which is what the
29
+ * ticketed path writes when it holds or answers UNVERIFIABLE — › "refuses
30
+ * when this run carries an unresolved revalidation hold";
31
+ * - a take-up this run declares — › "refuses when the declared run already
32
+ * carries a take-up";
33
+ * - a tracked `.rig/claims/*.json` this branch touches, added, modified,
34
+ * removed or renamed — › "refuses when the branch diff adds a tracked claim
35
+ * record", › "refuses when the branch diff modifies a tracked claim record",
36
+ * › "refuses when the branch RENAMES a claim record — the case
37
+ * --diff-filter=AM could not see" and › "refuses when the branch DELETES its
38
+ * claim record";
39
+ * - `BEFORE_CLOSE` — › "refuses owner-directed at BEFORE_CLOSE — the mode
40
+ * exists for BEFORE_PR only".
41
+ *
42
+ * The first of those is the one this mode most needs, and the first version
43
+ * shipped without it: a ticketed call that had already held was re-run here and
44
+ * exited 0 with nothing in the repository changed.
45
+ *
46
+ * ⚠ Its limits, stated because the mode is a governance surface. What makes a
47
+ * call owner-directed is the CALLER's word plus those four refusals: nothing
48
+ * here can prove an item does not exist. Three specific gaps, each measured
49
+ * rather than reasoned:
50
+ *
51
+ * - with no `RIG_RUN_DIR` there is no run state, so the hold and take-up
52
+ * refusals have nothing to read and cannot fire — and nothing is journalled
53
+ * either. The result says so in `evidence.runState`, and the report on
54
+ * stdout says so out loud — › "says out loud that an undeclared run checked
55
+ * neither the hold nor the take-up".
56
+ * - the claim refusal reads the branch DIFF, so a claim record already on the
57
+ * default branch, or written but not committed, is not seen.
58
+ * - `--base` decides more here than the verdict. The claim comparison that
59
+ * would otherwise survive a wrong base is absent, AND the claim-touch refusal
60
+ * reads the same `mergeBase..HEAD` range — so `--base HEAD` empties the
61
+ * branch diff and disarms that refusal as well as reporting `continue`. Pass
62
+ * the up-to-date `origin/<default>`; `pr-ship` step 1 refreshes it, since
63
+ * this script never talks to a remote itself.
64
+ *
65
+ * One existing checkpoint chain, with one authoritative durable baseline:
66
+ *
67
+ * - `.rig/claims/<ticket>.json` carries versioned, content-blind `scope` and
68
+ * `commentary` fingerprint sets. Scope is authoritative at BEFORE_PR;
69
+ * commentary is observed but does not hold until BEFORE_CLOSE. Missing,
70
+ * untracked or unreadable claim state is `UNVERIFIABLE` and exits 2. So is a
71
+ * tracker whose adapter this script cannot READ (RP-64) — that one means the
72
+ * question was never put, rather than that the claim record is unreadable.
73
+ * ⚠ Reads, precisely: the queue CONFIG failing to resolve at all — an unknown
74
+ * adapter name, a malformed `queue.json` — is exit 1, the refusal path, not a
75
+ * hold, and it is the operator's to fix rather than a claim waiting on a
76
+ * tracker. Pinned in the generator's
77
+ * `test/template/revalidate-adapter.test.ts` — absent in a generated rig —
78
+ * › "refuses an adapter name it cannot resolve with a readable message, not a
79
+ * stack trace" and › "refuses a queue config that is not valid JSON with a
80
+ * readable message, not a stack trace".
16
81
  * - `main:<path>` — what the default branch changed since this branch forked
17
82
  * (`git merge-base <base> HEAD` … `<base>`), intersected with the CITED
18
83
  * paths. Cited is a labelled assumption, not a recorded fact: the paths the
@@ -20,33 +85,49 @@
20
85
  * record in this run's journal — the files the run said its premises rest
21
86
  * on. An unrelated change on the default branch does not hold.
22
87
  *
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
88
+ * `updatedAt` and `takeUps` are still projected into `task` as compatibility
89
+ * evidence, but never contribute a source or action.
90
+ *
91
+ * At BEFORE_CLOSE (AR-135) there is no main comparison: both claim fingerprint
92
+ * sets are authoritative. Workflow state is already inside `claim:scope`,
93
+ * normalised to the adapter's expected claimed state, so an expected claim
94
+ * transition stays current while a close or rollback moves scope. The item comes
28
95
  * from the adapter's `find`, which sees closed items where `listEligible`
29
96
  * 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`)
97
+ * holds on `claim:scope` (revalidate.test.ts › "holds on claim:scope when
98
+ * someone already closed the item", › "holds on claim:scope when the item was
99
+ * moved back to open"). The result lists the item's dependants (`blocks`)
33
100
  * and re-reads each one's state through the same `find` (revalidate.test.ts ›
34
101
  * "re-reads each dependant's state, and names one the tracker no longer
35
102
  * offers") for the loop's write-back.
36
103
  *
37
- * The aggregates are `queue/core.mjs` beforePrRevalidationOf and
38
- * beforeCloseRevalidationOf; this file is the I/O around them.
104
+ * `outcome --point <P> {--ticket <id> | --owner-directed} --action-changed
105
+ * true|false [--note …]` (AR-136, extended by RP-94) is the second half of the
106
+ * evidence: after the re-read, it appends a `revalidation-outcome` record whose
107
+ * `answers` is the seq of the latest matching `revalidation` at that point in
108
+ * this run — the join a report needs, made by the writer rather than guessed by
109
+ * the reader.
39
110
  *
40
- * `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
41
- * (AR-136) is the second half of the evidence: after the re-read, it appends a
42
- * `revalidation-outcome` record whose `answers` is the seq of the latest
43
- * `revalidation` for that ticket and point in this run the join a report
44
- * needs, made by the writer rather than guessed by the reader. It refuses
45
- * 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.
111
+ * **Which revalidation it matches depends on the mode, and the two never
112
+ * cross.** `--ticket` matches by key and skips owner-directed detections
113
+ * outright; `--owner-directed` matches by `mode`, because such a detection
114
+ * carries `ticket: null` and cannot be addressed by keyand a ticketed
115
+ * `--ticket null` must not answer it either. Pinned in the generator's
116
+ * `test/template/owner-directed-revalidation.test.ts` (absent in a generated
117
+ * rig) "answers an owner-directed hold with an owner-directed outcome",
118
+ * "refuses a ticketed outcome aimed at an owner-directed detection" and
119
+ * "refuses an owner-directed outcome when a ticketed hold is the only one this
120
+ * run carries — and leaves that hold latched".
121
+ *
122
+ * It refuses without a run, without a matching revalidation, and with any word
123
+ * but `true`/`false`, and writes nothing then. The typed resolution names the
124
+ * stable detection id and clears only the matching run-level hold — and only a
125
+ * hold whose id it actually names, which is why an owner-directed outcome
126
+ * cannot release a ticketed one. Exit 2 on `hold` or `unverifiable`, 0 on
127
+ * `continue`, and 1 when the call cannot be acted on (unknown point, neither
128
+ * mode or both, a base that is not a revision — or, on the paths that reach it,
129
+ * a queue config that does not resolve) — and then nothing is journalled,
130
+ * because a refusal is not an answer.
50
131
  *
51
132
  * ⚠ It reads `<base>` as it is in this checkout and never updates the remote
52
133
  * ref itself; `pr-ship` step 1 does that before calling this. A stale ref
@@ -56,15 +137,30 @@
56
137
  */
57
138
 
58
139
  import { execFileSync } from 'node:child_process';
140
+ import { createHash } from 'node:crypto';
59
141
  import { realpathSync } from 'node:fs';
60
142
  import { dirname, join } from 'node:path';
61
143
  import { fileURLToPath } from 'node:url';
62
144
  import { withoutGitLocation } from './git-env.mjs';
63
145
  import { readRun, recordEvent } from './run-journal.mjs';
64
- import { readState } from './run-state.mjs';
146
+ import {
147
+ clearRevalidationHold,
148
+ readState,
149
+ readStateForSelection,
150
+ recordRevalidationHold,
151
+ } from './run-state.mjs';
65
152
  import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
66
- import { beforeCloseRevalidationOf, beforePrRevalidationOf, revalidationOf } from './queue/core.mjs';
153
+ import { takeUpEvidenceOf } from './queue/core.mjs';
67
154
  import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
155
+ import { projectRootOfConfig } from './queue/index.mjs';
156
+ import {
157
+ CLAIM_SCHEMA_VERSION,
158
+ revalidateClaim,
159
+ targetShaOf,
160
+ unverifiableResult,
161
+ withAdditionalDrift,
162
+ } from './lib/claim-records.mjs';
163
+ import { DEFAULT_SCAN_LIMIT, findSecretValues } from './lib/secrets.mjs';
68
164
 
69
165
  // Derived from the one source, never restated here (AR-137).
70
166
  export const POINTS = REVALIDATES;
@@ -77,6 +173,7 @@ const parseArgs = (argv) => {
77
173
  outcome: false,
78
174
  point: null,
79
175
  ticket: null,
176
+ ownerDirected: false,
80
177
  base: 'origin/master',
81
178
  config: null,
82
179
  json: false,
@@ -87,6 +184,7 @@ const parseArgs = (argv) => {
87
184
  for (let i = 0; i < argv.length; i += 1) {
88
185
  const arg = argv[i];
89
186
  if (i === 0 && arg === 'outcome') args.outcome = true;
187
+ else if (arg === '--owner-directed') args.ownerDirected = true;
90
188
  else if (arg === '--json') args.json = true;
91
189
  else if (arg === '--action-changed') args.actionChanged = argv[++i] ?? null;
92
190
  else if (arg === '--note') args.note = argv[++i] ?? null;
@@ -132,7 +230,7 @@ const citedByPremises = (runDir) => {
132
230
  .filter((file) => typeof file === 'string' && file !== '');
133
231
  };
134
232
 
135
- /** The last validation's marker for this item: the latest revalidation event, any point. */
233
+ /** The last compatibility marker observed for this item, at any revalidation point. */
136
234
  const lastValidationOf = (runDir, id) => {
137
235
  if (!runDir) return null;
138
236
  const { events } = readRun({ runDir });
@@ -155,6 +253,223 @@ const invokedDirectly = () => {
155
253
  return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
156
254
  };
157
255
 
256
+ /**
257
+ * The adapter's own message, unless it carries something credential-shaped.
258
+ *
259
+ * The messages this is written for name environment VARIABLES rather than their
260
+ * values, and naming them is exactly what the caller acts on. But an adapter is
261
+ * free to put a URL or a response body into a message, and this text is
262
+ * published to stdout and into a verdict a run journals — neither of which
263
+ * `guard-secret-file` or `validate-no-secrets` can see — so it is checked
264
+ * against the one credential vocabulary this repository has before it goes
265
+ * anywhere.
266
+ *
267
+ * ⚠ **It is defence in depth, not a general redacter.** Measured on the shapes
268
+ * an adapter could plausibly produce: an Atlassian token value, an opaque token
269
+ * after a credential keyword, and a token in a query string are caught by
270
+ * `findSecretValues`; `Authorization: Bearer <opaque>` and `Authorization: Basic
271
+ * <base64>` are NOT, and no shape outside the vocabulary is.
272
+ *
273
+ * 🔴 **URL userinfo is matched HERE rather than left to the vocabulary, because
274
+ * it is reachable through the adapter this repository configures.** `jira.mjs`
275
+ * builds its own errors from method, route and status — but its network arm
276
+ * re-raises the underlying error untouched, and `requireCredentials` accepts any
277
+ * `JIRA_BASE_URL` that begins with `https://`, userinfo included. Undici then
278
+ * throws "Request cannot be constructed from a URL that includes credentials:
279
+ * https://user:<password>@host/…". `findSecretValues` does not see that shape,
280
+ * and unlike the pre-RP-64 crash — which put it on stderr — this path PERSISTS
281
+ * it into the run journal. So the reason is withheld on userinfo as well.
282
+ *
283
+ * An earlier version of this comment argued the blind spots were acceptable
284
+ * because no adapter here produces them. That was false, and resting a safety
285
+ * property on a claim about every present and future adapter is the wrong shape
286
+ * of argument regardless.
287
+ *
288
+ * All-or-nothing on purpose: `findSecretValues` never returns the matched text,
289
+ * so redacting in place would need a second matcher, and a partial redacter is
290
+ * where redacters leak.
291
+ *
292
+ * Exported so the control itself is testable rather than only reachable through
293
+ * a subprocess. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
294
+ * — absent in a generated rig — › "publishes a message that names only environment variables"
295
+ * and › "withholds a message carrying a credential-shaped value".
296
+ */
297
+ /**
298
+ * Userinfo present in a URL at all — `//<anything but a slash or space>@host`.
299
+ * One forward pass, one negated bounded class, so it cannot backtrack.
300
+ *
301
+ * 🔴 It matches the CLASS, not a list of spellings, and that is the whole
302
+ * lesson of how it got here. It first required `user:pass@`, which published
303
+ * `//<token>@host`. Widened to make the password optional, it published
304
+ * `//:<token>@host` — the shape `https://${JIRA_EMAIL}:${JIRA_API_TOKEN}@host`
305
+ * degrades to when the first variable is unset, so the likeliest accident of
306
+ * the three. Two rounds of enumerating forms; the invariant was always "there
307
+ * is userinfo here", and it is shorter than any enumeration of it.
308
+ *
309
+ * The class excludes `/` and whitespace, which is what keeps an ordinary URL,
310
+ * a bare email address in prose, and a registry path carrying an `@scope`
311
+ * published. Pinned in the generator's `test/template/revalidate-adapter.test.ts`
312
+ * — absent in a generated rig — › "withholds a URL whose userinfo is %s — every
313
+ * shape, not the ones enumerated so far" and › "still publishes a message
314
+ * carrying %s", which are tables rather than cases so a future narrowing that
315
+ * handles the known spellings and reopens the class goes red.
316
+ */
317
+ const URL_USERINFO = /\/\/[^\s/@]*@/;
318
+
319
+ export const safeReason = (text) => {
320
+ // Scan and publish the SAME prefix: findSecretValues reads at most
321
+ // DEFAULT_SCAN_LIMIT, and publishing more than was scanned would ship the
322
+ // unscanned tail verbatim.
323
+ const scanned = String(text ?? '').slice(0, DEFAULT_SCAN_LIMIT);
324
+ return findSecretValues(scanned).length === 0 && !URL_USERINFO.test(scanned)
325
+ ? scanned
326
+ : 'the queue adapter could not be read; its message is withheld because it carries a credential-shaped value';
327
+ };
328
+
329
+ /**
330
+ * The revalidation boundary for a tracker that cannot be read (RP-64).
331
+ *
332
+ * 🔴 It answers `UNVERIFIABLE` and exits 2 — the same hold path a real drift
333
+ * takes, and never 0. "The adapter was unreachable" is not evidence that the
334
+ * branch is still the branch the run took up, and a caller that read it as a
335
+ * pass would carry an unchecked claim into a PR. A drift the adapter DID report
336
+ * still comes back as `hold`, and an unchanged claim still as `continue`;
337
+ * this only replaces the crash. Pinned in the generator's
338
+ * `test/template/revalidate-adapter.test.ts` — absent in a generated rig — ›
339
+ * "holds the same way at BEFORE_CLOSE, which reads the adapter through a different call"
340
+ * and › "still refuses an unusable invocation as before — this did not swallow argument errors".
341
+ *
342
+ * The detection `identity` names the point and the operation and NOT the
343
+ * message, so it is stable across retries of the same outage — which also means
344
+ * two unrelated failures at one operation share an id, and one `outcome`
345
+ * answers both.
346
+ */
347
+ const answerUnverifiable = ({ runDir, ticket, point, json }, operation, cause) => {
348
+ const result = unverifiableResult({
349
+ ticket: { id: ticket },
350
+ point,
351
+ reason: safeReason(
352
+ `the queue adapter could not be read (${operation}): ${cause?.message ?? cause}`,
353
+ ),
354
+ identity: `adapter-unreadable:${operation}`,
355
+ });
356
+ if (runDir) {
357
+ // Announce, never throw: a stale or unwritable RIG_RUN_DIR made these throw
358
+ // INSIDE the catch that was handling the adapter failure, and the run ended
359
+ // on the Node stack trace this whole change exists to remove.
360
+ try {
361
+ recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
362
+ recordRevalidationHold(runDir, result);
363
+ } catch (error) {
364
+ process.stderr.write(
365
+ `could not journal this revalidation into ${runDir}: ${error?.message ?? error}\n`,
366
+ );
367
+ }
368
+ }
369
+ if (json) {
370
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
371
+ } else {
372
+ process.stdout.write(
373
+ `revalidate ${point}: ${ticket} unverifiable — the queue adapter could not be read (${operation})\n`,
374
+ );
375
+ process.stdout.write(` ${result.evidence.error}\n`);
376
+ process.stdout.write(
377
+ ' this is NOT a pass: nothing about the claim was observed. Fix the adapter and run it again.\n',
378
+ );
379
+ }
380
+ process.exit(2);
381
+ };
382
+
383
+ /**
384
+ * A tracked claim record, by repository-relative path (RP-94).
385
+ *
386
+ * ⚠ It is a SECOND spelling of the path `claimPathFor` builds, and the two are
387
+ * kept in step by `test/template/owner-directed-revalidation.test.ts` (absent
388
+ * in a generated rig) › "matches the path claimPathFor actually builds, from
389
+ * the repository root and from a nested rig root" — a correspondence check
390
+ * rather than a comment asking the next reader to remember. The leading
391
+ * `(^|/)` is why a rig whose root sits below the git root is still matched;
392
+ * this mode resolves no queue config, so it cannot ask where that root is.
393
+ */
394
+ const CLAIM_RECORD = /(^|\/)\.rig\/claims\/[^/]+\.json$/;
395
+
396
+ const OWNER_DIRECTED = 'owner-directed';
397
+
398
+ /**
399
+ * The BEFORE_PR verdict for work that has no item (RP-94).
400
+ *
401
+ * It carries the SAME `main:<path>` drift decision the ticketed path reaches —
402
+ * this mode drops the claim comparison because there is no claim, and drops
403
+ * nothing else. `ticket` is `null` rather than a placeholder: a record naming
404
+ * an item that does not exist is worse than one that admits it has none, and
405
+ * every reader downstream distinguishes them by that field.
406
+ *
407
+ * Pinned in the generator's `test/template/owner-directed-revalidation.test.ts`
408
+ * — absent in a generated rig — › "runs BEFORE_PR with no item and no claim
409
+ * when the default branch did not move under the branch" and › "HOLDs when the
410
+ * default branch moved under a path the branch touches".
411
+ */
412
+ const ownerDirectedResult = ({ point, base, mergeBase, cited, changed, now, runDeclared }) => {
413
+ const source = changed.map((path) => `main:${path}`);
414
+ const held = source.length > 0;
415
+ return {
416
+ schemaVersion: CLAIM_SCHEMA_VERSION,
417
+ // 🔴 `mergeBase` is in the hash, and it is what keeps this id from being a
418
+ // CONSTANT. Without it the digest was `{mode, point, source}` alone — the
419
+ // same value for every `continue` that has ever run, and the same for any
420
+ // two holds naming the same paths. `revalidation-report.mjs` flattens every
421
+ // run into one typed-resolution index, so one `--action-changed false`
422
+ // recorded last week would mark a genuine hold today as already answered:
423
+ // the metric the report exists to produce, quietly wrong. It stays stable
424
+ // across RETRIES of the same checkpoint on the same branch, which is the
425
+ // property `answerUnverifiable`'s identity has and the one that matters.
426
+ id: createHash('sha256')
427
+ .update(JSON.stringify({ mode: OWNER_DIRECTED, point, mergeBase, source }))
428
+ .digest('hex'),
429
+ ticket: null,
430
+ mode: OWNER_DIRECTED,
431
+ point,
432
+ checkpoint: point,
433
+ result: held ? 'CHANGED' : 'CURRENT',
434
+ changed: held,
435
+ source,
436
+ action: held ? 'hold' : 'continue',
437
+ movedFingerprintSet: [],
438
+ sourcePointer: null,
439
+ evidence: {
440
+ claim: 'not compared: owner-directed work has no item, so there is no claim record',
441
+ tracker: 'not read: owner-directed mode resolves no queue adapter',
442
+ // 🔴 "Could not check" is recorded as itself, never as "checked and
443
+ // clean". With no run directory the hold and take-up refusals have
444
+ // nothing to read, and a reader who saw only `continue` would take the
445
+ // pair for having passed.
446
+ runState: runDeclared
447
+ ? 'read fail-closed: no unresolved revalidation hold, no declared take-up'
448
+ : 'NOT read: no RIG_RUN_DIR, so neither the revalidation hold nor the take-up was checked',
449
+ },
450
+ observedAt: now,
451
+ task: { changed: null, from: null, to: null },
452
+ main: { base, mergeBase, cited, changed },
453
+ };
454
+ };
455
+
456
+ /** Every adapter call in this script goes through here, or it can still crash. */
457
+ const readAdapter = async (operation, read, context) => {
458
+ try {
459
+ return await read();
460
+ } catch (error) {
461
+ answerUnverifiable(context, operation, error);
462
+ }
463
+ // Reached only if answerUnverifiable failed to exit. Throwing rather than
464
+ // returning null keeps a null ticket from reaching revalidateClaim and
465
+ // resolving to `continue` — the silent pass this whole change forbids.
466
+ //
467
+ // Outside the catch on purpose, and it carries no `cause`: the caught error
468
+ // is the raw adapter message, the one thing the frame above exists to
469
+ // withhold, and Node's uncaught printer walks a cause chain.
470
+ throw new Error('unreachable: answerUnverifiable did not exit');
471
+ };
472
+
158
473
  const refuse = (message) => {
159
474
  process.stderr.write(`${message}\n`);
160
475
  process.exit(1);
@@ -170,7 +485,31 @@ if (invokedDirectly()) {
170
485
  if (!known.includes(args.point)) {
171
486
  refuse(`unknown point: ${args.point ?? '(none)'}. This script knows ${known.join(', ')}.`);
172
487
  }
173
- if (!args.ticket) refuse('--ticket is required: the item whose take-up this branch is.');
488
+ // RP-94. BEFORE_PR has two modes, and neither is ever inferred. `pr-ship`
489
+ // already named owner-directed work with no item as a legitimate path while
490
+ // this script refused every call without `--ticket`, so that path could not
491
+ // be walked at all. The mode is now stated at the call site — silence is a
492
+ // refusal, not a default, because a mode chosen by absence is a mode nobody
493
+ // reviewed.
494
+ if (args.ticket && args.ownerDirected) {
495
+ refuse(
496
+ '--ticket and --owner-directed are mutually exclusive: a branch that is an item\'s ' +
497
+ 'take-up is not owner-directed work. Pass exactly one.',
498
+ );
499
+ }
500
+ if (args.ownerDirected) {
501
+ if (args.point !== 'BEFORE_PR') {
502
+ refuse(
503
+ `--owner-directed is a BEFORE_PR mode only; ${args.point} compares the claim record ` +
504
+ 'itself and still needs --ticket.',
505
+ );
506
+ }
507
+ } else if (!args.ticket) {
508
+ refuse(
509
+ '--ticket is required: the item whose take-up this branch is. For owner-directed ' +
510
+ 'work or a hotfix that has no item, pass --owner-directed instead.',
511
+ );
512
+ }
174
513
 
175
514
  const runDir = process.env.RIG_RUN_DIR || null;
176
515
 
@@ -180,49 +519,260 @@ if (invokedDirectly()) {
180
519
  refuse(`--action-changed must be true or false, got ${args.actionChanged ?? '(none)'}.`);
181
520
  }
182
521
  const { events } = readRun({ runDir });
522
+ // RP-94. An owner-directed detection carries `ticket: null`, so it cannot
523
+ // be addressed by key — `String(null)` would also match a literal ticket
524
+ // named "null". It is addressed by MODE instead, which is the only thing
525
+ // that distinguishes it. Without this, a `hold` the owner-directed path
526
+ // returned had no way to be answered at all: the skill's stated exit-2
527
+ // remedy was a command the script refused, which is the same shape of
528
+ // contradiction RP-94 exists to remove.
529
+ const subject = args.ownerDirected ? `${OWNER_DIRECTED} work` : args.ticket;
183
530
  const target = [...events]
184
531
  .reverse()
185
532
  .find(
186
533
  (e) =>
187
534
  e.kind === 'revalidation' &&
188
- String(e.data?.ticket) === String(args.ticket) &&
189
- e.data?.point === args.point,
535
+ e.data?.point === args.point &&
536
+ (args.ownerDirected
537
+ ? e.data?.mode === OWNER_DIRECTED
538
+ : e.data?.mode !== OWNER_DIRECTED &&
539
+ String(e.data?.ticket) === String(args.ticket)),
190
540
  );
191
541
  if (!target) {
192
- refuse(`no revalidation of ${args.ticket} at ${args.point} in ${runDir} for this outcome to answer.`);
542
+ refuse(`no revalidation of ${subject} at ${args.point} in ${runDir} for this outcome to answer.`);
193
543
  }
544
+ const now = new Date().toISOString();
545
+ const actionRequired = args.actionChanged === 'true';
194
546
  const record = recordEvent({
195
547
  runDir,
196
548
  kind: 'revalidation-outcome',
197
549
  data: {
198
- ticket: args.ticket,
550
+ detectionId: target.data?.id,
551
+ action: actionRequired ? 'semantic decision' : 'continue',
552
+ actionRequired,
553
+ driftOrigin: 'unknown',
554
+ resolvedAt: now,
555
+ // `null`, never a placeholder, for the same reason the detection this
556
+ // answers carries none.
557
+ ticket: args.ownerDirected ? null : args.ticket,
558
+ ...(args.ownerDirected ? { mode: OWNER_DIRECTED } : {}),
199
559
  point: args.point,
200
- actionChanged: args.actionChanged === 'true',
560
+ actionChanged: actionRequired,
201
561
  note: args.note,
202
562
  answers: target.seq,
203
563
  },
204
- now: new Date().toISOString(),
564
+ now,
205
565
  });
566
+ clearRevalidationHold(runDir, target.data?.id);
206
567
  process.stdout.write(
207
568
  args.json
208
569
  ? `${JSON.stringify(record, null, 2)}\n`
209
- : `revalidation-outcome: ${args.ticket} at ${args.point} answers seq ${target.seq} — actionChanged ${args.actionChanged}\n`,
570
+ : `revalidation-outcome: ${subject} at ${args.point} answers seq ${target.seq} — actionChanged ${args.actionChanged}\n`,
210
571
  );
211
572
  process.exit(0);
212
573
  }
213
574
 
575
+ if (args.ownerDirected) {
576
+ // RP-94. BEFORE_PR for work that has no item: the same main-vs-branch drift
577
+ // comparison, with no tracker, no adapter and no claim record.
578
+ //
579
+ // 🔴 It sits ABOVE the queue-config resolution on purpose. "This mode reads
580
+ // no tracker" has to be a property of the control flow rather than a
581
+ // promise in a comment — nothing below this block runs, so a missing
582
+ // credential or an adapter name that does not resolve cannot reach it.
583
+ // Pinned by › "needs no tracker credentials: an adapter name that cannot
584
+ // resolve is never reached".
585
+ //
586
+ // The refusals below are what keep the mode from becoming a way around the
587
+ // claim chain, and every one is decided BEFORE anything is journalled: a
588
+ // refusal is not an answer, so it leaves no revalidation record behind.
589
+ //
590
+ // 🔴 Read FAIL-CLOSED. `readState` is the permissive reader and its own
591
+ // header forbids exactly this use — "a corrupt file there may be hiding a
592
+ // persisted stop" — so an unreadable run refuses here instead of reading as
593
+ // "this run declares nothing". The first version used `readState`, and a
594
+ // truncated `state.json` carrying a take-up was measured continuing.
595
+ let state = {};
596
+ if (runDir) {
597
+ try {
598
+ state = readStateForSelection(runDir);
599
+ } catch (error) {
600
+ refuse(
601
+ `--owner-directed refused: this run's state could not be read (${runDir}): ` +
602
+ `${error.message}. An unreadable run may be hiding a stop.`,
603
+ );
604
+ }
605
+ }
606
+
607
+ // 🔴 The refusal this mode most needs, and the one its first version did
608
+ // not have. A ticketed BEFORE_PR that holds or comes back UNVERIFIABLE
609
+ // latches `revalidationHold`; re-running the same checkpoint here was
610
+ // MEASURED exiting 0 with nothing in the repository changed — the exact
611
+ // bypass RP-94 names. Neither other refusal can fire in that state, and
612
+ // that is structural rather than unlucky: `takeUps` is never populated on
613
+ // the default `plan-md` adapter at all, and the commonest hold is a
614
+ // MISSING claim record, which is precisely when the branch writes none.
615
+ // This one is adapter-independent because `recordRevalidationHold` is.
616
+ const hold = state.revalidationHold;
617
+ if (hold) {
618
+ // A malformed hold still refuses — it is a hold either way — but it must
619
+ // not print `undefined at undefined`, which reads as a broken command
620
+ // rather than as the stop it is.
621
+ const describe = (value, fallback) => (typeof value === 'string' ? value : fallback);
622
+ refuse(
623
+ `--owner-directed refused: this run carries an unresolved revalidation hold ` +
624
+ `(${describe(hold.ticket, 'an unnamed item')} at ` +
625
+ `${describe(hold.checkpoint, 'an unnamed checkpoint')}, ` +
626
+ `${describe(hold.result, 'result unrecorded')}, detection ` +
627
+ `${describe(hold.detectionId, 'unrecorded')}). ` +
628
+ 'Resolve it with `revalidate.mjs outcome`; re-running the checkpoint in the other ' +
629
+ 'mode is not a resolution, it is the bypass this mode refuses.',
630
+ );
631
+ }
632
+
633
+ const takeUps = state.takeUps;
634
+ // A present-but-unreadable take-up record is a refusal, not an empty one:
635
+ // `Object.keys` answers `[]` for a number and for a list, which would turn
636
+ // "this cannot be read" into "there is nothing here".
637
+ if (
638
+ takeUps !== undefined &&
639
+ (typeof takeUps !== 'object' || takeUps === null || Array.isArray(takeUps))
640
+ ) {
641
+ refuse(
642
+ `--owner-directed refused: this run's take-up record is not readable ` +
643
+ `(takeUps is ${Array.isArray(takeUps) ? 'a list' : typeof takeUps}, expected an object).`,
644
+ );
645
+ }
646
+ const takenUp = Object.keys(takeUps ?? {});
647
+ if (takenUp.length > 0) {
648
+ refuse(
649
+ `--owner-directed refused: this run already declares a take-up (${takenUp.join(', ')}). ` +
650
+ 'A run holding an item revalidates with --ticket; owner-directed is for work with none.',
651
+ );
652
+ }
653
+
654
+ let mergeBase;
655
+ try {
656
+ mergeBase = git(['merge-base', args.base, 'HEAD']).trim();
657
+ } catch (error) {
658
+ refuse(
659
+ `--base ${args.base} is not a revision this checkout can compare against: ${error.message}`,
660
+ );
661
+ }
662
+
663
+ // Any claim record this branch TOUCHES, in either direction.
664
+ //
665
+ // 🔴 `--no-renames`, and no `--diff-filter`, because both narrower forms
666
+ // were measured letting a claim record through. `--diff-filter=AM` reports
667
+ // NOTHING for `git mv .rig/claims/RP-1.json .rig/claims/RP-2.json` — git
668
+ // calls it `R100` — so a branch that demonstrably ends up carrying a claim
669
+ // record passed the check. And a branch that DELETES its claim makes the
670
+ // ticketed call `UNVERIFIABLE`, so excluding `D` left the deletion on the
671
+ // bypass path rather than out of scope. `--no-renames` splits a rename back
672
+ // into its delete and its add, which is what puts both halves in front of
673
+ // the filter. Match the CLASS — "this branch touched the claim store" —
674
+ // rather than enumerating the statuses that class can wear.
675
+ const claimsTouched = pathsOf(
676
+ git(['diff', '--name-only', '--no-renames', '-z', mergeBase, 'HEAD']),
677
+ ).filter((path) => CLAIM_RECORD.test(path));
678
+ if (claimsTouched.length > 0) {
679
+ refuse(
680
+ `--owner-directed refused: this branch touches tracked claim records ` +
681
+ `(${claimsTouched.join(', ')}). A branch that writes, moves or removes a claim is ` +
682
+ "an item's take-up; revalidate it with --ticket.",
683
+ );
684
+ }
685
+
686
+ const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
687
+ const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
688
+ const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
689
+ const mainChanged = mainPaths.filter((path) => cited.includes(path));
690
+ const result = ownerDirectedResult({
691
+ point: args.point,
692
+ base: args.base,
693
+ mergeBase,
694
+ cited,
695
+ changed: mainChanged,
696
+ now: new Date().toISOString(),
697
+ runDeclared: Boolean(runDir),
698
+ });
699
+
700
+ if (runDir) {
701
+ recordEvent({ runDir, kind: 'revalidation', data: result, now: result.observedAt });
702
+ // 🔴 No run-level revalidation hold is recorded here, and the reason is
703
+ // NOT the one first written down. That said "a hold written here could
704
+ // never be cleared" — which stopped being true the moment `outcome
705
+ // --owner-directed` computed the very id `clearRevalidationHold` matches
706
+ // on. The real reason is narrower: `recordRevalidationHold` requires a
707
+ // string ticket, and this mode has none to give it without inventing
708
+ // one, which is the thing the whole mode refuses to do.
709
+ //
710
+ // ⚠ **State the asymmetry rather than let a reader assume symmetry.** A
711
+ // ticketed hold has TWO stops — the exit code, and a latch that
712
+ // `queue/index.mjs` and `unresolvedBlockingDetectionOf` both read (the
713
+ // latter also requires a string ticket, so it skips this one). An
714
+ // owner-directed hold has ONE: the exit code below. A caller who ignores
715
+ // it is not stopped a second time. Widening the latch to a ticketless
716
+ // hold is a run-state schema change — Tier 2, and not this hotfix's.
717
+ }
718
+
719
+ if (args.json) {
720
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
721
+ } else {
722
+ const detail = result.source.length > 0 ? ` — ${result.source.join(', ')}` : '';
723
+ process.stdout.write(
724
+ `revalidate ${args.point}: ${OWNER_DIRECTED} (no item) ${result.action}${detail}\n`,
725
+ );
726
+ if (!runDir) {
727
+ // Loud, on the normal path, not only in the JSON: a run this command
728
+ // could not inspect must not read as a run it inspected and cleared.
729
+ process.stdout.write(
730
+ ' ⚠ no RIG_RUN_DIR: the revalidation-hold and take-up refusals were NOT checked,\n' +
731
+ ' and nothing was journalled. This is not evidence that neither exists.\n',
732
+ );
733
+ }
734
+ if (result.action === 'hold') {
735
+ process.stdout.write(
736
+ ' re-read the default branch on those paths before opening or updating the PR,\n' +
737
+ ' then record what the re-read concluded:\n' +
738
+ ` node .claude/scripts/revalidate.mjs outcome --point ${args.point} --owner-directed --action-changed <true | false>\n`,
739
+ );
740
+ }
741
+ }
742
+ process.exit(result.action === 'hold' ? 2 : 0);
743
+ }
744
+
214
745
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
215
746
  const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
216
- const config = loadConfig(configPath);
217
- const adapter = await resolveAdapter(config.adapter ?? 'plan-md');
747
+ // 🔴 The queue CONFIG, not the tracker behind it. `readAdapter` covers every
748
+ // adapter CALL, but resolving the config sat outside it, so an unknown
749
+ // adapter name or a malformed `queue.json` still crashed with the raw Node
750
+ // stack trace this script exists to remove — and the `[cause]` chain of the
751
+ // malformed case printed the parse error underneath it.
752
+ //
753
+ // It stays exit 1 rather than becoming `UNVERIFIABLE`: a config the operator
754
+ // has to fix is the command refusing, not a claim held pending a tracker
755
+ // that might come back. `refuse` is the path this file already uses for that.
756
+ let config;
757
+ let adapter;
758
+ try {
759
+ config = loadConfig(configPath);
760
+ adapter = await resolveAdapter(config.adapter ?? 'plan-md');
761
+ } catch (error) {
762
+ refuse(
763
+ `the queue configuration at ${configPath} could not be resolved: ` +
764
+ safeReason(error?.message ?? String(error)),
765
+ );
766
+ }
218
767
  const options = optionsWithPlanPath(config.options, configPath);
768
+ const claimRoot = projectRootOfConfig(configPath) ?? projectRoot;
219
769
 
220
770
  if (args.point === 'BEFORE_CLOSE') {
221
- const ticket = await adapter.find(args.ticket, options);
771
+ const context = { runDir, ticket: args.ticket, point: args.point, json: args.json };
772
+ const ticket = await readAdapter('find', () => adapter.find(args.ticket, options), context);
222
773
  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.
774
+ // Preserve the newest compatibility marker as evidence. This comparison
775
+ // never decides drift; the durable claim below is the authority.
226
776
  // ISO strings compare as text; a missing side yields to the other.
227
777
  const lastValidation = lastValidationOf(runDir, args.ticket);
228
778
  const baseline =
@@ -233,7 +783,7 @@ if (invokedDirectly()) {
233
783
  : (lastValidation ?? takeUp);
234
784
  const task =
235
785
  ticket && baseline !== null
236
- ? revalidationOf({ ticket, snapshot: baseline })
786
+ ? takeUpEvidenceOf({ ticket, snapshot: baseline })
237
787
  : { changed: null, task: { from: baseline, to: ticket?.updatedAt ?? null } };
238
788
  // Not found is not "in progress": the tracker no longer offers the item.
239
789
  const actual = ticket ? ticket.state : 'missing';
@@ -243,18 +793,33 @@ if (invokedDirectly()) {
243
793
  const dependants = Array.isArray(ticket?.blocks) ? ticket.blocks : [];
244
794
  const dependantState = {};
245
795
  for (const dependant of dependants) {
246
- dependantState[dependant] = (await adapter.find(dependant, options))?.state ?? 'missing';
796
+ dependantState[dependant] =
797
+ (await readAdapter('find dependant', () => adapter.find(dependant, options), context))
798
+ ?.state ?? 'missing';
247
799
  }
248
- const aggregate = beforeCloseRevalidationOf({ ticket: args.ticket, task, state: actual });
800
+ const claim = revalidateClaim({
801
+ projectRoot: claimRoot,
802
+ ticket: ticket ?? { id: args.ticket },
803
+ point: 'BEFORE_CLOSE',
804
+ claimedState: adapter.claimedState,
805
+ // Close has no caller-selected comparison base. Resolve the same default
806
+ // target SELECT pinned, so a missing `origin/master` cannot turn an
807
+ // otherwise current local rig into claim:scope drift.
808
+ targetSha: targetShaOf(claimRoot),
809
+ });
249
810
  const result = {
250
- ...aggregate,
811
+ ...claim,
812
+ observedAt: new Date().toISOString(),
251
813
  task: { changed: task.changed, from: task.task.from, to: task.task.to },
252
- state: { expected: 'in-progress', actual },
814
+ state: { expected: adapter.claimedState, actual },
253
815
  dependants,
254
816
  dependantState,
255
817
  };
256
818
  if (runDir) {
257
819
  recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
820
+ if (result.action === 'hold' || result.action === 'unverifiable') {
821
+ recordRevalidationHold(runDir, result);
822
+ }
258
823
  }
259
824
  if (args.json) {
260
825
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -265,7 +830,7 @@ if (invokedDirectly()) {
265
830
  process.stdout.write(' re-read the item before closing it; a late change is not published as Done.\n');
266
831
  }
267
832
  }
268
- process.exit(result.action === 'hold' ? 2 : 0);
833
+ process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
269
834
  }
270
835
 
271
836
  let mergeBase;
@@ -275,33 +840,49 @@ if (invokedDirectly()) {
275
840
  refuse(`--base ${args.base} is not a revision this checkout can compare against: ${error.message}`);
276
841
  }
277
842
 
278
- const tickets = await adapter.listEligible(options);
843
+ const tickets = await readAdapter('listEligible', () => adapter.listEligible(options), {
844
+ runDir,
845
+ ticket: args.ticket,
846
+ point: args.point,
847
+ json: args.json,
848
+ });
279
849
  const ticket = tickets.find((candidate) => String(candidate.id) === String(args.ticket)) ?? null;
280
850
 
281
851
  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`.
852
+ // BEFORE_PR reports this run's marker snapshot as compatibility evidence.
853
+ // A missing marker is evidence that cannot be compared, but it does not make
854
+ // the authoritative claim unverifiable; `revalidateClaim` decides that.
288
855
  const unverifiable = { changed: null, task: { from: snapshot, to: ticket?.updatedAt ?? null } };
289
- const task = ticket && snapshot !== null ? revalidationOf({ ticket, snapshot }) : unverifiable;
856
+ const task = ticket && snapshot !== null ? takeUpEvidenceOf({ ticket, snapshot }) : unverifiable;
290
857
 
291
858
  const branchPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, 'HEAD']));
292
859
  const mainPaths = pathsOf(git(['diff', '--name-only', '-z', mergeBase, args.base]));
293
860
  const cited = [...new Set([...branchPaths, ...citedByPremises(runDir)])];
294
861
  const mainChanged = mainPaths.filter((path) => cited.includes(path));
295
862
 
296
- const aggregate = beforePrRevalidationOf({ ticket: args.ticket, task, mainChanged });
863
+ const claim = revalidateClaim({
864
+ projectRoot: claimRoot,
865
+ ticket: ticket ?? { id: args.ticket },
866
+ point: 'BEFORE_PR',
867
+ claimedState: adapter.claimedState,
868
+ targetSha: targetShaOf(claimRoot, args.base),
869
+ });
870
+ const aggregate = withAdditionalDrift(
871
+ claim,
872
+ mainChanged.map((path) => `main:${path}`),
873
+ );
297
874
  const result = {
298
875
  ...aggregate,
876
+ observedAt: new Date().toISOString(),
299
877
  task: { changed: task.changed, from: task.task.from, to: task.task.to },
300
878
  main: { base: args.base, mergeBase, cited, changed: mainChanged },
301
879
  };
302
880
 
303
881
  if (runDir) {
304
882
  recordEvent({ runDir, kind: 'revalidation', data: result, now: new Date().toISOString() });
883
+ if (result.action === 'hold' || result.action === 'unverifiable') {
884
+ recordRevalidationHold(runDir, result);
885
+ }
305
886
  }
306
887
 
307
888
  if (args.json) {
@@ -313,5 +894,5 @@ if (invokedDirectly()) {
313
894
  process.stdout.write(' re-read the item and the default branch before opening or updating the PR.\n');
314
895
  }
315
896
  }
316
- process.exit(result.action === 'hold' ? 2 : 0);
897
+ process.exit(result.action === 'hold' || result.action === 'unverifiable' ? 2 : 0);
317
898
  }