gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,142 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { pathToFileURL } from "node:url";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
10
+ import type { NativeReviewCli } from "../lib/native-review-cli.ts";
11
+
12
+ // gentle-pi#560 / gentle-ai#4056, #4057: since 2026-08-01 Gentle AI stopped
13
+ // writing a runtime-specific review execution contract into Pi's generated
14
+ // APPEND_SYSTEM composition. This package now injects the mirrored provider
15
+ // contract bundle's own `orchestration/pi.md` text at session start instead.
16
+ // These tests exercise the real committed mirror (contracts/review-provider-contract-mirror/)
17
+ // rather than a fake one: the mirror IS the package under test.
18
+
19
+ type BeforeAgentStartResult = { systemPrompt: string };
20
+ type BeforeAgentStartHandler = (event: unknown, ctx: ExtensionContext) => Promise<BeforeAgentStartResult>;
21
+
22
+ const REPO_ROOT = join(import.meta.dirname, "..");
23
+ const MIRROR_LOCK_PATH = join(REPO_ROOT, "contracts", "review-provider-contract-mirror", "provider-contract.lock.json");
24
+
25
+ function mirroredPiOrchestrationText(): string {
26
+ const lock = JSON.parse(readFileSync(MIRROR_LOCK_PATH, "utf8")) as { contract_semver: string };
27
+ return readFileSync(
28
+ join(REPO_ROOT, "contracts", "review-provider-contract-mirror", `v${lock.contract_semver}`, "bundle", "orchestration", "pi.md"),
29
+ "utf8",
30
+ ).trim();
31
+ }
32
+
33
+ function harness(nativeReviewCli: NativeReviewCli | null): { beforeAgentStart: BeforeAgentStartHandler } {
34
+ const handlers = new Map<string, BeforeAgentStartHandler>();
35
+ const pi = {
36
+ on(name: string, handler: BeforeAgentStartHandler) {
37
+ handlers.set(name, handler);
38
+ },
39
+ events: { emit() {} },
40
+ registerCommand() {},
41
+ registerTool() {},
42
+ } as unknown as ExtensionAPI;
43
+ createGentleAiExtension({ nativeReviewCli })(pi);
44
+ const beforeAgentStart = handlers.get("before_agent_start");
45
+ assert.equal(typeof beforeAgentStart, "function");
46
+ return { beforeAgentStart: beforeAgentStart as BeforeAgentStartHandler };
47
+ }
48
+
49
+ function ctx(overrides: Record<string, unknown> = {}): ExtensionContext {
50
+ return {
51
+ cwd: process.cwd(),
52
+ hasUI: true,
53
+ ui: { notify() {} },
54
+ sessionManager: { getSessionId: () => "review-contract-prompt-session" },
55
+ ...overrides,
56
+ } as unknown as ExtensionContext;
57
+ }
58
+
59
+ const primaryEvent = { systemPrompt: "base" };
60
+
61
+ test("before_agent_start injects the mirrored review execution contract for the primary session", async () => {
62
+ const { beforeAgentStart } = harness({} as NativeReviewCli);
63
+ const result = await beforeAgentStart(primaryEvent, ctx());
64
+ const expected = mirroredPiOrchestrationText();
65
+ assert.match(result.systemPrompt, /## Gentle AI review execution contract \(mirrored provider bundle 1\.2\.0\)/);
66
+ assert.ok(result.systemPrompt.includes(expected), "the mirrored orchestration/pi.md text must appear verbatim");
67
+ assert.match(result.systemPrompt, /call `gentle_review` with {"operation":"inspect"}/);
68
+ assert.match(result.systemPrompt, /call `gentle_review` with operation `status`, the exact retained `lineageId`, and `workspaceRoot`/);
69
+ assert.match(result.systemPrompt, /Use `gentle_review_capture` for one current returned slot or `gentle_review_capture_group` for the complete current reviewer group/);
70
+ assert.match(result.systemPrompt, /An eligible interactive Pi host may resolve `gentle-ai\.review-integration\.consent\/v3` before the envelope reaches the model/);
71
+ assert.match(result.systemPrompt, /If `gentle_review` returns the envelope unresolved, it is still the original provider-owned two-choice contract/);
72
+ assert.match(result.systemPrompt, /Never add the host action to a decoded or relayed provider envelope/);
73
+ assert.match(result.systemPrompt, /An approved capture awaits acknowledgement; it is not burned\. On `approved`, use bound facade STATUS to obtain or replay the exact provider-issued `acknowledge-approved` continuation, then execute it unchanged\. Only its successful returned envelope burns authority; do not issue STATUS after that burn\./);
74
+ let previousLifecycleIndex = result.systemPrompt.indexOf("## Gentle AI review execution contract");
75
+ for (const marker of [
76
+ 'call `gentle_review` with {"operation":"inspect"}',
77
+ "2. **Freeze once.**",
78
+ "call `gentle_review` with operation `status`",
79
+ "Use `gentle_review_capture` for one current returned slot",
80
+ "5. **Acknowledge exactly.**",
81
+ ]) {
82
+ const markerIndex = result.systemPrompt.indexOf(marker, previousLifecycleIndex + 1);
83
+ assert.ok(markerIndex > previousLifecycleIndex, `${marker} must follow the previous lifecycle step`);
84
+ previousLifecycleIndex = markerIndex;
85
+ }
86
+ assert.doesNotMatch(result.systemPrompt, /authority is already burned/);
87
+ assert.doesNotMatch(result.systemPrompt, /gentle-ai review status\b.*--agent pi/);
88
+ });
89
+
90
+ test("before_agent_start does not inject the review execution contract for a named agent session", async () => {
91
+ const { beforeAgentStart } = harness({} as NativeReviewCli);
92
+ const result = await beforeAgentStart({ agentName: "review-readability", systemPrompt: "base" }, ctx());
93
+ assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
94
+ });
95
+
96
+ test("before_agent_start does not inject the review execution contract for an SDD executor session", async () => {
97
+ const { beforeAgentStart } = harness({} as NativeReviewCli);
98
+ const result = await beforeAgentStart({ systemPrompt: "SDD apply executor body" }, ctx());
99
+ assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
100
+ });
101
+
102
+ test("before_agent_start injects nothing when nativeReviewCli is null", async () => {
103
+ const { beforeAgentStart } = harness(null);
104
+ const result = await beforeAgentStart(primaryEvent, ctx());
105
+ assert.doesNotMatch(result.systemPrompt, /Gentle AI review execution contract/);
106
+ });
107
+
108
+ // gentle-ai R1/R3: a tampered mirrored orchestration/pi.md must never be spliced into the system prompt.
109
+ function tempMirror(text: string, entrySha256: string): string {
110
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-mirror-"));
111
+ const bundleDir = join(root, "v9.9.9", "bundle", "orchestration");
112
+ mkdirSync(bundleDir, { recursive: true });
113
+ writeFileSync(join(bundleDir, "pi.md"), text, "utf8");
114
+ const lock = JSON.stringify({ contract_semver: "9.9.9", entries: { "orchestration/pi.md": entrySha256 } });
115
+ writeFileSync(join(root, "provider-contract.lock.json"), lock, "utf8");
116
+ return root;
117
+ }
118
+
119
+ test("rejects a tampered mirror, accepts a matching one, and warns once", async () => {
120
+ const tampered = tempMirror("tampered contract text", "0".repeat(64));
121
+ const matchingText = "matching contract text";
122
+ const matching = tempMirror(matchingText, createHash("sha256").update(Buffer.from(matchingText, "utf8")).digest("hex"));
123
+ try {
124
+ // Cache-bust: isolate this module's fragment cache from the real-mirror tests above.
125
+ const cacheBustedUrl = `${pathToFileURL(join(import.meta.dirname, "..", "extensions", "gentle-ai.ts")).href}?tamper=${Math.random()}`;
126
+ type Testing = {
127
+ readMirroredReviewContractFragment: (mirrorRoot?: string) => string | null;
128
+ loadReviewContractPromptFragment: (ctx: ExtensionContext, mirrorRoot?: string) => string | null;
129
+ };
130
+ const fresh = (await import(cacheBustedUrl)) as { __testing: Testing };
131
+ assert.equal(fresh.__testing.readMirroredReviewContractFragment(tampered), null);
132
+ assert.ok(fresh.__testing.readMirroredReviewContractFragment(matching)?.includes(matchingText));
133
+ let notifyCount = 0;
134
+ const spyCtx = { hasUI: true, ui: { notify: () => { notifyCount += 1; } } } as unknown as ExtensionContext;
135
+ fresh.__testing.loadReviewContractPromptFragment(spyCtx, tampered);
136
+ fresh.__testing.loadReviewContractPromptFragment(spyCtx, tampered);
137
+ assert.equal(notifyCount, 1);
138
+ } finally {
139
+ rmSync(tampered, { recursive: true, force: true });
140
+ rmSync(matching, { recursive: true, force: true });
141
+ }
142
+ });
@@ -75,7 +75,6 @@ test("native abandon carries the exact discarded-work authorization and audit re
75
75
  snapshotIdentity: SHA,
76
76
  capturedLensResults: ["00-risk.json"],
77
77
  findingsPresent: true,
78
- evidenceRecordsPresent: false,
79
78
  actor: "maintainer",
80
79
  reason: "discard candidate",
81
80
  };
@@ -85,6 +84,33 @@ test("native abandon carries the exact discarded-work authorization and audit re
85
84
  assert.deepEqual(queue.calls[0]?.arguments.slice(0, 10), ["review", "abandon", "--cwd", "/repo", "--lineage", "abandoned", "--expected-revision", "revision-1", "--actor", "maintainer"]);
86
85
  });
