gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -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,79 @@ 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
+ test("ordinary START preserves sanitized foreign diagnostics through one ambiguous reconciliation", async (t) => {
914
+ const cwd = repository(t);
915
+ const target = startStatus(cwd);
916
+ let targetCalls = 0;
917
+ let startCalls = 0;
918
+ const native = {
919
+ targetStatus: async () => {
920
+ targetCalls += 1;
921
+ return target;
922
+ },
923
+ start: async () => {
924
+ startCalls += 1;
925
+ throw {
926
+ name: "NativeReviewCliError",
927
+ code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
928
+ mutationOutcome: "unknown",
929
+ nextAction: "review.status",
930
+ diagnostics: {
931
+ operation: "review/start",
932
+ error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
933
+ exit_code: 1,
934
+ timed_out: false,
935
+ output_limit_exceeded: false,
936
+ stderr: "token=secret",
937
+ },
938
+ };
939
+ },
940
+ } as unknown as NativeReviewCli;
941
+
942
+ const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
943
+ assert.equal(result.outcome, "native-mutation-status-reconciled");
944
+ assert.equal(result.mutation_outcome, "unknown");
945
+ assert.deepEqual(result.diagnostics, {
946
+ operation: "review/start",
947
+ error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
948
+ exit_code: 1,
949
+ timed_out: false,
950
+ output_limit_exceeded: false,
951
+ stderr: "token=[REDACTED]",
952
+ });
953
+ assert.match(String(result.required_status_action), /Run target-scoped review\.status/);
954
+ assert.equal(result.next_action, "start");
955
+ assert.equal(targetCalls, 2, "START failure reconciles STATUS exactly once");
956
+ assert.equal(startCalls, 1, "reconciliation never replays START");
957
+ });
958
+
959
+ test("ambiguous START reconciliation preserves the provider-selected recovery directive", async (t) => {
960
+ const cwd = repository(t), recoveryStatus = status("recovery-lineage");
961
+ recoveryStatus.action = "recover";
962
+ recoveryStatus.actionDisposition = "escalated";
963
+ recoveryStatus.nextTransition = { kind: "stop", reasonCode: "manual_intervention_required" };
964
+ let targetCalls = 0, startCalls = 0;
965
+ const native = {
966
+ targetStatus: async () => {
967
+ targetCalls += 1;
968
+ return targetCalls === 1 ? startStatus(cwd) : recoveryStatus;
969
+ },
970
+ start: async () => {
971
+ startCalls += 1;
972
+ throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, "review/start", true, true, "unknown mutation");
973
+ },
974
+ } as unknown as NativeReviewCli;
975
+
976
+ const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
977
+ assert.equal(result.outcome, "native-mutation-status-reconciled");
978
+ assert.equal(result.mutation_outcome, "unknown");
979
+ assert.equal(result.next_action, "recover-with-provider-disposition");
980
+ assert.equal(result.required_status_action, "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.");
981
+ assert.equal(targetCalls, 2, "START failure reconciles STATUS exactly once");
982
+ assert.equal(startCalls, 1, "reconciliation never replays START");
983
+ });
984
+
985
+ 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
986
  const cwd = realpathSync(repository(t)), sessionCwd = repository(t), eligible = "selected.md";
727
987
  writeFileSync(join(cwd, eligible), "selected\n");
728
988
  const initialTarget = startStatus(cwd), target = startStatus(cwd, undefined, [eligible]);
@@ -735,7 +995,7 @@ test("pre-lineage intended-untracked selection revalidates and starts at an expl
735
995
  ],
736
996
  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
997
  };
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;
998
+ const initial = { ...initialTarget, nextTransition: { kind: "collect", reasonCode: "intended_untracked_selection_required", collect: { inputs: [selection] } }, raw: { schema: statusSchema } } as ReviewStatusV3;
739
999
  const requests: Array<Record<string, unknown>> = [], starts: Array<Record<string, unknown>> = [], retained = new Map();
740
1000
  const native = {
741
1001
  reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "on", effective: "on", source: "clone_local" } }),
@@ -765,6 +1025,470 @@ test("pre-lineage intended-untracked selection revalidates and starts at an expl
765
1025
  assert.equal(requests.every((request) => !("lineageId" in request)), true);
766
1026
  });
767
1027
 
