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
@@ -0,0 +1,56 @@
1
+ /** Resolution indexes shared by selection and the aggregate report. */
2
+ export const typedResolutionsOf = (events = []) => {
3
+ const resolutions = new Map();
4
+ for (const event of events) {
5
+ const actionRequired = event.data?.actionRequired ?? event.data?.actionChanged;
6
+ if (
7
+ event.kind !== 'revalidation-outcome' ||
8
+ typeof event.data?.detectionId !== 'string' ||
9
+ typeof actionRequired !== 'boolean'
10
+ ) {
11
+ continue;
12
+ }
13
+ const resolvedAt = Date.parse(event.data?.resolvedAt ?? event.at);
14
+ if (!Number.isFinite(resolvedAt)) continue;
15
+ const matching = resolutions.get(event.data.detectionId) ?? [];
16
+ matching.push({ resolvedAt, data: event.data });
17
+ resolutions.set(event.data.detectionId, matching);
18
+ }
19
+ return resolutions;
20
+ };
21
+
22
+ export const typedResolutionOf = (resolutions, event) => {
23
+ const detectionAt = Date.parse(event.at);
24
+ if (typeof event.data?.id !== 'string' || !Number.isFinite(detectionAt)) return null;
25
+ return (
26
+ (resolutions.get(event.data.id) ?? [])
27
+ .filter((resolution) => resolution.resolvedAt >= detectionAt)
28
+ .sort((left, right) => left.resolvedAt - right.resolvedAt)[0]?.data ?? null
29
+ );
30
+ };
31
+
32
+ /** The newest blocking detection in one run that no typed outcome resolves. */
33
+ export const unresolvedBlockingDetectionOf = (events = []) => {
34
+ const resolutions = typedResolutionsOf(events);
35
+ for (const event of [...events].reverse()) {
36
+ if (event.kind !== 'revalidation') continue;
37
+ const result = event.data?.result;
38
+ if (!['CHANGED', 'CONFLICT', 'UNVERIFIABLE'].includes(result)) continue;
39
+ if (typedResolutionOf(resolutions, event)) continue;
40
+ if (
41
+ typeof event.data?.id !== 'string' ||
42
+ typeof event.data?.ticket !== 'string' ||
43
+ typeof (event.data?.checkpoint ?? event.data?.point) !== 'string'
44
+ ) {
45
+ continue;
46
+ }
47
+ return {
48
+ kind: 'revalidation-hold',
49
+ ticket: event.data.ticket,
50
+ checkpoint: event.data.checkpoint ?? event.data.point,
51
+ result,
52
+ detectionId: event.data.id,
53
+ };
54
+ }
55
+ return null;
56
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The shell-executing tools this rulebook's Never-tier guards must see.
3
+ *
4
+ * ONE list, because two spellings of the same fact disagree eventually and the
5
+ * copy nobody is looking at is the one that is wrong (`.claude/rules/invariants.md`,
6
+ * "One mechanism, one implementation"). `.claude/settings.json` cannot import
7
+ * this — it is data, not code — so the correspondence is held by a test rather
8
+ * than by generation: `test/template/shell-tools.test.ts` reads both and refuses
9
+ * a tool named here that some shell guard's matcher omits, or a tool in that
10
+ * matcher this file does not name. That test lives in the generator and is
11
+ * absent in a generated rig.
12
+ *
13
+ * 🔴 **Wiring is not behaviour, and certifying only the wiring is how the gap
14
+ * this file was written to close survived its own PR.** The matcher was widened
15
+ * while both guards still opened with a literal `tool_name !== 'Bash'` and
16
+ * returned allow, so every Never-tier rule and the kill switch stayed
17
+ * bypassable on the other surface — with the configuration tests green.
18
+ * The guards therefore IMPORT this list rather than restating it, and the same
19
+ * test file now spawns them. It is absent in a generated rig, like the one
20
+ * above: `test/template/shell-tools.test.ts`
21
+ * › "guard-bash reaches its verdict on every shell tool, not just the one it
22
+ * was written for" and
23
+ * › "block-no-verify reaches its verdict on every shell tool"
24
+ * run one refusal and one positive control per entry below, so a surface added
25
+ * here without being honoured goes red.
26
+ *
27
+ * ── Why this exists (RP-65) ─────────────────────────────────────────────────
28
+ *
29
+ * `settings.json` wired `block-no-verify` and `guard-bash` under the matcher
30
+ * `Bash` alone. Measured on the live harness: `git commit --no-verify --dry-run`
31
+ * was BLOCKED through the `Bash` tool and RAN through the `PowerShell` tool, in
32
+ * the same session and the same checkout. On that surface the Never tier and the
33
+ * kill switch were prose.
34
+ *
35
+ * ── Why an enumerated list and not a wildcard ───────────────────────────────
36
+ *
37
+ * A matcher that admitted anything would hand these guards tools that execute
38
+ * nothing — and a guard that fires on a non-shell tool is one somebody turns
39
+ * off. Adding a surface is a deliberate edit here, which is also where the
40
+ * reason for each entry is written down.
41
+ *
42
+ * ── What widening the matcher does NOT buy ──────────────────────────────────
43
+ *
44
+ * 🔴 The same RULES now run on both surfaces; the PARSING is not thereby
45
+ * identical. `guard-bash` tokenises POSIX shell — quoting, separators, wrappers
46
+ * — and PowerShell's syntax is its own. A command whose danger is visible only
47
+ * after PowerShell-specific parsing can read differently there.
48
+ *
49
+ * 🔴 **And the kill switch is narrower than it sounds.** It matches a command
50
+ * NAME, so it refuses the operation only when the operation is spelled that
51
+ * way. Measured with the brake armed: `gh pr merge …` is refused on both
52
+ * surfaces; `gh.exe pr merge …`, `Start-Process gh -ArgumentList …` and
53
+ * `Remove-Item -Recurse -Force C:\` are allowed. The `.exe` spelling is
54
+ * allowed under `Bash` as well, so the bound belongs to the rule set and not
55
+ * to the widened matcher.
56
+ *
57
+ * That gap is also why `guard-bash.mjs` keeps its name. A rename would promise
58
+ * a parity the parser does not have, and it would break every installed rig's
59
+ * manifest for a word.
60
+ */
61
+
62
+ /**
63
+ * The tool names KNOWN to run a shell command — a hand-maintained list.
64
+ *
65
+ * - `Bash` — the POSIX surface these guards were written for.
66
+ * - `PowerShell` — the Windows-native surface, measured to bypass them before
67
+ * RP-65. A harness that does not expose it simply never matches the name.
68
+ *
69
+ * 🔴 **Nothing checks this list against the harness, and that is the direction
70
+ * the defect came from.** The tests derive their expectations FROM this
71
+ * constant, so what they guard is list → matcher → verdict. The opposite
72
+ * direction — harness → list — is guarded by nobody. A harness that gains a
73
+ * third shell tool leaves every test green and the Never tier inert on it,
74
+ * which is exactly what `PowerShell` did until somebody measured it by hand.
75
+ * Over-listing is safe (an unexposed name never matches); under-listing is
76
+ * the whole bug, and it is invisible until a person edits this file.
77
+ */
78
+ export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
79
+
80
+ /** The `matcher` value `settings.json` must carry for the shell guards. */
81
+ export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
@@ -11,19 +11,21 @@
11
11
  // reason it is safe to script half a checklist. The honest objection to a partial
12
12
  // script — "a script that half-checks is worse than a list the run actually
13
13
  // reads" — is true exactly while the boundary is invisible. A silent script would
14
- // let a GO on four items read as a pass on seven.
14
+ // let a GO on the scripted subset read as a pass on the whole checklist.
15
15
  //
16
16
  // 🔴 **`unknown` never becomes `pass`.** A probe that could not run tells you
17
17
  // nothing, and "I could not look" recorded as "it is fine" is the failure this
18
18
  // checklist exists to prevent.
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import { realpathSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  // One implementation of the brake, shared with the hook that enforces it. This
23
24
  // file used to carry its own `process.env.AGENT_LOOP_STOP || <default>`, which is
24
25
  // the replace-not-add bug — fixed in the hook and left open here for a full review
25
26
  // cycle, because preflight is the only scripted brake check and had no test.
26
27
  import { brakeIsOn } from './stop-flag.mjs';
28
+ import { readRevalidationContract } from './lib/claim-records.mjs';
27
29
 
28
30
  /** The items this script cannot check: judgement, or a call worth more than it saves. */
29
31
  export const UNCHECKED = [
@@ -120,6 +122,20 @@ export const checkRunDirNotExported = (env = process.env) => {
120
122
  : { ok: true, detail: 'not exported' };
121
123
  };
122
124
 
125
+ export const checkDetectionContract = (projectRoot) => {
126
+ try {
127
+ const contract = readRevalidationContract(projectRoot);
128
+ return {
129
+ ok: true,
130
+ detail:
131
+ `${contract.detection.mode} via ${contract.detection.sources.join(' + ')}, ` +
132
+ `${contract.detection.acceptedLatency} accepted latency, no push`,
133
+ };
134
+ } catch (error) {
135
+ return { ok: false, detail: error.message };
136
+ }
137
+ };
138
+
123
139
  /** The last deploy must have concluded successfully — never start on a broken runtime. */
124
140
  export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
125
141
  try {
@@ -195,9 +211,11 @@ const invokedDirectly = () => {
195
211
  };
196
212
 
197
213
  if (invokedDirectly()) {
214
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
198
215
  const checks = {
199
216
  killSwitch: checkKillSwitch(),
200
217
  runDirNotExported: checkRunDirNotExported(),
218
+ detectionContract: checkDetectionContract(projectRoot),
201
219
  defaultBranchFresh: checkDefaultBranchFresh(),
202
220
  lastDeploy: checkLastDeploy(),
203
221
  };
@@ -677,7 +677,8 @@ export const selectNext = (
677
677
 
678
678
  /**
679
679
  * Revalidation at SELECT — is the item the run is about to take the item the
680
- * last take-up saw?
680
+ * last take-up saw? This is compatibility EVIDENCE only: claim fingerprints
681
+ * are the sole authority for scope and commentary drift.
681
682
  *
682
683
  * The snapshot is the ticket's `updatedAt` marker as recorded at the previous
683
684
  * take-up in THIS run (`run-state.mjs` › recordTakeUp). One string compare on
@@ -692,74 +693,13 @@ export const selectNext = (
692
693
  * marker that moved: a first sight (no snapshot yet) is `false` with the baseline
693
694
  * recorded, not a change.
694
695
  *
695
- * `action` says what the run does with it: `hold` — re-read the item before
696
- * acting; `continue` — nothing moved; `unverifiable` — no marker to compare.
697
- *
698
- * ⚠ Limit: the marker moves on the run's OWN claim and comments too. The
699
- * tracker adapters re-record the take-up after each write they make (AR-140),
700
- * so a move made THROUGH the adapter is not a hold — one made by any other
701
- * route (a hand-posted comment, a connector) still is. This function cannot
702
- * tell who moved it; the re-read can, and the `loop` skill records that
703
- * conclusion as a separate `revalidation-outcome` event.
696
+ * It deliberately returns no `source` and no `action`. Adding either would
697
+ * restore the marker as a second decision engine beside `.rig/claims/`.
704
698
  */
705
- export const revalidationOf = ({ ticket, snapshot = null }) => {
699
+ export const takeUpEvidenceOf = ({ ticket, snapshot = null }) => {
706
700
  const to = typeof ticket?.updatedAt === 'string' ? ticket.updatedAt : null;
707
701
  const from = typeof snapshot === 'string' ? snapshot : null;
708
- // One shape at every point (AR-136): `source` is the list of what moved,
709
- // `action` the same three words BEFORE_PR and BEFORE_CLOSE use, and the two
710
- // markers sit under `task` — so a reader of the evidence log needs one parser.
711
- const base = { ticket: ticket?.id ?? null, point: 'SELECT', task: { from, to } };
712
- if (to === null) return { ...base, changed: null, source: [], action: 'unverifiable' };
713
- const changed = from !== null && from !== to;
714
- return {
715
- ...base,
716
- changed,
717
- source: changed ? ['task:updatedAt'] : [],
718
- action: changed ? 'hold' : 'continue',
719
- };
720
- };
721
-
722
- /**
723
- * Revalidation at BEFORE_PR — the aggregate over two sources, pure.
724
- *
725
- * `task` is what {@link revalidationOf} returned for the ticket against the
726
- * take-up snapshot; `mainChanged` is the list of cited paths the default branch
727
- * changed since the branch forked (`revalidate.mjs` computes it from git). One
728
- * source name per finding — `task:updatedAt`, `main:<path>` — so a hold names
729
- * exactly what moved, never "something changed".
730
- *
731
- * `changed` keeps the three values of the SELECT point: `true` when any source
732
- * moved; `null` when nothing moved but the task could not be checked (no
733
- * snapshot, no marker, no run) — a blind spot on one side is not a clean pass
734
- * on both; `false` only when both sides were compared and neither moved.
735
- */
736
- export const beforePrRevalidationOf = ({ ticket, task = { changed: null }, mainChanged = [] }) => {
737
- const source = [
738
- ...(task?.changed === true ? ['task:updatedAt'] : []),
739
- ...mainChanged.map((path) => `main:${path}`),
740
- ];
741
- const changed = source.length > 0 ? true : task?.changed === null ? null : false;
742
- const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
743
- return { ticket, point: 'BEFORE_PR', changed, source, action };
744
- };
745
-
746
- /**
747
- * Revalidation at BEFORE_CLOSE — the aggregate over the item's marker and its
748
- * state, pure. `task` is what {@link revalidationOf} returned against the last
749
- * validation; `state` is the item's neutral state now. At close the item is
750
- * expected `in-progress`: `closed` means someone else published it, `open`
751
- * means someone moved it back, and either is a change the close must not
752
- * paper over. Same three-valued `changed` and the same actions as BEFORE_PR;
753
- * `task:updatedAt` is named before `task:state`.
754
- */
755
- export const beforeCloseRevalidationOf = ({ ticket, task = { changed: null }, state = null }) => {
756
- const source = [
757
- ...(task?.changed === true ? ['task:updatedAt'] : []),
758
- ...(state !== 'in-progress' ? ['task:state'] : []),
759
- ];
760
- const changed = source.length > 0 ? true : task?.changed === null ? null : false;
761
- const action = changed === true ? 'hold' : changed === null ? 'unverifiable' : 'continue';
762
- return { ticket, point: 'BEFORE_CLOSE', changed, source, action };
702
+ return { changed: to === null ? null : from !== null && from !== to, task: { from, to } };
763
703
  };
764
704
 
765
705
  /**
@@ -933,6 +873,7 @@ export const stopConditionOf = ({
933
873
  lastDeployVerdict = null,
934
874
  consecutiveEscalations = 0,
935
875
  killSwitch = false,
876
+ revalidationHold = null,
936
877
  budgetExhausted = false,
937
878
  queueReadable = true,
938
879
  } = {}) => {
@@ -966,6 +907,16 @@ export const stopConditionOf = ({
966
907
  'in-flight work is not what stopping cleanly means.',
967
908
  };
968
909
  }
910
+ if (revalidationHold) {
911
+ return {
912
+ kind: 'revalidation-hold',
913
+ success: false,
914
+ why:
915
+ `${revalidationHold.ticket} stopped at ${revalidationHold.checkpoint}: ` +
916
+ `${revalidationHold.result} (${revalidationHold.detectionId}). Resolve that ` +
917
+ 'detection before selecting more work in this run.',
918
+ };
919
+ }
969
920
  if (consecutiveEscalations >= 2) {
970
921
  return {
971
922
  kind: 'repeated-escalation',
@@ -17,8 +17,10 @@ import { execFileSync } from 'node:child_process';
17
17
  import { duplicateOf, fingerprintOf, lifecycleOf, ownerOfLabels, validateProposal } from './core.mjs';
18
18
  import { withAsOf } from './as-of.mjs';
19
19
  import { recordEscalation, recordTakeUp } from '../run-state.mjs';
20
+ import { recordClaimTransition } from '../lib/claim-records.mjs';
20
21
 
21
22
  export const name = 'github-issues';
23
+ export const claimedState = 'in-progress';
22
24
 
23
25
  /**
24
26
  * A dependency line, and everything after the keyword on it.
@@ -56,6 +58,7 @@ export const blockerIdsOf = (issue) => {
56
58
  export const toTicket = (issue, states = {}) => {
57
59
  const labels = labelNames(issue);
58
60
  const priorityLabel = labels.map((label) => PRIORITY.exec(label)).find(Boolean);
61
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
59
62
 
60
63
  return {
61
64
  id: String(issue.number),
@@ -76,10 +79,7 @@ export const toTicket = (issue, states = {}) => {
76
79
  blocks: [],
77
80
  priority: priorityLabel ? Number(priorityLabel[1]) : 999,
78
81
  createdAt: issue.createdAt ?? null,
79
- // The take-up marker for revalidation at SELECT: the tracker's own
80
- // last-modified field, whose contract (moves on edits, comments and state
81
- // changes) is assumed and not checked here. `null` when the listing did not
82
- // carry it.
82
+ // Compatibility evidence only; `.rig/claims/` fingerprints decide drift.
83
83
  updatedAt: issue.updatedAt ?? null,
84
84
  // The body travels on the neutral shape so the hygiene checks live in one
85
85
  // place (core.mjs) instead of once per adapter. This adapter also parses it
@@ -87,6 +87,18 @@ export const toTicket = (issue, states = {}) => {
87
87
  // purpose: that is exactly the disagreement `body-claims-unlinked-blocker`
88
88
  // exists to surface.
89
89
  body: typeof issue.body === 'string' ? issue.body : null,
90
+ commentary: {
91
+ count: comments.length,
92
+ ids: comments
93
+ .map((comment) => comment?.id)
94
+ .filter((id) => id !== undefined && id !== null)
95
+ .map(String),
96
+ // GitHub CLI expands `comments` to `comments(first: 100)` but exposes no
97
+ // total or pageInfo beside the resulting array. Fewer than 100 proves the
98
+ // first page was also the last; exactly 100 cannot prove there is no 101st
99
+ // comment and must fail closed rather than fingerprint a partial thread.
100
+ complete: Array.isArray(issue.comments) && comments.length < 100,
101
+ },
90
102
  triage: labels.includes('triage'),
91
103
  trigger: labels.includes('trigger-auto')
92
104
  ? 'auto'
@@ -130,7 +142,7 @@ const ghText = (args) =>
130
142
 
131
143
  const ghJson = (args) => JSON.parse(ghText(args));
132
144
 
133
- const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt';
145
+ const FIELDS = 'number,title,body,state,labels,url,createdAt,updatedAt,comments';
134
146
 
135
147
  // --- the adapter contract ------------------------------------------------------
136
148
 
@@ -176,10 +188,20 @@ const rebaseline = (ticket) => {
176
188
  }
177
189
  };
178
190
 
179
- export const claim = (ticket) => {
191
+ export const claim = (ticket, { projectRoot = process.cwd() } = {}) => {
180
192
  ghText(['issue', 'edit', ticket.id, '--add-label', 'in-progress']);
193
+ let workflowClaimRecorded = false;
194
+ try {
195
+ workflowClaimRecorded =
196
+ recordClaimTransition({ projectRoot, ticket, claimedState }) !== null;
197
+ } catch (error) {
198
+ process.stderr.write(
199
+ `#${ticket.id}: the workflow claim landed, but its durable acknowledgement was NOT recorded — ` +
200
+ `${error.message}\n`,
201
+ );
202
+ }
181
203
  rebaseline(ticket);