87
86
 
87
+ // gentle-ai 0ed9225f ("close on the last causal event") removed evidence records
88
+ // from CompactDiscardedWorkSummary, so the native v2 gate verifies an exact
89
+ // EIGHT-line binding with no evidence_records_present line. The facade-side
90
+ // derivation must match that contract byte-for-byte or native rejects every
91
+ // abandoned binding (issue #466).
92
+ test("nativeReviewAbandonAuthorization renders the native eight-line v2 binding", () => {
93
+ const binding = nativeReviewAbandonAuthorization({
94
+ lineage: "review-abc",
95
+ expectedRevision: "revision-9",
96
+ snapshotIdentity: "snapshot-1",
97
+ capturedLensResults: ["00-risk.json", "01-refuter.json"],
98
+ findingsPresent: true,
99
+ actor: "maintainer",
100
+ reason: "operator_disposition",
101
+ });
102
+ assert.equal(binding, [
103
+ "gentle-ai.review-abandon-authorization/v2",
104
+ "lineage=review-abc",
105
+ "revision=revision-9",
106
+ "snapshot_identity=snapshot-1",
107
+ "reason=operator_disposition",
108
+ "captured_lens_results=00-risk.json,01-refuter.json",
109
+ "findings_present=true",
110
+ "actor=maintainer",
111
+ ].join("\n"));
112
+ });
113
+
88
114
  test("native quarantine retains published legacy-v1 maintenance compatibility", async () => {
89
115
  const request = {
90
116
  cwd: "/repo",
@@ -215,7 +241,7 @@ test("native recovery operations reject malformed canonical inputs before a proc
215
241
  test("partial maintenance failures preserve the provider audit record and unknown mutation outcome", async () => {
216
242
  const request = {
217
243
  cwd: "/repo", lineage: "abandoned", expectedRevision: "revision-1", snapshotIdentity: SHA,
218
- capturedLensResults: ["00-risk.json"], findingsPresent: true, evidenceRecordsPresent: false, actor: "maintainer", reason: "discard candidate",
244
+ capturedLensResults: ["00-risk.json"], findingsPresent: true, actor: "maintainer", reason: "discard candidate",
219
245
  };
220
246
  const queue = queuedAdapter([{ exitCode: 1, stdout: JSON.stringify({ operation: "review/abandon", record: { schema: "gentle-ai.review-reclaim-audit/v1", lineage_id: request.lineage, status: "partial" } }) }]);
221
247
  await assert.rejects(
@@ -263,7 +289,6 @@ test("native abandon rejects a legacy authorization before process launch", asyn
263
289
  snapshotIdentity: SHA,
264
290
  capturedLensResults: ["00-risk.json"],
265
291
  findingsPresent: true,
266
- evidenceRecordsPresent: false,
267
292
  actor: "maintainer",
268
293
  reason: "discard candidate",
269
294
  };
@@ -311,7 +336,7 @@ test("maintenance cancellation preserves the exact signal and unknown mutation o
311
336
  const controller = new AbortController();
312
337
  const request = {
313
338
  cwd: "/repo", lineage: "abandoned", expectedRevision: "revision-1", snapshotIdentity: SHA,
314
- capturedLensResults: ["00-risk.json"], findingsPresent: true, evidenceRecordsPresent: false,
339
+ capturedLensResults: ["00-risk.json"], findingsPresent: true,
315
340
  actor: "maintainer", reason: "discard candidate",
316
341
  };
317
342
  const adapter: ExecFileAdapter = async (call) => {
@@ -1,8 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
4
+ import fs from "node:fs";
5
+ import fsp from "node:fs/promises";
6
+ import os, { tmpdir } from "node:os";
7
+ import { syncBuiltinESMExports } from "node:module";
8
+ import { dirname, join, resolve, sep } from "node:path";
6
9
  import test from "node:test";
7
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
11
  import { __testing, createGentleAiExtension, PendingReviewConsentRegistry } from "../extensions/gentle-ai.ts";
@@ -95,6 +98,37 @@ function burnedAcknowledgementStatus(lineageId: string): ReviewStatusV3 {
95
98
  return burned;
96
99
  }
97
100
 
101
+ // gentle-pi#627: gentle-ai reports a stale managed-asset set as a typed stop
102
+ // carrying the exact `gentle-ai sync` invocation that resolves it.
103
+ function managedAssetsOutdatedStatus(lineageId: string): ReviewStatusV3 {
104
+ const stopped = status(lineageId, [], "approved");
105
+ stopped.nextTransition = { kind: "stop", reasonCode: "managed_assets_outdated", continuation: { operation: "sync", command: "gentle-ai sync --agent claude-code", agent: "claude-code", staleAssets: ["orchestration/claude-code.md"] } };
106
+ return stopped;
107
+ }
108
+
109
+ test("STATUS renders the managed_assets_outdated continuation command as the actionable next step", async () => {
110
+ const lineageId = "managed-assets-outdated";
111
+ const native = { targetStatus: async () => managedAssetsOutdatedStatus(lineageId) } as unknown as NativeReviewCli;
112
+ const blocked = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, process.cwd(), native);
113
+ assert.equal(blocked.status, "blocked");
114
+ assert.equal(blocked.hint, "run gentle-ai sync --agent claude-code");
115
+
116
+ // an unknown stop reason code keeps rendering as a plain blocked result
117
+ const otherStopNative = { targetStatus: async () => burnedAcknowledgementStatus(lineageId) } as unknown as NativeReviewCli;
118
+ const plainBlocked = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, process.cwd(), otherStopNative);
119
+ assert.equal(plainBlocked.status, "blocked");
120
+ assert.equal(plainBlocked.hint, undefined);
121
+
122
+ // an older gentle-ai may report this same stop with no continuation; the
123
+ // renderer degrades to a plain blocked result instead of losing the status
124
+ const noContinuation = status(lineageId, [], "approved");
125
+ noContinuation.nextTransition = { kind: "stop", reasonCode: "managed_assets_outdated" };
126
+ const noContinuationNative = { targetStatus: async () => noContinuation } as unknown as NativeReviewCli;
127
+ const degraded = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, process.cwd(), noContinuationNative);
128
+ assert.equal(degraded.status, "blocked");
129
+ assert.equal(degraded.hint, undefined);
130
+ });
131
+
98
132
  test("public acknowledgement relays one current provider vector and never replays after authority burn", async () => {
99
133
  const lineageId = "acknowledge-approved";
100
134
  const requests: Array<Record<string, unknown>> = [];
@@ -423,6 +457,72 @@ test("REPAIR retains frozen committed collect selectors and leaves workspace rou
423
457
  assert.deepEqual(workspaceRequests, [{ cwd, lineageId: workspaceLineage }]);
424
458
  });
425
459
 
460
+ test("selectorless STATUS resumes a retained committed correction lineage", async (t) => {
461
+ const candidateViews = new CandidateViewRegistry();
462
+ t.after(() => candidateViews.cleanupAll());
463
+ const cwd = repository(t);
464
+ const lineageId = "retained-committed-correction";
465
+ const view = candidateViews.create({
466
+ contributorRoot: cwd,
467
+ baseRef: execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(),
468
+ committedOnly: true,
469
+ });
470
+ candidateViews.retain(view.token, lineageId);
471
+ const frozenTarget = candidateViews.resolveProjection(lineageId, cwd);
472
+ const requests: Array<Record<string, unknown>> = [];
473
+ const selections = new Map();
474
+ let captures = 0;
475
+ const native = {
476
+ targetStatus: async (request: Record<string, unknown>) => {
477
+ requests.push(request);
478
+ return status(lineageId, [correctionPlanInput(lineageId)], "correction_required");
479
+ },
480
+ captureCorrectionPlan: async () => {
481
+ captures += 1;
482
+ return { schema: "gentle-ai.review-last-event-closure/v1", operation: "review.capture-correction-plan", lineageId, state: "correction_required", storeRevision: SHA };
483
+ },
484
+ } as unknown as NativeReviewCli;
485
+
486
+ const listed = await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, cwd, native, undefined, candidateViews, undefined, selections);
487
+ const captured = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: bindingOf(listed), correctionLines: 1 }, cwd, native, undefined, candidateViews, selections, true);
488
+
489
+ assert.equal(captured.outcome, "native-last-event-closure");
490
+ assert.equal(captures, 1);
491
+ assert.deepEqual(requests, Array.from({ length: 2 }, () => ({ cwd, lineageId, agent: "pi", baseRef: frozenTarget.baseCommit, committedOnly: true })));
492
+ });
493
+
494
+ test("selectorless STATUS uses a retained committed selector only at its retained workspace", async (t) => {
495
+ const candidateViews = new CandidateViewRegistry();
496
+ t.after(() => candidateViews.cleanupAll());
497
+ const retainedRoot = repository(t);
498
+ const otherRoot = repository(t);
499
+ const lineageId = "retained-selector-boundary";
500
+ const view = candidateViews.create({
501
+ contributorRoot: retainedRoot,
502
+ baseRef: execFileSync("git", ["rev-parse", "HEAD"], { cwd: retainedRoot, encoding: "utf8" }).trim(),
503
+ committedOnly: true,
504
+ });
505
+ candidateViews.retain(view.token, lineageId);
506
+ const frozenTarget = candidateViews.resolveProjection(lineageId, retainedRoot);
507
+ const requests: Array<Record<string, unknown>> = [];
508
+ const native = {
509
+ targetStatus: async (request: Record<string, unknown>) => {
510
+ requests.push(request);
511
+ return status(String(request.lineageId ?? "unretained"));
512
+ },
513
+ } as unknown as NativeReviewCli;
514
+
515
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId, input: JSON.stringify({ baseRef: "explicit-base", committedOnly: true }) }, retainedRoot, native, undefined, candidateViews);
516
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId: "unretained" }, retainedRoot, native, undefined, candidateViews);
517
+ await __testing.executeReviewControllerOperation({ operation: "status", lineageId }, otherRoot, native, undefined, candidateViews);
518
+
519
+ assert.deepEqual(requests, [
520
+ { cwd: retainedRoot, lineageId, agent: "pi", baseRef: "explicit-base", committedOnly: true },
521
+ { cwd: retainedRoot, lineageId: "unretained", agent: "pi" },
522
+ { cwd: retainedRoot, lineageId, agent: "pi", baseRef: frozenTarget.baseCommit, committedOnly: true },
523
+ ]);
524
+ });
525
+
426
526
  test("STATUS preserves retained intended-untracked selection through selectorless same-lineage replacement collection", async () => {
427
527
  const cwd = process.cwd();
428
528
  const lineageId = "selectorless-replacement";
@@ -638,6 +738,94 @@ function repository(t: test.TestContext): string {
638
738
  return cwd;
639
739
  }
640
740
 
741
+ test("candidate lifecycle sweeps startup and cleans every shutdown including reload", async (t) => {
742
+ const cwd = repository(t);
743
+ for (const key of ["HOME", "GENTLE_PI_AGENT_HOME", "PI_CODING_AGENT_DIR", "GENTLE_PI_CONFIG_HOME", "GENTLE_PI_GENTLE_AI_DEV_BINARY"]) {
744
+ const previous = process.env[key];
745
+ process.env[key] = key === "GENTLE_PI_GENTLE_AI_DEV_BINARY" ? "" : join(cwd, key);
746
+ if (process.env[key]) mkdirSync(process.env[key]!, { recursive: true });
747
+ t.after(() => { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; });
748
+ }
749
+ t.mock.method(os, "homedir", () => join(cwd, "HOME"));
750
+ const homeAgent = join(cwd, "HOME", ".agents", "isolation-probe.md");
751
+ mkdirSync(dirname(homeAgent), { recursive: true });
752
+ writeFileSync(homeAgent, "---\nname: isolation-probe\ndescription: Fixture\n---\n");
753
+ writeFileSync(join(cwd, "GENTLE_PI_CONFIG_HOME", "models.json"), JSON.stringify({ "isolation-probe": { model: "fixture/model" } }));
754
+ t.diagnostic(`startup routing: cwd=${cwd}; HOME, GENTLE_PI_AGENT_HOME, PI_CODING_AGENT_DIR, GENTLE_PI_CONFIG_HOME are same-named children; dev-binary override disabled`);
755
+ const calls: string[] = [];
756
+ const destinations: string[] = [];
757
+ const violations: string[] = [];
758
+ const discovery: string[] = [];
759
+ const assets = resolve("assets");
760
+ const inside = (root: string, path: string) => path === root || path.startsWith(`${root}${sep}`);
761
+ const originalExists = fs.existsSync;
762
+ const originalRealpath = fs.realpathSync;
763
+ // Install guards BEFORE startup, including RED: a swallowed startup error
764
+ // must never hide an attempted external write or read real-home contents.
765
+ for (const [module, names] of [
766
+ [fs, ["existsSync", "lstatSync", "readdirSync", "readFileSync", "mkdirSync", "writeFileSync", "rmSync"]],
767
+ [fsp, ["access", "readdir", "readFile", "mkdir", "writeFile"]],
768
+ ] as const) {
769
+ for (const name of names) {
770
+ const original = (module as any)[name];
771
+ t.mock.method(module as any, name, (value: string, ...args: unknown[]) => {
772
+ const path = resolve(value);
773
+ const writes = /^(mkdir|writeFile|rm)/.test(name);
774
+ const allowed = inside(cwd, path) || (!writes && inside(assets, path));
775
+ if (!allowed && name === "existsSync") return false;
776
+ if (!allowed && ["access", "readdir"].includes(name)) return Promise.reject(Object.assign(new Error("fixture discovery boundary"), { code: "ENOENT" }));
777
+ if (!allowed) { violations.push(`${name}:${path}`); throw new Error("fixture filesystem boundary"); }
778
+ if (writes) {
779
+ let ancestor = path;
780
+ while (!originalExists(ancestor)) ancestor = dirname(ancestor);
781
+ assert.ok(inside(cwd, originalRealpath(ancestor)), "write ancestor escaped fixture");
782
+ destinations.push(path);
783
+ }
784
+ if (name.startsWith("readdir")) discovery.push(path);
785
+ return original(value, ...args);
786
+ });
787
+ }
788
+ }
789
+ syncBuiltinESMExports();
790
+ // Prove the guard refuses an escape without performing the attempted write.
791
+ const escape = join(cwd, "..", "startup-isolation-escape");
792
+ assert.throws(() => fs.writeFileSync(escape, "blocked"), /fixture filesystem boundary/);
793
+ assert.deepEqual(violations, [`writeFileSync:${escape}`]);
794
+ violations.length = 0;
795
+ const hooks = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
796
+ const registry = new CandidateViewRegistry();
797
+ t.mock.method(registry, "sweepOrphans", (root: string) => { assert.equal(root, cwd); calls.push("sweep"); });
798
+ t.mock.method(registry, "cleanupAll", () => { calls.push("cleanup"); });
799
+ createGentleAiExtension({ nativeReviewCli: null, candidateViews: registry, processEnv: {} })({
800
+ on(name: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
801
+ hooks.set(name, [...(hooks.get(name) ?? []), handler]);
802
+ },
803
+ registerTool() {}, registerCommand() {},
804
+ } as unknown as ExtensionAPI);
805
+ try {
806
+ assert.equal(hooks.get("session_start")?.length, 1);
807
+ await hooks.get("session_start")![0]!({ reason: "startup" }, reviewContext(cwd));
808
+ assert.deepEqual(violations, []);
809
+ assert.ok(destinations.length > 0, "startup must actually write fixture assets");
810
+ assert.ok(discovery.includes(join(cwd, "HOME", ".agents")), "home agent discovery must use the fixture");
811
+ assert.equal(existsSync(join(cwd, "GENTLE_PI_AGENT_HOME", "gentle-ai", "managed-assets.json")), true);
812
+ assert.match(readFileSync(homeAgent, "utf8"), /model: fixture\/model/);
813
+ assert.ok(destinations.includes(homeAgent), "the discovered fixture agent must actually receive routing");
814
+ assert.ok(destinations.includes(join(cwd, "GENTLE_PI_AGENT_HOME", "subagents.json")));
815
+ t.diagnostic(`confined startup: ${destinations.length} filesystem mutations; fixture home-agent routing and managed-assets manifest verified; external violations=0`);
816
+ assert.deepEqual(calls, ["sweep"]);
817
+ } finally {
818
+ // Restore only mocks before fixture teardown; never clean external paths.
819
+ t.mock.restoreAll();
820
+ syncBuiltinESMExports();
821
+ }
822
+ t.mock.method(registry, "cleanupAll", () => { calls.push("cleanup"); });
823
+ for (const reason of ["quit", "reload", "new", "resume", "fork"]) {
824
+ for (const hook of hooks.get("session_shutdown")!) await hook({ reason }, reviewContext(cwd));
825
+ }
826
+ assert.deepEqual(calls, ["sweep", ...Array(5).fill("cleanup")]);
827
+ });
828
+
641
829
  interface RegisteredControllerTool {
642
830
  execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details?: unknown }>;
643
831
  }
@@ -722,7 +910,7 @@ test("ordinary START binds the native workspace candidate and returns the native
722
910
  assert.equal(startCalls, 1);
723
911
  });