1028
+ // gentle-pi#706: inspect names the exact continuation for the intended-untracked
1029
+ // stop and can resolve it in one call through top-level untrackedScope.
1030
+ function untrackedStopFixture(
1031
+ t: test.TestContext,
1032
+ statusSchema = "gentle-ai.review-integration.status/v7",
1033
+ ): {
1034
+ cwd: string;
1035
+ eligible: string;
1036
+ initial: ReviewStatusV3;
1037
+ target: ReviewStatusV3;
1038
+ selection: ReviewCollectInputV3;
1039
+ } {
1040
+ const cwd = repository(t),
1041
+ eligible = "selected.md";
1042
+ writeFileSync(join(cwd, eligible), "selected\n");
1043
+ const initialTarget = startStatus(cwd),
1044
+ target = startStatus(cwd, undefined, [eligible]);
1045
+ const selection: ReviewCollectInputV3 = {
1046
+ name: "intended_untracked_selection",
1047
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
1048
+ captureOperation: "external.select_intended_untracked",
1049
+ arguments: [
1050
+ { name: "target_identity", value: SHA },
1051
+ { name: "projection", value: "workspace" },
1052
+ { name: "base_tree", value: initialTarget.projection.baseTree },
1053
+ {
1054
+ name: "candidate_tree",
1055
+ value: initialTarget.projection.currentCandidateTree,
1056
+ },
1057
+ { name: "eligible_paths_json", value: JSON.stringify([eligible]) },
1058
+ { name: "expected_untracked_inventory", value: SHA },
1059
+ ],
1060
+ submission: {
1061
+ operationToken: "status",
1062
+ argumentTokens: [
1063
+ "--contract=gentle-ai.review-integration/v2",
1064
+ "--next-transition=true",
1065
+ "--agent=pi",
1066
+ "--projection=workspace",
1067
+ "--intended-untracked-selection={{value}}",
1068
+ ],
1069
+ values: [
1070
+ {
1071
+ slot: "intended_untracked_selection",
1072
+ domain: "schema_bound_json",
1073
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
1074
+ substitutionLocation: 4,
1075
+ },
1076
+ ],
1077
+ },
1078
+ };
1079
+ const initial = {
1080
+ ...initialTarget,
1081
+ nextTransition: {
1082
+ kind: "collect",
1083
+ reasonCode: "intended_untracked_selection_required",
1084
+ collect: { inputs: [selection] },
1085
+ },
1086
+ raw: { schema: statusSchema },
1087
+ } as ReviewStatusV3;
1088
+ return { cwd, eligible, initial, target, selection };
1089
+ }
1090
+
1091
+ for (const statusSchema of [
1092
+ "gentle-ai.review-integration.status/v6",
1093
+ "gentle-ai.review-integration.status/v7",
1094
+ ])
1095
+ test(`inspect on the intended-untracked stop returns nextStep and the selection binding (${statusSchema})`, async (t) => {
1096
+ const { cwd, initial } = untrackedStopFixture(t, statusSchema);
1097
+ let targetCalls = 0;
1098
+ const native = {
1099
+ targetStatus: async () => {
1100
+ targetCalls += 1;
1101
+ return initial;
1102
+ },
1103
+ } as unknown as NativeReviewCli;
1104
+ const result = await __testing.executeReviewControllerOperation(
1105
+ { operation: "inspect" },
1106
+ cwd,
1107
+ native,
1108
+ );
1109
+ assert.equal(result.status, "blocked");
1110
+ assert.equal(typeof result.selectionBinding, "string");
1111
+ assert.equal(typeof result.nextStep, "string");
1112
+ for (const fragment of [
1113
+ "select-intended-untracked",
1114
+ "untrackedScope",
1115
+ ".gitignore",
1116
+ "path names",
1117
+ ]) {
1118
+ assert.equal((result.nextStep as string).includes(fragment), true, fragment);
1119
+ }
1120
+ assert.equal(targetCalls, 1);
1121
+ });
1122
+
1123
+ test("inspect with untrackedScope exclude resolves the intended-untracked stop in one round trip", async (t) => {
1124
+ const { cwd, initial, target, selection } = untrackedStopFixture(t);
1125
+ const requests: Array<Record<string, unknown>> = [],
1126
+ retained = new Map();
1127
+ const native = {
1128
+ targetStatus: async (request: Record<string, unknown>) => {
1129
+ requests.push(request);
1130
+ return "intendedUntrackedSelection" in request ? target : initial;
1131
+ },
1132
+ } as unknown as NativeReviewCli;
1133
+ const result = await __testing.executeReviewControllerOperation(
1134
+ { operation: "inspect", untrackedScope: "exclude" },
1135
+ cwd,
1136
+ native,
1137
+ undefined,
1138
+ undefined,
1139
+ undefined,
1140
+ retained,
1141
+ );
1142
+ assert.equal(result.status, "ready");
1143
+ assert.equal("selectionBinding" in result, false);
1144
+ assert.equal(requests.length, 2);
1145
+ assert.equal(requests[1]!.untrackedScope, "exclude");
1146
+ assert.equal(requests[1]!.expectedUntrackedInventory, SHA);
1147
+ assert.deepEqual(requests[1]!.intendedUntracked, []);
1148
+ assert.deepEqual(requests[1]!.intendedUntrackedSelection, {
1149
+ argumentTokens: selection.submission!.argumentTokens,
1150
+ value: JSON.stringify({
1151
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
1152
+ untracked_scope: "exclude",
1153
+ expected_untracked_inventory: SHA,
1154
+ intended_untracked: [],
1155
+ }),
1156
+ });
1157
+ const retainedEntry = retained.get(`${cwd}\u0000`) as
1158
+ | { untrackedScope: string; submission?: unknown }
1159
+ | undefined;
1160
+ assert.notEqual(retainedEntry, undefined);
1161
+ assert.equal(retainedEntry!.untrackedScope, "exclude");
1162
+ assert.notEqual(retainedEntry!.submission, undefined);
1163
+ });
1164
+
1165
+ test("inspect with untrackedScope select resolves the stop with exactly the selected paths", async (t) => {
1166
+ const { cwd, eligible, initial, target } = untrackedStopFixture(t);
1167
+ const requests: Array<Record<string, unknown>> = [];
1168
+ const native = {
1169
+ targetStatus: async (request: Record<string, unknown>) => {
1170
+ requests.push(request);
1171
+ return "intendedUntrackedSelection" in request ? target : initial;
1172
+ },
1173
+ } as unknown as NativeReviewCli;
1174
+ const result = await __testing.executeReviewControllerOperation(
1175
+ {
1176
+ operation: "inspect",
1177
+ untrackedScope: "select",
1178
+ intendedUntracked: [eligible],
1179
+ },
1180
+ cwd,
1181
+ native,
1182
+ );
1183
+ assert.equal(result.status, "ready");
1184
+ assert.equal(requests.length, 2);
1185
+ assert.equal(requests[1]!.untrackedScope, "select");
1186
+ assert.deepEqual(requests[1]!.intendedUntracked, [eligible]);
1187
+ const submission = requests[1]!.intendedUntrackedSelection as {
1188
+ value: string;
1189
+ };
1190
+ assert.deepEqual(JSON.parse(submission.value), {
1191
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
1192
+ untracked_scope: "select",
1193
+ expected_untracked_inventory: SHA,
1194
+ intended_untracked: [eligible],
1195
+ });
1196
+ });
1197
+
1198
+ test("inspect with untrackedScope select rejects a path outside the eligible inventory", async (t) => {
1199
+ const { cwd, initial } = untrackedStopFixture(t);
1200
+ let targetCalls = 0;
1201
+ const native = {
1202
+ targetStatus: async () => {
1203
+ targetCalls += 1;
1204
+ return initial;
1205
+ },
1206
+ } as unknown as NativeReviewCli;
1207
+ const result = await __testing.executeReviewControllerOperation(
1208
+ {
1209
+ operation: "inspect",
1210
+ untrackedScope: "select",
1211
+ intendedUntracked: ["docs/unknown.md"],
1212
+ },
1213
+ cwd,
1214
+ native,
1215
+ );
1216
+ assert.equal(result.status, "blocked");
1217
+ assert.equal(result.outcome, "inspect-untracked-scope-invalid");
1218
+ assert.equal(result.mutation_performed, false);
1219
+ assert.equal(result.mutation_outcome, "none");
1220
+ assert.equal(targetCalls, 1);
1221
+ });
1222
+
1223
+ for (const invalid of [
1224
+ { untrackedScope: "select" },
1225
+ { untrackedScope: "exclude", intendedUntracked: ["selected.md"] },
1226
+ ] as const)
1227
+ test(`inspect rejects an inconsistent untracked scope combination (${JSON.stringify(invalid)})`, async (t) => {
1228
+ const { cwd, initial } = untrackedStopFixture(t);
1229
+ let targetCalls = 0;
1230
+ const native = {
1231
+ targetStatus: async () => {
1232
+ targetCalls += 1;
1233
+ return initial;
1234
+ },
1235
+ } as unknown as NativeReviewCli;
1236
+ if (invalid.untrackedScope === "exclude") {
1237
+ await assert.rejects(
1238
+ () => __testing.executeReviewControllerOperation({ operation: "inspect", ...invalid }, cwd, native),
1239
+ /requires untrackedScope select/,
1240
+ );
1241
+ assert.equal(targetCalls, 0);
1242
+ return;
1243
+ }
1244
+ const result = await __testing.executeReviewControllerOperation(
1245
+ { operation: "inspect", ...invalid },
1246
+ cwd,
1247
+ native,
1248
+ );
1249
+ assert.equal(result.status, "blocked");
1250
+ assert.equal(result.outcome, "inspect-untracked-scope-invalid");
1251
+ assert.equal(result.mutation_performed, false);
1252
+ assert.equal(result.mutation_outcome, "none");
1253
+ assert.equal(targetCalls, 1);
1254
+ });
1255
+
1256
+ test("inspect with untrackedScope and no intended-untracked stop reports not-required", async (t) => {
1257
+ const { cwd, target } = untrackedStopFixture(t);
1258
+ let targetCalls = 0;
1259
+ const native = {
1260
+ targetStatus: async () => {
1261
+ targetCalls += 1;
1262
+ return target;
1263
+ },
1264
+ } as unknown as NativeReviewCli;
1265
+ const result = await __testing.executeReviewControllerOperation(
1266
+ { operation: "inspect", untrackedScope: "exclude" },
1267
+ cwd,
1268
+ native,
1269
+ );
1270
+ assert.equal(result.status, "ready");
1271
+ assert.equal(result.untracked_selection, "not-required");
1272
+ assert.equal(targetCalls, 1);
1273
+ });
1274
+
1275
+ test("plain START adopts the pre-lineage selection retained by inspect and deletes it after success", async (t) => {
1276
+ const { cwd, eligible, initial, target, selection } = untrackedStopFixture(t);
1277
+ const requests: Array<Record<string, unknown>> = [],
1278
+ starts: Array<Record<string, unknown>> = [],
1279
+ retained = new Map();
1280
+ const native = {
1281
+ targetStatus: async (request: Record<string, unknown>) => {
1282
+ requests.push(request);
1283
+ return "intendedUntrackedSelection" in request ? target : initial;
1284
+ },
1285
+ start: async (request: Record<string, unknown>) => {
1286
+ starts.push(request);
1287
+ return {
1288
+ lineageId: "review-started",
1289
+ state: "reviewing",
1290
+ riskLevel: "low",
1291
+ selectedLenses: [],
1292
+ changedFiles: 1,
1293
+ changedLines: 1,
1294
+ correctionBudget: 1,
1295
+ action: "created",
1296
+ lensesRequired: false,
1297
+ riskReasons: [],
1298
+ raw: {},
1299
+ };
1300
+ },
1301
+ } as unknown as NativeReviewCli;
1302
+ const resolved = await __testing.executeReviewControllerOperation(
1303
+ {
1304
+ operation: "inspect",
1305
+ untrackedScope: "select",
1306
+ intendedUntracked: [eligible],
1307
+ },
1308
+ cwd,
1309
+ native,
1310
+ undefined,
1311
+ undefined,
1312
+ undefined,
1313
+ retained,
1314
+ );
1315
+ assert.equal(resolved.status, "ready");
1316
+ const started = await __testing.executeReviewControllerOperation(
1317
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
1318
+ cwd,
1319
+ native,
1320
+ undefined,
1321
+ undefined,
1322
+ undefined,
1323
+ retained,
1324
+ );
1325
+ assert.equal(started.operation, "start");
1326
+ assert.equal(started.result.lineage_id, "review-started");
1327
+ assert.equal(starts.length, 1);
1328
+ assert.equal(requests.length, 3);
1329
+ const startStatusRequest = requests[2]!;
1330
+ assert.equal(startStatusRequest.untrackedScope, "select");
1331
+ assert.equal(startStatusRequest.expectedUntrackedInventory, SHA);
1332
+ assert.deepEqual(startStatusRequest.intendedUntracked, [eligible]);
1333
+ assert.deepEqual(startStatusRequest.intendedUntrackedSelection, {
1334
+ argumentTokens: selection.submission!.argumentTokens,
1335
+ value: JSON.stringify({
1336
+ schema: "gentle-ai.review-intended-untracked-selection/v1",
1337
+ untracked_scope: "select",
1338
+ expected_untracked_inventory: SHA,
1339
+ intended_untracked: [eligible],
1340
+ }),
1341
+ });
1342
+ assert.equal(starts[0]!.untrackedScope, "select");
1343
+ assert.equal(retained.has(`${cwd}\u0000`), false);
1344
+ const lineageEntry = retained.get(`${cwd}\u0000review-started`) as
1345
+ | { untrackedScope: string; submission?: unknown }
1346
+ | undefined;
1347
+ assert.notEqual(lineageEntry, undefined);
1348
+ assert.equal(lineageEntry!.untrackedScope, "select");
1349
+ assert.notEqual(lineageEntry!.submission, undefined);
1350
+ });
1351
+
1352
+ test("untrackedScope is accepted only by the inspect operation", async (t) => {
1353
+ const { cwd, initial } = untrackedStopFixture(t);
1354
+ const native = {
1355
+ targetStatus: async () => initial,
1356
+ } as unknown as NativeReviewCli;
1357
+ await assert.rejects(
1358
+ () =>
1359
+ __testing.executeReviewControllerOperation(
1360
+ { operation: "status", untrackedScope: "exclude" } as never,
1361
+ cwd,
1362
+ native,
1363
+ ),
1364
+ /does not accept untrackedScope/,
1365
+ );
1366
+ await assert.rejects(
1367
+ () =>
1368
+ __testing.executeReviewControllerOperation(
1369
+ {
1370
+ operation: "start",
1371
+ input: JSON.stringify({ mode: "ordinary" }),
1372
+ untrackedScope: "exclude",
1373
+ } as never,
1374
+ cwd,
1375
+ native,
1376
+ ),
1377
+ /does not accept untrackedScope/,
1378
+ );
1379
+ });
1380
+
1381
+ test("top-level intendedUntracked is accepted only by inspect select", async (t) => {
1382
+ const { cwd, initial } = untrackedStopFixture(t);
1383
+ const native = { targetStatus: async () => initial } as unknown as NativeReviewCli;
1384
+ for (const parameters of [
1385
+ { operation: "inspect", intendedUntracked: [] },
1386
+ { operation: "inspect", untrackedScope: "exclude", intendedUntracked: [] },
1387
+ { operation: "status", intendedUntracked: [] },
1388
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }), intendedUntracked: [] },
1389
+ ] as const) {
1390
+ await assert.rejects(
1391
+ () => __testing.executeReviewControllerOperation(parameters as never, cwd, native),
1392
+ /intendedUntracked/,
1393
+ JSON.stringify(parameters),
1394
+ );
1395
+ }
1396
+ });
1397
+
1398
+ test("a fresh unsuccessful inspect invalidates its prior pre-lineage selection", async (t) => {
1399
+ const { cwd, eligible, initial, target } = untrackedStopFixture(t);
1400
+ const retained = new Map();
1401
+ let failInspect = false;
1402
+ const native = {
1403
+ targetStatus: async (request: Record<string, unknown>) => {
1404
+ if (failInspect) throw new Error("inspect unavailable");
1405
+ return "intendedUntrackedSelection" in request ? target : initial;
1406
+ },
1407
+ } as unknown as NativeReviewCli;
1408
+ await __testing.executeReviewControllerOperation(
1409
+ { operation: "inspect", untrackedScope: "select", intendedUntracked: [eligible] },
1410
+ cwd, native, undefined, undefined, undefined, retained,
1411
+ );
1412
+ assert.equal(retained.has(`${cwd}\u0000`), true);
1413
+ failInspect = true;
1414
+ const failed = await __testing.executeReviewControllerOperation(
1415
+ { operation: "inspect" }, cwd, native, undefined, undefined, undefined, retained,
1416
+ );
1417
+ assert.equal(failed.outcome, "native-status-unavailable");
1418
+ assert.equal(retained.has(`${cwd}\u0000`), false);
1419
+ });
1420
+
1421
+ test("START rejects a retained pre-lineage selection when fresh STATUS identifies a changed candidate", async (t) => {
1422
+ const { cwd, eligible, initial, target } = untrackedStopFixture(t);
1423
+ const retained = new Map();
1424
+ const candidateA = {
1425
+ ...target,
1426
+ targetIdentity: "target-a",
1427
+ projection: { ...target.projection, currentCandidateTree: "candidate-a" },
1428
+ } as ReviewStatusV3;
1429
+ const candidateB = {
1430
+ ...target,
1431
+ targetIdentity: "target-b",
1432
+ projection: { ...target.projection, currentCandidateTree: "candidate-b" },
1433
+ } as ReviewStatusV3;
1434
+ let resolved = false;
1435
+ let starts = 0;
1436
+ const native = {
1437
+ targetStatus: async (request: Record<string, unknown>) => {
1438
+ if (!("intendedUntrackedSelection" in request)) return initial;
1439
+ if (!resolved) { resolved = true; return candidateA; }
1440
+ return candidateB;
1441
+ },
1442
+ start: async () => {
1443
+ starts += 1;
1444
+ return { lineageId: "unexpected", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} };
1445
+ },
1446
+ } as unknown as NativeReviewCli;
1447
+ await __testing.executeReviewControllerOperation(
1448
+ { operation: "inspect", untrackedScope: "select", intendedUntracked: [eligible] },
1449
+ cwd, native, undefined, null, undefined, retained,
1450
+ );
1451
+ const rejected = await __testing.executeReviewControllerOperation(
1452
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
1453
+ cwd, native, undefined, null, undefined, retained,
1454
+ );
1455
+ assert.equal(rejected.outcome, "native-start-retained-selection-candidate-mismatch");
1456
+ assert.equal(starts, 0);
1457
+ assert.equal(retained.has(`${cwd}\u0000`), false);
1458
+ });
1459
+
1460
+ test("a failed START retains a pre-lineage selection for a same-candidate retry", async (t) => {
1461
+ const { cwd, eligible, initial, target } = untrackedStopFixture(t);
1462
+ const retained = new Map();
1463
+ let starts = 0;
1464
+ const native = {
1465
+ targetStatus: async (request: Record<string, unknown>) =>
1466
+ "intendedUntrackedSelection" in request ? target : initial,
1467
+ start: async () => {
1468
+ starts += 1;
1469
+ if (starts === 1) throw Object.assign(new Error("retryable failure"), { mutationOutcome: "none" });
1470
+ return { lineageId: "retried", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [], raw: {} };
1471
+ },
1472
+ } as unknown as NativeReviewCli;
1473
+ await __testing.executeReviewControllerOperation(
1474
+ { operation: "inspect", untrackedScope: "select", intendedUntracked: [eligible] },
1475
+ cwd, native, undefined, null, undefined, retained,
1476
+ );
1477
+ const failed = await __testing.executeReviewControllerOperation(
1478
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
1479
+ cwd, native, undefined, null, undefined, retained,
1480
+ );
1481
+ assert.equal(failed.mutation_outcome, "none");
1482
+ assert.equal(retained.has(`${cwd}\u0000`), true);
1483
+ const retried = await __testing.executeReviewControllerOperation(
1484
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
1485
+ cwd, native, undefined, null, undefined, retained,
1486
+ );
1487
+ assert.equal(retried.result.lineage_id, "retried");
1488
+ assert.equal(starts, 2);
1489
+ assert.equal(retained.has(`${cwd}\u0000`), false);
1490
+ });
1491
+
768
1492
  test("ordinary START relays native consent without authoring or advancing it", async (t) => {
769
1493
  const cwd = repository(t);
770
1494
  const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
@@ -778,6 +1502,22 @@ test("ordinary START relays native consent without authoring or advancing it", a
778
1502
  assert.equal(result.mutation_outcome, "none");
779
1503
  });
780
1504
 
1505
+ test("shutdown preserves failed consent candidates without interrupting session teardown", async (t) => {
1506
+ const cwd = repository(t);
1507
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1508
+ const native = {
1509
+ targetStatus: async () => startStatus(cwd),
1510
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
1511
+ } as unknown as NativeReviewCli;
1512
+ const registry = new CandidateViewRegistry();
1513
+ const runtime = reviewRuntime(native, registry);
1514
+ const ctx = reviewContext(cwd);
1515
+ const result = await runtime.controller.execute("pending", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, ctx);
1516
+ assert.equal((result.details as { outcome: string }).outcome, "native-review-consent-required");
1517
+ t.mock.method(registry, "cleanup", () => { throw new Error("fixture unsafe ownership"); });
1518
+ assert.doesNotThrow(() => runtime.sessionShutdown({ reason: "reload" }, ctx));
1519
+ });
1520
+
781
1521
  test("ordinary START obeys the review-mode kill switch before target STATUS", async () => {
782
1522
  let targetCalls = 0;
783
1523
  const native = {
@@ -799,6 +1539,24 @@ test("STATUS preserves a native process failure without inventing authority reco
799
1539
  assert.equal(result.mutation_outcome, "none");
800
1540
  });
801
1541
 
1542
+ test("foreign native errors with malformed diagnostics remain untrusted", async () => {
1543
+ const native = {
1544
+ targetStatus: async () => { throw {
1545
+ name: "NativeReviewCliError",
1546
+ code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
1547
+ diagnostics: {
1548
+ operation: "review/status",
1549
+ error_code: "forged-code",
1550
+ timed_out: false,
1551
+ output_limit_exceeded: false,
1552
+ },
1553
+ }; },
1554
+ } as unknown as NativeReviewCli;
1555
+ const result = await __testing.executeReviewControllerOperation({ operation: "status" }, process.cwd(), native);
1556
+ assert.equal(result.outcome, "native-operation-failed");
1557
+ assert.equal("diagnostics" in result, false);
1558
+ });
1559
+
802
1560
  test("STATUS preserves ambiguous native status as read-only provider-owned state", async () => {
803
1561
  const lineageId = "ambiguous-lineage";
804
1562
  const native = {
@@ -1178,13 +1936,17 @@ test("START and consent ambiguity reconciliation register their returned committ
1178
1936
  const lineageId = "reconciled-collect", input = correctionPlanInput(lineageId), unknown = () => { throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, "review/start", true, true, "unknown mutation"); };
1179
1937
  const selectors = (requests: readonly Record<string, unknown>[]) => requests.map(({ baseRef: base, committedOnly }) => ({ baseRef: base, committedOnly }));
1180
1938
  const directRequests: Array<Record<string, unknown>> = [], directRoutes = new Map();
1939
+ let directStartCalls = 0;
1181
1940
  const directNative = {
1182
1941
  targetStatus: async (request: Record<string, unknown>) => { directRequests.push(request); return directRequests.length === 1 ? startStatus(cwd, baseRef) : status(lineageId, [input]); },
1183
- start: unknown,
1942
+ start: () => { directStartCalls += 1; return unknown(); },
1184
1943
  captureCorrectionPlan: async () => ({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review.capture-correction-plan", lineageId, state: "correction_required", storeRevision: SHA }),
1185
1944
  } as unknown as NativeReviewCli;
1186
- await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef, committedOnly: true }) }, cwd, directNative, undefined, undefined, undefined, directRoutes);
1187
- const directCapture = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: JSON.stringify(input), correctionLines: 1 }, cwd, directNative, undefined, undefined, directRoutes, true);
1945
+ const directStart = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef, committedOnly: true }) }, cwd, directNative, undefined, undefined, undefined, directRoutes);
1946
+ assert.deepEqual({ outcome: directStart.outcome, status: directStart.status, mutationOutcome: directStart.mutation_outcome, startCalls: directStartCalls, statusCalls: directRequests.length }, { outcome: "native-mutation-status-reconciled", status: "blocked", mutationOutcome: "unknown", startCalls: 1, statusCalls: 2 });
1947
+ assert.equal((directStart.diagnostics as { error_code?: string }).error_code, NATIVE_REVIEW_ERROR_CODE.NON_ZERO);
1948
+ assert.equal("next_action" in directStart, false);
1949
+ const directCapture = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: bindingOf(directStart), correctionLines: 1 }, cwd, directNative, undefined, undefined, directRoutes, true);
1188
1950
  const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1189
