gentle-pi 2.1.2 → 2.2.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 (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,1169 @@
1
+ // Cross-lane battery for the Pi direct lane. LOCAL, out of CI on purpose:
2
+ // it requires the dev-binary override and drives a real gentle-ai binary
3
+ // through live scratch-repository lifecycles.
4
+ //
5
+ // pnpm test:cross-lane # requires the dev-binary override
6
+ // pnpm test:cross-lane --with-model # adds the real pi reviewer run
7
+ //
8
+ // The battery exists because the pinned decoder lane never sees new envelope
9
+ // schemas and the controller sequencing was never driven through a full
10
+ // lifecycle before merge. Three check groups:
11
+ // 1. Full direct-lane lifecycles against the override binary through
12
+ // runtime/*.mjs (low to gate allow; medium consent/v3 granted).
13
+ // 2. Controller sequencing: at every step the client's decoded offered
14
+ // next step must equal the native transition, and correction evidence
15
+ // must be collected before targeted validation is ever offered
16
+ // (the validate-before-evidence class, pending fix/validate-before-evidence).
17
+ // 3. Forward-decoder freshness: every live envelope captured from the
18
+ // override binary must decode without unknown-key rejection - the
19
+ // early warning that gentle-ai main grew a field gentle-pi lacks.
20
+ //
21
+ // Excluded from `pnpm test` by construction: the default suite globs
22
+ // tests/*.test.ts only.
23
+ import { execFileSync } from "node:child_process";
24
+ import { createHash } from "node:crypto";
25
+ import { chmodSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+
29
+ import {
30
+ GENTLE_AI_DEV_BINARY_ENV,
31
+ resolveGentleAiDevBinaryOverride,
32
+ } from "../../runtime/gentle-ai-binary.mjs";
33
+ import {
34
+ createNativeReviewCli,
35
+ gentleAiProcessEnvironment,
36
+ nativeReviewAbandonAuthorization,
37
+ NativeReviewConsentRequiredError,
38
+ } from "../../runtime/native-review-cli.mjs";
39
+ import {
40
+ decodeReviewCapabilitiesV2,
41
+ decodeReviewConsentV3,
42
+ decodeReviewOperationV2,
43
+ decodeReviewResultArtifactV2,
44
+ decodeReviewStartV3,
45
+ decodeReviewStatusV3,
46
+ } from "../../runtime/review-integration-v2.mjs";
47
+ // The recovered-successor checks drive the CONTROLLER (not just the runtime
48
+ // adapter) against the live binary; Node's default type stripping loads the
49
+ // authored TypeScript directly.
50
+ import { __testing } from "../../extensions/gentle-ai.ts";
51
+ import { CandidateViewRegistry, injectReviewCandidateView } from "../../lib/review-candidate-view.ts";
52
+
53
+ const WITH_MODEL = process.argv.includes("--with-model");
54
+ const CONTRACT = "gentle-ai.review-integration/v2";
55
+ const KNOWN_RED_SEQUENCING = "known-red pending fix/validate-before-evidence";
56
+
57
+ // A schema-incompatible failure mid-lifecycle means the forward decoder lags
58
+ // a field gentle-ai main already emits: the exact parity gap this battery
59
+ // exists to surface before merge. The sequencing check (the
60
+ // validate-before-evidence class, pending fix/validate-before-evidence)
61
+ // stays unevaluated until decoder parity lands.
62
+ function knownRedParity(message) {
63
+ if (!message.includes("schema incompatible")) return message;
64
+ return `known-red pending gentle-pi decoder parity with gentle-ai main: ${message}`;
65
+ }
66
+
67
+ const checks = [];
68
+ const rawEnvelopes = [];
69
+
70
+ function describeError(error) {
71
+ if (!(error instanceof Error)) return String(error);
72
+ const parts = [error.message];
73
+ let cause = error.cause;
74
+ while (cause !== undefined && cause !== null) {
75
+ parts.push(cause instanceof Error ? cause.message : String(cause));
76
+ cause = cause instanceof Error ? cause.cause : undefined;
77
+ }
78
+ let described = parts.join(" <- ");
79
+ if (described.includes("schema incompatible")) {
80
+ described += " (the decoder freshness lane below names the exact rejected field)";
81
+ }
82
+ return described;
83
+ }
84
+
85
+ function report(name, status, note) {
86
+ checks.push({ name, status, note });
87
+ }
88
+
89
+ function pass(name, note) {
90
+ report(name, "PASS", note);
91
+ }
92
+ function fail(name, note) {
93
+ report(name, "FAIL", note);
94
+ }
95
+ function skip(name, note) {
96
+ report(name, "SKIP", note);
97
+ }
98
+
99
+ // --- binary resolution: the dev-binary override is this battery's reason to exist ---
100
+
101
+ function resolveBatteryBinary() {
102
+ const fromEnv = process.env[GENTLE_AI_DEV_BINARY_ENV];
103
+ if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
104
+ const override = resolveGentleAiDevBinaryOverride();
105
+ if (override !== undefined) {
106
+ // Project the registration into the env override so every downstream
107
+ // dev-mode relaxation (version banner, capability floor) is active.
108
+ process.env[GENTLE_AI_DEV_BINARY_ENV] = override.path;
109
+ return override.path;
110
+ }
111
+ throw new Error(
112
+ `no dev binary override: set ${GENTLE_AI_DEV_BINARY_ENV}=<absolute path> or register one with /gentle:dev-binary <path>`,
113
+ );
114
+ }
115
+
116
+ // --- raw native access (the battery's independent view of the binary) ---
117
+
118
+ function rawInvoke(binary, cwd, args) {
119
+ const stdout = execFileSync(binary, args, {
120
+ cwd,
121
+ encoding: "utf8",
122
+ env: gentleAiProcessEnvironment(),
123
+ });
124
+ const body = JSON.parse(stdout);
125
+ if (body !== null && typeof body === "object" && typeof body.schema === "string") {
126
+ rawEnvelopes.push({ schema: body.schema, source: args.slice(0, 2).join(" "), body });
127
+ }
128
+ return body;
129
+ }
130
+
131
+ function rawStatus(binary, cwd) {
132
+ return rawInvoke(binary, cwd, [
133
+ "review", "status", "--contract", CONTRACT, "--cwd", cwd,
134
+ "--projection", "workspace", "--next-transition",
135
+ ]);
136
+ }
137
+
138
+ // --- scratch repositories ---
139
+
140
+ function git(cwd, ...args) {
141
+ execFileSync("git", args, { cwd, encoding: "utf8" });
142
+ }
143
+
144
+ function scratchRepo(root, name) {
145
+ const cwd = join(root, name);
146
+ mkdirSync(cwd, { recursive: true });
147
+ git(cwd, "init", "-q", "-b", "main");
148
+ git(cwd, "config", "user.email", "crosslane@example.com");
149
+ git(cwd, "config", "user.name", "Cross Lane Battery");
150
+ git(cwd, "commit", "-q", "--allow-empty", "-m", "chore: root");
151
+ return cwd;
152
+ }
153
+
154
+ // A LINKED git worktree (not the primary checkout) of a fresh scratch repo:
155
+ // the field shape the reported defect was hit in.
156
+ function scratchLinkedWorktree(root, name) {
157
+ const primary = scratchRepo(root, `${name}-primary`);
158
+ const linked = join(root, `${name}-linked`);
159
+ git(primary, "worktree", "add", "-q", linked, "-b", `feature/${name}`);
160
+ git(linked, "config", "user.email", "crosslane@example.com");
161
+ git(linked, "config", "user.name", "Cross Lane Battery");
162
+ return linked;
163
+ }
164
+
165
+ function write(cwd, name, content) {
166
+ const path = join(cwd, name);
167
+ mkdirSync(dirname(path), { recursive: true });
168
+ writeFileSync(path, content);
169
+ }
170
+
171
+ function commitAll(cwd, message) {
172
+ git(cwd, "add", "-A");
173
+ git(cwd, "commit", "-q", "-m", message);
174
+ }
175
+
176
+ // --- transition parity: the controller's offered next step vs the native one ---
177
+
178
+ function transitionSummary(kind, reasonCode, executeOperation, collectOperations) {
179
+ return JSON.stringify({ kind, reasonCode, executeOperation, collectOperations });
180
+ }
181
+
182
+ function summarizeDecoded(transition) {
183
+ return transitionSummary(
184
+ transition?.kind ?? "(none)",
185
+ transition?.reasonCode ?? "(none)",
186
+ transition?.execute?.operation,
187
+ transition?.collect?.inputs.map((input) => input.captureOperation),
188
+ );
189
+ }
190
+
191
+ function summarizeRaw(transition) {
192
+ return transitionSummary(
193
+ transition?.kind ?? "(none)",
194
+ transition?.reason_code ?? "(none)",
195
+ transition?.execute?.operation,
196
+ transition?.collect?.inputs.map((input) => input.capture_operation),
197
+ );
198
+ }
199
+
200
+ function assertOfferedStepMatchesNative(step, decodedStatus, rawDocument) {
201
+ const offered = summarizeDecoded(decodedStatus.nextTransition);
202
+ const native = summarizeRaw(rawDocument.next_transition);
203
+ if (offered !== native) {
204
+ throw new Error(`${step}: controller offered ${offered} but native transition is ${native}`);
205
+ }
206
+ }
207
+
208
+ function executeTokens(transition) {
209
+ return transition.execute.arguments.map(
210
+ (argument) => argument.token ?? `--${argument.name.replaceAll("_", "-")}=${argument.value}`,
211
+ );
212
+ }
213
+
214
+ function rawArgumentValues(input) {
215
+ const values = {};
216
+ for (const argument of input.arguments ?? []) values[argument.name] = argument.value;
217
+ return values;
218
+ }
219
+
220
+ function substitute(tokens, slots) {
221
+ return tokens.map((token) => {
222
+ let out = token;
223
+ for (const [slot, value] of Object.entries(slots)) out = out.replaceAll(`{{${slot}}}`, value);
224
+ return out;
225
+ });
226
+ }
227
+
228
+ // --- checks ---
229
+
230
+ async function lowLifecycle(binary, cli, root) {
231
+ const cwd = scratchRepo(root, "pi-low");
232
+ write(cwd, "docs/ordinary-guide.md", "# Ordinary guide\n\nline one\n");
233
+ commitAll(cwd, "docs: guide");
234
+ write(cwd, "docs/ordinary-guide.md", "# Ordinary guide\n\nline one\nline two, purely passive documentation\n");
235
+
236
+ let raw = rawStatus(binary, cwd);
237
+ let status = await cli.targetStatus({ cwd });
238
+ assertOfferedStepMatchesNative("pre-start", status, raw);
239
+ if (status.nextTransition?.execute?.operation !== "review.start") {
240
+ throw new Error(`expected review.start, got ${summarizeDecoded(status.nextTransition)}`);
241
+ }
242
+ const start = await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
243
+ if (start.riskLevel !== "low" || start.state !== "reviewing" || start.lensesRequired) {
244
+ throw new Error(`low start decoded riskLevel=${start.riskLevel} state=${start.state} lensesRequired=${start.lensesRequired}`);
245
+ }
246
+
247
+ raw = rawStatus(binary, cwd);
248
+ status = await cli.targetStatus({ cwd });
249
+ assertOfferedStepMatchesNative("pre-finalize", status, raw);
250
+ if (status.nextTransition?.execute?.operation !== "review.finalize") {
251
+ throw new Error(`expected review.finalize, got ${summarizeDecoded(status.nextTransition)}`);
252
+ }
253
+ const finalize = await cli.finalizeTransition({ cwd, argumentTokens: executeTokens(status.nextTransition) });
254
+ if (finalize.state !== "approved") throw new Error(`finalize state=${finalize.state}`);
255
+
256
+ git(cwd, "add", "-A");
257
+ const validate = await cli.validate({ cwd, gate: "pre-commit", lineageId: finalize.lineageId });
258
+ if (!validate.allowed || validate.result !== "allow") {
259
+ throw new Error(`gate result=${validate.result} allowed=${validate.allowed}`);
260
+ }
261
+ return "start (low, zero lenses) -> finalize approved -> pre-commit validate allow, offered step matched native at every hop";
262
+ }
263
+
264
+ async function mediumConsent(binary, cli, root) {
265
+ const cwd = scratchRepo(root, "pi-medium");
266
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\n");
267
+ commitAll(cwd, "feat: mul");
268
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\nexport function twice(a) {\n return a + a;\n}\n");
269
+
270
+ const raw = rawStatus(binary, cwd);
271
+ const status = await cli.targetStatus({ cwd });
272
+ assertOfferedStepMatchesNative("pre-start", status, raw);
273
+ let consent;
274
+ try {
275
+ await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
276
+ throw new Error("medium start unexpectedly proceeded without surfacing consent");
277
+ } catch (error) {
278
+ if (!(error instanceof NativeReviewConsentRequiredError)) throw error;
279
+ consent = error.consent;
280
+ }
281
+ if (consent.schema !== "gentle-ai.review-integration.consent/v3") {
282
+ throw new Error(`consent schema=${consent.schema}`);
283
+ }
284
+ rawEnvelopes.push({ schema: consent.schema, source: "review start (consent)", body: consent.raw });
285
+ const answer = await cli.answerConsent({ cwd, consent, answer: "granted" });
286
+ if (answer.kind !== "started") throw new Error(`granted answer kind=${answer.kind}`);
287
+ const granted = answer.start;
288
+ if (granted.state !== "reviewing" || granted.riskLevel !== "medium" || granted.selectedLenses.length !== 1) {
289
+ throw new Error(`granted state=${granted.state} risk=${granted.riskLevel} lenses=${granted.selectedLenses.length}`);
290
+ }
291
+ return { cwd, note: "consent/v3 surfaced through the direct decoder lane; granted answer created a reviewing medium lineage" };
292
+ }
293
+
294
+ // sequencingLifecycle drives a scripted correction on its own medium lineage
295
+ // and checks, at every step, that the client's decoded offered step equals
296
+ // the native transition - including the evidence-before-validation ordering.
297
+ async function sequencingLifecycle(binary, cli, root) {
298
+ const cwd = scratchRepo(root, "pi-sequencing");
299
+ const base = "export function greet(name) {\n return \"hi \" + name;\n}\n";
300
+ write(cwd, "src/greet.js", base);
301
+ commitAll(cwd, "feat: greet");
302
+ write(cwd, "src/greet.js", `${base}export function shout(name) {\n return name.toUpperCase() + "!";\n}\n`);
303
+
304
+ let raw = rawStatus(binary, cwd);
305
+ let status = await cli.targetStatus({ cwd });
306
+ assertOfferedStepMatchesNative("pre-start", status, raw);
307
+ let consent;
308
+ try {
309
+ await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
310
+ throw new Error("medium start unexpectedly proceeded without surfacing consent");
311
+ } catch (error) {
312
+ if (!(error instanceof NativeReviewConsentRequiredError)) throw error;
313
+ consent = error.consent;
314
+ }
315
+ const sequencingAnswer = await cli.answerConsent({ cwd, consent, answer: "granted" });
316
+ if (sequencingAnswer.kind !== "started") throw new Error(`granted answer kind=${sequencingAnswer.kind}`);
317
+
318
+ // Reviewer slot: capture one deterministic candidate-causal blocker.
319
+ raw = rawStatus(binary, cwd);
320
+ status = await cli.targetStatus({ cwd });
321
+ assertOfferedStepMatchesNative("reviewer-slot", status, raw);
322
+ const reviewerInput = status.nextTransition?.collect?.inputs[0];
323
+ if (reviewerInput?.captureOperation !== "review.capture-result") {
324
+ throw new Error(`expected review.capture-result, got ${summarizeDecoded(status.nextTransition)}`);
325
+ }
326
+ const args = rawArgumentValues(raw.next_transition.collect.inputs[0]);
327
+ const reviewerFile = join(root, "pi-reviewer.json");
328
+ writeFileSync(reviewerFile, JSON.stringify({
329
+ subject_hash: args["subject-hash"],
330
+ inspection: { status: "completed", paths: ["src/greet.js"] },
331
+ evidence: ["shout calls toUpperCase without a nullish guard; introduced by the candidate hunk"],
332
+ findings: [{
333
+ claim: "shout calls toUpperCase on its argument without a null/undefined guard",
334
+ severity: "BLOCKER",
335
+ evidence_class: "deterministic",
336
+ causal_disposition: "introduced",
337
+ lens: "review-reliability",
338
+ location: "src/greet.js:5",
339
+ proof_refs: ["src/greet.js:4-6 calls name.toUpperCase() with no nullish guard in the candidate tree"],
340
+ }],
341
+ }));
342
+ rawInvoke(binary, cwd, [
343
+ "review", "capture-result",
344
+ "--lineage", args.lineage,
345
+ "--expected-revision", args["expected-revision"],
346
+ "--target", args.target,
347
+ "--repository-context", args["repository-context"],
348
+ "--lens", args.lens,
349
+ "--order", args.order,
350
+ "--subject-hash", args["subject-hash"],
351
+ "--input", reviewerFile,
352
+ ]);
353
+
354
+ // Finalize into correction_required through the client.
355
+ raw = rawStatus(binary, cwd);
356
+ status = await cli.targetStatus({ cwd });
357
+ assertOfferedStepMatchesNative("post-capture", status, raw);
358
+ if (status.nextTransition?.execute?.operation !== "review.finalize") {
359
+ throw new Error(`expected review.finalize, got ${summarizeDecoded(status.nextTransition)}`);
360
+ }
361
+ const finalize = await cli.finalizeTransition({ cwd, argumentTokens: executeTokens(status.nextTransition) });
362
+ if (finalize.state !== "correction_required") throw new Error(`finalize state=${finalize.state}`);
363
+
364
+ // Correction plan forecast is submitted BEFORE editing.
365
+ raw = rawStatus(binary, cwd);
366
+ status = await cli.targetStatus({ cwd });
367
+ assertOfferedStepMatchesNative("correction-plan", status, raw);
368
+ const planInput = raw.next_transition?.collect?.inputs[0];
369
+ if (planInput?.capture_operation !== "external.plan_correction") {
370
+ throw new Error(`expected external.plan_correction, got ${summarizeRaw(raw.next_transition)}`);
371
+ }
372
+ const planTokens = substitute(planInput.submission.argument_tokens, { value: "2" });
373
+ rawInvoke(binary, root, ["review", planInput.submission.operation_token, ...planTokens]);
374
+
375
+ // Bounded fix edit.
376
+ write(cwd, "src/greet.js", `${base}export function shout(name) {\n if (name == null) return "!";\n return name.toUpperCase() + "!";\n}\n`);
377
+
378
+ // THE sequencing class check: correction evidence must be collected
379
+ // before any targeted validation is offered.
380
+ raw = rawStatus(binary, cwd);
381
+ status = await cli.targetStatus({ cwd });
382
+ assertOfferedStepMatchesNative("post-fix", status, raw);
383
+ const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
384
+ // An offered validation STEP is a targeted-validation collect input. The
385
+ // bare `validation_request` field is descriptive context both live
386
+ // emitters (pinned 2.2.3 and 2.4.0-main, probed 2026-08-16) publish
387
+ // alongside the evidence collect at `correction_required`.
388
+ const validationOffered =
389
+ inputs.some((input) => input.captureOperation === "external.run_targeted_validation" || input.captureOperation === "review.capture-validation");
390
+ const evidenceInputs = inputs.filter((input) => input.captureOperation === "review.capture-evidence");
391
+ if (validationOffered) {
392
+ throw new Error(`${KNOWN_RED_SEQUENCING}: targeted validation was offered before correction evidence was captured`);
393
+ }
394
+ if (evidenceInputs.length !== 1) {
395
+ throw new Error(`${KNOWN_RED_SEQUENCING}: expected exactly one review.capture-evidence input before validation, got ${summarizeDecoded(status.nextTransition)}`);
396
+ }
397
+ pass("sequencing: evidence collected before targeted validation", "correction status offers exactly one review.capture-evidence and no validation until evidence lands");
398
+
399
+ const evidenceArgs = rawArgumentValues(raw.next_transition.collect.inputs[0]);
400
+ const evidence = await cli.captureEvidence({
401
+ cwd,
402
+ lineageId: evidenceArgs.lineage,
403
+ targetIdentity: evidenceArgs.target,
404
+ expectedRevision: evidenceArgs["expected-revision"],
405
+ outcome: "passed",
406
+ evidenceDocument: `cross-lane battery: node --check src/greet.js passed; shout(null) now returns "!" instead of throwing\n`,
407
+ });
408
+ if (evidence.outcome !== "passed") throw new Error(`evidence outcome=${evidence.outcome}`);
409
+
410
+ // Only now may targeted validation be offered.
411
+ raw = rawStatus(binary, cwd);
412
+ status = await cli.targetStatus({ cwd });
413
+ assertOfferedStepMatchesNative("post-evidence", status, raw);
414
+ const validationInput = raw.next_transition?.collect?.inputs[0];
415
+ if (
416
+ validationInput?.capture_operation !== "external.run_targeted_validation" &&
417
+ validationInput?.capture_operation !== "review.capture-validation"
418
+ ) {
419
+ throw new Error(`expected targeted validation after evidence, got ${summarizeRaw(raw.next_transition)}`);
420
+ }
421
+ if (status.validationRequest === undefined) {
422
+ throw new Error("decoded status is missing the validation request after evidence capture");
423
+ }
424
+ const validatorFile = join(root, "pi-validator.json");
425
+ writeFileSync(validatorFile, JSON.stringify({
426
+ targeted_validation_request_hash: status.validationRequest.requestHash,
427
+ correction_target_identity: status.validationRequest.correctionTargetIdentity,
428
+ original_criteria: { passed: true, evidence: ["frozen correction tree guards name == null before toUpperCase per the embedded diff"] },
429
+ correction_regression: { passed: true, evidence: ["greet() is untouched by the correction diff; only shout gained the guard"] },
430
+ follow_ups: [],
431
+ }));
432
+ const validationTokens = substitute(validationInput.submission.argument_tokens, { value: validatorFile });
433
+ const approved = rawInvoke(binary, root, ["review", validationInput.submission.operation_token, ...validationTokens]);
434
+ const approvedState = approved?.result?.state ?? approved?.state;
435
+ if (approvedState !== "approved") throw new Error(`validation finalize state=${approvedState}`);
436
+ return "offered step matched native at every hop; plan -> fix -> evidence -> targeted validation -> approved receipt";
437
+ }
438
+
439
+ // abandonLifecycle drives the audited abandon end-to-end through the real
440
+ // adapter runtime against the real binary: start a low lineage, abandon it
441
+ // with the adapter-built maintainer authorization, and confirm the native
442
+ // gate accepted the binding, committed a quarantine record, and no longer
443
+ // offers the lineage as live authority. RED-provable: the check asserts the
444
+ // adapter-built binding is exactly the nine-line
445
+ // gentle-ai.review-abandon-authorization/v2 discarded-work binding, so a
446
+ // v1-emitting builder (the drift class that escaped to a live Pi session)
447
+ // fails this check before the binary is even invoked - and would be refused
448
+ // by the native gate anyway.
449
+ async function abandonLifecycle(binary, cli, root) {
450
+ const cwd = scratchRepo(root, "pi-abandon");
451
+ write(cwd, "docs/abandon-guide.md", "# Abandon guide\n\nline one\n");
452
+ commitAll(cwd, "docs: abandon guide");
453
+ write(cwd, "docs/abandon-guide.md", "# Abandon guide\n\nline one\nline two, purely passive documentation\n");
454
+
455
+ let raw = rawStatus(binary, cwd);
456
+ let status = await cli.targetStatus({ cwd });
457
+ assertOfferedStepMatchesNative("pre-start", status, raw);
458
+ const start = await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
459
+ if (start.state !== "reviewing") throw new Error(`abandon precondition start state=${start.state}`);
460
+
461
+ raw = rawStatus(binary, cwd);
462
+ if (raw.authority?.lineage_id !== start.lineageId || typeof raw.authority?.revision !== "string") {
463
+ throw new Error(`live authority missing for started lineage ${start.lineageId}: ${JSON.stringify(raw.authority)}`);
464
+ }
465
+ // A fresh lineage before any capture carries the smallest discarded-work
466
+ // summary the v2 binding names: no captured lens results, no findings,
467
+ // no evidence records.
468
+ const request = {
469
+ cwd,
470
+ lineage: raw.authority.lineage_id,
471
+ expectedRevision: raw.authority.revision,
472
+ snapshotIdentity: raw.projection.initial_snapshot_identity,
473
+ capturedLensResults: [],
474
+ findingsPresent: false,
475
+ evidenceRecordsPresent: false,
476
+ actor: "cross-lane-battery",
477
+ reason: "operator_disposition",
478
+ };
479
+ const authorization = nativeReviewAbandonAuthorization(request);
480
+ const expectedBinding = [
481
+ "gentle-ai.review-abandon-authorization/v2",
482
+ `lineage=${request.lineage}`,
483
+ `revision=${request.expectedRevision}`,
484
+ `snapshot_identity=${request.snapshotIdentity}`,
485
+ "reason=operator_disposition",
486
+ "captured_lens_results=",
487
+ "findings_present=false",
488
+ "evidence_records_present=false",
489
+ "actor=cross-lane-battery",
490
+ ].join("\n");
491
+ if (authorization !== expectedBinding) {
492
+ throw new Error(
493
+ `adapter built ${authorization.split("\n")[0]} instead of the exact nine-line gentle-ai.review-abandon-authorization/v2 binding: the audited-abandon drift class (v1 emission) is back`,
494
+ );
495
+ }
496
+ const result = await cli.abandon({ ...request, maintainerAuthorization: authorization });
497
+ if (result.record?.status !== "committed") throw new Error(`abandon record status=${result.record?.status}`);
498
+ if (result.record?.abandonment?.schema !== "gentle-ai.review-abandon-authorization/v2") {
499
+ throw new Error(`abandon record binding schema=${result.record?.abandonment?.schema}`);
500
+ }
501
+
502
+ // The abandoned lineage must no longer be offered as live authority.
503
+ raw = rawStatus(binary, cwd);
504
+ status = await cli.targetStatus({ cwd });
505
+ assertOfferedStepMatchesNative("post-abandon", status, raw);
506
+ if (raw.authority !== null && raw.authority !== undefined) {
507
+ throw new Error(`post-abandon status still reports live authority ${JSON.stringify(raw.authority)}`);
508
+ }
509
+ if (status.nextTransition?.execute?.operation !== "review.start") {
510
+ throw new Error(`post-abandon expected a fresh review.start, got ${summarizeDecoded(status.nextTransition)}`);
511
+ }
512
+ return "adapter-built nine-line v2 binding accepted natively; quarantine record committed; post-abandon status offers only a fresh start";
513
+ }
514
+
515
+ // recoveredSuccessorLifecycle reproduces the maintainer's live scenario
516
+ // (2026-08-16, Engram #12461/#12466): a lineage recovered EXTERNALLY through
517
+ // the native CLI, then driven by the Pi controller from STATUS alone.
518
+ // 1. approve a low documentation lineage;
519
+ // 2. change the scope with a code edit (medium risk);
520
+ // 3. native `review recover --disposition scope_changed` with the explicit
521
+ // LF-only gentle-ai.review-recovery-authorization/v1 binding (an ACTIVE
522
+ // reviewing predecessor refuses recovery, so approval comes first);
523
+ // 4. defect A: the controller's dispatch binding must hydrate from the
524
+ // STATUS the controller itself decodes — before that STATUS, dispatch
525
+ // refuses with current-binding-missing;
526
+ // 5. defect B: finalize at reviewer_results_required must surface the
527
+ // provider-offered review.capture-result step, never the correction
528
+ // evidence-first-ordering lane;
529
+ // 6. the drive completes to one really captured lens.
530
+ async function recoveredSuccessorLifecycle(binary, cli, root) {
531
+ const cwd = scratchRepo(root, "pi-recovered");
532
+ write(cwd, "docs/recover-guide.md", "# Recover guide\n\nline one\n");
533
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\n");
534
+ commitAll(cwd, "feat: base");
535
+ write(cwd, "docs/recover-guide.md", "# Recover guide\n\nline one\nline two, purely passive documentation\n");
536
+
537
+ // Low predecessor to approval.
538
+ let raw = rawStatus(binary, cwd);
539
+ let status = await cli.targetStatus({ cwd });
540
+ assertOfferedStepMatchesNative("pre-start", status, raw);
541
+ const start = await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
542
+ if (start.riskLevel !== "low" || start.state !== "reviewing") throw new Error(`predecessor start risk=${start.riskLevel} state=${start.state}`);
543
+ status = await cli.targetStatus({ cwd });
544
+ if (status.nextTransition?.execute?.operation !== "review.finalize") {
545
+ throw new Error(`expected review.finalize, got ${summarizeDecoded(status.nextTransition)}`);
546
+ }
547
+ const finalize = await cli.finalizeTransition({ cwd, argumentTokens: executeTokens(status.nextTransition) });
548
+ if (finalize.state !== "approved") throw new Error(`predecessor finalize state=${finalize.state}`);
549
+
550
+ // External scope change: code joins the approved documentation change.
551
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\nexport function twice(a) {\n return a + a;\n}\n");
552
+ raw = rawStatus(binary, cwd);
553
+ const successor = "recovered-successor-crosslane";
554
+ const authorization = [
555
+ "gentle-ai.review-recovery-authorization/v1",
556
+ `predecessor_lineage=${finalize.lineageId}`,
557
+ `predecessor_revision=${finalize.storeRevision}`,
558
+ `target_identity=${raw.target_identity}`,
559
+ "actor=cross-lane-battery",
560
+ "reason=scope changed after approval",
561
+ ].join("\n");
562
+ rawInvoke(binary, cwd, [
563
+ "review", "recover", "--cwd", cwd,
564
+ "--predecessor-lineage", finalize.lineageId,
565
+ "--expected-predecessor-revision", finalize.storeRevision,
566
+ "--successor-lineage", successor,
567
+ "--disposition", "scope_changed",
568
+ "--actor", "cross-lane-battery",
569
+ "--reason", "scope changed after approval",
570
+ "--maintainer-authorization", authorization,
571
+ ]);
572
+
573
+ const registry = new CandidateViewRegistry();
574
+ try {
575
+ // Defect A: before the controller decodes the successor's STATUS, the
576
+ // dispatch registry knows nothing — the refusal is the pre-fix shape.
577
+ let refused = false;
578
+ try {
579
+ injectReviewCandidateView({ agent: "review-reliability", task: "probe", mode: "task" }, registry);
580
+ } catch (error) {
581
+ refused = /no current controller-owned candidate view lineage binding/.test(String(error instanceof Error ? error.message : error));
582
+ }
583
+ if (!refused) throw new Error("pre-STATUS dispatch unexpectedly resolved a binding for the recovered successor");
584
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId: successor }, cwd, new Map(), cli, undefined, undefined, undefined, registry);
585
+ if (!registry.hasCurrentBinding()) {
586
+ throw new Error("controller STATUS did not hydrate the candidate-view dispatch binding for the recovered successor");
587
+ }
588
+ const dispatch = { agent: "review-reliability", task: "review the recovered successor", mode: "task" };
589
+ injectReviewCandidateView(dispatch, registry);
590
+ if (!dispatch.task.includes(successor)) throw new Error("hydrated dispatch context is not bound to the recovered successor lineage");
591
+ pass(
592
+ "recovered binding: STATUS hydrates controller dispatch",
593
+ "external scope_changed successor driven from STATUS alone: pre-STATUS dispatch refused, post-STATUS dispatch injected the successor candidate context (a controller without STATUS hydration fails here)",
594
+ );
595
+
596
+ // Defect B: finalize must follow the provider transition for reviewer
597
+ // results and never the correction evidence-first-ordering lane. On a
598
+ // provider that admits the pi transport the correct route is the host
599
+ // relay; the pi-subprocess hop is stubbed to keep this check free.
600
+ let relaySlots = 0;
601
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
602
+ relaySlots += 1;
603
+ return { promptByteLength: request.captureArgumentTokens.length, resultByteLength: 0, submission: "{}" };
604
+ });
605
+ let finalizeEnvelope;
606
+ try {
607
+ finalizeEnvelope = await __testing.executeReviewControllerOperation({ operation: "finalize", lineageId: successor, input: JSON.stringify({ reviewer_run_acknowledged: true }) }, cwd, new Map(), cli, undefined, undefined, undefined, registry);
608
+ } finally {
609
+ __testing.setReviewHostRelayRunnerForTesting();
610
+ }
611
+ const routedToRelay = relaySlots > 0 && finalizeEnvelope.host_relay?.transport === "pi_host_relay";
612
+ const routedToBlockedCapture = finalizeEnvelope.outcome === "reviewer-results-required"
613
+ && /capture the reviewer result first/i.test(String(finalizeEnvelope.reason))
614
+ && finalizeEnvelope.mutation_performed === false;
615
+ if (!routedToRelay && !routedToBlockedCapture) {
616
+ throw new Error(`finalize routed to ${String(finalizeEnvelope.outcome ?? finalizeEnvelope.status)} instead of the provider reviewer-result step`);
617
+ }
618
+ if (JSON.stringify(finalizeEnvelope).includes("evidence-first-ordering")) {
619
+ throw new Error("finalize still leaked the correction evidence-first-ordering lane");
620
+ }
621
+ pass(
622
+ "recovered routing: finalize offers capture-result, never evidence ordering",
623
+ routedToRelay
624
+ ? "finalize followed the provider transition into the pi host relay for the outstanding reviewer result. Accepting either provider-correct route (relay when the provider admits the pi transport, blocked capture-result otherwise) is NOT a relaxation of the evidence-ordering guard: this check still fails on any evidence-first-ordering leak in the envelope, and on any route that is neither of the two"
625
+ : "document-free finalize at reviewer_results_required returned the actionable review.capture-result block with zero mutations. Accepting either provider-correct route (relay when the provider admits the pi transport, blocked capture-result otherwise) is NOT a relaxation of the evidence-ordering guard: this check still fails on any evidence-first-ordering leak in the envelope, and on any route that is neither of the two",
626
+ );
627
+
628
+ // Complete the drive to one really captured lens through the exact
629
+ // provider collect input.
630
+ raw = rawStatus(binary, cwd);
631
+ const input = raw.next_transition?.collect?.inputs?.[0];
632
+ if (input?.capture_operation !== "review.capture-result") throw new Error(`expected review.capture-result, got ${summarizeRaw(raw.next_transition)}`);
633
+ const args = rawArgumentValues(input);
634
+ const reviewerFile = join(root, "pi-recovered-reviewer.json");
635
+ writeFileSync(reviewerFile, JSON.stringify({
636
+ subject_hash: args["subject-hash"],
637
+ inspection: { status: "completed", paths: (input.changed_path_manifest ?? []).map((entry) => entry.path) },
638
+ evidence: ["twice(a) returns a + a: pure arithmetic introduced by the candidate hunk with no external effects"],
639
+ findings: [],
640
+ }));
641
+ const artifact = rawInvoke(binary, cwd, [
642
+ "review", "capture-result",
643
+ "--lineage", args.lineage,
644
+ "--expected-revision", args["expected-revision"],
645
+ "--target", args.target,
646
+ "--repository-context", args["repository-context"],
647
+ "--lens", args.lens,
648
+ "--order", args.order,
649
+ "--subject-hash", args["subject-hash"],
650
+ "--input", reviewerFile,
651
+ ]);
652
+ if (artifact?.schema !== "gentle-ai.review-result-artifact/v2") throw new Error(`successor lens capture returned schema=${artifact?.schema}`);
653
+ return "low predecessor approved -> native scope_changed recover (explicit v1 binding) -> controller drove the successor from STATUS alone to one captured lens";
654
+ } finally {
655
+ try {
656
+ registry.cleanup(registry.resolveCurrentForLens("review-reliability").token);
657
+ } catch {
658
+ // No hydrated view to clean when the check failed before binding.
659
+ }
660
+ }
661
+ }
662
+
663
+ // externallyRecoveredSuccessor performs the shared setup both recovered-
664
+ // lineage checks need: approve a predecessor in `cwd`, change the scope, and
665
+ // create a successor through the EXTERNAL native `review recover` command
666
+ // with its explicit LF-only v1 binding. Returns the successor lineage id.
667
+ async function externallyRecoveredSuccessor(binary, cli, cwd, successor) {
668
+ let raw = rawStatus(binary, cwd);
669
+ let status = await cli.targetStatus({ cwd });
670
+ assertOfferedStepMatchesNative("pre-start", status, raw);
671
+ const start = await cli.start({ cwd, targetIdentity: status.targetIdentity, projection: "workspace" });
672
+ if (start.state !== "reviewing") throw new Error(`predecessor start state=${start.state}`);
673
+ status = await cli.targetStatus({ cwd });
674
+ if (status.nextTransition?.execute?.operation !== "review.finalize") {
675
+ throw new Error(`expected review.finalize, got ${summarizeDecoded(status.nextTransition)}`);
676
+ }
677
+ const finalize = await cli.finalizeTransition({ cwd, argumentTokens: executeTokens(status.nextTransition) });
678
+ if (finalize.state !== "approved") throw new Error(`predecessor finalize state=${finalize.state}`);
679
+ // The caller has already staged its scope change in the worktree.
680
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\nexport function twice(a) {\n return a + a;\n}\n");
681
+ raw = rawStatus(binary, cwd);
682
+ const authorization = [
683
+ "gentle-ai.review-recovery-authorization/v1",
684
+ `predecessor_lineage=${finalize.lineageId}`,
685
+ `predecessor_revision=${finalize.storeRevision}`,
686
+ `target_identity=${raw.target_identity}`,
687
+ "actor=cross-lane-battery",
688
+ "reason=scope changed after approval",
689
+ ].join("\n");
690
+ rawInvoke(binary, cwd, [
691
+ "review", "recover", "--cwd", cwd,
692
+ "--predecessor-lineage", finalize.lineageId,
693
+ "--expected-predecessor-revision", finalize.storeRevision,
694
+ "--successor-lineage", successor,
695
+ "--disposition", "scope_changed",
696
+ "--actor", "cross-lane-battery",
697
+ "--reason", "scope changed after approval",
698
+ "--maintainer-authorization", authorization,
699
+ ]);
700
+ return successor;
701
+ }
702
+
703
+ // recoveredSuccessorFieldFlow reproduces the FIELD-REPORTED flow (2026-08-16,
704
+ // gentle-pi 402f9f77 + gentle-ai 2.4.0-main): the candidate lives in a LINKED
705
+ // git worktree with UNCOMMITTED tracked modifications, the successor came
706
+ // from an external native recover, and the session drives `finalize` FIRST
707
+ // and dispatches the reviewer straight after — never calling the STATUS
708
+ // controller operation. Hydration wired only into STATUS leaves that flow
709
+ // refusing, which is exactly what the maintainer hit after #340.
710
+ //
711
+ // Residual gap, honestly stated: the battery cannot age a lineage by days or
712
+ // span real OS processes; it reproduces linked-worktree placement, dirty
713
+ // tracked files, external recovery, and a FRESH controller registry (the
714
+ // property a new process actually contributes), not wall-clock age.
715
+ async function recoveredSuccessorFieldFlow(binary, cli, root) {
716
+ const cwd = scratchLinkedWorktree(root, "pi-recovered-linked");
717
+ write(cwd, "docs/recover-guide.md", "# Recover guide\n\nline one\n");
718
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\n");
719
+ commitAll(cwd, "feat: base");
720
+ write(cwd, "docs/recover-guide.md", "# Recover guide\n\nline one\nline two, purely passive documentation\n");
721
+ const successor = await externallyRecoveredSuccessor(binary, cli, cwd, "recovered-linked-successor");
722
+ // The tracked scope change stays UNCOMMITTED, like the reported worktree.
723
+ const dirty = execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8" });
724
+ if (!/^ M src\/mul\.js$/m.test(dirty)) throw new Error(`expected an uncommitted tracked modification, got ${JSON.stringify(dirty)}`);
725
+
726
+ // A brand-new registry stands in for the fresh Pi session.
727
+ const registry = new CandidateViewRegistry();
728
+ try {
729
+ // The pi-subprocess hop is stubbed: this check is about the dispatch
730
+ // binding the finalize-first flow leaves behind, on whichever route the
731
+ // provider offers (host relay when it admits the pi transport).
732
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => ({ promptByteLength: request.captureArgumentTokens.length, resultByteLength: 0, submission: "{}" }));
733
+ let finalizeEnvelope;
734
+ try {
735
+ finalizeEnvelope = await __testing.executeReviewControllerOperation({ operation: "finalize", lineageId: successor, input: JSON.stringify({ reviewer_run_acknowledged: true }) }, cwd, new Map(), cli, undefined, undefined, undefined, registry);
736
+ } finally {
737
+ __testing.setReviewHostRelayRunnerForTesting();
738
+ }
739
+ const routed = finalizeEnvelope.outcome === "reviewer-results-required" || finalizeEnvelope.host_relay?.transport === "pi_host_relay";
740
+ if (!routed) {
741
+ throw new Error(`finalize routed to ${String(finalizeEnvelope.outcome ?? finalizeEnvelope.status)} instead of the provider reviewer-result step`);
742
+ }
743
+ if (JSON.stringify(finalizeEnvelope).includes("evidence-first-ordering")) {
744
+ throw new Error("finalize leaked the correction evidence-first-ordering lane");
745
+ }
746
+ const binding = finalizeEnvelope.dispatch_binding;
747
+ if (binding === undefined) throw new Error("the blocked finalize envelope does not report a dispatch-binding hydration outcome");
748
+ if (binding.hydrated !== true) {
749
+ throw new Error(`finalize reported hydration failure ${String(binding.reason)}: ${String(binding.message)}`);
750
+ }
751
+ if (!registry.hasCurrentBinding()) throw new Error("finalize did not hydrate the candidate-view dispatch binding");
752
+ // The reviewer dispatch the maintainer runs next must resolve.
753
+ const dispatch = { agent: "review-reliability", task: "review the recovered successor", mode: "task" };
754
+ injectReviewCandidateView(dispatch, registry);
755
+ if (!dispatch.task.includes(successor)) throw new Error("hydrated dispatch context is not bound to the recovered successor lineage");
756
+ return "linked worktree + uncommitted tracked changes + external recover: finalize-first (no STATUS call) hydrated the dispatch binding and the reviewer dispatch resolved, on whichever provider-correct route was offered, and the envelope still carries no evidence-first-ordering leak; residual gap: the battery cannot age a lineage by days or span OS processes, only a fresh registry";
757
+ } finally {
758
+ try {
759
+ registry.cleanup(registry.resolveCurrentForLens("review-reliability").token);
760
+ } catch {
761
+ // No hydrated view to clean when the check failed before binding.
762
+ }
763
+ }
764
+ }
765
+
766
+ // relayMaterializeSlotLifecycle covers the shape every earlier check missed:
767
+ // the provider's MATERIALIZE-marked host-relay slot (agent=pi,
768
+ // materialize=true, provider submission) on an externally recovered lineage.
769
+ // Measured root cause (third field report): the adapter's negotiated STATUS
770
+ // never named its agent, so the provider only ever returned a bare
771
+ // capture-result input, reviewHostRelaySlots() saw zero slots, and the relay
772
+ // never ran. This check fails on any build that drops `--agent pi`.
773
+ //
774
+ // Residual gap, stated honestly: the locked-down pi subprocess and the final
775
+ // provider submit leg are NOT executed here — those cost model spend. The
776
+ // check drives everything up to and including the REAL materialize leg
777
+ // against the real binary (the provider-issued tokens must actually produce
778
+ // prompt bytes), and stubs only the pi-subprocess hop.
779
+ // correctedLifecycleThroughAdapter drives the shape three field defects in a
780
+ // row escaped through: a MEDIUM candidate taken all the way through detection,
781
+ // bounded correction, evidence and targeted validation to an approved receipt
782
+ // THROUGH THE ADAPTER — not the clean-approval path, and not raw CLI.
783
+ //
784
+ // Field defect it locks down (Engram #12547): after an admitted correction the
785
+ // candidate identity legitimately moves, and a FINALIZE that merely follows the
786
+ // provider's own execute transition carries no documents. That made the
787
+ // adapter resolve the START-time reviewer view and report
788
+ // `candidate-target-projection-drift`, so no corrected lineage could ever reach
789
+ // a receipt through Pi.
790
+ //
791
+ // The controller-driven client is built from lib/*.ts on purpose: the battery's
792
+ // shared `cli` comes from runtime/*.mjs, and mixing that module instance with
793
+ // the extension's lib/*.ts instance breaks `instanceof` across the boundary,
794
+ // which silently turns the consent path into a generic failure.
795
+ async function correctedLifecycleThroughAdapter(binary, root) {
796
+ const { createNativeReviewCli } = await import("../../lib/native-review-cli.ts");
797
+ const cli = createNativeReviewCli(undefined, binary);
798
+ const cwd = scratchRepo(root, "pi-corrected");
799
+ const base = "export function parsePath(input) {\n return input.split(\"/\");\n}\n";
800
+ write(cwd, "src/parse.js", base);
801
+ commitAll(cwd, "feat: parse");
802
+ // The intentional reliability defect: the last component is omitted.
803
+ write(cwd, "src/parse.js", `${base}export function lastComponent(input) {\n const parts = input.split("/");\n return parts[parts.length - 2];\n}\n`);
804
+
805
+ const registry = new CandidateViewRegistry();
806
+ const context = { cwd, hasUI: false, ui: { confirm: async () => true, notify: () => {} } };
807
+ // The real tool is used, not the bare entry point: the pending-consent map
808
+ // lives for the tool's lifetime, so START and ANSWER-CONSENT share it.
809
+ const { createGentleAiExtension } = await import("../../extensions/gentle-ai.ts");
810
+ const tools = new Map();
811
+ createGentleAiExtension({ nativeReviewCli: cli, candidateViews: registry })({
812
+ on() {}, registerTool(definition) { tools.set(definition.name, definition); }, registerCommand() {},
813
+ });
814
+ const tool = tools.get("gentle_review");
815
+ let call = 0;
816
+ const controller = async (parameters) => (await tool.execute(`corrected-${call++}`, parameters, undefined, undefined, context)).details;
817
+ let boundLineageId;
818
+ try {
819
+ // START through the controller so this session holds the START-time
820
+ // immutable reviewer view — the state the defect needs.
821
+ let envelope = await controller({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) });
822
+ if (envelope.consent_binding === undefined) throw new Error(`medium START did not surface consent: ${String(envelope.outcome ?? envelope.status)}`);
823
+ envelope = await controller({ operation: "answer-consent", input: JSON.stringify({ consentBinding: envelope.consent_binding, answer: "granted" }) });
824
+ const lineageId = envelope.result?.lineage_id;
825
+ boundLineageId = lineageId;
826
+ if (envelope.result?.state !== "reviewing" || lineageId === undefined) throw new Error(`granted START state=${String(envelope.result?.state)}`);
827
+ if (!registry.hasCurrentBinding()) throw new Error("START did not bind the immutable reviewer view for this session");
828
+ const startTree = rawStatus(binary, cwd).projection.current_candidate_tree;
829
+
830
+ // Reviewer slot: one deterministic candidate-caused BLOCKER.
831
+ let raw = rawStatus(binary, cwd);
832
+ let slot = raw.next_transition?.collect?.inputs?.[0];
833
+ if (slot?.capture_operation !== "review.capture-result") throw new Error(`expected review.capture-result, got ${summarizeRaw(raw.next_transition)}`);
834
+ let args = rawArgumentValues(slot);
835
+ const reviewerFile = join(root, "pi-corrected-reviewer.json");
836
+ writeFileSync(reviewerFile, JSON.stringify({
837
+ subject_hash: args["subject-hash"],
838
+ inspection: { status: "completed", paths: ["src/parse.js"] },
839
+ evidence: ["lastComponent indexes parts.length - 2 and omits the last component; introduced by the candidate hunk"],
840
+ findings: [{
841
+ claim: "lastComponent returns the second-to-last path component instead of the last",
842
+ severity: "BLOCKER",
843
+ evidence_class: "deterministic",
844
+ causal_disposition: "introduced",
845
+ lens: args.lens,
846
+ location: "src/parse.js:6",
847
+ proof_refs: ["src/parse.js:4-7 indexes parts.length - 2 in the candidate tree"],
848
+ }],
849
+ }));
850
+ rawInvoke(binary, cwd, ["review", "capture-result", "--lineage", args.lineage, "--expected-revision", args["expected-revision"],
851
+ "--target", args.target, "--repository-context", args["repository-context"], "--lens", args.lens,
852
+ "--order", args.order, "--subject-hash", args["subject-hash"], "--input", reviewerFile]);
853
+
854
+ envelope = await controller({ operation: "finalize", lineageId, input: JSON.stringify({}) });
855
+ if (envelope.result?.state !== "correction_required") throw new Error(`finalize after capture state=${String(envelope.result?.state ?? envelope.outcome)}`);
856
+
857
+ // Bounded correction: forecast BEFORE editing, then the edit.
858
+ raw = rawStatus(binary, cwd);
859
+ const planInput = raw.next_transition?.collect?.inputs?.[0];
860
+ if (planInput?.capture_operation !== "external.plan_correction") throw new Error(`expected external.plan_correction, got ${summarizeRaw(raw.next_transition)}`);
861
+ const bounds = planInput.submission?.values?.[0] ?? {};
862
+ envelope = await controller({ operation: "finalize", lineageId, input: JSON.stringify({ correction_line_forecast: bounds.minimum ?? 1 }) });
863
+ if (envelope.result?.state !== "correction_required") throw new Error(`correction forecast rejected: ${JSON.stringify(envelope.diagnostics ?? envelope.outcome)}`);
864
+ write(cwd, "src/parse.js", `${base}export function lastComponent(input) {\n const parts = input.split("/");\n return parts[parts.length - 1];\n}\n`);
865
+ const correctedTree = rawStatus(binary, cwd).projection.current_candidate_tree;
866
+ if (correctedTree === startTree) throw new Error("the correction did not move the candidate identity");
867
+
868
+ // THE REGRESSION PROBE: a FINALIZE that just follows the provider
869
+ // transition carries no documents. Whatever the provider answers is
870
+ // fine; what must never come back is adapter-side reviewer-view drift.
871
+ envelope = await controller({ operation: "finalize", lineageId, input: JSON.stringify({}) });
872
+ if (envelope.diagnostics?.code === "candidate-target-projection-drift") {
873
+ throw new Error("document-free FINALIZE on the corrected candidate still reports candidate-target-projection-drift");
874
+ }
875
+
876
+ // Correction evidence, then targeted validation, to the receipt.
877
+ envelope = await controller({ operation: "finalize", lineageId, input: JSON.stringify({ final_evidence: "node --check src/parse.js passed; lastComponent now returns the final component", final_verification_passed: true }) });
878
+ if (envelope.diagnostics?.code === "candidate-target-projection-drift") throw new Error("evidence FINALIZE reported candidate-target-projection-drift");
879
+ raw = rawStatus(binary, cwd);
880
+ const validationInput = raw.next_transition?.collect?.inputs?.[0];
881
+ if (validationInput?.capture_operation !== "external.run_targeted_validation") {
882
+ return `corrected lineage reached ${String(raw.authority?.state)} through the adapter with no candidate-target-projection-drift at any step; residual gap: this provider renders targeted validation as the Go-owned ${String(validationInput?.capture_operation)} vector, which costs a model run, so the battery stops before the receipt`;
883
+ }
884
+ // The evidence-only FINALIZE above already advanced the provider to
885
+ // targeted validation, so the receipt is completed through the exact
886
+ // provider-rendered submission (the same way the sequencing check does).
887
+ // Re-calling FINALIZE with final_evidence AND validation re-enters
888
+ // evidence capture on this provider; that lane question is pre-existing
889
+ // and out of scope for this defect, and is noted rather than papered over.
890
+ const request = raw.validation_request ?? validationInput.validation_request;
891
+ const validatorFile = join(root, "pi-corrected-validator.json");
892
+ writeFileSync(validatorFile, JSON.stringify({
893
+ targeted_validation_request_hash: request.request_hash,
894
+ correction_target_identity: request.correction_target_identity,
895
+ original_criteria: { passed: true, evidence: ["frozen correction tree returns parts[parts.length - 1]"] },
896
+ correction_regression: { passed: true, evidence: ["parsePath is untouched by the correction diff"] },
897
+ follow_ups: [],
898
+ }));
899
+ const submitted = rawInvoke(binary, root, ["review", validationInput.submission.operation_token,
900
+ ...substitute(validationInput.submission.argument_tokens, { value: validatorFile })]);
901
+ const finalState = submitted?.result?.state ?? submitted?.state ?? rawStatus(binary, cwd).authority?.state;
902
+ if (finalState !== "approved") throw new Error(`corrected lineage ended at ${String(finalState)} instead of approved`);
903
+ // The receipt must be the adapter-validatable one for the corrected tree.
904
+ const gate = await controller({ operation: "status", lineageId });
905
+ if (gate.result?.authority?.state !== "approved") throw new Error(`adapter status does not see the approved corrected lineage: ${String(gate.result?.authority?.state)}`);
906
+ return "medium candidate driven through the adapter: BLOCKER detected -> bounded correction -> document-free provider-transition FINALIZE with no candidate-target-projection-drift -> correction evidence -> targeted validation -> approved receipt the adapter can see. Residual gap: the final targeted-validation document is submitted through the exact provider-rendered vector, because a combined evidence+validation FINALIZE re-enters evidence capture on this provider (pre-existing lane question, not this defect)";
907
+ } finally {
908
+ // Candidate views are materialized read-only; leaving one behind makes
909
+ // the battery's own root cleanup fail with EACCES.
910
+ const resolvers = [
911
+ () => registry.resolveCurrentForLens("review-reliability"),
912
+ ...(boundLineageId === undefined ? [] : [() => registry.resolveForFinalize(boundLineageId)]),
913
+ ];
914
+ for (const resolve of resolvers) {
915
+ try { registry.cleanup(resolve().token); } catch { /* nothing bound to clean */ }
916
+ }
917
+ }
918
+ }
919
+
920
+ async function relayMaterializeSlotLifecycle(binary, cli, root) {
921
+ const cwd = scratchLinkedWorktree(root, "pi-relay-slot");
922
+ write(cwd, "docs/relay-guide.md", "# Relay guide\n\nline one\n");
923
+ write(cwd, "src/mul.js", "export function mul(a, b) {\n return a * b;\n}\n");
924
+ commitAll(cwd, "feat: base");
925
+ write(cwd, "docs/relay-guide.md", "# Relay guide\n\nline one\nline two, purely passive documentation\n");
926
+ const successor = await externallyRecoveredSuccessor(binary, cli, cwd, "relay-materialize-successor");
927
+
928
+ const relayed = [];
929
+ let materializedBytes = 0;
930
+ const registry = new CandidateViewRegistry();
931
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
932
+ relayed.push(request);
933
+ // The REAL materialize leg: the provider-issued tokens must produce a
934
+ // non-empty opaque prompt from the real binary. No model spend.
935
+ const prompt = execFileSync(binary, ["review", "capture-result", ...request.captureArgumentTokens], {
936
+ cwd,
937
+ env: gentleAiProcessEnvironment(),
938
+ maxBuffer: 64 * 1024 * 1024,
939
+ });
940
+ materializedBytes = prompt.length;
941
+ if (materializedBytes === 0) throw new Error("provider materialize produced no prompt bytes");
942
+ return { promptByteLength: materializedBytes, resultByteLength: 0, submission: "{}" };
943
+ });
944
+ try {
945
+ const envelope = await __testing.executeReviewControllerOperation(
946
+ { operation: "finalize", lineageId: successor, input: JSON.stringify({ reviewer_run_acknowledged: true }) },
947
+ cwd, new Map(), cli, undefined, undefined, undefined, registry,
948
+ );
949
+ if (relayed.length !== 1) {
950
+ throw new Error(`the provider materialize slot never reached the host relay (relayed ${relayed.length}); outcome=${String(envelope.outcome ?? envelope.status)}`);
951
+ }
952
+ const tokens = relayed[0].captureArgumentTokens;
953
+ if (!tokens.includes("--agent=pi") || !tokens.includes("--materialize=true")) {
954
+ throw new Error(`relay slot is missing the provider transport tokens: ${tokens.join(" ")}`);
955
+ }
956
+ if (relayed[0].submission?.operationToken !== "capture-result") {
957
+ throw new Error("relay slot carries no provider submission completing form");
958
+ }
959
+ const hostRelay = envelope.host_relay;
960
+ if (hostRelay?.transport !== "pi_host_relay" || hostRelay.captured_slots?.length !== 1) {
961
+ throw new Error(`controller envelope did not report one relayed slot: ${JSON.stringify(hostRelay)}`);
962
+ }
963
+ return `provider offered the materialize slot to the adapter and it reached the relay verbatim (agent=pi, materialize=true, submission=capture-result); the real materialize leg returned ${materializedBytes} prompt bytes. Residual gap: the locked-down pi subprocess and the provider submit leg are not executed here (model spend)`;
964
+ } finally {
965
+ __testing.setReviewHostRelayRunnerForTesting();
966
+ try {
967
+ registry.cleanup(registry.resolveCurrentForLens("review-reliability").token);
968
+ } catch {
969
+ // No hydrated view to clean when the relay lane bound nothing.
970
+ }
971
+ }
972
+ }
973
+
974
+ async function modelReview(binary, cli, cwd) {
975
+ const raw = rawStatus(binary, cwd);
976
+ const input = raw.next_transition?.collect?.inputs?.[0];
977
+ if (input?.capture_operation !== "review.capture-result") {
978
+ throw new Error(`expected review.capture-result, got ${summarizeRaw(raw.next_transition)}`);
979
+ }
980
+ const args = rawArgumentValues(input);
981
+ const artifact = rawInvoke(binary, cwd, [
982
+ "review", "capture-result",
983
+ "--lineage", args.lineage,
984
+ "--expected-revision", args["expected-revision"],
985
+ "--target", args.target,
986
+ "--repository-context", args["repository-context"],
987
+ "--lens", args.lens,
988
+ "--order", args.order,
989
+ "--subject-hash", args["subject-hash"],
990
+ "--agent", "pi",
991
+ ]);
992
+ if (artifact?.schema !== "gentle-ai.review-result-artifact/v2") {
993
+ throw new Error(`model capture returned schema=${artifact?.schema}`);
994
+ }
995
+ return "Go-owned locked-down pi reviewer captured a native result artifact";
996
+ }
997
+
998
+ // --- forward-decoder freshness over every captured live envelope ---
999
+
1000
+ function decoderFor(schema, binaryDigest) {
1001
+ switch (schema) {
1002
+ case "gentle-ai.review-integration.status/v3":
1003
+ case "gentle-ai.review-integration.status/v4":
1004
+ case "gentle-ai.review-integration.status/v5":
1005
+ return (body) => decodeReviewStatusV3(body);
1006
+ case "gentle-ai.review-integration.start/v3":
1007
+ return (body) => decodeReviewStartV3(body);
1008
+ case "gentle-ai.review-integration.consent/v3":
1009
+ return (body) => decodeReviewConsentV3(body);
1010
+ case "gentle-ai.review-integration.operation/v2":
1011
+ return (body) => decodeReviewOperationV2(body);
1012
+ case "gentle-ai.review-result-artifact/v2":
1013
+ return (body) => decodeReviewResultArtifactV2(body);
1014
+ case "gentle-ai.review-integration.capabilities/v2":
1015
+ case "gentle-ai.review-integration.capabilities/v2.1":
1016
+ case "gentle-ai.review-integration.capabilities/v2.2":
1017
+ return (body) => decodeReviewCapabilitiesV2(body, binaryDigest);
1018
+ default:
1019
+ return undefined;
1020
+ }
1021
+ }
1022
+
1023
+ function decoderFreshness(binary) {
1024
+ const digest = `sha256:${createHash("sha256").update(readFileSync(binary)).digest("hex")}`;
1025
+ const outcomes = new Map();
1026
+ for (const envelope of rawEnvelopes) {
1027
+ const state = outcomes.get(envelope.schema) ?? { total: 0, failures: [] };
1028
+ state.total += 1;
1029
+ const decoder = decoderFor(envelope.schema, digest);
1030
+ if (decoder === undefined) {
1031
+ state.failures.push(`no forward decoder maps ${envelope.schema} (${envelope.source})`);
1032
+ } else {
1033
+ try {
1034
+ decoder(envelope.body);
1035
+ } catch (error) {
1036
+ state.failures.push(`${envelope.source}: ${describeError(error)}`);
1037
+ }
1038
+ }
1039
+ outcomes.set(envelope.schema, state);
1040
+ }
1041
+ for (const schema of [...outcomes.keys()].sort()) {
1042
+ const state = outcomes.get(schema);
1043
+ const unique = [...new Set(state.failures)].slice(0, 3);
1044
+ if (state.failures.length === 0) {
1045
+ pass(`decoder freshness: ${schema}`, `${state.total} live envelope(s) decoded without unknown-key rejection`);
1046
+ } else {
1047
+ fail(`decoder freshness: ${schema}`, `${state.failures.length}/${state.total} rejected: ${unique.join(" | ")}`);
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ // Candidate views are materialized read-only, so a leaked one makes a plain
1053
+ // rmSync fail with EACCES — and losing the battery's results table to a scratch
1054
+ // permission is worse than leaving bytes in /tmp. Make everything writable
1055
+ // first, then remove, and never let cleanup mask the run's outcome.
1056
+ function removeScratchRoot(root) {
1057
+ const makeWritable = (path) => {
1058
+ let entry;
1059
+ try { entry = statSync(path); } catch { return; }
1060
+ try { chmodSync(path, entry.isDirectory() ? 0o700 : 0o600); } catch { /* best effort */ }
1061
+ if (!entry.isDirectory()) return;
1062
+ let children = [];
1063
+ try { children = readdirSync(path); } catch { return; }
1064
+ for (const child of children) makeWritable(join(path, child));
1065
+ };
1066
+ makeWritable(root);
1067
+ try {
1068
+ rmSync(root, { recursive: true, force: true });
1069
+ } catch (error) {
1070
+ console.log(`note: scratch root ${root} could not be fully removed (${error.code ?? "unknown"}); results below are unaffected`);
1071
+ }
1072
+ }
1073
+
1074
+ // --- driver ---
1075
+
1076
+ async function main() {
1077
+ const binary = resolveBatteryBinary();
1078
+ console.log(`cross-lane battery (Pi direct lane)`);
1079
+ console.log(`binary: ${binary}`);
1080
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-crosslane-"));
1081
+ const cli = createNativeReviewCli(undefined, binary);
1082
+ try {
1083
+ // Capture one live capabilities envelope for the freshness lane.
1084
+ rawInvoke(binary, root, ["review", "capabilities", "--contract", CONTRACT]);
1085
+
1086
+ try {
1087
+ pass("low lifecycle to gate allow", await lowLifecycle(binary, cli, root));
1088
+ } catch (error) {
1089
+ fail("low lifecycle to gate allow", knownRedParity(describeError(error)));
1090
+ }
1091
+
1092
+ let mediumRepo;
1093
+ try {
1094
+ const outcome = await mediumConsent(binary, cli, root);
1095
+ mediumRepo = outcome.cwd;
1096
+ pass("medium consent/v3 granted round-trip", outcome.note);
1097
+ } catch (error) {
1098
+ fail("medium consent/v3 granted round-trip", describeError(error));
1099
+ }
1100
+
1101
+ try {
1102
+ pass("sequencing lifecycle to approved receipt", await sequencingLifecycle(binary, cli, root));
1103
+ } catch (error) {
1104
+ const message = describeError(error);
1105
+ const name = message.startsWith(KNOWN_RED_SEQUENCING)
1106
+ ? "sequencing: evidence collected before targeted validation"
1107
+ : "sequencing lifecycle to approved receipt";
1108
+ fail(name, knownRedParity(message));
1109
+ }
1110
+
1111
+ try {
1112
+ pass("audited abandon end-to-end (v2 discarded-work binding)", await abandonLifecycle(binary, cli, root));
1113
+ } catch (error) {
1114
+ fail("audited abandon end-to-end (v2 discarded-work binding)", knownRedParity(describeError(error)));
1115
+ }
1116
+
1117
+ try {
1118
+ pass("external native recover to captured successor lens", await recoveredSuccessorLifecycle(binary, cli, root));
1119
+ } catch (error) {
1120
+ fail("external native recover to captured successor lens", knownRedParity(describeError(error)));
1121
+ }
1122
+
1123
+ try {
1124
+ pass("recovered field flow: linked dirty worktree, finalize-first dispatch", await recoveredSuccessorFieldFlow(binary, cli, root));
1125
+ } catch (error) {
1126
+ fail("recovered field flow: linked dirty worktree, finalize-first dispatch", knownRedParity(describeError(error)));
1127
+ }
1128
+
1129
+ try {
1130
+ pass("relay materialize slot on an externally recovered lineage", await relayMaterializeSlotLifecycle(binary, cli, root));
1131
+ } catch (error) {
1132
+ fail("relay materialize slot on an externally recovered lineage", knownRedParity(describeError(error)));
1133
+ }
1134
+
1135
+ try {
1136
+ pass("corrected lifecycle through the adapter to an approved receipt", await correctedLifecycleThroughAdapter(binary, root));
1137
+ } catch (error) {
1138
+ fail("corrected lifecycle through the adapter to an approved receipt", knownRedParity(describeError(error)));
1139
+ }
1140
+
1141
+ if (WITH_MODEL && mediumRepo !== undefined) {
1142
+ try {
1143
+ pass("medium reviewer model run (pi)", await modelReview(binary, cli, mediumRepo));
1144
+ } catch (error) {
1145
+ fail("medium reviewer model run (pi)", describeError(error));
1146
+ }
1147
+ } else {
1148
+ skip("medium reviewer model run (pi)", "pass --with-model to run the Go-owned pi reviewer (model spend)");
1149
+ }
1150
+
1151
+ decoderFreshness(binary);
1152
+ } finally {
1153
+ removeScratchRoot(root);
1154
+ }
1155
+
1156
+ const nameWidth = Math.max(...checks.map((check) => check.name.length), "check".length);
1157
+ console.log("");
1158
+ console.log(`${"check".padEnd(nameWidth)} status note`);
1159
+ let failed = 0;
1160
+ for (const check of checks) {
1161
+ console.log(`${check.name.padEnd(nameWidth)} ${check.status.padEnd(6)} ${check.note}`);
1162
+ if (check.status === "FAIL") failed += 1;
1163
+ }
1164
+ console.log("");
1165
+ console.log(`total: ${checks.length} checks, ${failed} failed`);
1166
+ if (failed > 0) process.exitCode = 1;
1167
+ }
1168
+
1169
+ await main();