gentle-pi 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
- import { createHash } from "node:crypto";
3
2
  import { execFile } from "node:child_process";
4
- import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { mkdtempSync, realpathSync, rmSync } from "node:fs";
4
+ import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
7
7
  import baseTest from "node:test";
@@ -9,21 +9,35 @@ import { fileURLToPath } from "node:url";
9
9
  import { promisify } from "node:util";
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
12
- import { GENTLE_AI_VERSION, resolveGentleAiBinary } from "../lib/gentle-ai-binary.ts";
12
+ import {
13
+ GENTLE_AI_DEV_BINARY_ENV,
14
+ resolveGentleAiBinary,
15
+ type GentleAiDevBinaryEnvironment,
16
+ } from "../lib/gentle-ai-binary.ts";
13
17
  import { NativeReviewCliV216 } from "../lib/native-review-cli.ts";
14
- import { NativeReviewCliV216 as RuntimeNativeReviewCliV216 } from "../runtime/native-review-cli.mjs";
15
18
  import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
16
- import { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
19
+ import { decodeReviewLastEventClosureV1 } from "../lib/review-integration-v2.ts";
20
+ import { decodeReviewLastEventClosureV1 as decodeRuntimeReviewLastEventClosureV1 } from "../runtime/review-integration-v2.mjs";
17
21
  import { requireNativeBinary } from "./support/native-binary-gate.ts";
18
22
 
19
23
  const execFileAsync = promisify(execFile);
20
24
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
25
+ const pinnedBinaryHome = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-pinned-runtime-home-")));
26
+ const pinnedBinaryEnvironment: GentleAiDevBinaryEnvironment = {
27
+ env: { ...process.env },
28
+ home: pinnedBinaryHome,
29
+ };
30
+ delete pinnedBinaryEnvironment.env[GENTLE_AI_DEV_BINARY_ENV];
31
+ delete pinnedBinaryEnvironment.env.GENTLE_PI_CONFIG_HOME;
32
+ baseTest.after(() => rmSync(pinnedBinaryHome, { recursive: true, force: true }));
21
33
  // The parity suite exercises the published official binary; it skips while a
22
34
  // re-pinned release's archives and digest table are still pending, because the
23
35
  // pinned package-local binary cannot be installed or integrity-verified yet.
36
+ // Dev-binary override state is intentionally excluded: these assertions verify
37
+ // the package pin, while explicit dev-binary behavior belongs to its own suite.
24
38
  const resolvedBinary = (() => {
25
39
  try {
26
- return resolveGentleAiBinary(packageRoot, process.platform);
40
+ return resolveGentleAiBinary(packageRoot, process.platform, undefined, pinnedBinaryEnvironment);
27
41
  } catch {
28
42
  return undefined;
29
43
  }
@@ -32,12 +46,6 @@ const nativeBinaryGate = requireNativeBinary({ resolvedBinary, digestsPinned: tr
32
46
  if (!nativeBinaryGate.run) console.log(`native-review-parity-runtime: ${nativeBinaryGate.reason}`);
33
47
  const test = nativeBinaryGate.run ? baseTest : baseTest.skip;
34
48
  const binary = resolvedBinary ?? "";
35
- const OFFICIAL_BINARY_SHA256 = resolveGentleAiReleaseAsset(process.platform, process.arch).binarySha256;
36
- const REVIEWED_PATHS = ["tracked.txt", "initially-untracked.txt"] as const;
37
- // Golden captured by the clean external-artifact differential fixture for v2.1.3.
38
- // This is released runtime output, not a locally reconstructed authority digest.
39
- const CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST = "sha256:5d91d7650fcbd1165e9cd88c144bf28d82913e3537abd7b4fdc8ad0adb9eab9c";
40
- const REVIEWED_MODES = ["100644", "100644"];
41
49
 
42
50
  interface CommandResult {
43
51
  exitCode: number;
@@ -49,44 +57,65 @@ interface RegisteredController {
49
57
  execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ details?: unknown }>;
50
58
  }
51
59
 
52
- interface ReviewStart {
53
- lineage_id: string;
54
- selected_lenses: string[];
55
- action?: string;
56
- state?: string;
57
- }
58
-
59
- interface ReviewFinalize {
60
- lineage_id: string;
61
- state: string;
62
- store_revision: string;
63
- receipt_path: string;
64
- }
65
-
66
- interface ReviewAuthorityEntry {
67
- version: string;
68
- lineage_id: string;
69
- status: string;
70
- state: string;
71
- revision: string;
72
- problems: unknown[];
73
- }
74
-
75
- interface ReviewGateContext {
76
- candidate_tree: string;
77
- paths_digest: string;
78
- denial?: ReviewDenial;
79
- }
80
-
81
- interface ReviewDenial {
82
- stage: string;
83
- code: string;
84
- }
85
-
86
- interface ReviewGateResult {
87
- result: string;
88
- allowed: boolean;
89
- context: ReviewGateContext;
60
+ // Every test in this file drives the real pinned binary through a full review
61
+ // lifecycle, and gentle-ai v2.4.0 made receipt-driven development opt-in: an
62
+ // install whose global mode was never set now resolves to off, and START is
63
+ // rejected before it reaches any of the behavior these tests exist to check.
64
+ //
65
+ // Until that release these tests had a silent dependency on whatever the
66
+ // operator happened to have configured. That is not a new hazard introduced by
67
+ // the pin — it was always there — but the flip is what made it visible: the
68
+ // suite was green on a machine with review enabled and red on a fresh CI
69
+ // runner, from identical bytes. A test whose result depends on unversioned
70
+ // machine state is not evidence either way.
71
+ //
72
+ // So each test owns a sandbox HOME and opts in the same way a user does,
73
+ // exactly as gentle-ai did for its own lifecycle fixtures in the commit that
74
+ // flipped the default. The extension-registered controller path inherits this
75
+ // process environment, so HOME and XDG state are restored afterwards. The
76
+ // global enable runs from a disposable repository: this package worktree may
77
+ // intentionally have clone-local mode off, which must never participate in the
78
+ // fixture's lifecycle.
79
+ async function reviewEnabledHome(t: baseTest.TestContext): Promise<string> {
80
+ const home = await realpath(await mkdtemp(join(tmpdir(), "gentle-pi-review-home-")));
81
+ const lifecycleCwd = await realpath(await mkdtemp(join(tmpdir(), "gentle-pi-review-lifecycle-")));
82
+ const xdgConfigHome = join(home, ".config");
83
+ const xdgDataHome = join(home, ".local", "share");
84
+ const xdgCacheHome = join(home, ".cache");
85
+ const previousHome = process.env.HOME;
86
+ const previousXdgConfigHome = process.env.XDG_CONFIG_HOME;
87
+ const previousXdgDataHome = process.env.XDG_DATA_HOME;
88
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
89
+ process.env.HOME = home;
90
+ process.env.XDG_CONFIG_HOME = xdgConfigHome;
91
+ process.env.XDG_DATA_HOME = xdgDataHome;
92
+ process.env.XDG_CACHE_HOME = xdgCacheHome;
93
+ const environment = {
94
+ ...process.env,
95
+ HOME: home,
96
+ XDG_CONFIG_HOME: xdgConfigHome,
97
+ XDG_DATA_HOME: xdgDataHome,
98
+ XDG_CACHE_HOME: xdgCacheHome,
99
+ };
100
+ t.after(async () => {
101
+ if (previousHome === undefined) delete process.env.HOME;
102
+ else process.env.HOME = previousHome;
103
+ if (previousXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
104
+ else process.env.XDG_CONFIG_HOME = previousXdgConfigHome;
105
+ if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
106
+ else process.env.XDG_DATA_HOME = previousXdgDataHome;
107
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
108
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
109
+ await rm(home, { recursive: true, force: true });
110
+ await rm(lifecycleCwd, { recursive: true, force: true });
111
+ });
112
+ await run("git", ["init", "--quiet"], lifecycleCwd, false, environment);
113
+ const enabled = await run(binary, ["review", "mode", "enable", "--scope", "global", "--cwd", lifecycleCwd, "--json"], lifecycleCwd, false, environment);
114
+ // Assert the opt-in landed rather than assuming it. A silently ineffective
115
+ // enable would put these tests straight back to depending on ambient state,
116
+ // which is the exact failure this helper exists to remove.
117
+ assert.match(enabled.stdout, /"effective": "on"/, "the sandbox HOME must have receipt-driven development explicitly enabled");
118
+ return home;
90
119
  }
91
120
 
92
121
  async function run(command: string, arguments_: readonly string[], cwd: string, allowFailure = false, environment?: NodeJS.ProcessEnv): Promise<CommandResult> {
@@ -100,288 +129,25 @@ async function run(command: string, arguments_: readonly string[], cwd: string,
100
129
  }
101
130
  }
102
131
 
103
- async function stagedEntries(repository: string): Promise<string[]> {
104
- const output = (await run("git", ["ls-files", "--stage"], repository)).stdout.trim();
105
- return output === "" ? [] : output.split("\n");
106
- }
107
-
108
- async function assertPublishedProjection(repository: string, candidateTree: string): Promise<void> {
109
- const stagedTree = (await run("git", ["write-tree"], repository)).stdout.trim();
110
- const entries = await stagedEntries(repository);
111
- const stagedPaths = entries.map((entry) => entry.split("\t", 2)[1]);
112
- const stagedModes = entries.map((entry) => entry.split(" ", 1)[0]);
113
- assert.equal(stagedTree, candidateTree, `staged tree must match frozen candidate; entries: ${entries.join(", ")}`);
114
- assert.deepEqual(stagedPaths, [...REVIEWED_PATHS].toSorted());
115
- assert.deepEqual(stagedModes, REVIEWED_MODES);
116
- }
117
-
118
- async function assertScopeChanged(repository: string, lineageId: string): Promise<void> {
119
- const deniedCommand = await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", lineageId], repository, true);
120
- const denied = JSON.parse(deniedCommand.stdout) as ReviewGateResult;
121
- assert.equal(deniedCommand.exitCode, 1);
122
- assert.equal(denied.result, "scope-changed");
123
- assert.equal(denied.allowed, false);
124
- assert.deepEqual(denied.context.denial, { stage: "receipt-binding", code: "candidate-or-paths-mismatch" });
125
- }
126
-
127
- async function restoreCandidate(repository: string, candidateTree: string): Promise<void> {
128
- await writeFile(join(repository, "tracked.txt"), "candidate\n");
129
- await rm(join(repository, "extra-reviewed-path.txt"), { force: true });
130
- await run("git", ["reset", "--", "extra-reviewed-path.txt"], repository);
131
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository);
132
- await assertPublishedProjection(repository, candidateTree);
133
- }
134
-
135
- async function reviewStatus(repository: string): Promise<Record<string, unknown>> {
136
- return JSON.parse((await run(binary, ["review", "status", "--cwd", repository], repository)).stdout) as Record<string, unknown>;
137
- }
138
-
139
- function authorityInventory(status: Record<string, unknown>): ReviewAuthorityEntry[] {
140
- assert.ok(Array.isArray(status.entries), "review status must expose authority entries");
141
- return status.entries.map((entry) => {
142
- assert.ok(entry !== null && typeof entry === "object", "authority entry must be an object");
143
- const candidate = entry as Record<string, unknown>;
144
- for (const key of ["version", "lineage_id", "status", "state", "revision"] as const) {
145
- assert.equal(typeof candidate[key], "string", `authority entry ${key} must be stable text`);
146
- }
147
- assert.ok(Array.isArray(candidate.problems), "authority entry problems must be an array");
148
- return {
149
- version: candidate.version as string,
150
- lineage_id: candidate.lineage_id as string,
151
- status: candidate.status as string,
152
- state: candidate.state as string,
153
- revision: candidate.revision as string,
154
- problems: candidate.problems,
155
- };
156
- });
157
- }
158
-
159
- // Every reviewer result reaches authority through `review capture-result`.
160
- // `review finalize --result` was removed because a result handed over that way
161
- // carries no provider-owned admission, so it cannot prove the lens inspected
162
- // the frozen candidate. The bindings are never guessed here: each one comes
163
- // from the collect transition the provider itself publishes, which is also
164
- // what the removal message tells a caller to do.
165
- //
166
- // Artifacts stay outside the repository on purpose. Writing them inside changes
167
- // the workspace snapshot between deriving a target and using it, and authority
168
- // correctly rejects the stale target that results.
169
- async function nextTransition(repository: string): Promise<Record<string, unknown>> {
170
- const status = JSON.parse((await run(binary, ["review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", repository, "--projection", "workspace", "--next-transition"], repository)).stdout) as Record<string, unknown>;
171
- return status.next_transition as Record<string, unknown>;
172
- }
173
-
174
- async function captureSelectedLenses(repository: string, artifacts: string, evidenceName: string): Promise<void> {
175
- for (let guard = 0; guard < 8; guard += 1) {
176
- const transition = await nextTransition(repository);
177
- const collect = transition.collect as { inputs?: readonly Record<string, unknown>[] } | undefined;
178
- if (transition.kind !== "collect" || collect?.inputs === undefined) return;
179
- for (const [index, input] of collect.inputs.entries()) {
180
- const subject = input.artifact_subject as { subject_hash: string };
181
- const manifest = input.changed_path_manifest as readonly { path: string }[];
182
- const result = join(artifacts, `${evidenceName}-capture-${index}.json`);
183
- await writeFile(result, JSON.stringify({
184
- subject_hash: subject.subject_hash,
185
- inspection: { status: "completed", paths: manifest.map((entry) => entry.path) },
186
- findings: [],
187
- evidence: ["reviewed the complete frozen candidate scope"],
188
- }));
189
- // The published tokens already carry the repository context, and
190
- // capture-result accepts that or --cwd, never both.
191
- const tokens = (input.arguments as readonly { token: string }[]).map((argument) => argument.token);
192
- const captured = await run(binary, ["review", "capture-result", ...tokens, "--input", result], repository);
193
- assert.equal(captured.exitCode, 0, `capture-result failed: ${captured.stderr}`);
194
- }
195
- }
196
- assert.fail("collect transitions did not converge");
197
- }
198
-
199
- async function finalizeEmptyReview(repository: string, artifacts: string, started: ReviewStart, evidenceName: string): Promise<CommandResult> {
200
- const evidence = join(artifacts, evidenceName);
201
- await writeFile(evidence, `evidence for ${started.lineage_id}\n`);
202
- await captureSelectedLenses(repository, artifacts, evidenceName);
203
- return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], repository);
204
- }
205
-
206
- test("official pinned package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => {
207
- assert.equal(createHash("sha256").update(await readFile(binary)).digest("hex"), OFFICIAL_BINARY_SHA256);
208
- assert.deepEqual(await run(binary, ["version"], packageRoot), { exitCode: 0, stdout: `gentle-ai ${GENTLE_AI_VERSION}\n`, stderr: "" });
209
-
210
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v216-parity-"));
211
- const repository = join(workspace, "repository");
212
- const view = join(workspace, "candidate-view");
213
- const artifacts = join(workspace, "artifacts");
214
- const temporaryIndex = join(workspace, "controller.index");
215
- t.after(async () => rm(workspace, { recursive: true, force: true }));
216
-
217
- await mkdir(repository);
218
- await mkdir(artifacts);
219
- await run("git", ["init", "--initial-branch=main"], repository);
220
- await run("git", ["config", "user.email", "test@example.invalid"], repository);
221
- await run("git", ["config", "user.name", "Gentle Pi test"], repository);
222
- await writeFile(join(repository, "tracked.txt"), "base\n");
223
- await run("git", ["add", "--", "tracked.txt"], repository);
224
- await run("git", ["commit", "-m", "base"], repository);
225
- const native = new RuntimeNativeReviewCliV216(async (request) => {
226
- const command = await run(binary, request.arguments, request.cwd, true);
227
- return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
228
- });
229
- assert.equal((await native.reviewStatus({ cwd: repository })).status, "clean");
230
- await writeFile(join(repository, "tracked.txt"), "candidate\n");
231
- await writeFile(join(repository, "initially-untracked.txt"), "included\n");
232
-
233
- const temporaryIndexEnvironment = { ...process.env, GIT_INDEX_FILE: temporaryIndex };
234
- await run("git", ["read-tree", "HEAD"], repository, false, temporaryIndexEnvironment);
235
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository, false, temporaryIndexEnvironment);
236
- const candidateTree = (await run("git", ["write-tree"], repository, false, temporaryIndexEnvironment)).stdout.trim();
237
- await run("git", ["worktree", "add", "--detach", view, "HEAD"], repository);
238
- await run("git", ["read-tree", candidateTree], view);
239
- await run("git", ["checkout-index", "--all", "--force"], view);
240
-
241
- assert.equal((await readFile(join(view, "tracked.txt"), "utf8")), "candidate\n");
242
- assert.equal((await readFile(join(view, "initially-untracked.txt"), "utf8")), "included\n");
243
- const started = JSON.parse((await run(binary, ["review", "start", "--cwd", view], view)).stdout) as ReviewStart;
244
-
245
- const evidence = join(artifacts, "final-evidence.txt");
246
- await writeFile(evidence, "linked-view parity probe\n");
247
- // The linked view is the root authority froze, so its collect bindings are
248
- // derived from the view, not from the contributor repository.
249
- await captureSelectedLenses(view, artifacts, "final-evidence.txt");
250
- await run(binary, ["review", "finalize", "--cwd", view, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], view);
251
-
252
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository);
253
- await assertPublishedProjection(repository, candidateTree);
254
- const allowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", started.lineage_id], repository)).stdout) as ReviewGateResult;
255
- assert.equal(allowed.result, "allow");
256
- assert.equal(allowed.allowed, true);
257
- assert.equal(allowed.context.candidate_tree, candidateTree);
258
- assert.equal(allowed.context.paths_digest, CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST);
259
- t.diagnostic(JSON.stringify({ candidateTree, publishedPathsDigest: CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST, reviewedPaths: REVIEWED_PATHS, reviewedModes: REVIEWED_MODES, binary, startCwd: view, finalizeCwd: view, artifacts }));
260
-
261
- await writeFile(join(repository, "tracked.txt"), "changed-after-review\n");
262
- await run("git", ["add", "--", "tracked.txt"], repository);
263
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
264
- await assertScopeChanged(repository, started.lineage_id);
265
- await restoreCandidate(repository, candidateTree);
266
-
267
- await writeFile(join(repository, "extra-reviewed-path.txt"), "not in the frozen candidate\n");
268
- await run("git", ["add", "extra-reviewed-path.txt"], repository);
269
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
270
- assert.deepEqual((await stagedEntries(repository)).map((entry) => entry.split("\t", 2)[1]), [...REVIEWED_PATHS, "extra-reviewed-path.txt"].toSorted());
271
- await assertScopeChanged(repository, started.lineage_id);
272
- await restoreCandidate(repository, candidateTree);
273
-
274
- await chmod(join(repository, "tracked.txt"), 0o755);
275
- await run("git", ["add", "--", "tracked.txt"], repository);
276
- const modeDrifted = (await stagedEntries(repository)).some((entry) => entry === "100755" || entry.startsWith("100755 "));
277
- if (modeDrifted) {
278
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
279
- await assertScopeChanged(repository, started.lineage_id);
280
- } else {
281
- t.diagnostic("skipped native mode-drift denial because this repository/platform does not stage executable-bit changes");
282
- }
283
- await chmod(join(repository, "tracked.txt"), 0o644);
284
- await restoreCandidate(repository, candidateTree);
132
+ test("generated runtime decoder consumes the captured terminal closure directly", async () => {
133
+ const fixture = JSON.parse(await readFile(join(packageRoot, "tests", "fixtures", "devbinary", "last-event-capture-result-approved.captured.json"), "utf8"));
134
+ const source = decodeReviewLastEventClosureV1(fixture);
135
+ const runtime = decodeRuntimeReviewLastEventClosureV1(fixture);
136
+ assert.deepEqual(runtime, source);
137
+ assert.equal(runtime.operation, "review/capture-result");
138
+ assert.equal(runtime.state, "approved");
285
139
  });
286
140
 
287
- test("official pinned package runtime keeps frozen candidate lineages and receipts isolated across replay and replacement", async (t) => {
288
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-lineage-"));
141
+ test("registered gentle_review surfaces the package-pinned Pi transport refusal before native START for a safe internal symlink candidate", async (t) => {
142
+ await reviewEnabledHome(t);
143
+ const workspace = await realpath(await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-")));
289
144
  const repository = join(workspace, "repository");
290
- const artifacts = join(workspace, "artifacts");
291
- t.after(async () => rm(workspace, { recursive: true, force: true }));
292
-
293
- await mkdir(repository);
294
- await mkdir(artifacts);
295
- await run("git", ["init", "--initial-branch=main"], repository);
296
- await run("git", ["config", "user.email", "test@example.invalid"], repository);
297
- await run("git", ["config", "user.name", "Gentle Pi test"], repository);
298
- await writeFile(join(repository, "tracked.txt"), "base\n");
299
- await run("git", ["add", "--", "tracked.txt"], repository);
300
- await run("git", ["commit", "-m", "base"], repository);
301
-
302
- await writeFile(join(repository, "tracked.txt"), "candidate one\n");
303
- const first = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
304
- const firstInventory = authorityInventory(await reviewStatus(repository));
305
- const firstReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
306
- const firstReplayInventory = authorityInventory(await reviewStatus(repository));
307
- assert.equal(firstReplay.lineage_id, first.lineage_id, "replaying an exact START must reuse the frozen lineage");
308
- assert.deepEqual(firstReplayInventory, firstInventory, "replaying an exact START must not create durable authority");
309
-
310
- const firstFinalized = JSON.parse((await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt")).stdout) as ReviewFinalize;
311
- const firstFinalizedInventory = authorityInventory(await reviewStatus(repository));
312
- // v2.1.7 terminal replay contract: replay uses the exact explicit lineage with
313
- // no mutation inputs; re-sending reviewer results after approval is rejected.
314
- const repeatedResults = await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt").catch((error: NodeJS.ErrnoException & { stderr?: string }) => error);
315
- assert.match((repeatedResults as { stderr?: string }).stderr ?? "", /terminal review finalize accepts no review inputs; exact replay requires only --lineage/, "terminal authority must reject replayed reviewer results");
316
- const firstFinalizeReplay = JSON.parse((await run(binary, ["review", "finalize", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewFinalize;
317
- const firstFinalizeReplayInventory = authorityInventory(await reviewStatus(repository));
318
- assert.equal(firstFinalized.lineage_id, first.lineage_id);
319
- assert.equal(firstFinalized.state, "approved");
320
- assert.match(firstFinalized.store_revision, /^sha256:[a-f0-9]{64}$/);
321
- assert.equal(firstFinalizeReplay.store_revision, firstFinalized.store_revision, "replaying an exact FINALIZE must reuse its receipt revision");
322
- assert.equal(firstFinalizeReplay.receipt_path, firstFinalized.receipt_path, "replaying an exact FINALIZE must reuse its receipt location");
323
- assert.deepEqual(firstFinalizeReplayInventory, firstFinalizedInventory, "replaying an exact FINALIZE must not create a durable receipt or authority");
324
- assert.deepEqual(firstFinalizedInventory, [{ version: "compact-v2", lineage_id: first.lineage_id, status: "approved", state: "approved", revision: firstFinalized.store_revision, problems: [] }]);
325
-
326
- await run("git", ["add", "--", "tracked.txt"], repository);
327
- const firstCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
328
- const firstAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
329
- assert.equal(firstAllowed.result, "allow");
330
- assert.equal(firstAllowed.context.candidate_tree, firstCandidateTree);
331
-
332
- await writeFile(join(repository, "tracked.txt"), "candidate two\n");
333
- await run("git", ["add", "--", "tracked.txt"], repository);
334
- const secondCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
335
- assert.notEqual(secondCandidateTree, firstCandidateTree);
336
- const authorityBeforeCompetingStart = authorityInventory(await reviewStatus(repository));
337
- const competingStart = await run(binary, ["review", "start", "--cwd", repository, "--lineage", first.lineage_id], repository);
338
- const competingStartResult = JSON.parse(competingStart.stdout) as ReviewStart;
339
- const authorityAfterCompetingStart = authorityInventory(await reviewStatus(repository));
340
- assert.equal(competingStartResult.action, "blocked-scope-action", "a frozen lineage must return a structured scope-action block for a competing candidate");
341
- assert.equal(competingStartResult.lineage_id, first.lineage_id);
342
- assert.equal(competingStartResult.state, "approved");
343
- assert.deepEqual(authorityAfterCompetingStart, authorityBeforeCompetingStart, "a blocked scope action must not mutate approved authority");
344
- // A plain START over the moved candidate does NOT mint a fresh lineage. An
345
- // approved lineage whose scope changed is recovered, keeping the operator
346
- // pointed at the receipt that already exists instead of quietly abandoning
347
- // it, and the recovery itself needs a maintainer to authorize it. Pi mirrors
348
- // this because the experience must be the one gentle-ai defines, not a
349
- // friendlier one Pi invents.
350
- const second = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
351
- const secondInventory = authorityInventory(await reviewStatus(repository));
352
- assert.equal(second.action, "recover");
353
- assert.equal(second.state, "approved");
354
- assert.equal(second.lineage_id, first.lineage_id, "a moved candidate recovers the approved lineage rather than replacing it");
355
- const secondReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
356
- const secondReplayInventory = authorityInventory(await reviewStatus(repository));
357
- assert.equal(secondReplay.action, "recover", "recovery is idempotent");
358
- assert.equal(secondReplay.lineage_id, second.lineage_id, "replaying the second START must reuse its lineage");
359
- assert.deepEqual(secondReplayInventory, secondInventory, "replaying the second START must not duplicate durable authority");
360
- const recoveryTransition = await nextTransition(repository);
361
- assert.equal(recoveryTransition.kind, "collect");
362
- assert.equal(recoveryTransition.reason_code, "recovery_authorization_required", "recovery must ask a maintainer, never proceed on its own");
363
-
364
- // No receipt covers the moved candidate, so delivery is denied rather than
365
- // waved through, and the denial names `gentle-ai review recover` with the
366
- // inputs it needs. A blocked gate that names its exit is the whole point.
367
- await assertScopeChanged(repository, first.lineage_id);
368
-
369
- await writeFile(join(repository, "tracked.txt"), "candidate one\n");
370
- await run("git", ["add", "--", "tracked.txt"], repository);
371
- assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), firstCandidateTree);
372
- const firstRestored = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
373
- assert.equal(firstRestored.result, "allow", "the old receipt must remain valid for its exact frozen candidate");
374
-
375
- await writeFile(join(repository, "tracked.txt"), "candidate two\n");
376
- await run("git", ["add", "--", "tracked.txt"], repository);
377
- assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), secondCandidateTree);
378
- t.diagnostic("pre-push, pre-pr, and release require remote/publication evidence; their network-aware gate contracts remain covered by dedicated gate integration tests rather than this hermetic binary E2E.");
379
- });
380
-
381
- test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
382
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
383
- const repository = join(workspace, "repository");
384
- t.after(async () => rm(workspace, { recursive: true, force: true }));
145
+ t.after(async () => {
146
+ // Candidate views are intentionally read-only. Restore test-workspace write
147
+ // permissions before cleanup when transport refusal stops before START.
148
+ await run("chmod", ["-R", "u+w", workspace], workspace, true);
149
+ await rm(workspace, { recursive: true, force: true });
150
+ });
385
151
 
386
152
  await mkdir(join(repository, ".agents", "skills", "example"), { recursive: true });
387
153
  await mkdir(join(repository, ".agent", "skills"), { recursive: true });
@@ -403,18 +169,17 @@ test("registered gentle_review START materializes a safe internal skill symlink
403
169
 
404
170
  const candidateViews = new CandidateViewRegistry();
405
171
  let nativeStartReached = false;
406
- // The production controller pairs with the negotiated client; v2.1.9's ordinary
407
- // (non-negotiated) START output carries additional facade fields that the
408
- // pinned legacy decoder intentionally rejects.
172
+ // Materialization and lexical symlink escape rejection have dedicated
173
+ // candidate-view coverage. This safe shape proves negotiated Pi transport
174
+ // refusal happens before native START, regardless of candidate materialization.
409
175
  const native = new NativeReviewCliV216(async (request) => {
410
176
  if (request.arguments[0] === "review" && request.arguments[1] === "start") nativeStartReached = true;
411
177
  const command = await run(binary, request.arguments, request.cwd, true);
412
178
  return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
413
179
  });
414
180
  const tools = new Map<string, RegisteredController>();
415
- let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise<void> | void) | undefined;
416
181
  createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
417
- on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise<void> | void) { if (name === "session_shutdown") sessionShutdown = handler; },
182
+ on() {},
418
183
  registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
419
184
  registerCommand() {},
420
185
  } as unknown as ExtensionAPI);
@@ -424,27 +189,20 @@ test("registered gentle_review START materializes a safe internal skill symlink
424
189
  let returned: { details?: unknown } | undefined;
425
190
  let thrown: unknown;
426
191
  try {
427
- // A headless context still receives notices, so the stub carries notify.
428
- // Without it the consent path throws on a real ExtensionContext member,
429
- // and the failure reads as a START problem rather than a stub gap.
430
- returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { confirm: async () => true, notify: () => {} } } as unknown as ExtensionContext);
192
+ returned = await controller.execute("issue-146-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext);
431
193
  } catch (caught) {
432
194
  thrown = caught;
433
195
  }
434
196
  const error = thrown instanceof Error ? { name: thrown.name, message: thrown.message } : thrown === undefined ? undefined : String(thrown);
435
197
  t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
436
- assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
437
- assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START");
198
+ assert.equal(thrown, undefined, "the Pi transport refusal must be returned, not thrown");
438
199
  const result = returned?.details as Record<string, unknown> | undefined;
200
+ const relayTransport = result?.relay_transport as Record<string, unknown> | undefined;
439
201
  assert.equal(result?.status, "blocked");
440
- assert.equal(result?.outcome, "native-review-consent-required");
441
- assert.equal(typeof result?.consent_binding, "string");
442
- assert.equal(result?.lineage_created, false);
443
- assert.ok(sessionShutdown, "extension must register session_shutdown cleanup");
444
- const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext;
445
- await sessionShutdown({}, context);
446
- await assert.rejects(
447
- controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context),
448
- /unknown, expired, or already consumed/,
449
- );
202
+ assert.equal(result?.outcome, "pi-host-relay-transport-unavailable");
203
+ assert.equal(relayTransport?.code, "immutable_review_transport_unsupported");
204
+ assert.equal(result?.mutation_performed, false);
205
+ assert.equal(result?.mutation_outcome, "none");
206
+ if (result !== undefined && "lineage_created" in result) assert.equal(result.lineage_created, false);
207
+ assert.equal(nativeStartReached, false, "negotiated Pi transport refusal must preclude native START and any agent-less fallback");
450
208
  });