724
912
 
725
- test("pre-lineage intended-untracked selection revalidates and starts at an explicit workspace root", async (t) => {
913
+ for (const statusSchema of ["gentle-ai.review-integration.status/v6", "gentle-ai.review-integration.status/v7"]) test(`pre-lineage intended-untracked selection revalidates and starts at an explicit workspace root (${statusSchema})`, async (t) => {
726
914
  const cwd = realpathSync(repository(t)), sessionCwd = repository(t), eligible = "selected.md";
727
915
  writeFileSync(join(cwd, eligible), "selected\n");
728
916
  const initialTarget = startStatus(cwd), target = startStatus(cwd, undefined, [eligible]);
@@ -735,7 +923,7 @@ test("pre-lineage intended-untracked selection revalidates and starts at an expl
735
923
  ],
736
924
  submission: { operationToken: "status", argumentTokens: ["--contract=gentle-ai.review-integration/v2", "--next-transition=true", "--agent=pi", "--projection=workspace", "--intended-untracked-selection={{value}}"], values: [{ slot: "intended_untracked_selection", domain: "schema_bound_json", schema: "gentle-ai.review-intended-untracked-selection/v1", substitutionLocation: 4 }] },
737
925
  };
738
- const initial = { ...initialTarget, nextTransition: { kind: "collect", reasonCode: "intended_untracked_selection_required", collect: { inputs: [selection] } }, raw: { schema: "gentle-ai.review-integration.status/v6" } } as ReviewStatusV3;
926
+ const initial = { ...initialTarget, nextTransition: { kind: "collect", reasonCode: "intended_untracked_selection_required", collect: { inputs: [selection] } }, raw: { schema: statusSchema } } as ReviewStatusV3;
739
927
  const requests: Array<Record<string, unknown>> = [], starts: Array<Record<string, unknown>> = [], retained = new Map();
740
928
  const native = {
741
929
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "on", effective: "on", source: "clone_local" } }),
@@ -778,6 +966,22 @@ test("ordinary START relays native consent without authoring or advancing it", a
778
966
  assert.equal(result.mutation_outcome, "none");
779
967
  });
