create-agent-rig 0.7.0 → 0.8.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.
@@ -5,6 +5,62 @@
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]
9
+ *
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".
17
+ *
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.
8
64
  *
9
65
  * One existing checkpoint chain, with one authoritative durable baseline:
10
66
  *
@@ -45,18 +101,33 @@
45
101
  * "re-reads each dependant's state, and names one the tracker no longer
46
102
  * offers") for the loop's write-back.
47
103
  *
48
- * `outcome --point <P> --ticket <id> --action-changed true|false [--note …]`
49
- * (AR-136) is the second half of the evidence: after the re-read, it appends a
50
- * `revalidation-outcome` record whose `answers` is the seq of the latest
51
- * `revalidation` for that ticket and point in this run — the join a report
52
- * needs, made by the writer rather than guessed by the reader. It refuses
53
- * without a run, without a matching revalidation, and with any word but
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.
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.
110
+ *
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 key — and 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.
60
131
  *
61
132
  * ⚠ It reads `<base>` as it is in this checkout and never updates the remote
62
133
  * ref itself; `pr-ship` step 1 does that before calling this. A stale ref
@@ -66,17 +137,24 @@
66
137
  */
67
138
 
68
139
  import { execFileSync } from 'node:child_process';
140
+ import { createHash } from 'node:crypto';
69
141
  import { realpathSync } from 'node:fs';
70
142
  import { dirname, join } from 'node:path';
71
143
  import { fileURLToPath } from 'node:url';
72
144
  import { withoutGitLocation } from './git-env.mjs';
73
145
  import { readRun, recordEvent } from './run-journal.mjs';
74
- import { clearRevalidationHold, readState, recordRevalidationHold } from './run-state.mjs';
146
+ import {
147
+ clearRevalidationHold,
148
+ readState,
149
+ readStateForSelection,
150
+ recordRevalidationHold,
151
+ } from './run-state.mjs';
75
152
  import { POINTS as ALL_POINTS, REVALIDATES } from './lib/revalidation-points.mjs';
76
153
  import { takeUpEvidenceOf } from './queue/core.mjs';
77
154
  import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
78
155
  import { projectRootOfConfig } from './queue/index.mjs';
