@sabaiway/agent-workflow-kit 14.4.0 → 14.4.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.4.1 — `review-state.mjs` becomes a facade over five tested leaves; nothing the tool decides, prints or records changes (AD-149)
8
+
9
+ **The review-receipt checker is now five small modules behind its old entry point.** `tools/review-state.mjs` was one
10
+ 918-line file. 14.4.1 moves its bodies into five library modules and keeps a 218-line facade that parses the command
11
+ line, composes them and keeps its 28 exported names, so no import site outside the leaf suites moves. Exit codes, reasons, the `--json`
12
+ output and the tool header are the same as in 14.4.0. Story S1 of the epic `REVIEW-STATE-AND-CORE-EVIDENCE-ARE-MONOLITHS`;
13
+ contract `docs/ai/specs/kit/review-state.md` (revision 1).
14
+
15
+ - **The leaves.** `review-state-judge.mjs` (receipt status, the degrade escape, the held-session arm, the check
16
+ decision), `review-state-build.mjs` (the read-only git queries and the state build; the only one that imports
17
+ `node:child_process`), `review-state-flow.mjs` (plan-adoption coverage and the armed-flow arms), `review-state-render.mjs` (the
18
+ human report and the mask advisory line) and `review-state-await.mjs` (the `--await` timeout grammar and poll loop).
19
+ No leaf imports the facade or runs as a script; each is under the 400-line cap, and the facade's size record is gone.
20
+ - **Tests.** Nine unit cases move from `review-state.test.mjs` (116 stay) into four new leaf suites, which add five
21
+ written cases; `review-state-await.test.mjs` imports its leaf; `review-state-harness.test.mjs` holds the shared
22
+ fixtures. `test/review-state-layout.test.mjs` (new) pins each exported name to its home module, the header's digest,
23
+ the import directions and the `--json` key order. `test/package-content.test.mjs` names the five leaves and pins 318
24
+ packed files.
25
+
7
26
  ## 14.4.0 — a delegated task engages the held session: `review-state --check` reads a configured `task.execute`, the fold lane follows a resolved one, and `task-brief check` admits a lone suite (AD-148)
8
27
 
