gentle-pi 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -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,46 +57,6 @@ 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;
90
- }
91
-
92
60
  // Every test in this file drives the real pinned binary through a full review
93
61
  // lifecycle, and gentle-ai v2.4.0 made receipt-driven development opt-in: an
94
62
  // install whose global mode was never set now resolves to off, and START is
@@ -103,19 +71,46 @@ interface ReviewGateResult {
103
71
  //
104
72
  // So each test owns a sandbox HOME and opts in the same way a user does,
105
73
  // exactly as gentle-ai did for its own lifecycle fixtures in the commit that
106
- // flipped the default. The process-wide HOME is what the extension-registered
107
- // controller path needs, because it spawns the CLI with the inherited
108
- // environment rather than an explicit one; it is restored afterwards.
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.
109
79
  async function reviewEnabledHome(t: baseTest.TestContext): Promise<string> {
110
- const home = await mkdtemp(join(tmpdir(), "gentle-pi-review-home-"));
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");
111
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;
112
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
+ };
113
100
  t.after(async () => {
114
101
  if (previousHome === undefined) delete process.env.HOME;
115
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;
116
109
  await rm(home, { recursive: true, force: true });
110
+ await rm(lifecycleCwd, { recursive: true, force: true });
117
111
  });
118
- const enabled = await run(binary, ["review", "mode", "enable", "--scope", "global", "--json"], packageRoot, false, { ...process.env, HOME: home });
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);
119
114
  // Assert the opt-in landed rather than assuming it. A silently ineffective
120
115
  // enable would put these tests straight back to depending on ambient state,
121
116
  // which is the exact failure this helper exists to remove.
@@ -134,291 +129,25 @@ async function run(command: string, arguments_: readonly string[], cwd: string,
134
129
  }
135
130
  }
136
131
 
