gentle-pi 2.2.0 → 2.3.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 (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -0,0 +1,360 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import test from "node:test";
8
+
9
+ // gentle-pi#311 P6 — restart parity for the provider-relay capture lane.
10
+ //
11
+ // P1-P5 proved same-process STATUS re-query after a relay transport failure
12
+ // (tests/review-host-relay-routing.test.ts): the capture tool re-queries STATUS
13
+ // in-process and directs the operator to relaunch only if the exact same bound
14
+ // slot is reoffered. P6 closes the remaining gap: the binding must survive a
15
+ // TRUE controller-process restart, and the retry discipline must be provable,
16
+ // not merely asserted.
17
+ //
18
+ // Each scenario runs the controller in its own child process with a fresh
19
+ // module load of extensions/gentle-ai.ts. Module-level state and every closure
20
+ // restart from scratch. The truthful restart protocol uses the public surface:
21
+ // 1. Process A: INSPECT exposes one opaque collectBinding, which is copied
22
+ // unchanged to exactly one gentle_review_capture invocation. The relay
23
+ // transport fails without capture or source/authority mutation.
24
+ // 2. Process B (fresh): INSPECT queries fresh provider STATUS and exposes the
25
+ // pending opaque collectBinding; it invokes neither capture nor the relay.
26
+ // 3. The parent compares Process B's provider-returned binding to Process A's
27
+ // observed binding without parsing or rebuilding it.
28
+ // 4. Only when equal, fresh Process C copies its newly reoffered binding to
29
+ // one capture invocation.
30
+ // 5. Drifted and missing reoffers expose no eligible retry; the parent does
31
+ // not spawn a capture process.
32
+ //
33
+ // The provider (native targetStatus) is stubbed from a JSON file the parent
34
+ // controls; the provider's own durability is proven elsewhere
35
+ // (native-review-parity-runtime.test.ts drives the real binary). What is under
36
+ // test is the controller's restart safety.
37
+
38
+ const SHA = `sha256:${"1".repeat(64)}`;
39
+ const TREE = "2".repeat(40);
40
+ const LINEAGE = "relay-lineage";
41
+ const LENS = "review-reliability";
42
+ const ORDER = 0;
43
+
44
+ function bindingArguments(lineageId, lens, order) {
45
+ return [
46
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
47
+ { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
48
+ { name: "target", value: SHA, token: `--target=${SHA}` },
49
+ { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
50
+ { name: "lens", value: lens, token: `--lens=${lens}` },
51
+ { name: "order", value: String(order), token: `--order=${order}` },
52
+ { name: "subject-hash", value: `sha256:${String(order).repeat(64)}`, token: `--subject-hash=sha256:${String(order).repeat(64)}` },
53
+ ];
54
+ }
55
+
56
+ function providerSubmission(lineageId, lens, order) {
57
+ const bindingTokens = bindingArguments(lineageId, lens, order).map((a) => a.token);
58
+ return {
59
+ operationToken: "capture-result",
60
+ argumentTokens: [...bindingTokens, "--input={{value}}"],
61
+ values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: bindingTokens.length }],
62
+ };
63
+ }
64
+
65
+ function relayCollectInput(lineageId, lens, order) {
66
+ return {
67
+ name: "reviewer_result",
68
+ schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
69
+ captureOperation: "review.capture-result",
70
+ arguments: [
71
+ ...bindingArguments(lineageId, lens, order),
72
+ { name: "agent", value: "pi", token: "--agent=pi" },
73
+ { name: "materialize", value: "true", token: "--materialize=true" },
74
+ ],
75
+ submission: providerSubmission(lineageId, lens, order),
76
+ };
77
+ }
78
+
79
+ function rawNextTransition(inputs) {
80
+ return inputs.length === 0 ? undefined : {
81
+ kind: "collect",
82
+ reason_code: "reviewer_results_required",
83
+ collect: { inputs: inputs.map((input) => ({
84
+ name: input.name,
85
+ schema: input.schema,
86
+ capture_operation: input.captureOperation,
87
+ arguments: input.arguments,
88
+ submission: input.submission,
89
+ })) },
90
+ };
91
+ }
92
+
93
+ function collectStatus(lineageId, inputs) {
94
+ const transition = rawNextTransition(inputs);
95
+ return {
96
+ contract: "gentle-ai.review-integration/v2",
97
+ applicability: "current_target",
98
+ authority: { version: "compact-v2", lineageId, state: "reviewing", generation: 1, revision: SHA },
99
+ receipt: { status: "none" },
100
+ action: "stop",
101
+ replayability: "not_replayable",
102
+ targetIdentity: SHA,
103
+ projection: {
104
+ schema: "gentle-ai.review-candidate-projection/v1", kind: "current-changes", projection: "workspace",
105
+ baseTree: TREE, initialReviewTree: TREE, currentCandidateTree: TREE,
106
+ pathsDigest: SHA, paths: ["app.ts"], intendedUntracked: [], intendedUntrackedProof: SHA,
107
+ initialSnapshotIdentity: SHA, currentSnapshotIdentity: SHA,
108
+ },
109
+ candidates: [],
110
+ ...(transition === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }),
111
+ raw: {
112
+ schema: "gentle-ai.review-integration.status/v5", contract: "gentle-ai.review-integration/v2",
113
+ action: "stop", lineage_id: lineageId, target_identity: SHA,
114
+ ...(transition === undefined ? {} : { next_transition: transition }),
115
+ },
116
+ };
117
+ }
118
+
119
+ function repository(t) {
120
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-restart-")));
121
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
122
+ execFileSync("git", ["init", "-b", "main"], { cwd });
123
+ writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
124
+ execFileSync("git", ["add", "."], { cwd });
125
+ execFileSync("git", ["-c", "user.name=Relay Restart Test", "-c", "user.email=relay-restart@example.invalid", "commit", "-m", "initial"], { cwd });
126
+ return cwd;
127
+ }
128
+
129
+ function restartWorkerSource() {
130
+ const extensionUrl = pathToFileURL(join(import.meta.dirname, "..", "extensions", "gentle-ai.ts")).href;
131
+ const relayUrl = pathToFileURL(join(import.meta.dirname, "..", "lib", "review-host-relay.ts")).href;
132
+ const terminalSubmission = JSON.stringify({
133
+ schema: "gentle-ai.review-last-event-closure/v1",
134
+ operation: "review/capture-result",
135
+ lineage_id: LINEAGE,
136
+ state: "approved",
137
+ store_revision: SHA,
138
+ action: "native last event closed the review",
139
+ });
140
+ return `
141
+ import { readFile, writeFile } from "node:fs/promises";
142
+
143
+ const [cwd, statusFile, mode, outFile] = process.argv.slice(-4);
144
+ const { __testing } = await import(${JSON.stringify(extensionUrl)});
145
+ const { ReviewHostRelayError, REVIEW_HOST_RELAY_FAILURE } = await import(${JSON.stringify(relayUrl)});
146
+ const statusQueue = JSON.parse(await readFile(statusFile, "utf8"));
147
+ const relayRequests = [];
148
+ const statusCalls = [];
149
+ let captureCalls = 0;
150
+
151
+ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
152
+ relayRequests.push({
153
+ captureArgumentTokens: request.captureArgumentTokens,
154
+ submission: request.submission,
155
+ });
156
+ if (mode === "capture-fail") {
157
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4 });
158
+ }
159
+ if (mode === "capture-ambiguous") {
160
+ throw Object.assign(new Error("capture response lost"), { mutationOutcome: "unknown", nextAction: "review.status" });
161
+ }
162
+ if (mode === "capture-terminal") {
163
+ return { promptByteLength: 64, resultByteLength: 32, submission: ${JSON.stringify(terminalSubmission)} };
164
+ }
165
+ return { promptByteLength: 64, resultByteLength: 32, submission: "{\\"admission_decision\\":\\"completed\\"}" };
166
+ });
167
+
168
+ const nativeReviewCli = {
169
+ targetStatus: async (request) => {
170
+ statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }) });
171
+ const next = statusQueue.shift();
172
+ if (next === undefined) throw new Error("status queue exhausted");
173
+ return next;
174
+ },
175
+ };
176
+
177
+ let result;
178
+ let inspectResult;
179
+ let captureBinding;
180
+ let error;
181
+ try {
182
+ inspectResult = await __testing.executeReviewControllerOperation({ operation: "inspect" }, cwd, nativeReviewCli);
183
+ if (mode === "inspect") {
184
+ result = inspectResult;
185
+ } else {
186
+ const bindings = inspectResult.collectBindings;
187
+ if (!Array.isArray(bindings) || bindings.length !== 1 || typeof bindings[0]?.collectBinding !== "string") {
188
+ throw new Error("INSPECT did not expose exactly one public collectBinding");
189
+ }
190
+ captureBinding = bindings[0].collectBinding;
191
+ captureCalls += 1;
192
+ result = await __testing.executeReviewCaptureOperation(
193
+ { lineageId: "relay-lineage", collectBinding: captureBinding, reviewerRunAcknowledged: true },
194
+ cwd,
195
+ nativeReviewCli,
196
+ );
197
+ }
198
+ } catch (caught) {
199
+ error = caught instanceof Error ? { name: caught.name, message: caught.message } : String(caught);
200
+ }
201
+
202
+ await writeFile(outFile, JSON.stringify({ result, inspectResult, captureBinding, error, relayRequests, statusCalls, captureCalls }, null, 2));
203
+ `;
204
+ }
205
+
206
+ function runWorker(t, cwd, statuses, mode) {
207
+ const scratch = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-restart-run-")));
208
+ t.after(() => rmSync(scratch, { recursive: true, force: true }));
209
+ const statusFile = join(scratch, "statuses.json");
210
+ const outFile = join(scratch, "out.json");
211
+ writeFileSync(statusFile, JSON.stringify(statuses));
212
+ const out = execFileSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", restartWorkerSource(), cwd, statusFile, mode, outFile], {
213
+ encoding: "utf8", env: process.env, timeout: 15_000,
214
+ });
215
+ assert.equal(out.length, 0, `worker should write nothing to stdout, got: ${out}`);
216
+ return JSON.parse(readFileSync(outFile, "utf8"));
217
+ }
218
+
219
+ function inspectCollectBinding(inspectResult) {
220
+ const bindings = inspectResult?.collectBindings;
221
+ if (!Array.isArray(bindings) || bindings.length !== 1) return undefined;
222
+ return typeof bindings[0]?.collectBinding === "string" ? bindings[0].collectBinding : undefined;
223
+ }
224
+
225
+ const PENDING = collectStatus(LINEAGE, [relayCollectInput(LINEAGE, LENS, ORDER)]);
226
+ const CONVERGED = collectStatus(LINEAGE, []);
227
+
228
+ test("a fresh controller process reoffers the exact public binding before one capture closes", async (t) => {
229
+ const cwd = repository(t);
230
+
231
+ // Process A copies its own public binding to capture. Transport fails without
232
+ // a capture mutation, and the capture path does not automatically re-query.
233
+ const failure = runWorker(t, cwd, [PENDING, PENDING], "capture-fail");
234
+ assert.equal(failure.error, undefined, `failure run threw: ${JSON.stringify(failure.error)}`);
235
+ assert.equal(failure.captureCalls, 1, "Process A invokes one capture");
236
+ assert.equal(failure.relayRequests.length, 1, "Process A observes exactly one pending slot");
237
+ assert.equal(failure.statusCalls.length, 2, "Process A performs INSPECT and the selected capture STATUS only");
238
+ assert.deepEqual(failure.statusCalls, [{ cwd }, { cwd, lineageId: LINEAGE }]);
239
+ assert.equal(failure.result.tool, "gentle_review_capture");
240
+ assert.equal(failure.result.status, "blocked");
241
+ assert.equal(failure.result.outcome, "pi-host-relay-transport-failure");
242
+ assert.deepEqual(failure.result.failure, { kind: "pi-failed", stage: "pi", exit_code: 4, timed_out: false });
243
+ assert.equal((failure.result.captured_slots ?? []).length, 0, "transport failure captures nothing");
244
+ assert.equal(failure.result.mutation_performed, false);
245
+ assert.match(String(failure.result.next_action), /fresh STATUS/);
246
+ assert.match(String(failure.result.next_action), /exact reoffered one-slot binding/);
247
+ const observedBinding = failure.captureBinding;
248
+ assert.equal(observedBinding, inspectCollectBinding(failure.inspectResult), "Process A copies the public binding without rebuilding it");
249
+ const observedRelayRequest = failure.relayRequests[0];
250
+
251
+ // Process B is a true restart. It only exposes the provider-owned public
252
+ // binding, so the parent can decide whether a retry is safe.
253
+ const inspect = runWorker(t, cwd, [PENDING], "inspect");
254
+ assert.equal(inspect.error, undefined, `inspect run threw: ${JSON.stringify(inspect.error)}`);
255
+ assert.equal(inspect.captureCalls, 0, "INSPECT never invokes capture");
256
+ assert.equal(inspect.relayRequests.length, 0, "INSPECT never launches the relay");
257
+ assert.equal(inspect.statusCalls.length, 1, "INSPECT queries STATUS exactly once");
258
+ assert.deepEqual(inspect.statusCalls, [{ cwd }]);
259
+ assert.equal(inspect.result.operation, "inspect");
260
+ const reofferedBinding = inspectCollectBinding(inspect.result);
261
+ assert.ok(reofferedBinding !== undefined, "INSPECT exposed one provider-returned pending binding");
262
+ assert.equal(reofferedBinding, observedBinding, "the fresh reoffer is byte-identical to the observed opaque binding");
263
+
264
+ // Process C runs only after the parent confirms equality. It copies its fresh
265
+ // reoffer to one capture and receives the terminal native closure directly.
266
+ const relaunch = runWorker(t, cwd, [PENDING, PENDING], "capture-terminal");
267
+ assert.equal(relaunch.error, undefined, `relaunch threw: ${JSON.stringify(relaunch.error)}`);
268
+ assert.equal(relaunch.captureCalls, 1, "Process C invokes exactly one capture");
269
+ assert.equal(relaunch.relayRequests.length, 1, "Process C launches one relay from the fresh reoffer");
270
+ assert.deepEqual(relaunch.statusCalls, [{ cwd }, { cwd, lineageId: LINEAGE }], "terminal capture performs no post-success STATUS");
271
+ assert.equal(relaunch.captureBinding, observedBinding, "Process C copies the exact reoffered binding");
272
+ assert.deepEqual(relaunch.relayRequests[0], observedRelayRequest, "the relay receives the same provider-owned slot after restart");
273
+ assert.deepEqual(relaunch.relayRequests[0].submission, providerSubmission(LINEAGE, LENS, ORDER));
274
+ assert.equal(relaunch.result.tool, "gentle_review_capture");
275
+ assert.equal(relaunch.result.status, "closed");
276
+ assert.equal(relaunch.result.outcome, "native-last-event-closure");
277
+ assert.equal(relaunch.result.closure.operation, "review/capture-result");
278
+
279
+ // A nonterminal result also stops at the one capture; it does not follow the
280
+ // next lifecycle transition on its own.
281
+ const nonterminal = runWorker(t, cwd, [PENDING, PENDING], "capture-nonterminal");
282
+ assert.equal(nonterminal.error, undefined, `nonterminal capture threw: ${JSON.stringify(nonterminal.error)}`);
283
+ assert.equal(nonterminal.captureCalls, 1);
284
+ assert.equal(nonterminal.relayRequests.length, 1);
285
+ assert.deepEqual(nonterminal.statusCalls, [{ cwd }, { cwd, lineageId: LINEAGE }], "nonterminal capture does not auto-follow STATUS");
286
+ assert.equal(nonterminal.result.status, "captured");
287
+ assert.equal(nonterminal.result.outcome, "native-reviewer-result-captured");
288
+
289
+ // A response with an unknown mutation outcome reconciles once through STATUS
290
+ // without replaying the capture or relay.
291
+ const ambiguous = runWorker(t, cwd, [PENDING, PENDING, CONVERGED], "capture-ambiguous");
292
+ assert.equal(ambiguous.error, undefined, `ambiguous capture threw: ${JSON.stringify(ambiguous.error)}`);
293
+ assert.equal(ambiguous.captureCalls, 1, "ambiguous capture is never replayed");
294
+ assert.equal(ambiguous.relayRequests.length, 1, "ambiguity does not relaunch the relay");
295
+ assert.deepEqual(ambiguous.statusCalls, [{ cwd }, { cwd, lineageId: LINEAGE }, { cwd, lineageId: LINEAGE }], "one capture STATUS plus one reconciliation STATUS");
296
+ assert.equal(ambiguous.result.tool, "gentle_review_capture");
297
+ assert.equal(ambiguous.result.status, "reconciled");
298
+ assert.equal(ambiguous.result.outcome, "native-capture-outcome-unknown");
299
+ });
300
+
301
+ test("every provider-owned binding drift forbids a capture after restart", async (t) => {
302
+ const cwd = repository(t);
303
+
304
+ // Process A observes the original public binding and suffers a transport
305
+ // failure. The parent retains the opaque binding only for equality checks.
306
+ const failure = runWorker(t, cwd, [PENDING, PENDING], "capture-fail");
307
+ assert.equal(failure.captureCalls, 1);
308
+ assert.equal(failure.relayRequests.length, 1);
309
+ assert.equal(failure.result.outcome, "pi-host-relay-transport-failure");
310
+ const observedBinding = failure.captureBinding;
311
+ const baseInput = relayCollectInput(LINEAGE, LENS, ORDER);
312
+ const driftArgument = (name, value, token) => {
313
+ const input = structuredClone(baseInput);
314
+ const argument = input.arguments.find((candidate) => candidate.name === name);
315
+ assert.ok(argument !== undefined);
316
+ const originalToken = argument.token;
317
+ Object.assign(argument, { value, token });
318
+ input.submission.argumentTokens = input.submission.argumentTokens.map((candidate) => candidate === originalToken ? token : candidate);
319
+ return input;
320
+ };
321
+ const driftedSubmission = structuredClone(baseInput);
322
+ driftedSubmission.submission.values[0]!.slot = "drifted_reviewer_result";
323
+ const driftSha = `sha256:${"f".repeat(64)}`;
324
+ const driftCases = [
325
+ { field: "submission", input: driftedSubmission },
326
+ { field: "captureArgumentTokens", input: driftArgument("expected-revision", driftSha, `--expected-revision=${driftSha}`) },
327
+ { field: "order", input: driftArgument("order", String(ORDER + 1), `--order=${ORDER + 1}`) },
328
+ { field: "subjectHash", input: driftArgument("subject-hash", driftSha, `--subject-hash=${driftSha}`) },
329
+ { field: "lens", input: driftArgument("lens", "review-risk", "--lens=review-risk") },
330
+ ];
331
+
332
+ for (const { field, input } of driftCases) {
333
+ // Process B is fresh and exposes one independently drifted public binding.
334
+ const inspect = runWorker(t, cwd, [collectStatus(LINEAGE, [input])], "inspect");
335
+ assert.equal(inspect.captureCalls, 0, `${field}: INSPECT never invokes capture`);
336
+ assert.equal(inspect.relayRequests.length, 0, `${field}: INSPECT never launches the relay`);
337
+ assert.deepEqual(inspect.statusCalls, [{ cwd }], `${field}: INSPECT queries STATUS once`);
338
+ const reofferedBinding = inspectCollectBinding(inspect.result);
339
+ assert.ok(reofferedBinding !== undefined, `${field}: INSPECT exposes the drifted binding for comparison`);
340
+ assert.notEqual(reofferedBinding, observedBinding, `${field}: the drifted reoffer must not equal the observed binding`);
341
+ }
342
+ });
343
+
344
+ test("a missing restarted reoffer exposes no public binding and forbids capture", async (t) => {
345
+ const cwd = repository(t);
346
+
347
+ // Process A observes the original binding and suffers a transport failure.
348
+ const failure = runWorker(t, cwd, [PENDING, PENDING], "capture-fail");
349
+ assert.equal(failure.captureCalls, 1);
350
+ assert.equal(failure.relayRequests.length, 1);
351
+ assert.equal(failure.result.outcome, "pi-host-relay-transport-failure");
352
+
353
+ // Process B is fresh and returns a converged STATUS with no public binding.
354
+ // The parent confirms the absence, so it does not launch another process.
355
+ const inspect = runWorker(t, cwd, [CONVERGED], "inspect");
356
+ assert.equal(inspect.captureCalls, 0, "INSPECT never invokes capture");
357
+ assert.equal(inspect.relayRequests.length, 0, "INSPECT never launches the relay");
358
+ assert.deepEqual(inspect.statusCalls, [{ cwd }], "INSPECT queries STATUS once");
359
+ assert.equal(inspectCollectBinding(inspect.result), undefined, "INSPECT exposed no matching public binding");
360
+ });