1951
  const consentRequests: Array<Record<string, unknown>> = [], consentRoutes = new Map(), registry = new PendingReviewConsentRegistry(), session = Symbol("consent-session");
1190
1952
  const consentNative = {
@@ -1199,9 +1961,11 @@ test("START and consent ambiguity reconciliation register their returned committ
1199
1961
  const consentCapture = await __testing.executeReviewCaptureOperation({ lineageId, collectBinding: JSON.stringify(input), correctionLines: 1 }, cwd, consentNative, undefined, undefined, consentRoutes, true);
1200
1962
  assert.deepEqual({
1201
1963
  outcomes: [directCapture.outcome, consentCapture.outcome],
1964
+ directStartCalls,
1202
1965
  selectors: [selectors(directRequests), selectors(consentRequests)],
1203
1966
  }, {
1204
1967
  outcomes: ["native-last-event-closure", "native-last-event-closure"],
1968
+ directStartCalls: 1,
1205
1969
  selectors: [Array.from({ length: 3 }, () => ({ baseRef, committedOnly: true })), Array.from({ length: 3 }, () => ({ baseRef, committedOnly: true }))],
1206
1970
  });
1207
1971
  });
@@ -1220,6 +1984,119 @@ test("ordinary START refuses a target projection that no longer matches the froz
1220
1984
  assert.equal(startCalls, 0);
1221
1985
  });