182
- return { ok: true };
204
+ return { ok: true, workflowClaimRecorded };
183
205
  };
184
206
 
185
207
  /**
@@ -20,7 +20,6 @@ import {
20
20
  citedPathsOf,
21
21
  hygieneOf,
22
22
  overtakenOf,
23
- revalidationOf,
24
23
  selectNext,
25
24
  stopConditionOf,
26
25
  } from './core.mjs';
@@ -30,6 +29,7 @@ import { changedSinceOf, headShaOf } from './as-of.mjs';
30
29
  // apart from `state.mjs` so the read path does not drag the tier computation —
31
30
  // and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
32
31
  import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
32
+ import { revalidateClaim, targetShaOf } from '../lib/claim-records.mjs';
33
33
 
34
34
  const ADAPTERS = {
35
35
  'plan-md': './plan-md.mjs',
@@ -317,12 +317,16 @@ const renderNext = (result, stop, revalidation = null) => {
317
317
  return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
318
318
  }
319
319
  const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
320
- // Only a marker that MOVED earns a line: the unchanged case stays quiet and
321
- // cheap, and the no-marker case is in the JSON and the event log, not here.
322
- if (revalidation?.changed === true) {
320
+ // CURRENT and BASELINE_CREATED stay quiet. Every blocking claim result is
321
+ // visible in text mode too; an exit code with no reason is not operable.
322
+ if (revalidation?.action === 'hold' || revalidation?.action === 'unverifiable') {
323
+ const detail =
324
+ revalidation.source.length > 0
325
+ ? revalidation.source.join(', ')
326
+ : (revalidation.evidence?.error ?? revalidation.sourcePointer);
323
327
  lines.push(
324
- `revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
325
- `(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
328
+ `revalidate: ${revalidation.ticket} ${revalidation.action} — ${detail} — ` +
329
+ 're-read the item before acting',
326
330
  );
327
331
  }
328
332
  if (result.skipped.length > 0) {
@@ -530,12 +534,21 @@ if (invokedDirectly()) {
530
534
  // pattern it matched on. The load lives here; the READ stays behind the
531
535
  // declaration, because a session with no run directory has no state to read
532
536
  // and must keep working exactly as before.
533
- let readState;
537
+ let readStateForSelection;
534
538
  let stopInputsOf;
535
539
  let recordTakeUp;
540
+ let recordRevalidationHold;
536
541
  let previousTakeUp;
542
+ let previousRunEvidence;
537
543
  try {
538
- ({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
544
+ ({
545
+ readStateForSelection,
546
+ stopInputsOf,
547
+ recordTakeUp,
548
+ recordRevalidationHold,
549
+ previousTakeUp,
550
+ previousRunEvidence,
551
+ } = await import('../run-state.mjs'));
539
552
  } catch (error) {
540
553
  process.stderr.write(
541
554
  `run state: ${error.message}\n` +
@@ -586,7 +599,46 @@ if (invokedDirectly()) {
586
599
  // `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
587
600
  // the `loop` skill tells the run to keep checking it between tasks.
588
601
  //
589
- const runState = process.env.RIG_RUN_DIR ? readState(process.env.RIG_RUN_DIR) : {};
602
+ let runState;
603
+ try {
604
+ runState = process.env.RIG_RUN_DIR
605
+ ? readStateForSelection(process.env.RIG_RUN_DIR)
606
+ : {};
607
+ } catch (error) {
608
+ const stop = {
609
+ kind: 'run-state-unreadable',
610
+ success: false,
611
+ why: `${error.message}; the run may contain a stop condition, so selection is refused.`,
612
+ };
613
+ process.stdout.write(
614
+ args.json
615
+ ? `${JSON.stringify({ stop, revalidation: null }, null, 2)}\n`
616
+ : `queue: ${stop.kind}\n ${stop.why}\n`,
617
+ );
618
+ process.exit(1);
619
+ }
620
+ // `state.json` is the fast stop input; the append-only journal is the durable
621
+ // run evidence that can reconstruct it after an absent or valid-but-empty
622
+ // state file. Reuse the shared temporal resolution rule rather than growing
623
+ // a second definition of "unresolved" in selection. A broken journal keeps
624
+ // its established lost-trace behaviour; a readable unresolved detection can
625
+ // never be erased merely by deleting the derived state cache.
626
+ if (process.env.RIG_RUN_DIR && !runState.revalidationHold) {
627
+ try {
628
+ const [{ readRun }, { unresolvedBlockingDetectionOf }] = await Promise.all([
629
+ import('../run-journal.mjs'),
630
+ import('../lib/revalidation-evidence.mjs'),
631
+ ]);
632
+ const recovered = unresolvedBlockingDetectionOf(
633
+ readRun({ runDir: process.env.RIG_RUN_DIR }).events,
634
+ );
635
+ if (recovered) runState = { ...runState, revalidationHold: recovered };
636
+ } catch {
637
+ // The journal's read/write failure contract is handled at its existing
638
+ // call site below. This reconciliation only restores evidence it can
639
+ // validate; it never guesses a detection from unreadable bytes.
640
+ }
641
+ }
590
642
  let stopInputs;
591
643
  try {
592
644
  // Read through the module that owns the vocabulary, never field by field
@@ -726,27 +778,107 @@ if (invokedDirectly()) {
726
778
  // a default derived from `projectRoot` would land the trace inside the very
727
779
  // template tree this repository publishes.
728
780
  const runDir = process.env.RIG_RUN_DIR;
729
- // Revalidation at SELECT (AR-133): the selected item against the marker this
730
- // run recorded at its last take-up. Computed only under a declared run —
731
- // there is no snapshot to compare against anywhere else — and `null` in the
732
- // output then, so a reader can tell "not compared" from "compared, unchanged".
781
+ // Revalidation at SELECT: the selected item against its durable claim
782
+ // fingerprints. It runs with or without a declared run; only the evidence
783
+ // log is run-scoped. Otherwise an attended first SELECT would bypass the
784
+ // cross-harness baseline the next unattended resume requires.
733
785
  //
734
- // The baseline is this run's take-up when it has one; otherwise the newest
735
- // earlier run's (AR-138) — an item taken up yesterday and re-offered today
736
- // used to compare against nothing and report a first sight. `baseline` says
737
- // which it was (`this-run` | `previous-run` | null), and `baselineRun` names
738
- // the earlier run, so the report can tell the three apart. A rig whose
739
- // run-state module predates `previousTakeUp` keeps the per-run behaviour.
786
+ // Take-up markers remain attached as compatibility evidence. `baseline`
787
+ // says where that evidence came from; neither it nor `updatedAt` contributes
788
+ // to the claim result or decides whether the first claim may be created.
740
789
  let revalidation = null;
741
- if (runDir && result.ticket) {
742
- const own = runState.takeUps?.[result.ticket.id];
790
+ const configuredProjectRoot = projectRootOfConfig(configPath);
791
+ const claimRoot = configuredProjectRoot ?? projectRoot;
792
+ if (result.ticket && configuredProjectRoot !== null) {
793
+ const own = runDir ? runState.takeUps?.[result.ticket.id] : undefined;
743
794
  const prior =
744
- own === undefined && typeof previousTakeUp === 'function'
795
+ runDir && own === undefined && typeof previousTakeUp === 'function'
745
796
  ? previousTakeUp(runDir, result.ticket.id)
746
797
  : null;
747
798
  const snapshot = own ?? prior?.updatedAt ?? null;
799
+ // SELECT events are the sole authority for whether this is first sight or
800
+ // a resume. A take-up may still supply the compatibility snapshot above,
801
+ // but it never enters this decision. Every sibling journal comes from the
802
+ // same bounded resolver as the marker lookup so the evidence windows cannot
803
+ // drift apart.
804
+ let selectedBefore = false;
805
+ let resumeEvidenceError = null;
806
+ if (runDir) {
807
+ let journal = null;
808
+ try {
809
+ journal = await import('../run-journal.mjs');
810
+ } catch {
811
+ selectedBefore = true;
812
+ resumeEvidenceError = 'current run journal module is unreadable or missing';
813
+ }
814
+ const selectedIn = (candidateRunDir) =>
815
+ journal.readRun({ runDir: candidateRunDir }).events.some(
816
+ (event) =>
817
+ event.kind === 'revalidation' &&
818
+ event.data?.point === 'SELECT' &&
819
+ String(event.data?.ticket) === String(result.ticket.id) &&
820
+ typeof event.data?.result === 'string' &&
821
+ typeof event.data?.sourcePointer === 'string',
822
+ );
823
+ if (journal) {
824
+ try {
825
+ selectedBefore = selectedIn(runDir);
826
+ } catch {
827
+ selectedBefore = true;
828
+ resumeEvidenceError = 'current run journal is unreadable or invalid';
829
+ }
830
+ }
831
+ if (journal && !selectedBefore) {
832
+ let candidates = [];
833
+ let complete;
834
+ try {
835
+ const previous = previousRunEvidence(runDir);
836
+ candidates = previous.runDirs;
837
+ complete = previous.complete;
838
+ } catch {
839
+ complete = false;
840
+ }
841
+ let unreadablePrior = false;
842
+ for (const candidateRunDir of candidates) {
843
+ try {
844
+ if (selectedIn(candidateRunDir)) {
845
+ selectedBefore = true;
846
+ break;
847
+ }
848
+ } catch {
849
+ unreadablePrior = true;
850
+ }
851
+ }
852
+ if (!selectedBefore && (!complete || unreadablePrior)) {
853
+ selectedBefore = true;
854
+ resumeEvidenceError = !complete
855
+ ? 'prior run journal search is incomplete or truncated by its safety limit'
856
+ : 'prior run journal is unreadable or invalid';
857
+ }
858
+ }
859
+ }
860
+ let claim = revalidateClaim({
861
+ projectRoot: claimRoot,
862
+ ticket: result.ticket,
863
+ point: 'SELECT',
864
+ claimedState: adapter.claimedState,
865
+ targetSha: targetShaOf(claimRoot),
866
+ allowCreate: true,
867
+ isResume: selectedBefore,
868
+ });
869
+ if (resumeEvidenceError && claim.result === 'UNVERIFIABLE') {
870
+ claim = {
871
+ ...claim,
872
+ evidence: {
873
+ ...claim.evidence,
874
+ error: `${resumeEvidenceError}; ${claim.evidence?.error ?? 'claim cannot be verified'}`,
875
+ },
876
+ };
877
+ }
748
878
  revalidation = {
749
- ...revalidationOf({ ticket: result.ticket, snapshot }),
879
+ ...claim,
880
+ observedAt: new Date().toISOString(),
881
+ task: { from: snapshot, to: result.ticket.updatedAt ?? null },
750
882
  baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
751
883
  ...(prior ? { baselineRun: prior.runDir } : {}),
752
884
  };
@@ -821,6 +953,9 @@ if (invokedDirectly()) {
821
953
  }
822
954
 
823
955
  if (revalidation) {
956
+ if (revalidation.action === 'hold' || revalidation.action === 'unverifiable') {
957
+ recordRevalidationHold(runDir, revalidation);
958
+ }
824
959
  // Its own try, after the journal's: a state file that cannot be written is
825
960
  // not the journal failing, and the selection stands either way — the
826
961
  // comparison was made and recorded; only the next baseline is lost.
@@ -840,4 +975,5 @@ if (invokedDirectly()) {
840
975
  ? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
841
976
  : renderNext(result, stop, revalidation),
842
977
  );
978
+ process.exit(revalidation?.action === 'hold' || revalidation?.action === 'unverifiable' ? 2 : 0);
843
979
  }