137
- async function stagedEntries(repository: string): Promise<string[]> {
138
- const output = (await run("git", ["ls-files", "--stage"], repository)).stdout.trim();
139
- return output === "" ? [] : output.split("\n");
140
- }
141
-
142
- async function assertPublishedProjection(repository: string, candidateTree: string): Promise<void> {
143
- const stagedTree = (await run("git", ["write-tree"], repository)).stdout.trim();
144
- const entries = await stagedEntries(repository);
145
- const stagedPaths = entries.map((entry) => entry.split("\t", 2)[1]);
146
- const stagedModes = entries.map((entry) => entry.split(" ", 1)[0]);
147
- assert.equal(stagedTree, candidateTree, `staged tree must match frozen candidate; entries: ${entries.join(", ")}`);
148
- assert.deepEqual(stagedPaths, [...REVIEWED_PATHS].toSorted());
149
- assert.deepEqual(stagedModes, REVIEWED_MODES);
150
- }
151
-
152
- async function assertScopeChanged(repository: string, lineageId: string): Promise<void> {
153
- const deniedCommand = await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", lineageId], repository, true);
154
- const denied = JSON.parse(deniedCommand.stdout) as ReviewGateResult;
155
- assert.equal(deniedCommand.exitCode, 1);
156
- assert.equal(denied.result, "scope-changed");
157
- assert.equal(denied.allowed, false);
158
- assert.deepEqual(denied.context.denial, { stage: "receipt-binding", code: "candidate-or-paths-mismatch" });
159
- }
160
-
161
- async function restoreCandidate(repository: string, candidateTree: string): Promise<void> {
162
- await writeFile(join(repository, "tracked.txt"), "candidate\n");
163
- await rm(join(repository, "extra-reviewed-path.txt"), { force: true });
164
- await run("git", ["reset", "--", "extra-reviewed-path.txt"], repository);
165
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository);
166
- await assertPublishedProjection(repository, candidateTree);
167
- }
168
-
169
- async function reviewStatus(repository: string): Promise<Record<string, unknown>> {
170
- return JSON.parse((await run(binary, ["review", "status", "--cwd", repository], repository)).stdout) as Record<string, unknown>;
171
- }
172
-
173
- function authorityInventory(status: Record<string, unknown>): ReviewAuthorityEntry[] {
174
- assert.ok(Array.isArray(status.entries), "review status must expose authority entries");
175
- return status.entries.map((entry) => {
176
- assert.ok(entry !== null && typeof entry === "object", "authority entry must be an object");
177
- const candidate = entry as Record<string, unknown>;
178
- for (const key of ["version", "lineage_id", "status", "state", "revision"] as const) {
179
- assert.equal(typeof candidate[key], "string", `authority entry ${key} must be stable text`);
180
- }
181
- assert.ok(Array.isArray(candidate.problems), "authority entry problems must be an array");
182
- return {
183
- version: candidate.version as string,
184
- lineage_id: candidate.lineage_id as string,
185
- status: candidate.status as string,
186
- state: candidate.state as string,
187
- revision: candidate.revision as string,
188
- problems: candidate.problems,
189
- };
190
- });
191
- }
192
-
193
- // Every reviewer result reaches authority through `review capture-result`.
194
- // `review finalize --result` was removed because a result handed over that way
195
- // carries no provider-owned admission, so it cannot prove the lens inspected
196
- // the frozen candidate. The bindings are never guessed here: each one comes
197
- // from the collect transition the provider itself publishes, which is also
198
- // what the removal message tells a caller to do.
199
- //
200
- // Artifacts stay outside the repository on purpose. Writing them inside changes
201
- // the workspace snapshot between deriving a target and using it, and authority
202
- // correctly rejects the stale target that results.
203
- async function nextTransition(repository: string): Promise<Record<string, unknown>> {
204
- 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>;
205
- return status.next_transition as Record<string, unknown>;
206
- }
207
-
208
- async function captureSelectedLenses(repository: string, artifacts: string, evidenceName: string): Promise<void> {
209
- for (let guard = 0; guard < 8; guard += 1) {
210
- const transition = await nextTransition(repository);
211
- const collect = transition.collect as { inputs?: readonly Record<string, unknown>[] } | undefined;
212
- if (transition.kind !== "collect" || collect?.inputs === undefined) return;
213
- for (const [index, input] of collect.inputs.entries()) {
214
- const subject = input.artifact_subject as { subject_hash: string };
215
- const manifest = input.changed_path_manifest as readonly { path: string }[];
216
- const result = join(artifacts, `${evidenceName}-capture-${index}.json`);
217
- await writeFile(result, JSON.stringify({
218
- subject_hash: subject.subject_hash,
219
- inspection: { status: "completed", paths: manifest.map((entry) => entry.path) },
220
- findings: [],
221
- evidence: ["reviewed the complete frozen candidate scope"],
222
- }));
223
- // The published tokens already carry the repository context, and
224
- // capture-result accepts that or --cwd, never both.
225
- const tokens = (input.arguments as readonly { token: string }[]).map((argument) => argument.token);
226
- const captured = await run(binary, ["review", "capture-result", ...tokens, "--input", result], repository);
227
- assert.equal(captured.exitCode, 0, `capture-result failed: ${captured.stderr}`);
228
- }
229
- }
230
- assert.fail("collect transitions did not converge");
231
- }
232
-
233
- async function finalizeEmptyReview(repository: string, artifacts: string, started: ReviewStart, evidenceName: string): Promise<CommandResult> {
234
- const evidence = join(artifacts, evidenceName);
235
- await writeFile(evidence, `evidence for ${started.lineage_id}\n`);
236
- await captureSelectedLenses(repository, artifacts, evidenceName);
237
- return run(binary, ["review", "finalize", "--cwd", repository, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], repository);
238
- }
239
-
240
- test("official pinned package runtime authorizes an unchanged linked-view candidate and denies a changed staging tree", async (t) => {
241
- await reviewEnabledHome(t);
242
- assert.equal(createHash("sha256").update(await readFile(binary)).digest("hex"), OFFICIAL_BINARY_SHA256);
243
- assert.deepEqual(await run(binary, ["version"], packageRoot), { exitCode: 0, stdout: `gentle-ai ${GENTLE_AI_VERSION}\n`, stderr: "" });
244
-
245
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v216-parity-"));
246
- const repository = join(workspace, "repository");
247
- const view = join(workspace, "candidate-view");
248
- const artifacts = join(workspace, "artifacts");
249
- const temporaryIndex = join(workspace, "controller.index");
250
- t.after(async () => rm(workspace, { recursive: true, force: true }));
251
-
252
- await mkdir(repository);
253
- await mkdir(artifacts);
254
- await run("git", ["init", "--initial-branch=main"], repository);
255
- await run("git", ["config", "user.email", "test@example.invalid"], repository);
256
- await run("git", ["config", "user.name", "Gentle Pi test"], repository);
257
- await writeFile(join(repository, "tracked.txt"), "base\n");
258
- await run("git", ["add", "--", "tracked.txt"], repository);
259
- await run("git", ["commit", "-m", "base"], repository);
260
- const native = new RuntimeNativeReviewCliV216(async (request) => {
261
- const command = await run(binary, request.arguments, request.cwd, true);
262
- return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
263
- });
264
- assert.equal((await native.reviewStatus({ cwd: repository })).status, "clean");
265
- await writeFile(join(repository, "tracked.txt"), "candidate\n");
266
- await writeFile(join(repository, "initially-untracked.txt"), "included\n");
267
-
268
- const temporaryIndexEnvironment = { ...process.env, GIT_INDEX_FILE: temporaryIndex };
269
- await run("git", ["read-tree", "HEAD"], repository, false, temporaryIndexEnvironment);
270
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository, false, temporaryIndexEnvironment);
271
- const candidateTree = (await run("git", ["write-tree"], repository, false, temporaryIndexEnvironment)).stdout.trim();
272
- await run("git", ["worktree", "add", "--detach", view, "HEAD"], repository);
273
- await run("git", ["read-tree", candidateTree], view);
274
- await run("git", ["checkout-index", "--all", "--force"], view);
275
-
276
- assert.equal((await readFile(join(view, "tracked.txt"), "utf8")), "candidate\n");
277
- assert.equal((await readFile(join(view, "initially-untracked.txt"), "utf8")), "included\n");
278
- const started = JSON.parse((await run(binary, ["review", "start", "--cwd", view], view)).stdout) as ReviewStart;
279
-
280
- const evidence = join(artifacts, "final-evidence.txt");
281
- await writeFile(evidence, "linked-view parity probe\n");
282
- // The linked view is the root authority froze, so its collect bindings are
283
- // derived from the view, not from the contributor repository.
284
- await captureSelectedLenses(view, artifacts, "final-evidence.txt");
285
- await run(binary, ["review", "finalize", "--cwd", view, "--lineage", started.lineage_id, "--captured-results=true", "--evidence", evidence], view);
286
-
287
- await run("git", ["add", "--", ...REVIEWED_PATHS], repository);
288
- await assertPublishedProjection(repository, candidateTree);
289
- const allowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", started.lineage_id], repository)).stdout) as ReviewGateResult;
290
- assert.equal(allowed.result, "allow");
291
- assert.equal(allowed.allowed, true);
292
- assert.equal(allowed.context.candidate_tree, candidateTree);
293
- assert.equal(allowed.context.paths_digest, CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST);
294
- t.diagnostic(JSON.stringify({ candidateTree, publishedPathsDigest: CLEAN_DIFFERENTIAL_PUBLISHED_PATHS_DIGEST, reviewedPaths: REVIEWED_PATHS, reviewedModes: REVIEWED_MODES, binary, startCwd: view, finalizeCwd: view, artifacts }));
295
-
296
- await writeFile(join(repository, "tracked.txt"), "changed-after-review\n");
297
- await run("git", ["add", "--", "tracked.txt"], repository);
298
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
299
- await assertScopeChanged(repository, started.lineage_id);
300
- await restoreCandidate(repository, candidateTree);
301
-
302
- await writeFile(join(repository, "extra-reviewed-path.txt"), "not in the frozen candidate\n");
303
- await run("git", ["add", "extra-reviewed-path.txt"], repository);
304
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
305
- assert.deepEqual((await stagedEntries(repository)).map((entry) => entry.split("\t", 2)[1]), [...REVIEWED_PATHS, "extra-reviewed-path.txt"].toSorted());
306
- await assertScopeChanged(repository, started.lineage_id);
307
- await restoreCandidate(repository, candidateTree);
308
-
309
- await chmod(join(repository, "tracked.txt"), 0o755);
310
- await run("git", ["add", "--", "tracked.txt"], repository);
311
- const modeDrifted = (await stagedEntries(repository)).some((entry) => entry === "100755" || entry.startsWith("100755 "));
312
- if (modeDrifted) {
313
- assert.notEqual((await run("git", ["write-tree"], repository)).stdout.trim(), candidateTree);
314
- await assertScopeChanged(repository, started.lineage_id);
315
- } else {
316
- t.diagnostic("skipped native mode-drift denial because this repository/platform does not stage executable-bit changes");
317
- }
318
- await chmod(join(repository, "tracked.txt"), 0o644);
319
- 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");
320
139
  });