79
156
  import {
157
+ CLAIM_SCHEMA_VERSION,
80
158
  revalidateClaim,
81
159
  targetShaOf,
82
160
  unverifiableResult,
@@ -95,6 +173,7 @@ const parseArgs = (argv) => {
95
173
  outcome: false,
96
174
  point: null,
97
175
  ticket: null,
176
+ ownerDirected: false,
98
177
  base: 'origin/master',
99
178
  config: null,
100
179
  json: false,
@@ -105,6 +184,7 @@ const parseArgs = (argv) => {
105
184
  for (let i = 0; i < argv.length; i += 1) {
106
185
  const arg = argv[i];
107
186
  if (i === 0 && arg === 'outcome') args.outcome = true;
187
+ else if (arg === '--owner-directed') args.ownerDirected = true;
108
188
  else if (arg === '--json') args.json = true;
109
189
  else if (arg === '--action-changed') args.actionChanged = argv[++i] ?? null;
110
190
  else if (arg === '--note') args.note = argv[++i] ?? null;
@@ -300,6 +380,79 @@ const answerUnverifiable = ({ runDir, ticket, point, json }, operation, cause) =
300
380
  process.exit(2);
301
381
  };
302
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
+
303
456
  /** Every adapter call in this script goes through here, or it can still crash. */
304
457
  const readAdapter = async (operation, read, context) => {
305
458
  try {
@@ -332,7 +485,31 @@ if (invokedDirectly()) {
332
485
  if (!known.includes(args.point)) {
333
486
  refuse(`unknown point: ${args.point ?? '(none)'}. This script knows ${known.join(', ')}.`);
334
487
  }
335
- 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
+ }
336
513
 
337
514
  const runDir = process.env.RIG_RUN_DIR || null;
338
515
 
@@ -342,16 +519,27 @@ if (invokedDirectly()) {
342
519
  refuse(`--action-changed must be true or false, got ${args.actionChanged ?? '(none)'}.`);
343
520
  }
344
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;
345
530
  const target = [...events]
346
531
  .reverse()
347
532
  .find(
348
533
  (e) =>
349
534
  e.kind === 'revalidation' &&
350
- String(e.data?.ticket) === String(args.ticket) &&
351
- 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)),
352
540
  );
353
541
  if (!target) {
354
- 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.`);
355
543
  }
356
544
  const now = new Date().toISOString();
357
545
  const actionRequired = args.actionChanged === 'true';
@@ -364,7 +552,10 @@ if (invokedDirectly()) {
364
552
  actionRequired,
365
553
  driftOrigin: 'unknown',
366
554
  resolvedAt: now,
367
- ticket: args.ticket,
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 } : {}),
368
559
  point: args.point,
369
560
  actionChanged: actionRequired,
370
561
  note: args.note,
@@ -376,11 +567,181 @@ if (invokedDirectly()) {
376
567
  process.stdout.write(
377
568
  args.json
378
569
  ? `${JSON.stringify(record, null, 2)}\n`
379
- : `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`,
380
571
  );
381
572
  process.exit(0);
382
573
  }
383
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
+
384
745
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
385
746
  const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
386
747
  // 🔴 The queue CONFIG, not the tracker behind it. `readAdapter` covers every
@@ -153,9 +153,13 @@ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run
153
153
 
154
154
  `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
155
155
  on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
156
- rulebook — both harnesses' rules, skills, agents and hook wiring, plus their
157
- scripts, queue config and integrity manifest — is refused unless its
158
- path starts with an allowed prefix; the board selector is the one always-refused
156
+ rulebook is refused unless its
157
+ path starts with an allowed prefix. 🔴 **Which paths that covers is
158
+ `RULEBOOK_PREFIXES` in `.claude/scripts/unattended-flag.mjs`** — read it before
159
+ composing an allow-list, rather than working from a summary here. A summary is a
160
+ second copy, and the one that used to sit in this sentence had gone stale against
161
+ the set it described. One fact the set cannot carry, so it is stated: the board
162
+ selector is the one always-refused
159
163
  exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
160
164
  item that needs a rulebook path names it here — a decision made at claim
161
165
  time, never a default — and the stop step below turns the flag off. Pinned in
@@ -57,13 +57,66 @@ blockers.
57
57
  confidently-wrong reviews. Everything below is scoped to this diff.
58
58
 
59
59
  Then, on the fetched ref, ask whether the branch is still the branch the run
60
- took up:
60
+ took up. **Two paths, and you state which one — the command infers neither.**
61
+ A branch that is a queue item's take-up:
61
62
 
62
63
  ```sh
63
64
  node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
64
65
  ```
65
66
 
66
- It runs the existing revalidation chain against the tracked, versioned
67
+ Owner-directed work or a hotfix that has **no item** — the case step 4 below
68
+ already tells you to declare to the reviewers:
69
+
70
+ ```sh
71
+ node .claude/scripts/revalidate.mjs --point BEFORE_PR --owner-directed --base origin/<default>
72
+ ```
73
+
74
+ 🔴 **The second path is not a lighter checkpoint, and it is never a skip.**
75
+ It runs the same `main:<path>` comparison and holds on the same exit 2; what
76
+ it drops is the claim comparison, because work with no item has no claim to
77
+ compare. It resolves no queue config, so it reaches no tracker and needs no
78
+ credentials. Passing both flags, or neither, is exit 1.
79
+
80
+ **Four refusals** — exit 1, nothing journalled — keep it from being the way
81
+ around a claim or revalidation failure. It is refused when this run carries
82
+ an **unresolved revalidation hold** (what the ticketed path writes when it
83
+ holds or answers `UNVERIFIABLE`), when this run **declares a take-up**, when
84
+ the branch **touches a tracked `.rig/claims/*.json`** in any direction —
85
+ added, modified, removed or renamed — and at `BEFORE_CLOSE`. So re-running a
86
+ held ticketed call in this mode does not get past it: resolve the hold with
87
+ `outcome` instead.
88
+
89
+ ⚠ **What those refusals do not cover**, because a governance mode is trusted
90
+ exactly as far as it is described:
91
+
92
+ - **With no `RIG_RUN_DIR` there is no run state to read**, so the hold and
93
+ take-up refusals cannot fire — and nothing is journalled. The command says
94
+ so on stdout and in `evidence.runState`; it is not evidence that neither
95
+ exists. An attended gate run is exactly this shape, so read that line.
96
+ - The claim refusal reads the **branch diff**, so a claim record already on
97
+ the default branch, or written and not committed, is not seen.
98
+ - **`--base` is the sole authority for the verdict here**, the claim
99
+ comparison that would otherwise survive a wrong base being absent. Pass
100
+ the fetched `origin/<default>`, not a local copy and not `HEAD`.
101
+ - Nothing can prove an item does not exist. The rest is your word, recorded
102
+ as such, with `ticket: null` and no invented id.
103
+
104
+ Pinned in the generator's `test/template/owner-directed-revalidation.test.ts`
105
+ (absent in a generated rig) › "refuses when this run carries an unresolved
106
+ revalidation hold", › "refuses when the declared run already carries a
107
+ take-up", › "refuses when the branch RENAMES a claim record — the case
108
+ --diff-filter=AM could not see" and › "says out loud that an undeclared run
109
+ checked neither the hold nor the take-up".
110
+
111
+ **Exit 2 here is a HOLD with the same shape as the ticketed one**, and the
112
+ same two-step remedy: re-read the default branch on each named path, then
113
+ record what the re-read concluded — `node .claude/scripts/revalidate.mjs
114
+ outcome --point BEFORE_PR --owner-directed --action-changed <true | false>
115
+ --note '…'` — and come back through step 0. The owner-directed detection
116
+ carries no ticket, so `--owner-directed` is how the outcome addresses it;
117
+ the ticketed `--ticket <item-id>` form below cannot match it and is refused.
118
+
119
+ The ticketed path runs the existing revalidation chain against the tracked, versioned
67
120
  `.rig/claims/<item-id>.json`: the content-blind `scope` fingerprint set is
68
121
  authoritative here, while `takeUps` / `updatedAt` remain evidence only. It
69
122
  also names what the default branch changed since this branch forked on paths
@@ -71,14 +124,18 @@ blockers.
71
124
  journals one `revalidation` event at `point: BEFORE_PR`; **exit code 2 is a HOLD**, with one blocker per named source: re-read the item, or the default
72
125
  branch on that path, record what the re-read concluded —
73
126
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
74
- — and come back through step 0. A hold with no outcome is counted by the
75
- report as a re-read the run skipped. A missing, untracked, unreadable or
127
+ — and come back through step 0. **That `--ticket` form is this path's, not
128
+ both paths'** — the owner-directed detection carries no ticket for it to
129
+ name, and its own `--owner-directed` outcome is written out above. A hold
130
+ with no outcome, in either mode, is counted by the report as a re-read the
131
+ run skipped. A missing, untracked, unreadable or
76
132
  unsupported claim is `UNVERIFIABLE`, exits 2, and stops automatic progress;
77
133
  so is a tracker whose adapter the command cannot READ, which means the
78
134
  question was never put rather than that the claim record is unreadable.
79
135
  Neither is ever read as a pass. Exit 1 is the command refusing (unknown
80
- point, no ticket, a base that is not a revision, or a queue config that does
81
- not resolve): fix the call or the config — the message says which. Its limits are its own header's; the
136
+ point, neither mode or both, a base that is not a revision, or a queue config
137
+ that does not resolve — plus, on the owner-directed path, the four refusals
138
+ above): fix the call or the config — the message says which. Its limits are its own header's; the
82
139
  cited-path set is a labelled assumption, not a recorded fact. Pinned in the
83
140
  generator's `test/template/revalidate.test.ts` (absent in a generated rig) ›
84
141
  "continues when only updatedAt moved and still reports the marker evidence"
@@ -182,9 +239,13 @@ blockers.
182
239
  **Whatever you launch, pass it the text of the queue item this branch
183
240
  implements.** A reviewer given only a diff cannot check the change against
184
241
  what was asked: a cold context has no way to know, and reconstructing it from
185
- the PR description would mean trusting the run under review. If there is no
186
- item — owner-directed work, a hotfix — say so when launching, and the
187
- reviewer skips that check openly instead of guessing at it.
242
+ the PR description would mean trusting the run under review. When there is
243
+ no item — owner-directed work, a hotfix — launch every reviewer with the
244
+ words **`no item — owner-directed`** instead. That skips the item-contract
245
+ check, openly, and **nothing else**: the checks, the routing, the security,
246
+ code and prose/governance reviews, the coverage check and the DoD all still
247
+ run. It is one check narrower than a ticketed fan-out, not a cheaper gate,
248
+ and the same words are what step 1's `--owner-directed` call records.
188
249
 
189
250
  🔴 **The triggers below are lane-independent and may only ADD.** They read
190
251
  *what the code does*; the router reads *paths*, and a path cannot say that a