780
968
 
969
+ test("shutdown preserves failed consent candidates without interrupting session teardown", async (t) => {
970
+ const cwd = repository(t);
971
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
972
+ const native = {
973
+ targetStatus: async () => startStatus(cwd),
974
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
975
+ } as unknown as NativeReviewCli;
976
+ const registry = new CandidateViewRegistry();
977
+ const runtime = reviewRuntime(native, registry);
978
+ const ctx = reviewContext(cwd);
979
+ const result = await runtime.controller.execute("pending", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, ctx);
980
+ assert.equal((result.details as { outcome: string }).outcome, "native-review-consent-required");
981
+ t.mock.method(registry, "cleanup", () => { throw new Error("fixture unsafe ownership"); });
982
+ assert.doesNotThrow(() => runtime.sessionShutdown({ reason: "reload" }, ctx));
983
+ });
984
+
781
985
  test("ordinary START obeys the review-mode kill switch before target STATUS", async () => {
782
986
  let targetCalls = 0;
783
987
  const native = {
@@ -1220,6 +1424,119 @@ test("ordinary START refuses a target projection that no longer matches the froz
1220
1424
  assert.equal(startCalls, 0);
1221
1425
  });
1222
1426
 
1427
+ // gentle-pi#455: a consent binding one active Pi session's START created must
1428
+ // be answerable from another active session presenting the same opaque
1429
+ // binding id -- consent bindings are not partitioned by which session's
1430
+ // START created them, only by the repository and candidate they are scoped to.
1431
+ test("answer-consent resolves a valid binding presented by a different active Pi session", async (t) => {
1432
+ const cwd = repository(t);
1433
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1434
+ const registry = new PendingReviewConsentRegistry();
1435
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
1436
+ let answerCalls = 0;
1437
+ const native = {
1438
+ targetStatus: async () => startStatus(cwd),
1439
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
1440
+ answerConsent: async () => {
1441
+ answerCalls += 1;
1442
+ return { kind: "granted", start: { lineageId: "cross-session", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
1443
+ },
1444
+ } as unknown as NativeReviewCli;
1445
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
1446
+ assert.equal(typeof started.consent_binding, "string", JSON.stringify(started));
1447
+ const answered = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: started.consent_binding, answer: "granted" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionB);
1448
+ assert.equal(answerCalls, 1, JSON.stringify(answered));
1449
+ assert.equal((answered.result as { lineage_id?: string } | undefined)?.lineage_id, "cross-session", JSON.stringify(answered));
1450
+ assert.notEqual(answered.status, "blocked", JSON.stringify(answered));
1451
+ });
1452
+
1453
+ // gentle-pi#455: cross-session resolution must still enforce single use --
1454
+ // once a binding is answered by any session, no session (including the one
1455
+ // whose START created it) may answer it again.
1456
+ test("a consumed consent binding cannot be answered a second time from any session", async (t) => {
1457
+ const cwd = repository(t);
1458
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1459
+ const registry = new PendingReviewConsentRegistry();
1460
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
1461
+ let answerCalls = 0;
1462
+ const native = {
1463
+ targetStatus: async () => startStatus(cwd),
1464
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
1465
+ answerConsent: async () => {
1466
+ answerCalls += 1;
1467
+ return { kind: "granted", start: { lineageId: "single-use", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
1468
+ },
1469
+ } as unknown as NativeReviewCli;
1470
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
1471
+ const binding = started.consent_binding as string;
1472
+ const first = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionB);
1473
+ assert.equal(answerCalls, 1, JSON.stringify(first));
1474
+ const replay = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
1475
+ assert.equal(answerCalls, 1, JSON.stringify(replay));
1476
+ assert.equal(replay.status, "blocked", JSON.stringify(replay));
1477
+ assert.equal(replay.outcome, "consent-binding-stale", JSON.stringify(replay));
1478
+ assert.equal((replay.diagnostics as { code?: string } | undefined)?.code, "consent-binding-already-consumed", JSON.stringify(replay));
1479
+ });
1480
+
1481
+ // gentle-pi#455 correction: cross-session resolution looks a binding up by
1482
+ // its opaque id alone, so it must independently refuse an answer presented
1483
+ // from a different repository than the one its owning START minted it for,
1484
+ // without ever running the mode gate or native answerConsent, and without
1485
+ // consuming the binding -- leaving it answerable from the right repository.
1486
+ test("answer-consent refuses a binding presented from a different repository than the one its START minted, and leaves it answerable from the right one", async (t) => {
1487
+ const cwdA = repository(t), cwdB = repository(t);
1488
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1489
+ const registry = new PendingReviewConsentRegistry();
1490
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
1491
+ let answerCalls = 0;
1492
+ const native = {
1493
+ targetStatus: async () => startStatus(cwdA),
1494
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
1495
+ answerConsent: async () => {
1496
+ answerCalls += 1;
1497
+ return { kind: "granted", start: { lineageId: "right-repo", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
1498
+ },
1499
+ } as unknown as NativeReviewCli;
1500
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwdA, native, undefined, undefined, undefined, undefined, registry, sessionA);
1501
+ const binding = started.consent_binding as string;
1502
+ const wrongRepo = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwdB, native, undefined, undefined, undefined, undefined, registry, sessionB);
1503
+ assert.equal(answerCalls, 0, JSON.stringify(wrongRepo));
1504
+ assert.equal(wrongRepo.status, "blocked", JSON.stringify(wrongRepo));
1505
+ assert.equal(wrongRepo.outcome, "consent-binding-repository-mismatch", JSON.stringify(wrongRepo));
1506
+ assert.equal((wrongRepo.diagnostics as { code?: string } | undefined)?.code, "consent-binding-repository-mismatch", JSON.stringify(wrongRepo));
1507
+ assert.equal(wrongRepo.mutation_performed, false, JSON.stringify(wrongRepo));
1508
+ const rightRepo = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwdA, native, undefined, undefined, undefined, undefined, registry, sessionA);
1509
+ assert.equal(answerCalls, 1, JSON.stringify(rightRepo));
1510
+ assert.equal((rightRepo.result as { lineage_id?: string } | undefined)?.lineage_id, "right-repo", JSON.stringify(rightRepo));
1511
+ });
1512
+
1513
+ // gentle-pi#323: within the live consent TTL window, a content-independent
1514
+ // replay key let a second ordinary START reuse the first START's still-live
1515
+ // (never lineage-bound) frozen candidate view even though the live candidate
1516
+ // content changed underneath it, and then dead-ended at
1517
+ // candidate-target-projection-drift instead of minting a fresh view and a
1518
+ // fresh consent envelope. An intended-untracked selection is retained across
1519
+ // both calls so the pre-existing empty-untracked drift-recovery branch does
1520
+ // not mask the general content-change gap this covers.
1521
+ test("ordinary START mints a fresh candidate view when candidate content changes within the live consent TTL window", async (t) => {
1522
+ const candidateViews = new CandidateViewRegistry();
1523
+ t.after(() => candidateViews.cleanupAll());
1524
+ const cwd = repository(t);
1525
+ writeFileSync(join(cwd, "extra.md"), "kept\n");
1526
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1527
+ const native = {
1528
+ targetStatus: async () => startStatus(cwd, undefined, ["extra.md"]),
1529
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
1530
+ } as unknown as NativeReviewCli;
1531
+ const first = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, candidateViews);
1532
+ assert.equal(first.outcome, "native-review-consent-required", JSON.stringify(first));
1533
+ writeFileSync(join(cwd, "tracked.txt"), "candidate two\n");
1534
+ const second = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, candidateViews);
1535
+ assert.notEqual(second.outcome, "native-operation-failed", JSON.stringify(second));
1536
+ assert.equal(second.outcome, "native-review-consent-required", JSON.stringify(second));
1537
+ assert.notEqual(second.consent_binding, first.consent_binding, JSON.stringify(second));
1538
+ });
1539
+
1223
1540
  test("controller-owned dispatch confines single and parallel graph actors to the current candidate view", async (t) => {
1224
1541
  const cwd = repository(t);
1225
1542
  const candidateViews = new CandidateViewRegistry();