321
140
 
322
- test("official pinned package runtime keeps frozen candidate lineages and receipts isolated across replay and replacement", async (t) => {
141
+ test("registered gentle_review surfaces the package-pinned Pi transport refusal before native START for a safe internal symlink candidate", async (t) => {
323
142
  await reviewEnabledHome(t);
324
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-lineage-"));
143
+ const workspace = await realpath(await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-")));
325
144
  const repository = join(workspace, "repository");
326
- const artifacts = join(workspace, "artifacts");
327
- t.after(async () => rm(workspace, { recursive: true, force: true }));
328
-
329
- await mkdir(repository);
330
- await mkdir(artifacts);
331
- await run("git", ["init", "--initial-branch=main"], repository);
332
- await run("git", ["config", "user.email", "test@example.invalid"], repository);
333
- await run("git", ["config", "user.name", "Gentle Pi test"], repository);
334
- await writeFile(join(repository, "tracked.txt"), "base\n");
335
- await run("git", ["add", "--", "tracked.txt"], repository);
336
- await run("git", ["commit", "-m", "base"], repository);
337
-
338
- await writeFile(join(repository, "tracked.txt"), "candidate one\n");
339
- const first = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
340
- const firstInventory = authorityInventory(await reviewStatus(repository));
341
- const firstReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
342
- const firstReplayInventory = authorityInventory(await reviewStatus(repository));
343
- assert.equal(firstReplay.lineage_id, first.lineage_id, "replaying an exact START must reuse the frozen lineage");
344
- assert.deepEqual(firstReplayInventory, firstInventory, "replaying an exact START must not create durable authority");
345
-
346
- const firstFinalized = JSON.parse((await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt")).stdout) as ReviewFinalize;
347
- const firstFinalizedInventory = authorityInventory(await reviewStatus(repository));
348
- // v2.1.7 terminal replay contract: replay uses the exact explicit lineage with
349
- // no mutation inputs; re-sending reviewer results after approval is rejected.
350
- const repeatedResults = await finalizeEmptyReview(repository, artifacts, first, "first-evidence.txt").catch((error: NodeJS.ErrnoException & { stderr?: string }) => error);
351
- 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");
352
- const firstFinalizeReplay = JSON.parse((await run(binary, ["review", "finalize", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewFinalize;
353
- const firstFinalizeReplayInventory = authorityInventory(await reviewStatus(repository));
354
- assert.equal(firstFinalized.lineage_id, first.lineage_id);
355
- assert.equal(firstFinalized.state, "approved");
356
- assert.match(firstFinalized.store_revision, /^sha256:[a-f0-9]{64}$/);
357
- assert.equal(firstFinalizeReplay.store_revision, firstFinalized.store_revision, "replaying an exact FINALIZE must reuse its receipt revision");
358
- assert.equal(firstFinalizeReplay.receipt_path, firstFinalized.receipt_path, "replaying an exact FINALIZE must reuse its receipt location");
359
- assert.deepEqual(firstFinalizeReplayInventory, firstFinalizedInventory, "replaying an exact FINALIZE must not create a durable receipt or authority");
360
- assert.deepEqual(firstFinalizedInventory, [{ version: "compact-v2", lineage_id: first.lineage_id, status: "approved", state: "approved", revision: firstFinalized.store_revision, problems: [] }]);
361
-
362
- await run("git", ["add", "--", "tracked.txt"], repository);
363
- const firstCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
364
- const firstAllowed = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
365
- assert.equal(firstAllowed.result, "allow");
366
- assert.equal(firstAllowed.context.candidate_tree, firstCandidateTree);
367
-
368
- await writeFile(join(repository, "tracked.txt"), "candidate two\n");
369
- await run("git", ["add", "--", "tracked.txt"], repository);
370
- const secondCandidateTree = (await run("git", ["write-tree"], repository)).stdout.trim();
371
- assert.notEqual(secondCandidateTree, firstCandidateTree);
372
- const authorityBeforeCompetingStart = authorityInventory(await reviewStatus(repository));
373
- const competingStart = await run(binary, ["review", "start", "--cwd", repository, "--lineage", first.lineage_id], repository);
374
- const competingStartResult = JSON.parse(competingStart.stdout) as ReviewStart;
375
- const authorityAfterCompetingStart = authorityInventory(await reviewStatus(repository));
376
- assert.equal(competingStartResult.action, "blocked-scope-action", "a frozen lineage must return a structured scope-action block for a competing candidate");
377
- assert.equal(competingStartResult.lineage_id, first.lineage_id);
378
- assert.equal(competingStartResult.state, "approved");
379
- assert.deepEqual(authorityAfterCompetingStart, authorityBeforeCompetingStart, "a blocked scope action must not mutate approved authority");
380
- // A plain START over the moved candidate does NOT mint a fresh lineage. An
381
- // approved lineage whose scope changed is recovered, keeping the operator
382
- // pointed at the receipt that already exists instead of quietly abandoning
383
- // it, and the recovery itself needs a maintainer to authorize it. Pi mirrors
384
- // this because the experience must be the one gentle-ai defines, not a
385
- // friendlier one Pi invents.
386
- const second = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
387
- const secondInventory = authorityInventory(await reviewStatus(repository));
388
- assert.equal(second.action, "recover");
389
- assert.equal(second.state, "approved");
390
- assert.equal(second.lineage_id, first.lineage_id, "a moved candidate recovers the approved lineage rather than replacing it");
391
- const secondReplay = JSON.parse((await run(binary, ["review", "start", "--cwd", repository], repository)).stdout) as ReviewStart;
392
- const secondReplayInventory = authorityInventory(await reviewStatus(repository));
393
- assert.equal(secondReplay.action, "recover", "recovery is idempotent");
394
- assert.equal(secondReplay.lineage_id, second.lineage_id, "replaying the second START must reuse its lineage");
395
- assert.deepEqual(secondReplayInventory, secondInventory, "replaying the second START must not duplicate durable authority");
396
- const recoveryTransition = await nextTransition(repository);
397
- assert.equal(recoveryTransition.kind, "collect");
398
- assert.equal(recoveryTransition.reason_code, "recovery_authorization_required", "recovery must ask a maintainer, never proceed on its own");
399
-
400
- // No receipt covers the moved candidate, so delivery is denied rather than
401
- // waved through, and the denial names `gentle-ai review recover` with the
402
- // inputs it needs. A blocked gate that names its exit is the whole point.
403
- await assertScopeChanged(repository, first.lineage_id);
404
-
405
- await writeFile(join(repository, "tracked.txt"), "candidate one\n");
406
- await run("git", ["add", "--", "tracked.txt"], repository);
407
- assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), firstCandidateTree);
408
- const firstRestored = JSON.parse((await run(binary, ["review", "validate", "--gate", "pre-commit", "--cwd", repository, "--lineage", first.lineage_id], repository)).stdout) as ReviewGateResult;
409
- assert.equal(firstRestored.result, "allow", "the old receipt must remain valid for its exact frozen candidate");
410
-
411
- await writeFile(join(repository, "tracked.txt"), "candidate two\n");
412
- await run("git", ["add", "--", "tracked.txt"], repository);
413
- assert.equal((await run("git", ["write-tree"], repository)).stdout.trim(), secondCandidateTree);
414
- 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.");
415
- });
416
-
417
- test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
418
- await reviewEnabledHome(t);
419
- const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
420
- const repository = join(workspace, "repository");
421
- 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
+ });
422
151
 
423
152
  await mkdir(join(repository, ".agents", "skills", "example"), { recursive: true });
424
153
  await mkdir(join(repository, ".agent", "skills"), { recursive: true });
@@ -440,18 +169,17 @@ test("registered gentle_review START materializes a safe internal skill symlink
440
169
 
441
170
  const candidateViews = new CandidateViewRegistry();
442
171
  let nativeStartReached = false;
443
- // The production controller pairs with the negotiated client; v2.1.9's ordinary
444
- // (non-negotiated) START output carries additional facade fields that the
445
- // 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.
446
175
  const native = new NativeReviewCliV216(async (request) => {
447
176
  if (request.arguments[0] === "review" && request.arguments[1] === "start") nativeStartReached = true;
448
177
  const command = await run(binary, request.arguments, request.cwd, true);
449
178
  return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
450
179
  });
451
180
  const tools = new Map<string, RegisteredController>();
452
- let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise<void> | void) | undefined;
453
181
  createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
454
- on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise<void> | void) { if (name === "session_shutdown") sessionShutdown = handler; },
182
+ on() {},
455
183
  registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
456
184
  registerCommand() {},
457
185
  } as unknown as ExtensionAPI);
@@ -461,27 +189,20 @@ test("registered gentle_review START materializes a safe internal skill symlink
461
189
  let returned: { details?: unknown } | undefined;
462
190
  let thrown: unknown;
463
191
  try {
464
- // A headless context still receives notices, so the stub carries notify.
465
- // Without it the consent path throws on a real ExtensionContext member,
466
- // and the failure reads as a START problem rather than a stub gap.
467
- 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);
468
193
  } catch (caught) {
469
194
  thrown = caught;
470
195
  }
471
196
  const error = thrown instanceof Error ? { name: thrown.name, message: thrown.message } : thrown === undefined ? undefined : String(thrown);
472
197
  t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
473
- assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
474
- 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");
475
199
  const result = returned?.details as Record<string, unknown> | undefined;
200
+ const relayTransport = result?.relay_transport as Record<string, unknown> | undefined;
476
201
  assert.equal(result?.status, "blocked");
477
- assert.equal(result?.outcome, "native-review-consent-required");
478
- assert.equal(typeof result?.consent_binding, "string");
479
- assert.equal(result?.lineage_created, false);
480
- assert.ok(sessionShutdown, "extension must register session_shutdown cleanup");
481
- const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext;
482
- await sessionShutdown({}, context);
483
- await assert.rejects(
484
- controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context),
485
- /unknown, expired, or already consumed/,
486
- );
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");
487
208
  });