9
28
  **A task set to `delegated` is now gated and advised on its own.** In 14.3.0 the held-session gate and the fold lane
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.4.0'
6
+ version: '14.4.1'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.4.0",
6
+ "version": "14.4.1",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.4.0",
3
+ "version": "14.4.1",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,77 @@
1
+ // review-state-await.mjs — the --timeout grammar and the poll loop.
2
+
3
+ import { detectBackends } from './detect-backends.mjs';
4
+ import { fail } from './orchestration-config.mjs';
5
+ import { decideCheck } from './review-state-judge.mjs';
6
+ import { buildState } from './review-state-build.mjs';
7
+
8
+ // --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
9
+ // a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
10
+ // injectable clock) so hermetic tests never spend wall-clock.
11
+ export const DEFAULT_AWAIT_TIMEOUT_S = 900;
12
+ export const AWAIT_POLL_MS = 5000;
13
+
14
+ // ── --await: block until the configured review obligations are SATISFIED ───────────────
15
+ // It waits until `--check` would pass: under `reviewed`, a ship-class attestation from any
16
+ // review-capable backend; under `council`, every configured backend ship-class OR carrying a
17
+ // current-tree degrade RECORD in the core-evidence store (never all backends) — once such a
18
+ // record lands, --await stops waiting for that backend and returns READY (before, it waited
19
+ // forever for a receipt that never comes). It inherits everything for FREE — it polls the SAME
20
+ // decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT/RECORD
21
+ // (i.e. `--check` would PASS), NEVER a process event — a harness "completed" notification fires
22
+ // early and a bridge's output late-flushes, so polling state is the durable mechanization of
23
+ // receipts-not-pgrep. Stays read-only (it only re-reads state — now the evidence store too, a
24
+ // few KB per tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests
25
+ // never spend wall-clock.
26
+
27
+ const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
28
+
29
+ const parseAwaitTimeoutS = (argv) => {
30
+ const i = argv.indexOf('--timeout');
31
+ if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
32
+ const raw = argv[i + 1];
33
+ if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
34
+ return Number(raw);
35
+ };
36
+
37
+ export const mainAwait = async (argv, ctx = {}) => {
38
+ const cwd = ctx.cwd ?? process.cwd();
39
+ const env = ctx.env ?? process.env;
40
+ const detect = ctx.detect ?? detectBackends;
41
+ const now = ctx.now ?? (() => Date.now());
42
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
43
+ const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
44
+ const build = ctx.buildState ?? buildState;
45
+ try {
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const a = argv[i];
48
+ if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
49
+ if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
50
+ }
51
+ const timeoutS = parseAwaitTimeoutS(argv);
52
+ const timeoutMs = timeoutS * 1000;
53
+ const start = now();
54
+ // Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
55
+ // plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
56
+ // every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
57
+ // is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
58
+ // over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
59
+ // sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
60
+ let lastReason = 'no poll completed before the deadline';
61
+ for (;;) {
62
+ const elapsed = now() - start;
63
+ if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
64
+ const check = decideCheck(build({ cwd, env, detect }));
65
+ lastReason = check.reason;
66
+ if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
67
+ if (check.heldSession === true) return { code: 1, stdout: '', stderr: `review-state --await: HELD SESSION — ${check.reason}` };
68
+ // Decision 4 (#50): an AUTHORITATIVE veto ends the wait loudly BEFORE the deadline — the
69
+ // dispatched review answered with a landed negative; classifying that as a timeout was the
70
+ // misclassification this amendment closes. Only a fresh review can move it, never waiting.
71
+ if (check.veto === true) return { code: 1, stdout: '', stderr: `review-state --await: VETO — ${check.reason}` };
72
+ await sleep(Math.min(pollMs, timeoutMs - elapsed));
73
+ }
74
+ } catch (err) {
75
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
76
+ }
77
+ };
@@ -0,0 +1,163 @@
1
+ // review-state-build.mjs - read-only git plumbing and buildState.
2
+
3
+ import { lstatSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { detectBackends } from './detect-backends.mjs';
7
+ import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
8
+ import { loadConfig } from './orchestration-config.mjs';
9
+ import { plansInFlight } from './plan-files.mjs';
10
+ import { GIT_MAX_BUFFER, resolveGitLocation, stripGitLocationEnv, withGitPath } from './git-env.mjs';
11
+ import {
12
+ computeTreeFingerprint,
13
+ isTreeClean,
14
+ isNeverCommittableStat,
15
+ resolveReceiptsPath,
16
+ readReceipts,
17
+ resolveBase,
18
+ } from './core-evidence.mjs';
19
+ import {
20
+ ACTIVITY, SLOT, backendReceiptStatus, buildHeldSessionState, degradeRecordSet, selectHeldSessionDegrades,
21
+ } from './review-state-judge.mjs';
22
+ import {
23
+ attestedPlanIdsFor, computePlanAdoptionCoverage, consumeVetoOverride, liftStaleRow, readFlowArming,
24
+ } from './review-state-flow.mjs';
25
+
26
+ // ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
27
+
28
+ const gitRaw = (args, cwd, env) =>
29
+ spawnSync('git', args, { cwd, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
30
+
31
+ // stdout Buffer of a git query, or null when git fails (not a repo / git absent).
32
+ const gitBuf = (args, cwd, env) => {
33
+ const r = gitRaw(args, cwd, env);
34
+ if (r.error || r.status !== 0) return null;
35
+ return r.stdout;
36
+ };
37
+
38
+ const gitLine = (args, cwd, env) => {
39
+ const buf = gitBuf(args, cwd, env);
40
+ return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
41
+ };
42
+
43
+ // ── the sandbox-masks advisory (D lane, AD-044 Plan 4 Phase 1.5) ────────────────────
44
+
45
+ // Count the never-committable untracked paths the STANDARD walk still shows. The review domain
46
+ // ignores them by construction; this count only feeds ONE non-failing advisory line naming the
47
+ // cosmetic sandbox-masks apply — an applied managed block hides the paths from --exclude-standard,
48
+ // so the advisory disappears exactly when the status noise does (no standing detector).
49
+ export const countNeverCommittableUntracked = (cwd, { lstat = lstatSync, env } = {}) => {
50
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd, env);
51
+ if (top == null) return 0;
52
+ const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top, env);
53
+ if (untrackedZ == null) return 0;
54
+ return untrackedZ
55
+ .toString('utf8')
56
+ .split('\0')
57
+ .filter(Boolean)
58
+ .filter((rel) => {
59
+ try {
60
+ return isNeverCommittableStat(lstat(join(top, rel)));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }).length;
65
+ };
66
+
67
+ // buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
68
+ // EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
69
+ // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
70
+ // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
71
+ // flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
72
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
73
+ // WHICH repository, first; the config/plan anchor is the stripped discovery's top (from a subdirectory the bare cwd would miss docs/plans — decideCheck refuses on an unanchored root).
74
+ const location = resolveGitLocation(cwd, { env });
75
+ const anchoredTop = location.top ?? gitLine(['rev-parse', '--show-toplevel'], cwd, stripGitLocationEnv(env));
76
+ const root = anchoredTop ?? cwd;
77
+ const { config, source: configSource } = loadConfig(root);
78
+ // A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
79
+ // executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
80
+ let detectionWarning = null;
81
+ const onDetectError = (err) => {
82
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
83
+ };
84
+ const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
85
+ // The resolver stays for DISPLAY only; the OBLIGATIONS come from the configured recipe (never the readiness-degraded effective one — no silent solo).
86
+ const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
87
+ const obligations = requiredBackendsForConfiguredRecipe({ config: config ?? {}, readiness: detection, detectionFailed: detectionWarning != null });
88
+ const requiredBackends = obligations.backends;
89
+ const plans = plansInFlight(root);
90
+ // The injected lstat threads through EVERY stat-dependent computation (fingerprint, clean, the
91
+ // mask count) — a partial injection would let a test observe an inconsistent state.
92
+ const fingerprint = location.state === 'work-tree' ? computeTreeFingerprint(cwd, { lstat, env }) : null;
93
+ const clean = fingerprint == null ? null : isTreeClean(cwd, { lstat, env });
94
+ const receiptsPath = resolveReceiptsPath(cwd, env);
95
+ const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
96
+ const { receipts, malformed } = receiptsRead;
97
+ const receiptsReadError = receiptsRead.readError ?? null;
98
+ const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
99
+ // The D3(b) degrade escape: read the core-evidence store ONLY here, ONLY for the exemption —
100
+ // the gate never otherwise depends on it (an unavailable store denies the exemption CLOSED,
101
+ // never fails a tree whose receipts independently satisfy the gate).
102
+ const base = resolveBase(cwd);
103
+ const degrade = degradeRecordSet({ cwd, env, fingerprint });
104
+ const degradedExempt = requiredBackends.filter((b) => degrade.set.has(b));
105
+ const heldSession = buildHeldSessionState({
106
+ cwd,
107
+ env,
108
+ configuredExecute: [config?.[ACTIVITY]?.execute, config?.task?.execute].find((recipe) => recipe === 'delegated'),
109
+ plans,
110
+ fingerprint,
111
+ clean,
112
+ degrades: selectHeldSessionDegrades(degrade.records),
113
+ });
114
+ // ── the Phase-2 flow arms' state (two-tier activation, P3) ──
115
+ const flow = readFlowArming({ cwd, env, lstat });
116
+ const planCoverage = flow.flowArmed
117
+ ? computePlanAdoptionCoverage({ root, plans, records: flow.records, owner: flow.flowOwner, readFile })
118
+ : [];
119
+ const { attestedPlanIds, lensRefusedAttestations } = flow.flowArmed
120
+ ? attestedPlanIdsFor({ records: flow.records, base, fingerprint })
121
+ : { attestedPlanIds: [], lensRefusedAttestations: [] };
122
+ const arms = { flowArmed: flow.flowArmed, receipts, records: flow.records, base, fingerprint, flowConfig: config?.flow ?? null };
123
+ const armedBackends = backends.map((b) => consumeVetoOverride(liftStaleRow(b, arms), arms));
124
+ // The solo-class veto probe (#48): under an armed solo recipe every review-capable backend's
125
+ // CURRENT-tree receipt is still consulted, so a recipe flip to solo never buries a standing veto.
126
+ const soloVetoRows = flow.flowArmed && obligations.recipe === 'solo'
127
+ ? Object.values(DISPLAY_ALIASES).map((b) => consumeVetoOverride(liftStaleRow({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }, arms), arms))
128
+ : [];
129
+ return {
130
+ resolved,
131
+ configSource,
132
+ obligations,
133
+ requiredBackends,
134
+ backends: armedBackends,
135
+ plans,
136
+ root,
137
+ rootAnchored: anchoredTop !== null,
138
+ location,
139
+ fingerprint,
140
+ clean,
141
+ receiptsPath,
142
+ receiptCount: receipts.length,
143
+ malformed,
144
+ receiptsReadError,
145
+ base,
146
+ evidenceStorePath: degrade.storePath,
147
+ evidenceMalformed: degrade.malformed,
148
+ evidenceReadError: degrade.readError,
149
+ evidenceUnavailable: degrade.unavailable,
150
+ degradedExempt,
151
+ heldSession,
152
+ maskedUntracked: countNeverCommittableUntracked(cwd, { lstat, env }),
153
+ detectionWarning,
154
+ flowPresent: flow.flowPresent,
155
+ flowArmed: flow.flowArmed,
156
+ flowBrokenReason: flow.flowBrokenReason,
157
+ flowOwner: flow.flowOwner,
158
+ planCoverage,
159
+ attestedPlanIds,
160
+ lensRefusedAttestations,
161
+ soloVetoRows,
162
+ };
163
+ };
@@ -0,0 +1,110 @@
1
+ // review-state-flow.mjs - plan-adoption coverage and the armed-flow arms.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { DISPLAY_ALIASES } from './recipes.mjs';
7
+ import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
8
+ import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
9
+ import { selectReliedOnReceipt, evaluateVetoOverride, evaluateInternalAttestationLenses } from './flow-check.mjs';
10
+ import { PLANS_REL } from './plan-files.mjs';
11
+ import { summarizeReviewReceiptsForTree, isShipVerdict } from './core-evidence.mjs';
12
+
13
+ // The in-flight-plan → adopted-chain coverage map (P13/P19): ONE bounded read + ONE hash per plan
14
+ // file; coverage requires the frontmatter planId, the FULL content digest, and the owner to match
15
+ // the adoption record — a mismatch on any axis refuses internal-only arming by name.
16
+ export const computePlanAdoptionCoverage = ({ root, plans, records, owner, readFile = readFileSync }) =>
17
+ plans.map((plan) => {
18
+ let bytes;
19
+ try {
20
+ bytes = readFile(join(root, PLANS_REL, plan));
21
+ } catch (err) {
22
+ return { plan, planId: null, covered: false, reason: `the plan file is unreadable (${(err && err.code) || (err && err.message) || err})` };
23
+ }
24
+ const buf = typeof bytes === 'string' ? Buffer.from(bytes) : bytes;
25
+ const planId = readPlanFrontmatterId(buf.toString('utf8'));
26
+ if (planId == null) return { plan, planId: null, covered: false, reason: 'no frontmatter planId — plan filenames are never chain identity (#58); adopt the plan through the flow writer' };
27
+ const adoptionRecord = records.find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
28
+ if (adoptionRecord === undefined) return { plan, planId, covered: false, reason: `no adoption record for planId "${planId}"` };
29
+ if (adoptionRecord.planDigest !== createHash('sha256').update(buf).digest('hex')) {
30
+ return { plan, planId, covered: false, reason: 'the plan content no longer matches its adoption record (edited after adoption)' };
31
+ }
32
+ if (adoptionRecord.owner !== owner) {
33
+ return { plan, planId, covered: false, reason: `the adoption is owned by "${adoptionRecord.owner}", not this worktree ("${owner}") — a foreign-owner adoption never arms internal-only here` };
34
+ }
35
+ return { plan, planId, covered: true };
36
+ });
37
+
38
+ // Tier 1 is store-file PRESENCE: no file ⇒ every field below stays inert and the decision is
39
+ // byte-identical to the pre-flow checker (the unarmed fast path also reads NO plan file).
40
+ export const readFlowArming = ({ cwd, env, lstat }) => {
41
+ const flowPath = resolveFlowStorePath(cwd, env);
42
+ const flowStat = (() => {
43
+ if (flowPath == null) return null;
44
+ try {
45
+ return lstat(flowPath);
46
+ } catch (err) {
47
+ return err && err.code === 'ENOENT' ? null : 'unstatable';
48
+ }
49
+ })();
50
+ const flowPresent = flowStat !== null;
51
+ const flowRead = flowPresent && flowStat !== 'unstatable' ? readFlowStore(flowPath) : null;
52
+ const flowBrokenReason = !flowPresent ? null
53
+ : flowStat === 'unstatable' ? 'the store leaf cannot be stat-ed (fail closed)'
54
+ : flowRead.readError != null ? `read error: ${flowRead.readError}`
55
+ : flowRead.malformed > 0 ? `${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]})`
56
+ : null;
57
+ const flowArmed = flowPresent && flowBrokenReason == null
58
+ && flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
59
+ const flowOwner = flowArmed ? deriveFlowOwner(cwd) : null;
60
+ return { flowPresent, flowArmed, flowBrokenReason, flowOwner, records: flowArmed ? flowRead.records : [] };
61
+ };
62
+
63
+ // The lens-substitution rung (#15/#3): an attestation whose lens set claims a review provider's
64
+ // slot without a then-active down-mark never counts — and its refusal is NAMED, never silent
65
+ // (lensRefusedAttestations feeds the internal-only floor's failure reason).
66
+ export const attestedPlanIdsFor = ({ records, base, fingerprint }) => {
67
+ const lensRefusedAttestations = [];
68
+ const attestedPlanIds = [...new Set(authoritativeFlowRecords(records)
69
+ .filter((r) => r.kind === 'internal-attestation' && r.base === base && r.fingerprint === fingerprint)
70
+ .filter((r) => {
71
+ const lensCheck = evaluateInternalAttestationLenses({ record: r, records, providerBackends: Object.values(DISPLAY_ALIASES) });
72
+ if (!lensCheck.ok) {
73
+ lensRefusedAttestations.push({ planId: r.planId, reason: lensCheck.reason });
74
+ return false;
75
+ }
76
+ return true;
77
+ })
78
+ .map((r) => r.planId))];
79
+ return { attestedPlanIds, lensRefusedAttestations };
80
+ };
81
+
82
+ // #61: the SHARED relied-on selector (one home with the flow-check coverage rung) — an unbroken
83
+ // declared-path bookkeeping-delta chain lifts the backend's last receipt; any break, fork, or
84
+ // cap exhaustion stays stale.
85
+ export const liftStaleRow = (row, { flowArmed, receipts, records, base, fingerprint, flowConfig }) => {
86
+ if (!flowArmed || row.state !== 'stale') return row;
87
+ const selected = selectReliedOnReceipt({
88
+ receipts, backend: row.backend, tree: { base, fingerprint },
89
+ records,
90
+ declaredPaths: [flowConfig?.debtQueue, flowConfig?.convergenceSummary].filter((p) => typeof p === 'string'),
91
+ refreshCap: flowConfig?.councilRounds,
92
+ });
93
+ if (selected.receipt == null || selected.lifted === 0) return row;
94
+ return {
95
+ ...row, state: 'current', verdict: selected.receipt.verdict ?? 'unknown',
96
+ shipClass: isShipVerdict(selected.receipt.verdict), grounded: true,
97
+ timestamp: selected.receipt.timestamp ?? null, deltaLift: selected.lifted,
98
+ };
99
+ };
100
+
101
+ // #56/#38: a standing CURRENT-tree veto consults its override instance; a delta-lifted veto has
102
+ // no override lane here (the bound set pins the veto receipt's own tree).
103
+ export const consumeVetoOverride = (row, { flowArmed, receipts, records, base, fingerprint }) => {
104
+ if (!flowArmed || !(row.state === 'current' && !row.shipClass) || row.deltaLift != null) return row;
105
+ const own = receipts.filter((r) => r.backend === row.backend);
106
+ const summary = summarizeReviewReceiptsForTree(own, fingerprint);
107
+ if (summary.state !== 'current') return row;
108
+ const lift = evaluateVetoOverride({ records, vetoReceipt: summary.receipt, tree: { base, fingerprint } });
109
+ return lift.lifted ? { ...row, overrideLabel: lift.label } : row;
110
+ };