1222
1986
 
1987
+ // gentle-pi#455: a consent binding one active Pi session's START created must
1988
+ // be answerable from another active session presenting the same opaque
1989
+ // binding id -- consent bindings are not partitioned by which session's
1990
+ // START created them, only by the repository and candidate they are scoped to.
1991
+ test("answer-consent resolves a valid binding presented by a different active Pi session", async (t) => {
1992
+ const cwd = repository(t);
1993
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
1994
+ const registry = new PendingReviewConsentRegistry();
1995
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
1996
+ let answerCalls = 0;
1997
+ const native = {
1998
+ targetStatus: async () => startStatus(cwd),
1999
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
2000
+ answerConsent: async () => {
2001
+ answerCalls += 1;
2002
+ return { kind: "granted", start: { lineageId: "cross-session", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
2003
+ },
2004
+ } as unknown as NativeReviewCli;
2005
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
2006
+ assert.equal(typeof started.consent_binding, "string", JSON.stringify(started));
2007
+ 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);
2008
+ assert.equal(answerCalls, 1, JSON.stringify(answered));
2009
+ assert.equal((answered.result as { lineage_id?: string } | undefined)?.lineage_id, "cross-session", JSON.stringify(answered));
2010
+ assert.notEqual(answered.status, "blocked", JSON.stringify(answered));
2011
+ });
2012
+
2013
+ // gentle-pi#455: cross-session resolution must still enforce single use --
2014
+ // once a binding is answered by any session, no session (including the one
2015
+ // whose START created it) may answer it again.
2016
+ test("a consumed consent binding cannot be answered a second time from any session", async (t) => {
2017
+ const cwd = repository(t);
2018
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
2019
+ const registry = new PendingReviewConsentRegistry();
2020
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
2021
+ let answerCalls = 0;
2022
+ const native = {
2023
+ targetStatus: async () => startStatus(cwd),
2024
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
2025
+ answerConsent: async () => {
2026
+ answerCalls += 1;
2027
+ return { kind: "granted", start: { lineageId: "single-use", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
2028
+ },
2029
+ } as unknown as NativeReviewCli;
2030
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
2031
+ const binding = started.consent_binding as string;
2032
+ const first = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionB);
2033
+ assert.equal(answerCalls, 1, JSON.stringify(first));
2034
+ const replay = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwd, native, undefined, undefined, undefined, undefined, registry, sessionA);
2035
+ assert.equal(answerCalls, 1, JSON.stringify(replay));
2036
+ assert.equal(replay.status, "blocked", JSON.stringify(replay));
2037
+ assert.equal(replay.outcome, "consent-binding-stale", JSON.stringify(replay));
2038
+ assert.equal((replay.diagnostics as { code?: string } | undefined)?.code, "consent-binding-already-consumed", JSON.stringify(replay));
2039
+ });
2040
+
2041
+ // gentle-pi#455 correction: cross-session resolution looks a binding up by
2042
+ // its opaque id alone, so it must independently refuse an answer presented
2043
+ // from a different repository than the one its owning START minted it for,
2044
+ // without ever running the mode gate or native answerConsent, and without
2045
+ // consuming the binding -- leaving it answerable from the right repository.
2046
+ 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) => {
2047
+ const cwdA = repository(t), cwdB = repository(t);
2048
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
2049
+ const registry = new PendingReviewConsentRegistry();
2050
+ const sessionA = Symbol("session-a"), sessionB = Symbol("session-b");
2051
+ let answerCalls = 0;
2052
+ const native = {
2053
+ targetStatus: async () => startStatus(cwdA),
2054
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
2055
+ answerConsent: async () => {
2056
+ answerCalls += 1;
2057
+ return { kind: "granted", start: { lineageId: "right-repo", state: "reviewing", riskLevel: "low", selectedLenses: [], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: false, riskReasons: [] } };
2058
+ },
2059
+ } as unknown as NativeReviewCli;
2060
+ const started = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwdA, native, undefined, undefined, undefined, undefined, registry, sessionA);
2061
+ const binding = started.consent_binding as string;
2062
+ const wrongRepo = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwdB, native, undefined, undefined, undefined, undefined, registry, sessionB);
2063
+ assert.equal(answerCalls, 0, JSON.stringify(wrongRepo));
2064
+ assert.equal(wrongRepo.status, "blocked", JSON.stringify(wrongRepo));
2065
+ assert.equal(wrongRepo.outcome, "consent-binding-repository-mismatch", JSON.stringify(wrongRepo));
2066
+ assert.equal((wrongRepo.diagnostics as { code?: string } | undefined)?.code, "consent-binding-repository-mismatch", JSON.stringify(wrongRepo));
2067
+ assert.equal(wrongRepo.mutation_performed, false, JSON.stringify(wrongRepo));
2068
+ const rightRepo = await __testing.executeReviewControllerOperation({ operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer: "granted" }) }, cwdA, native, undefined, undefined, undefined, undefined, registry, sessionA);
2069
+ assert.equal(answerCalls, 1, JSON.stringify(rightRepo));
2070
+ assert.equal((rightRepo.result as { lineage_id?: string } | undefined)?.lineage_id, "right-repo", JSON.stringify(rightRepo));
2071
+ });
2072
+
2073
+ // gentle-pi#323: within the live consent TTL window, a content-independent
2074
+ // replay key let a second ordinary START reuse the first START's still-live
2075
+ // (never lineage-bound) frozen candidate view even though the live candidate
2076
+ // content changed underneath it, and then dead-ended at
2077
+ // candidate-target-projection-drift instead of minting a fresh view and a
2078
+ // fresh consent envelope. An intended-untracked selection is retained across
2079
+ // both calls so the pre-existing empty-untracked drift-recovery branch does
2080
+ // not mask the general content-change gap this covers.
2081
+ test("ordinary START mints a fresh candidate view when candidate content changes within the live consent TTL window", async (t) => {
2082
+ const candidateViews = new CandidateViewRegistry();
2083
+ t.after(() => candidateViews.cleanupAll());
2084
+ const cwd = repository(t);
2085
+ writeFileSync(join(cwd, "extra.md"), "kept\n");
2086
+ const consent = decodeReviewConsentV3(JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "consent-v3.captured.json"), "utf8")));
2087
+ const native = {
2088
+ targetStatus: async () => startStatus(cwd, undefined, ["extra.md"]),
2089
+ start: async () => { throw new NativeReviewConsentRequiredError(consent); },
2090
+ } as unknown as NativeReviewCli;
2091
+ const first = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, candidateViews);
2092
+ assert.equal(first.outcome, "native-review-consent-required", JSON.stringify(first));
2093
+ writeFileSync(join(cwd, "tracked.txt"), "candidate two\n");
2094
+ const second = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, candidateViews);
2095
+ assert.notEqual(second.outcome, "native-operation-failed", JSON.stringify(second));
2096
+ assert.equal(second.outcome, "native-review-consent-required", JSON.stringify(second));
2097
+ assert.notEqual(second.consent_binding, first.consent_binding, JSON.stringify(second));
2098
+ });
2099
+
1223
2100
  test("controller-owned dispatch confines single and parallel graph actors to the current candidate view", async (t) => {
1224
2101
  const cwd = repository(t);
1225
2102
  const candidateViews = new CandidateViewRegistry();