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,1051 +1,800 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
8
+ import { __testing, createGentleAiExtension, PendingReviewConsentRegistry } from "../extensions/gentle-ai.ts";
9
+ import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
9
10
  import {
10
11
  NATIVE_REVIEW_ERROR_CODE,
11
- REVIEW_CONSENT_NOTICES,
12
+ NATIVE_REVIEW_MODE_OPERATION,
13
+ NATIVE_REVIEW_OPERATION,
12
14
  NativeReviewCliError,
13
- NativeReviewConsentBindingError,
15
+ NativeReviewCliV216,
16
+ NativeReviewIntegrationError,
14
17
  NativeReviewConsentRequiredError,
15
- NativeReviewCliV213 as NativeReviewCliV213Production,
18
+ nativeRiskEvidencePhrases,
16
19
  normalizeNativeReviewCwd,
17
- setNativeCliContractForTesting,
18
- type ExecFileAdapter,
19
20
  type NativeReviewCli,
20
- type NativeReviewConsentAnswer,
21
- type NativeReviewConsentAnswerRequest,
22
- type NativeStartRequest,
21
+ type ExecFileAdapter,
23
22
  } from "../lib/native-review-cli.ts";
24
- import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
25
- import { readReviewConsentLatch, recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
26
- import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
23
+ import {
24
+ decodeReviewConsentV3,
25
+ decodeReviewFailureV2,
26
+ decodeReviewLastEventClosureV1,
27
+ decodeReviewStartV3,
28
+ type ReviewStatusV3,
29
+ } from "../lib/review-integration-v2.ts";
27
30
 
28
- // Queued-adapter clients never execute a real process; default to a fixed
29
- // absolute package-local path so these tests do not depend on an installed
30
- // binary.
31
- class NativeReviewCliV213 extends NativeReviewCliV213Production {
32
- constructor(...parameters: ConstructorParameters<typeof NativeReviewCliV213Production>) {
33
- const [adapter, executable, ...rest] = parameters;
34
- super(adapter, executable ?? "/package/.gentle-ai/gentle-ai", ...rest);
35
- }
36
- }
31
+ const CAPTURED_FIXTURES = join(process.cwd(), "tests", "fixtures", "devbinary");
37
32
 
38
- interface QueuedResult {
39
- stdout: string;
40
- stderr?: string;
41
- exitCode?: number;
42
- timedOut?: boolean;
43
- signal?: NodeJS.Signals | null;
44
- outputLimitExceeded?: boolean;
33
+ function captured(name: string): unknown {
34
+ return JSON.parse(readFileSync(join(CAPTURED_FIXTURES, name), "utf8"));
45
35
  }
46
36
 
47
- function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> } {
48
- const calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> = [];
37
+ test("captured native start and terminal capture decode as one last-event lifecycle", () => {
38
+ const start = decodeReviewStartV3(captured("start-v3-zero-lens-closed.captured.json"));
39
+ const closure = decodeReviewLastEventClosureV1(captured("last-event-capture-result-approved.captured.json"));
40
+ assert.equal(start.action, "closed");
41
+ assert.equal(start.state, "approved");
42
+ assert.equal(closure.operation, "review/capture-result");
43
+ assert.equal(closure.state, "approved");
44
+ assert.notEqual(closure.operation, "review.finalize");
45
+ });
46
+
47
+ function queuedAdapter(stdout: readonly string[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
48
+ const queue = [...stdout];
49
+ const calls: Array<readonly string[]> = [];
49
50
  return {
50
51
  calls,
51
52
  adapter: async (request) => {
52
- calls.push(request);
53
- const result = results.shift();
54
- if (!result) throw new Error("unexpected native invocation");
55
- return {
56
- stdout: result.stdout,
57
- stderr: result.stderr ?? "",
58
- exitCode: result.exitCode ?? 0,
59
- signal: result.signal ?? null,
60
- timedOut: result.timedOut ?? false,
61
- outputLimitExceeded: result.outputLimitExceeded ?? false,
62
- };
53
+ calls.push(request.arguments);
54
+ const body = queue.shift();
55
+ if (body === undefined) throw new Error("unexpected native invocation");
56
+ return { stdout: body, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
63
57
  },
64
58
  };
65
59
  }
66
60
 
67
- // A synthetic testing-only version whose capability row (in the testing
68
- // overlay, never in the shipped NATIVE_CLI_CONTRACTS table) reports every
69
- // organic-parity column true, so decode paths that are dark for every real
70
- // shipped version including the pinned 2.1.11 — can still be exercised.
71
- const CAPABLE_VERSION = "9.9.9";
72
- const CAPABLE_VERSION_LINE = { stdout: `gentle-ai ${CAPABLE_VERSION}\n` };
73
- const DARK_VERSION = { stdout: "gentle-ai 2.1.11\n" };
74
-
75
- test.beforeEach(() => {
76
- setNativeCliContractForTesting(CAPABLE_VERSION, {
77
- start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true,
78
- reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true,
79
- mode: true, riskEvidence: true, hint: true, delivery: true,
80
- });
81
- });
82
- test.afterEach(() => {
83
- setNativeCliContractForTesting(CAPABLE_VERSION, undefined);
84
- });
85
-
86
- const START = { stdout: JSON.stringify({ operation: "review/start", lineage_id: "lineage-1", state: "reviewing", risk_level: "medium", selected_lenses: ["review-reliability"], changed_files: 1, changed_lines: 2, correction_budget: 1, action: "created", lenses_required: true, projection: "workspace" }) };
87
-
88
- function reviewModeStatusBody(effective: "on" | "off", overrides: Record<string, unknown> = {}): Record<string, unknown> {
89
- return {
61
+ function mode(
62
+ operation: "status" | "enable" | "disable",
63
+ effective: "on" | "off",
64
+ source: "default" | "global" | "clone_local",
65
+ reach?: "machine" | "this_build" | "future",
66
+ ): string {
67
+ const global = source === "global" ? (effective === "on" ? "on" : "off") : source === "clone_local" ? "on" : "";
68
+ const cloneLocal = source === "clone_local" ? "off" : "";
69
+ return JSON.stringify({
90
70
  schema: "gentle-ai.review-mode/v1",
91
- operation: "status",
92
- scope: "both",
93
- status: {
94
- schema: "gentle-ai.rdd-mode-status/v1",
95
- global: "",
96
- clone_local: "",
97
- effective,
98
- source: "default",
99
- ...overrides,
100
- },
101
- };
71
+ operation,
72
+ scope: "clone",
73
+ status: { schema: "gentle-ai.rdd-mode-status/v1", global, clone_local: cloneLocal, effective, source, ...(reach === undefined ? {} : { reach }) },
74
+ });
102
75
  }
103
76
 
104
- test("reviewMode requires the mode capability and fails closed for a version without it", async () => {
105
- const queue = queuedAdapter([DARK_VERSION]);
106
- await assert.rejects(
107
- () => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" }),
108
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
109
- );
110
- });
111
-
112
- test("reviewMode status uses the exact fixed argv and decodes the effective mode", async () => {
113
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
114
- const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
115
- assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", "/repo", "--json"]);
116
- assert.equal(result.status.effective, "on");
117
- assert.equal(result.scope, "both");
118
- });
119
-
120
- test("reviewMode canonicalizes an existing repository cwd before the version probe and status argv", async (t) => {
121
- if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
122
- const repository = mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-cwd-"));
123
- const alias = `${repository}-alias`;
124
- symlinkSync(repository, alias, "dir");
125
- t.after(() => { rmSync(alias, { force: true }); rmSync(repository, { recursive: true, force: true }); });
126
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { global: "off", source: "global" })) }]);
127
- const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: alias, operation: "status" });
77
+ test("review mode STATUS keeps the provider-decided default-off source", async () => {
78
+ const queue = queuedAdapter([mode("status", "off", "default")]);
79
+ const result = await new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
128
80
  assert.equal(result.status.effective, "off");
129
- assert.equal(queue.calls.every((call) => call.cwd === repository), true);
130
- assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", repository, "--json"]);
81
+ assert.equal(result.status.source, "default");
82
+ assert.deepEqual(queue.calls[0], ["review", "mode", "status", "--cwd", process.cwd(), "--json"]);
131
83
  });
132
84
 
133
- test("native review cwd normalization unifies Git Bash and drive-form Windows paths", () => {
134
- const expected = "C:\\Users\\Alan\\worktree B";
135
- assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "win32"), expected);
136
- assert.equal(normalizeNativeReviewCwd("c:/Users/Alan/worktree B", "win32"), expected);
137
- assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "linux"), "/c/Users/Alan/worktree B");
85
+ test("review mode mutations remain clone-scoped and retain their exact operation discriminator", async () => {
86
+ const queue = queuedAdapter([mode("disable", "off", "clone_local"), mode("enable", "on", "global")]);
87
+ const review = new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai");
88
+ assert.equal((await review.reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.DISABLE })).operation, "disable");
89
+ assert.equal((await review.reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.ENABLE })).operation, "enable");
90
+ assert.deepEqual(queue.calls.map((arguments_) => arguments_.slice(-3)), [["--scope", "clone", "--json"], ["--scope", "clone", "--json"]]);
138
91
  });
139
92
 
140
- test("reviewMode status decodes an off effective mode with its deciding source", async () => {
141
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
142
- const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
143
- assert.equal(result.status.effective, "off");
144
- assert.equal(result.status.source, "clone_local");
145
- assert.equal(result.status.cloneLocal, "off");
146
- assert.equal(result.status.revision, "sha256:deadbeef");
147
- });
148
-
149
- test("reviewMode enable and disable pass --scope clone and mutate without a timeout", async () => {
150
- const queue = queuedAdapter([
151
- CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ schema: "gentle-ai.review-mode/v1", operation: "disable", scope: "clone", status: { schema: "gentle-ai.rdd-mode-status/v1", global: "", clone_local: "off", effective: "off", source: "clone_local" } }) },
152
- CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ schema: "gentle-ai.review-mode/v1", operation: "enable", scope: "clone", status: { schema: "gentle-ai.rdd-mode-status/v1", global: "", clone_local: "", effective: "on", source: "default" } }) },
153
- ]);
154
- const client = new NativeReviewCliV213(queue.adapter);
155
- const disabled = await client.reviewMode({ cwd: "/repo", operation: "disable" });
156
- const enabled = await client.reviewMode({ cwd: "/repo", operation: "enable" });
157
- assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "disable", "--cwd", "/repo", "--scope", "clone", "--json"]);
158
- assert.deepEqual(queue.calls[3]?.arguments, ["review", "mode", "enable", "--cwd", "/repo", "--scope", "clone", "--json"]);
159
- assert.equal(disabled.status.effective, "off");
160
- assert.equal(enabled.status.effective, "on");
161
- });
162
-
163
- test("reviewMode rejects a response whose operation discriminator does not match the request", async () => {
164
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
93
+ test("review mode rejects a response with a foreign operation discriminator", async () => {
94
+ const queue = queuedAdapter([mode("enable", "on", "global")]);
165
95
  await assert.rejects(
166
- () => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "disable" }),
167
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
96
+ () => new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
97
+ /schema incompatible/,
168
98
  );
169
99
  });
170
100
 
171
- test("native START decodes optional riskEvidence (a phrase array, matching gentle-ai's []string wire shape) and hint only when present", async () => {
172
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
173
- const withEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ ...start, risk_evidence: ["service credentials in .env.example"], hint: "no reviewable candidate: every changed path is documentation." }) }]);
174
- const result = await new NativeReviewCliV213(withEvidence.adapter).start({ cwd: "/repo" });
175
- assert.deepEqual(result.riskEvidence, ["service credentials in .env.example"]);
176
- assert.equal(result.hint, "no reviewable candidate: every changed path is documentation.");
177
-
178
- const withoutEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: START.stdout }]);
179
- const bare = await new NativeReviewCliV213(withoutEvidence.adapter).start({ cwd: "/repo" });
180
- assert.equal(bare.riskEvidence, undefined);
181
- assert.equal(bare.hint, undefined);
101
+ test("all terminal capture fixtures retain exact, non-interchangeable operation identities", () => {
102
+ assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-refuter-approved.captured.json")).operation, "review.capture-refuter");
103
+ assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-validation-approved.captured.json")).operation, "review/capture-validation");
104
+ assert.equal(decodeReviewLastEventClosureV1(captured("last-event-capture-result-approved.captured.json")).operation, "review/capture-result");
182
105
  });
183
106
 
184
- test("native START rejects a scalar risk_evidence: the wire shape is always a phrase array, even with one phrase", async () => {
185
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
186
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ ...start, risk_evidence: "not an array" }) }]);
187
- await assert.rejects(
188
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
189
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
190
- );
107
+ test("native risk evidence derives only published medium and high risk phrases", () => {
108
+ assert.deepEqual(nativeRiskEvidencePhrases("low", [{ code: "process_boundary", path: "runner.ts" }]), []);
109
+ assert.deepEqual(nativeRiskEvidencePhrases("medium", [{ code: "process_boundary", path: "runner.ts" }]), [
110
+ "this change is not purely passive documentation, so it gets one consolidated review.",
111
+ "code that starts other processes in runner.ts",
112
+ ]);
113
+ assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "hot_path", signal: "auth", path: "auth.ts" }, { code: "unknown", path: "ignored.ts" }]), ["authentication in auth.ts"]);
191
114
  });
192
115
 
193
- test("native VALIDATE decodes the disabled/unmanaged delivery alternate discriminator at exit 0", async () => {
194
- const body = {
195
- schema: "gentle-ai.review-gate-result/v1",
196
- result: "invalidated",
197
- allowed: false,
198
- action: "repository-policy",
199
- reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
200
- // gentle-ai's RDDDeliveryDisabledUnmanaged is a single literal
201
- // "disabled/unmanaged" — never split into two separate enum values.
202
- delivery: "disabled/unmanaged",
203
- context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
204
- };
205
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
206
- const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" });
207
- assert.equal(result.result, "invalidated");
208
- assert.equal(result.allowed, false);
209
- assert.equal(result.action, "repository-policy");
210
- assert.equal(result.delivery, "disabled/unmanaged");
116
+ test("Windows native cwd normalization preserves drive identity without changing POSIX paths", () => {
117
+ assert.equal(normalizeNativeReviewCwd("/c/Users/example/repo", "win32"), "C:\\Users\\example\\repo");
118
+ assert.equal(normalizeNativeReviewCwd("c:\\Users\\example\\repo", "win32"), "C:\\Users\\example\\repo");
119
+ assert.equal(normalizeNativeReviewCwd("/repo with spaces", "linux"), "/repo with spaces");
211
120
  });
212
121
 
213
- test("native VALIDATE rejects a split disabled-only or unmanaged-only delivery value: the wire literal is always the combined string", async () => {
214
- for (const delivery of ["disabled", "unmanaged"]) {
215
- const body = {
216
- schema: "gentle-ai.review-gate-result/v1", result: "invalidated", allowed: false, action: "repository-policy",
217
- reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
218
- delivery,
219
- context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
220
- };
221
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
222
- await assert.rejects(
223
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
224
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
225
- `delivery ${JSON.stringify(delivery)} must still fail closed`,
226
- );
122
+ test("current review mode keeps canonical reach values and rejects unrecognized wire values", async (t) => {
123
+ for (const reach of ["machine", "this_build"] as const) {
124
+ const queue = queuedAdapter([mode("status", "off", "clone_local", reach)]);
125
+ const result = await new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
126
+ assert.equal(result.status.reach, reach);
227
127
  }
228
- });
229
-
230
- test("native VALIDATE without delivery keeps the strict exit-code/action pairing unchanged", async () => {
231
- const body = {
232
- schema: "gentle-ai.review-gate-result/v1",
233
- result: "invalidated",
234
- allowed: false,
235
- action: "repository-policy",
236
- reason: "should require exit 1 because delivery is absent",
237
- context: { gate: "pre-commit", lineage_id: "", generation: 0, base_tree: "", candidate_tree: "", paths_digest: "", fix_delta_hash: "", policy_hash: "", ledger_hash: "", evidence_hash: "", base_relationship_valid: true },
238
- };
239
- // Without `delivery`, an "invalidated" result with exit 0 is not the
240
- // alternate discriminator shape and must still fail closed.
241
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
128
+ const malformed = queuedAdapter([mode("status", "off", "clone_local", "future")]);
242
129
  await assert.rejects(
243
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
130
+ () => new NativeReviewCliV216(malformed.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
244
131
  (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
245
132
  );
133
+ if (process.platform !== "win32") {
134
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-")));
135
+ const alias = `${root}-alias`;
136
+ const { symlinkSync } = await import("node:fs");
137
+ symlinkSync(root, alias, "dir");
138
+ t.after(() => {
139
+ rmSync(alias, { force: true });
140
+ rmSync(root, { recursive: true, force: true });
141
+ });
142
+ const observed: string[] = [];
143
+ const adapter: ExecFileAdapter = async (request) => {
144
+ observed.push(request.cwd);
145
+ return { stdout: mode("status", "off", "global"), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
146
+ };
147
+ await new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: alias, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
148
+ assert.deepEqual(observed, [root]);
149
+ }
246
150
  });
247
151
 
248
- test("START tolerates the exact review-consent-skipped stderr line only when the mode capability is true", async () => {
249
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
250
- const notice = "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.";
251
-
252
- const tolerated = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: notice }]);
253
- const result = await new NativeReviewCliV213(tolerated.adapter).start({ cwd: "/repo" });
254
- assert.equal(result.lineageId, "lineage-1");
255
-
256
- // Same exact stderr text, but the negotiated version's `mode` capability is
257
- // false (the pinned 2.1.11 row), so the notice must still fail closed.
258
- const notTolerated = queuedAdapter([DARK_VERSION, { stdout: JSON.stringify(start), stderr: notice }]);
259
- await assert.rejects(
260
- () => new NativeReviewCliV213(notTolerated.adapter).start({ cwd: "/repo" }),
261
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
262
- );
152
+ test("published start/v3 wire rejects scalar risk reasons without Pi lens policy", () => {
153
+ const start = captured("start-v3-zero-lens-closed.captured.json") as Record<string, unknown>;
154
+ assert.throws(() => decodeReviewStartV3({ ...start, risk_reasons: "not-an-array" }), /risk_reasons/);
263
155
  });
264
156
 
265
- // gentle-ai v2.4.0 (feat(review)!: make receipt-driven development opt-in)
266
- // DELETED reviewConsentSkippedDefaultProvenance. It existed to admit reviews
267
- // were on because nobody chose; under opt-in RDD a default-source clone is
268
- // refused before the consent ceremony runs, so the pinned binary can never
269
- // emit it. The allowlist is a statement about what the PINNED binary writes
270
- // while still succeeding, so a line no pinned binary can produce does not
271
- // belong in it: keeping it would silently tolerate that exact text from
272
- // anywhere else in a START's stderr.
273
- test("the tolerated-stderr allowlist carries no line the pinned binary cannot emit", async () => {
274
- assert.ok(
275
- !REVIEW_CONSENT_NOTICES.some((notice) => notice.startsWith("Reviews are on by default")),
276
- "the default-provenance notice was deleted upstream in v2.4.0 and must not stay tolerated",
277
- );
157
+ interface RegisteredControllerTool {
158
+ execute: (
159
+ toolCallId: string,
160
+ parameters: unknown,
161
+ signal: AbortSignal | undefined,
162
+ onUpdate: undefined,
163
+ context: ExtensionContext,
164
+ ) => Promise<{ details?: unknown }>;
165
+ }
278
166
 
279
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
280
- const deleted = "Reviews are on by default; this was never explicitly chosen. Run 'gentle-ai review mode enable' to make reviews an explicit choice, or 'gentle-ai review mode disable' to turn them off.";
281
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: deleted }]);
282
- await assert.rejects(
283
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
284
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
285
- );
286
- });
167
+ interface RegisteredCommand {
168
+ handler: (args: string, context: ExtensionContext) => Promise<void>;
169
+ }
287
170
 
288
- test("START rejects near-miss, prefixed, or extra-line stderr even when the mode capability is true", async () => {
289
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
290
- const notice = "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.";
291
- for (const stderr of [
292
- `prefix: ${notice}`,
293
- `${notice}\nextra line`,
294
- notice.slice(0, -1),
295
- ]) {
296
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr }]);
297
- await assert.rejects(
298
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
299
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
300
- `stderr ${JSON.stringify(stderr)} must still fail closed`,
301
- );
302
- }
303
- });
171
+ interface RegisteredEvent {
172
+ (event: unknown, context: ExtensionContext): Promise<unknown> | unknown;
173
+ }
304
174
 
305
- // ---------------------------------------------------------------------------
306
- // Phase 3 (kill switch) + Phase 4 (consent) integration through the
307
- // `gentle_review` controller tool and the `gentle:review-mode` command,
308
- // against a fake NativeReviewCli (no binary needed).
309
- // ---------------------------------------------------------------------------
175
+ interface ParityRuntime {
176
+ controller: RegisteredControllerTool;
177
+ commands: Map<string, RegisteredCommand>;
178
+ events: Map<string, RegisteredEvent>;
179
+ }
310
180
 
311
- function git(cwd: string, ...args: string[]): string {
312
- return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
181
+ interface ParityRuntimeOptions {
182
+ candidateViews?: CandidateViewRegistry;
183
+ pendingReviewConsentRegistry?: PendingReviewConsentRegistry;
184
+ now?: () => number;
185
+ scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
186
+ writeConsentLatch?: (cwd: string) => void;
187
+ }
188
+
189
+ function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime {
190
+ const tools = new Map<string, RegisteredControllerTool>();
191
+ const commands = new Map<string, RegisteredCommand>();
192
+ const events = new Map<string, RegisteredEvent>();
193
+ const dependencies = {
194
+ nativeReviewCli,
195
+ candidateViews: options.candidateViews ?? new CandidateViewRegistry(),
196
+ pendingReviewConsentRegistry: options.pendingReviewConsentRegistry ?? new PendingReviewConsentRegistry(),
197
+ now: options.now,
198
+ scheduleTimer: options.scheduleTimer,
199
+ } as unknown as Parameters<typeof createGentleAiExtension>[0];
200
+ __testing.createGentleAiExtension(dependencies, options.writeConsentLatch ?? (() => {}))({
201
+ on(name: string, handler: RegisteredEvent) { events.set(name, handler); },
202
+ registerTool(definition: RegisteredControllerTool & { name: string }) { tools.set(definition.name, definition); },
203
+ registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); },
204
+ } as unknown as ExtensionAPI);
205
+ const controller = tools.get("gentle_review");
206
+ assert.ok(controller);
207
+ return { controller: controller!, commands, events };
313
208
  }
314
209
 
315
210
  function repository(t: test.TestContext): string {
316
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-organic-parity-"));
317
- // A materialized (retained) candidate view chmods its worktree read-only
318
- // (0o444/0o555) to prevent tampering while lenses run; restore write
319
- // permission recursively before cleanup so a retained-but-never-cleaned-up
320
- // candidate view does not leak an unremovable temp directory.
211
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-native-parity-")));
321
212
  t.after(() => {
322
- try { execFileSync("chmod", ["-R", "u+w", cwd]); } catch { /* best effort */ }
213
+ try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
323
214
  rmSync(cwd, { recursive: true, force: true });
324
215
  });
325
- git(cwd, "init", "-b", "main");
326
- git(cwd, "config", "user.email", "tests@example.com");
327
- git(cwd, "config", "user.name", "Tests");
216
+ execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
328
217
  writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
329
- git(cwd, "add", ".");
330
- git(cwd, "commit", "-m", "initial");
218
+ execFileSync("git", ["add", "app.ts"], { cwd, stdio: "ignore" });
219
+ execFileSync("git", ["-c", "user.name=Parity Test", "-c", "user.email=parity@example.invalid", "commit", "-m", "base"], { cwd, stdio: "ignore" });
331
220
  writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
332
221
  return cwd;
333
222
  }
334
223
 
335
- const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
336
- schema: "gentle-ai.review-authority-repair-assessment/v1",
337
- status: "unsupported",
338
- counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 },
339
- supportedOperations: ["review/complete-fix", "review/validate-fix"],
340
- authorizationSchema: "gentle-ai.review-repair-authorization/v1",
341
- };
224
+ function startStatus(cwd: string): ReviewStatusV3 {
225
+ const candidates = new CandidateViewRegistry();
226
+ const candidate = candidates.create({ contributorRoot: cwd });
227
+ try {
228
+ return {
229
+ contract: "gentle-ai.review-integration/v2",
230
+ applicability: "unrelated",
231
+ action: "start",
232
+ replayability: "not_replayable",
233
+ targetIdentity: `sha256:${"a".repeat(64)}`,
234
+ projection: {
235
+ schema: "gentle-ai.review-candidate-projection/v1",
236
+ kind: "current-changes",
237
+ projection: "workspace",
238
+ baseTree: candidate.baseTree,
239
+ initialReviewTree: candidate.candidateTree,
240
+ currentCandidateTree: candidate.candidateTree,
241
+ pathsDigest: `sha256:${"a".repeat(64)}`,
242
+ paths: [...candidate.paths],
243
+ intendedUntracked: [],
244
+ intendedUntrackedProof: `sha256:${"a".repeat(64)}`,
245
+ initialSnapshotIdentity: `sha256:${"a".repeat(64)}`,
246
+ currentSnapshotIdentity: `sha256:${"a".repeat(64)}`,
247
+ },
248
+ candidates: [],
249
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
250
+ } as unknown as ReviewStatusV3;
251
+ } finally {
252
+ candidates.cleanup(candidate.token);
253
+ }
254
+ }
342
255
 
343
- function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
344
- const sha = `sha256:${"a".repeat(64)}`;
345
- const candidate = new CandidateViewRegistry().create({ contributorRoot: cwd });
346
- const tree = candidate.candidateTree;
347
- const baseTree = candidate.baseTree;
348
- const paths = candidate.paths;
349
- candidate.cleanup();
350
- const projection = {
351
- schema: "gentle-ai.review-integration.projection/v1" as const,
352
- kind: "current-changes" as const,
353
- projection: "workspace" as const,
354
- baseTree,
355
- initialReviewTree: tree,
356
- currentCandidateTree: tree,
357
- pathsDigest: sha,
358
- paths,
359
- intendedUntracked: [],
360
- intendedUntrackedProof: sha,
361
- initialSnapshotIdentity: sha,
362
- currentSnapshotIdentity: sha,
363
- };
364
- const rawRepair = {
365
- schema: UNSUPPORTED_REPAIR_ASSESSMENT.schema,
366
- status: UNSUPPORTED_REPAIR_ASSESSMENT.status,
367
- counts: {
368
- lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0,
369
- eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0,
370
- },
371
- supported_operations: UNSUPPORTED_REPAIR_ASSESSMENT.supportedOperations,
372
- authorization_schema: UNSUPPORTED_REPAIR_ASSESSMENT.authorizationSchema,
373
- };
256
+ function context(cwd: string, sessionId = "parity-session", notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
374
257
  return {
375
- contract: "gentle-ai.review-integration/v2",
376
- applicability: "unrelated",
377
- receipt: { status: "not_applicable" },
378
- action: "start",
379
- replayability: "not_replayable",
380
- targetIdentity: sha,
381
- projection,
382
- repair: UNSUPPORTED_REPAIR_ASSESSMENT,
383
- candidates: [],
384
- raw: {
385
- schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
386
- applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
387
- repair: rawRepair,
388
- projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree: baseTree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths, intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
389
- candidates: [],
390
- },
391
- };
258
+ cwd,
259
+ hasUI: false,
260
+ ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } },
261
+ sessionManager: { getSessionId: () => sessionId },
262
+ } as unknown as ExtensionContext;
392
263
  }
393
264
 
394
- interface FakeOrganicNativeOptions {
395
- reviewModeCapable?: boolean;
396
- reviewModeEffective?: "on" | "off";
397
- reviewModeSource?: "default" | "global" | "clone_local";
398
- reviewModeThrows?: boolean;
399
- lensesRequired?: boolean;
400
- riskEvidence?: readonly string[];
401
- hint?: string;
265
+ async function executeController(runtime: ParityRuntime, operation: unknown, cwd: string, sessionId = "parity-session"): Promise<Record<string, unknown>> {
266
+ const response = await runtime.controller.execute("parity-controller", operation, undefined, undefined, context(cwd, sessionId));
267
+ return response.details as Record<string, unknown>;
402
268
  }
403
269
 
404
- function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: NativeReviewCli; state: { startCalls: number } } {
405
- const state = { startCalls: 0 };
406
- const reviewModeCapable = options.reviewModeCapable ?? true;
407
- const native = {
408
- async start() {
409
- state.startCalls += 1;
410
- return {
411
- lineageId: "native-lineage", state: "reviewing", riskLevel: "high",
412
- selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"],
413
- changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created",
414
- lensesRequired: options.lensesRequired ?? true,
415
- ...(options.riskEvidence === undefined ? {} : { riskEvidence: options.riskEvidence }),
416
- ...(options.hint === undefined ? {} : { hint: options.hint }),
417
- };
418
- },
419
- async finalize(): Promise<never> { throw new Error("finalize not used in this test"); },
420
- async validate(): Promise<never> { throw new Error("validate not used in this test"); },
421
- async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
422
- async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
423
- async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
424
- async targetStatus(request: { cwd: string }) {
425
- return unrelatedStartTargetStatus(request.cwd);
426
- },
427
- ...(reviewModeCapable
428
- ? {
429
- async reviewMode(request: { operation: string }) {
430
- if (options.reviewModeThrows === true) throw new Error("native review mode process failed");
431
- const effective = options.reviewModeEffective ?? "on";
432
- const source = options.reviewModeSource ?? "default";
270
+ function consentNative(cwd: string): { native: NativeReviewCli; starts: { count: number }; answers: string[] } {
271
+ const consent = decodeReviewConsentV3(captured("consent-v3.captured.json"));
272
+ const starts = { count: 0 };
273
+ const answers: string[] = [];
274
+ return {
275
+ starts,
276
+ answers,
277
+ native: {
278
+ targetStatus: async () => startStatus(cwd),
279
+ start: async () => {
280
+ starts.count += 1;
281
+ throw new NativeReviewConsentRequiredError(consent);
282
+ },
283
+ answerConsent: async (request) => {
284
+ answers.push(request.answer);
285
+ if (request.answer === "declined") {
433
286
  return {
434
- operation: request.operation,
435
- scope: "both",
436
- status: {
437
- global: source === "global" ? effective : "",
438
- cloneLocal: source === "clone_local" && effective === "off" ? "off" : "",
439
- effective,
440
- source,
441
- },
287
+ kind: "declined",
288
+ targetIdentity: consent.targetIdentity,
289
+ projection: consent.projection,
290
+ riskLevel: consent.riskLevel,
291
+ changedFiles: consent.changedFiles,
292
+ changedLines: consent.changedLines,
293
+ consent: "declined_this_candidate",
294
+ raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" },
442
295
  };
443
- },
444
- }
445
- : {}),
446
- } as unknown as NativeReviewCli;
447
- return { native, state };
448
- }
449
-
450
- interface RegisteredTool {
451
- execute: (
452
- toolCallId: string,
453
- params: unknown,
454
- signal: AbortSignal | undefined,
455
- onUpdate: undefined,
456
- ctx: ExtensionContext,
457
- ) => Promise<{ details?: unknown }>;
458
- }
459
-
460
- interface RegisteredCommandFixture {
461
- handler: (args: string, ctx: ExtensionContext) => Promise<void>;
462
- }
463
-
464
- interface RegisteredEventFixture {
465
- (event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
466
- }
467
-
468
- function runtime(
469
- nativeReviewCli: NativeReviewCli | null,
470
- writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
471
- clock?: { now?: () => number; scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void } },
472
- ): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
473
- const tools = new Map<string, RegisteredTool>();
474
- const commands = new Map<string, RegisteredCommandFixture>();
475
- const events = new Map<string, RegisteredEventFixture>();
476
- const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry(), ...clock } as unknown as Parameters<typeof createGentleAiExtension>[0];
477
- __testing.createGentleAiExtension(dependencies, writeReviewConsentLatch)({
478
- on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
479
- registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
480
- registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
481
- } as unknown as ExtensionAPI);
482
- const controller = tools.get("gentle_review");
483
- assert.ok(controller);
484
- return { controller: controller!, commands, events };
485
- }
486
-
487
- function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
488
- return { cwd, hasUI: false, ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } } } as unknown as ExtensionContext;
296
+ }
297
+ return {
298
+ kind: "started",
299
+ start: {
300
+ lineageId: "consent-lineage",
301
+ state: "approved",
302
+ riskLevel: "high",
303
+ selectedLenses: [],
304
+ changedFiles: consent.changedFiles,
305
+ changedLines: consent.changedLines,
306
+ correctionBudget: 0,
307
+ action: "closed",
308
+ lensesRequired: false,
309
+ riskReasons: [],
310
+ },
311
+ };
312
+ },
313
+ sddStatus: async () => ({ ready: false, artifactStore: "none", artifacts: {}, nextRecommended: "propose" }),
314
+ reviewStatus: async () => ({ schema: "gentle-ai.review-authority-status/v1", repository: cwd, complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [], raw: {} }),
315
+ } as unknown as NativeReviewCli,
316
+ };
489
317
  }
490
318
 
491
- function confirmContext(cwd: string, answer: boolean): ExtensionContext {
492
- return { cwd, hasUI: true, ui: { confirm: async () => answer, notify: () => {} } } as unknown as ExtensionContext;
319
+ async function beginConsent(runtime: ParityRuntime, cwd: string, sessionId = "parity-session"): Promise<Record<string, unknown>> {
320
+ const blocked = await executeController(runtime, { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, sessionId);
321
+ assert.equal(blocked.outcome, "native-review-consent-required");
322
+ assert.equal(typeof blocked.consent_binding, "string");
323
+ return blocked;
493
324
  }
494
325
 
495
- function throwingConfirmContext(cwd: string): ExtensionContext {
496
- return { cwd, hasUI: true, ui: { confirm: async () => { throw new Error("no answer available"); }, notify: () => {} } } as unknown as ExtensionContext;
326
+ async function answerConsent(runtime: ParityRuntime, cwd: string, binding: unknown, answer: "granted" | "declined", sessionId = "parity-session"): Promise<Record<string, unknown>> {
327
+ return await executeController(runtime, { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, cwd, sessionId);
497
328
  }
498
329
 
499
- const START_ORDINARY = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
500
-
501
- async function execStart(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
502
- const { details } = await controller.execute(id, START_ORDINARY, undefined, undefined, ctx);
503
- return details as Record<string, unknown>;
330
+ // gentle-pi#516: an unknown or expired binding is a typed, blocked outcome
331
+ // that names itself and its exit. It still carries the current negotiated
332
+ // STATUS as reconciliation context, but it never reads as a healthy
333
+ // pre-start "ready".
334
+ function assertStaleConsentBinding(outcome: Record<string, unknown>, binding: unknown, code: "consent-binding-unknown" | "consent-binding-expired" | "consent-binding-already-consumed"): void {
335
+ assert.equal(outcome.status, "blocked");
336
+ assert.equal(outcome.outcome, "consent-binding-stale");
337
+ assert.equal(outcome.consent_binding, binding);
338
+ assert.equal((outcome.diagnostics as { code?: unknown } | undefined)?.code, code);
339
+ assert.match(String((outcome.diagnostics as { message?: unknown } | undefined)?.message), /START again/);
340
+ assert.equal(outcome.native_invocation_attempted, false);
341
+ assert.equal(outcome.lineage_created, false);
342
+ assert.equal(outcome.mutation_performed, false);
343
+ assert.equal(outcome.mutation_outcome, "none");
344
+ assert.equal(outcome.provider_action, "start");
345
+ assert.equal(outcome.next_action, "restart-for-fresh-consent");
504
346
  }
505
347
 
506
- test("kill-switch: effective off returns a non-failure skipped envelope and never calls native start", async (t) => {
507
- const cwd = repository(t);
508
- const { native, state } = fakeOrganicNative({ reviewModeEffective: "off" });
509
- const { controller } = runtime(native);
510
- const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
511
- assert.equal(result.status, "skipped");
512
- assert.equal(result.outcome, "review-mode-disabled");
513
- assert.equal(result.delivery, "disabled/unmanaged");
514
- assert.equal(result.mutation_performed, false);
515
- assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
516
- });
517
-
518
- // Parity with gentle-ai's RDDDisabledError.Error()
519
- // (internal/reviewtransaction/rdd_mode.go): a refusal names the situation, the
520
- // source that actually decided, and a continuation scoped to that source. Pi
521
- // never blocks here the envelope is a non-failure skip — but it must not
522
- // throw away which source decided, nor leave the caller without a way back on.
523
- test("kill-switch: a clone-local off names the deciding source and the Pi command that turns reviews back on", async (t) => {
524
- const cwd = repository(t);
525
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "clone_local" });
526
- const { controller } = runtime(native);
527
- const result = await execStart(controller, "kill-switch-clone-local", headlessContext(cwd));
528
- assert.equal(result.status, "skipped");
529
- assert.equal(result.outcome, "review-mode-disabled");
530
- assert.equal(result.mode_source, "clone_local");
531
- assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the clone_local mode source keeps it off");
532
- assert.equal(result.next_action, "Run /gentle:review-mode enable to turn reviews back on for this clone.");
533
- });
534
-
535
- // gentle-ai maps RDDModeSourceGlobal onto `--scope=global`, and a clone-local
536
- // override may only ever disable (cloneLocalRDDOverrideValue rejects RDDModeOn).
537
- // Pi's own /gentle:review-mode always passes --scope clone, so it CANNOT clear a
538
- // global off. Naming it here would be naming a dead end, so the continuation has
539
- // to be the native command that actually resolves it.
540
- test("kill-switch: a global off names the native global-scope command, never Pi's clone-scope one", async (t) => {
541
- const cwd = repository(t);
542
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
543
- const { controller } = runtime(native);
544
- const result = await execStart(controller, "kill-switch-global", headlessContext(cwd));
545
- assert.equal(result.mode_source, "global");
546
- assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the global mode source keeps it off");
547
- assert.equal(
548
- result.next_action,
549
- "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off.",
348
+ test("review-mode gate retains every off-source continuation and fails closed on native errors", async () => {
349
+ for (const [source, expected] of [
350
+ ["clone_local", /clear this clone-local override/],
351
+ ["global", /cannot override a global off/],
352
+ ["default", /off by default until explicitly enabled/],
353
+ ] as const) {
354
+ const native = {
355
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: source === "global" ? "off" : "", cloneLocal: source === "clone_local" ? "off" : "", effective: "off" as const, source } }),
356
+ } as unknown as NativeReviewCli;
357
+ const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, process.cwd(), native);
358
+ assert.equal(result.outcome, "review-mode-disabled");
359
+ assert.equal(result.mode_source, source);
360
+ assert.match(String(result.next_action), expected);
361
+ }
362
+ const failed = await __testing.executeReviewControllerOperation(
363
+ { operation: "start", input: JSON.stringify({ mode: "ordinary" }) },
364
+ process.cwd(),
365
+ { reviewMode: async () => { throw new Error("native mode failure"); } } as unknown as NativeReviewCli,
550
366
  );
551
- assert.ok(!/\/gentle:review-mode enable to turn/.test(String(result.next_action)), "a global off must never be sent to Pi's clone-scope command");
367
+ assert.equal(failed.outcome, "native-operation-failed");
552
368
  });
553
369
 
554
- // Parity with reviewModeScopeForSource as of gentle-ai v2.4.0, which made
555
- // receipt-driven development opt-in. Before that release an all-sources-unset
556
- // install resolved to ON with source `default`, so `default` could never be
557
- // what kept reviews off and naming a continuation would have been a guess.
558
- // v2.4.0 resolves the same install to OFF with source `default`, which makes
559
- // this the MOST COMMON refusal there is: every install that never opted in.
560
- // gentle-ai answers `global` for it -- not because default is a global
561
- // opinion, but because global is the only scope that can turn reviews on at
562
- // all. Pi must say the same thing, because the alternative is handing the
563
- // single most common state a dead end with no way forward.
564
- test("kill-switch: an off with the default source names the only scope that can turn reviews on", async (t) => {
370
+ test("public gentle:review-mode handler reports current operations, global-off warnings, and unavailability", async () => {
371
+ const calls: string[] = [];
372
+ const native = {
373
+ reviewMode: async ({ operation }: { operation: "status" | "enable" | "disable" }) => {
374
+ calls.push(operation);
375
+ const globalOff = operation === "enable";
376
+ return {
377
+ operation,
378
+ scope: "clone",
379
+ status: { global: globalOff ? "off" : "", cloneLocal: operation === "disable" ? "off" : "", effective: globalOff || operation === "disable" ? "off" as const : "on" as const, source: globalOff ? "global" as const : operation === "disable" ? "clone_local" as const : "default" as const },
380
+ };
381
+ },
382
+ } as unknown as NativeReviewCli;
383
+ const runtime = parityRuntime(native);
384
+ const command = runtime.commands.get("gentle:review-mode");
385
+ assert.ok(command);
386
+ const notices: Array<{ message: string; type?: string }> = [];
387
+ const ctx = context(process.cwd(), "review-mode-command", notices);
388
+ await command!.handler("status", ctx);
389
+ await command!.handler("disable", ctx);
390
+ await command!.handler("enable", ctx);
391
+ assert.deepEqual(calls, ["status", "disable", "enable"]);
392
+ assert.match(notices[0]?.message ?? "", /receipt-driven development: on/);
393
+ assert.match(notices[1]?.message ?? "", /receipt-driven development: off/);
394
+ assert.equal(notices[2]?.type, "warning");
395
+ assert.match(notices[2]?.message ?? "", /gentle-ai review mode enable --scope=global/);
396
+
397
+ const unavailable = parityRuntime({} as NativeReviewCli).commands.get("gentle:review-mode");
398
+ assert.ok(unavailable);
399
+ const unavailableNotices: Array<{ message: string; type?: string }> = [];
400
+ await unavailable!.handler("status", context(process.cwd(), "review-mode-unavailable", unavailableNotices));
401
+ assert.deepEqual(unavailableNotices, [{ message: "Gentle AI review mode is not available with the currently negotiated native version.", type: "info" }]);
402
+ });
403
+
404
+ test("public consent relay is session-bound, one-shot, and candidate-scoped", async (t) => {
565
405
  const cwd = repository(t);
566
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "default" });
567
- const { controller } = runtime(native);
568
- const result = await execStart(controller, "kill-switch-default", headlessContext(cwd));
569
- assert.equal(result.mode_source, "default");
570
- assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the default mode source keeps it off");
571
- assert.equal(
572
- result.next_action,
573
- "Run `gentle-ai review mode enable --scope=global` to turn reviews on; receipt-driven development is opt-in and nothing here has enabled it yet. /gentle:review-mode enable only sets clone scope, which can never turn reviews on.",
574
- );
575
- assert.ok(!/\/gentle:review-mode enable to turn/.test(String(result.next_action)), "a default off must never be sent to Pi's clone-scope command");
576
- });
406
+ const sharedRegistry = new PendingReviewConsentRegistry();
407
+ const fixture = consentNative(cwd);
408
+ const first = parityRuntime(fixture.native, { pendingReviewConsentRegistry: sharedRegistry });
409
+ const second = parityRuntime(fixture.native, { pendingReviewConsentRegistry: sharedRegistry });
410
+ const sameSession = await beginConsent(first, cwd, "same-session");
411
+ const sameSessionResult = await answerConsent(second, cwd, sameSession.consent_binding, "declined", "same-session");
412
+ assert.equal(sameSessionResult.outcome, "consent-declined-this-candidate");
413
+
414
+ const blockedA = await beginConsent(first, cwd, "session-a");
415
+ assert.deepEqual(blockedA.consent, decodeReviewConsentV3(captured("consent-v3.captured.json")).raw);
416
+ const staleOtherSession = await answerConsent(second, cwd, blockedA.consent_binding, "declined", "session-b");
417
+ assert.equal(staleOtherSession.operation, "answer-consent");
418
+ // gentle-pi#516: a binding this session does not hold must never read as
419
+ // a healthy pre-start STATUS; it names itself and the exit.
420
+ assertStaleConsentBinding(staleOtherSession, blockedA.consent_binding, "consent-binding-unknown");
421
+ assert.deepEqual(fixture.answers, ["declined"]);
422
+ const blockedB = await beginConsent(second, cwd, "session-b");
423
+ const declined = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
424
+ assert.equal(declined.outcome, "consent-declined-this-candidate");
425
+ assert.equal(declined.lineage_created, false);
426
+ const staleConsumed = await answerConsent(second, cwd, blockedB.consent_binding, "declined", "session-b");
427
+ assert.equal(staleConsumed.operation, "answer-consent");
428
+ assertStaleConsentBinding(staleConsumed, blockedB.consent_binding, "consent-binding-already-consumed");
429
+ assert.deepEqual(fixture.answers, ["declined", "declined"]);
430
+
431
+ const shutdown = first.events.get("session_shutdown");
432
+ assert.ok(shutdown);
433
+ await shutdown!({}, context(cwd, "session-a"));
434
+ await shutdown!({}, context(cwd, "session-a"));
435
+ const staleShutdown = await answerConsent(first, cwd, blockedA.consent_binding, "declined", "session-a");
436
+ assert.equal(staleShutdown.operation, "answer-consent");
437
+ assertStaleConsentBinding(staleShutdown, blockedA.consent_binding, "consent-binding-unknown");
577
438
 
578
- test("kill-switch: capability-absent (no reviewMode) leaves today's path unchanged", async (t) => {
579
- const cwd = repository(t);
580
- const { native } = fakeOrganicNative({ reviewModeCapable: false, lensesRequired: false });
581
- const { controller } = runtime(native);
582
- const result = await execStart(controller, "kill-switch-absent", headlessContext(cwd));
583
- assert.notEqual(result.status, "skipped");
584
- assert.ok(result.result);
439
+ writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
440
+ const nextCandidate = await beginConsent(second, cwd, "session-b");
441
+ assert.notEqual(nextCandidate.consent_binding, blockedB.consent_binding);
442
+ fixture.native.reviewMode = async ({ operation }) => ({
443
+ operation,
444
+ scope: "clone",
445
+ status: { global: "", cloneLocal: "off", effective: "off", source: "clone_local" },
446
+ });
447
+ const disable = second.commands.get("gentle:review-mode");
448
+ assert.ok(disable);
449
+ await disable!.handler("disable", context(cwd, "session-b"));
450
+ const staleModeCleared = await answerConsent(second, cwd, nextCandidate.consent_binding, "declined", "session-b");
451
+ assert.equal(staleModeCleared.operation, "answer-consent");
452
+ assertStaleConsentBinding(staleModeCleared, nextCandidate.consent_binding, "consent-binding-unknown");
453
+ assert.equal(fixture.starts.count, 4);
585
454
  });
586
455
 
587
- test("kill-switch: an unexpected reviewMode failure maps to the existing native-operation-failed envelope", async (t) => {
456
+ test("already-consumed local consent bindings reconcile exactly once with the native status transition", async (t) => {
588
457
  const cwd = repository(t);
589
- const { native } = fakeOrganicNative({ reviewModeThrows: true });
590
- const { controller } = runtime(native);
591
- const result = await execStart(controller, "kill-switch-error", headlessContext(cwd));
592
- assert.equal(result.status, "blocked");
593
- assert.equal(result.outcome, "native-operation-failed");
594
- });
595
-
596
- function candidateConsent(cwd: string): ReviewConsentV2 {
597
- const targetIdentity = `sha256:${"a".repeat(64)}`;
598
- const choices = [
599
- { answer: "granted" as const, label: "Run the review now", effect: "Review this exact candidate only.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent granted` },
600
- { answer: "declined" as const, label: "Not now, just this once", effect: "Create no authority and ask again next candidate.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent declined` },
601
- ] as const;
602
- const raw = { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, target_identity: targetIdentity, projection: "workspace", risk_level: "high", changed_files: 1, changed_lines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", risk_evidence: ["shell process"], choices: choices.map((choice) => ({ ...choice })), off_path: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" } };
603
- return { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", riskEvidence: ["shell process"], choices, offPath: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" }, raw };
604
- }
605
-
606
- function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
607
- const { native } = fakeOrganicNative();
608
- const consent = candidateConsent(cwd);
609
- const answers: NativeReviewConsentAnswer[] = [];
610
- const startRequests: NativeStartRequest[] = [];
611
- const answerRequests: NativeReviewConsentAnswerRequest[] = [];
612
- native.start = async (request) => {
613
- startRequests.push(request);
614
- throw new NativeReviewConsentRequiredError(consent);
458
+ const fixture = consentNative(cwd);
459
+ const nextTransition = {
460
+ kind: "execute",
461
+ reason_code: "fresh_start_available",
462
+ execute: { operation: "review.start", arguments: ["opaque-native-start-token"] },
615
463
  };
616
- native.answerConsent = async (request) => {
617
- answers.push(request.answer);
618
- answerRequests.push(request);
619
- if (request.answer === "declined") return { kind: "declined", targetIdentity: consent.targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, consent: "declined_this_candidate", raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" } };
620
- return { kind: "started", start: { lineageId: "native-lineage", state: "reviewing", riskLevel: "high", selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true } };
464
+ const reconciledStatus = {
465
+ ...startStatus(cwd),
466
+ raw: { schema: "gentle-ai.review-integration.status/v5", next_transition: nextTransition },
467
+ } as ReviewStatusV3;
468
+ const statusRequests: Array<{ agent?: string }> = [];
469
+ fixture.native.targetStatus = async (request) => {
470
+ statusRequests.push(request);
471
+ return reconciledStatus;
621
472
  };
622
- return { native, answers, startRequests, answerRequests };
623
- }
624
-
625
- async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
626
- const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
627
- return details as Record<string, unknown>;
628
- }
629
-
630
- async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
631
- const result = await execStart(controller, id, ctx);
632
- assert.equal(result.status, "blocked");
633
- assert.equal(result.outcome, "native-review-consent-required");
634
- assert.equal(typeof result.consent_binding, "string");
635
- return result;
636
- }
637
-
638
- test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
473
+ const runtime = parityRuntime(fixture.native);
474
+ const blocked = await beginConsent(runtime, cwd);
475
+ await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
476
+
477
+ const reconciled = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
478
+ assert.equal(reconciled.operation, "answer-consent");
479
+ assertStaleConsentBinding(reconciled, blocked.consent_binding, "consent-binding-already-consumed");
480
+ assert.deepEqual(reconciled.result, reconciledStatus.raw);
481
+ assert.deepEqual((reconciled.result as { next_transition?: unknown }).next_transition, nextTransition);
482
+ assert.equal(statusRequests.length, 2, "the stale binding performs one reconciliation after the initial START status");
483
+ assert.equal(statusRequests[1]?.agent, "pi", "stale binding reconciliation must retain the Pi host transport identity");
484
+ assert.equal(fixture.starts.count, 1, "stale binding recovery must not start a new review automatically");
485
+ assert.deepEqual(fixture.answers, ["declined"], "stale binding recovery must not replay answer-consent");
486
+ });
487
+
488
+ test("launched answer-consent failures remain blocked after fresh-target STATUS reconciliation", async (t) => {
639
489
  const cwd = repository(t);
640
- recordReviewConsentLatch(cwd);
641
- for (const ctx of [
642
- { cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
643
- headlessContext(cwd),
644
- ]) {
645
- const { native, answers } = relayedConsentNative(cwd);
646
- const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
647
- assert.deepEqual(result.consent, candidateConsent(cwd).raw);
648
- assert.deepEqual(answers, []);
649
- assert.equal(result.lineage_created, false);
650
- }
651
- });
652
-
653
- test("explicit consent follow-up grants or declines exactly once", async (t) => {
654
- for (const answer of ["granted", "declined"] as const) {
655
- const repositoryCwd = repository(t);
656
- const cwd = `${repositoryCwd}-alias`;
657
- symlinkSync(repositoryCwd, cwd, process.platform === "win32" ? "junction" : "dir");
658
- t.after(() => rmSync(cwd, { force: true }));
659
- const canonicalCwd = realpathSync(cwd);
660
- assert.equal(readReviewConsentLatch(cwd), false);
661
- const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
662
- const { controller } = runtime(native);
663
- const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
664
- const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
665
- assert.deepEqual(answers, [answer]);
666
- assert.equal(startRequests.length, 1);
667
- assert.equal(startRequests[0]?.cwd, cwd);
668
- assert.equal(startRequests[0]?.targetIdentity, candidateConsent(cwd).targetIdentity);
669
- assert.equal(startRequests[0]?.projection, "workspace");
670
- assert.equal(answerRequests.length, 1);
671
- assert.equal(answerRequests[0]?.cwd, cwd);
672
- assert.equal(answerRequests[0]?.consent.targetIdentity, startRequests[0]?.targetIdentity);
673
- if (answer === "granted") {
674
- const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
675
- assert.equal(actorBinding.workspace_root, canonicalCwd);
676
- assert.notEqual(actorBinding.candidate_root, canonicalCwd);
677
- assert.equal(readReviewConsentLatch(cwd), true);
678
- } else {
679
- assert.equal(result.outcome, "consent-declined-this-candidate");
680
- assert.equal(result.lineage_created, false);
681
- assert.equal("actor_binding" in result, false);
682
- assert.equal("result" in result, false);
683
- assert.equal(readReviewConsentLatch(cwd), false);
684
- }
685
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
686
- }
687
- });
688
-
689
- test("granted consent preserves the completed native start when local latch persistence fails", async (t) => {
690
- const cwd = repository(t);
691
- const { native, answers } = relayedConsentNative(cwd);
692
- const notices: Array<{ message: string; type?: string }> = [];
693
- let latchWrites = 0;
694
- const { controller } = runtime(native, () => {
695
- latchWrites += 1;
696
- throw new Error("injected consent latch failure");
490
+ const fixture = consentNative(cwd);
491
+ const statusRequests: Array<{ agent?: string }> = [];
492
+ const freshTargetStatus = {
493
+ ...startStatus(cwd),
494
+ raw: { schema: "gentle-ai.review-integration.status/v5", next_transition: { kind: "execute", reason_code: "fresh_start_available" } },
495
+ } as ReviewStatusV3;
496
+ fixture.native.targetStatus = async (request) => {
497
+ statusRequests.push(request);
498
+ return freshTargetStatus;
499
+ };
500
+ let answerCalls = 0;
501
+ fixture.native.answerConsent = async () => {
502
+ answerCalls += 1;
503
+ throw new NativeReviewCliError(
504
+ NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
505
+ NATIVE_REVIEW_OPERATION.START,
506
+ true,
507
+ true,
508
+ "native review/start failed after launch",
509
+ );
510
+ };
511
+ const runtime = parityRuntime(fixture.native);
512
+ const blocked = await beginConsent(runtime, cwd);
513
+
514
+ const first = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
515
+ assert.equal(first.operation, "answer-consent");
516
+ assert.equal(first.status, "blocked");
517
+ assert.equal(first.outcome, "native-mutation-status-reconciled");
518
+ assert.deepEqual(first.diagnostics, {
519
+ operation: NATIVE_REVIEW_OPERATION.START,
520
+ error_code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO,
521
+ timed_out: false,
522
+ output_limit_exceeded: false,
697
523
  });
698
- const blocked = await blockedConsent(controller, "consent-latch-failure", headlessContext(cwd, notices));
699
- const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd, notices));
524
+ assert.deepEqual(first.reconciliation, freshTargetStatus.raw);
525
+ assert.equal(statusRequests.length, 2, "the launched failure reconciles exactly once after the initial START status");
526
+ assert.equal(answerCalls, 1, "reconciliation must not replay answer-consent");
700
527
 
701
- assert.deepEqual(answers, ["granted"]);
702
- assert.equal(latchWrites, 1);
703
- assert.deepEqual(result.result, { lineage_id: "native-lineage", state: "reviewing", risk_tier: "high", selected_lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changed_files: 1, original_changed_lines: 1, correction_budget: 1, action: "created", lenses_required: true });
704
- assert.equal((result.actor_binding as { workspace_root: string }).workspace_root, realpathSync(cwd));
705
- assert.equal(readReviewConsentLatch(cwd), false);
706
- assert.equal(notices.length, 1);
707
- assert.equal(notices[0]?.type, "warning");
708
- assert.match(notices[0]?.message ?? "", /native review start completed, but Pi could not record the local consent latch/i);
528
+ const stale = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
529
+ assertStaleConsentBinding(stale, blocked.consent_binding, "consent-binding-already-consumed");
530
+ assert.equal(statusRequests.length, 3, "a later already-consumed binding performs one current STATUS read");
531
+ assert.equal(answerCalls, 1, "the stale binding must not replay answer-consent");
709
532
  });
710
533
 
711
- // Issue #247: a local binding mismatch was indistinguishable from a provider
712
- // outage, so the reporter diagnosed a missing --cwd that Pi does forward.
713
- test("a consent binding mismatch surfaces as an actionable local failure, not an opaque native operation failure", async (t) => {
534
+ test("stale local consent bindings preserve a typed native STATUS failure", async (t) => {
714
535
  const cwd = repository(t);
715
- const { native, answers } = relayedConsentNative(cwd);
716
- native.answerConsent = async () => {
717
- throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
536
+ const fixture = consentNative(cwd);
537
+ const runtime = parityRuntime(fixture.native);
538
+ const blocked = await beginConsent(runtime, cwd);
539
+ await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
540
+ fixture.native.targetStatus = async () => {
541
+ throw new NativeReviewCliError(
542
+ NATIVE_REVIEW_ERROR_CODE.TIMEOUT,
543
+ NATIVE_REVIEW_OPERATION.STATUS,
544
+ true,
545
+ false,
546
+ "native STATUS timed out",
547
+ );
718
548
  };
719
- const { controller } = runtime(native);
720
- const blocked = await blockedConsent(controller, "consent-binding", headlessContext(cwd));
721
- const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
722
- assert.equal(result.status, "blocked");
723
- assert.equal(result.outcome, "consent-binding-invalid");
724
- assert.deepEqual(result.diagnostics, { code: "consent-invocation-cwd-changed", message: "Native consent invocation repository binding changed" });
725
- assert.equal(result.native_invocation_attempted, false);
726
- assert.equal(result.lineage_created, false);
727
- assert.equal(result.mutation_performed, false);
728
- assert.equal(result.mutation_outcome, "none");
729
- assert.equal(result.next_action, "resolve-consent-binding");
730
- assert.deepEqual(answers, []);
731
- assert.equal(readReviewConsentLatch(cwd), false);
732
- });
733
549
 
734
- test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
735
- const cwd = repository(t);
736
- const consent = candidateConsent(cwd);
737
- const { native, answers } = relayedConsentNative(cwd);
738
- native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
739
- const { controller } = runtime(native);
740
- const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
741
- await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
742
- await assert.rejects(() => controller.execute("extra", { operation: "answer-consent", input: JSON.stringify({ consentBinding: blocked.consent_binding, answer: "granted", target: "substitute" }) }, undefined, undefined, headlessContext(cwd)), /exactly consentBinding and answer/);
743
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
744
- await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
745
- const other = repository(t);
746
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
747
- const originalTarget = consent.targetIdentity;
748
- (consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
749
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
750
- (consent as { targetIdentity: string }).targetIdentity = originalTarget;
751
- (consent as { projection: string }).projection = "staged";
752
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
753
- (consent as { projection: string }).projection = "workspace";
754
- (consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
755
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
756
- assert.deepEqual(answers, []);
757
- assert.equal(readReviewConsentLatch(cwd), false);
758
- });
759
-
760
- test("unavailable consent follow-up leaves the clone latch unset", async (t) => {
761
- const cwd = repository(t);
762
- const { native } = relayedConsentNative(cwd);
763
- delete native.answerConsent;
764
- const { controller } = runtime(native);
765
- const blocked = await blockedConsent(controller, "consent-unavailable", headlessContext(cwd));
766
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent follow-up is unavailable/);
767
- assert.equal(readReviewConsentLatch(cwd), false);
768
- });
550
+ const failed = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
551
+ assert.equal(failed.operation, "answer-consent");
552
+ assert.equal(failed.status, "blocked");
553
+ assert.equal(failed.outcome, "native-status-unavailable");
554
+ assert.deepEqual(failed.diagnostics, {
555
+ operation: NATIVE_REVIEW_OPERATION.STATUS,
556
+ error_code: NATIVE_REVIEW_ERROR_CODE.TIMEOUT,
557
+ timed_out: false,
558
+ output_limit_exceeded: false,
559
+ });
560
+ assert.equal(fixture.starts.count, 1, "native STATUS failure must not start a new review");
561
+ assert.deepEqual(fixture.answers, ["declined"], "native STATUS failure must not replay answer-consent");
769
562
 
770
- test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
771
- const cwd = repository(t);
772
- const { native } = relayedConsentNative(cwd);
773
- let statusCalls = 0;
774
- const targetStatus = native.targetStatus!;
775
- native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
776
- native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
777
- const { controller } = runtime(native);
778
- const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
779
- await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
780
- assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
781
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
782
- assert.equal(readReviewConsentLatch(cwd), false);
563
+ delete fixture.native.targetStatus;
564
+ const unsupported = await answerConsent(runtime, cwd, blocked.consent_binding, "declined");
565
+ assert.equal(unsupported.operation, "answer-consent");
566
+ assert.equal(unsupported.status, "blocked");
567
+ assert.equal(unsupported.outcome, "native-status-unsupported");
568
+ assert.equal(fixture.starts.count, 1, "unavailable native STATUS must not start a new review");
569
+ assert.deepEqual(fixture.answers, ["declined"], "unavailable native STATUS must not replay answer-consent");
783
570
  });
784
571
 
785
- test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
572
+ test("stale local consent bindings retain typed Pi transport refusal without an agentless retry", async (t) => {
786
573
  const cwd = repository(t);
787
- const { native, answers, startRequests } = relayedConsentNative(cwd);
788
- const { controller, events } = runtime(native);
789
- const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
790
- const shutdown = events.get("session_shutdown");
791
- assert.ok(shutdown);
792
- await shutdown({}, headlessContext(cwd));
793
- await shutdown({}, headlessContext(cwd));
794
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
795
- assert.deepEqual(answers, []);
796
- const afterShutdown = await blockedConsent(controller, "consent-after-shutdown", headlessContext(cwd));
797
- assert.notEqual(afterShutdown.consent_binding, blocked.consent_binding, "the same candidate gets a fresh binding after shutdown cleanup");
798
- assert.equal(startRequests.length, 2, "shutdown loss requires a fresh native START instead of local replay");
574
+ const fixture = consentNative(cwd);
575
+ const statusRequests: Array<{ agent?: string }> = [];
576
+ fixture.native.targetStatus = async (request) => {
577
+ statusRequests.push(request);
578
+ throw new NativeReviewIntegrationError(decodeReviewFailureV2({
579
+ schema: "gentle-ai.review-integration.failure/v2",
580
+ contract: "gentle-ai.review-integration/v2",
581
+ operation: "review.status",
582
+ phase: "preflight",
583
+ code: "unsupported_agent",
584
+ message: "The native provider does not support Pi host transport.",
585
+ mutation_outcome: "not_started",
586
+ authority_applicability: "not_evaluated",
587
+ retry_safe: true,
588
+ replayability: "not_replayable",
589
+ required_inputs: [],
590
+ next_action: "review.status",
591
+ }));
592
+ };
593
+ const runtime = parityRuntime(fixture.native);
594
+
595
+ const refused = await answerConsent(runtime, cwd, "missing-consent-binding", "declined");
596
+ assert.equal(refused.operation, "answer-consent");
597
+ assert.equal(refused.status, "blocked");
598
+ assert.equal(refused.outcome, "pi-host-relay-transport-unavailable");
599
+ assert.deepEqual(refused.relay_transport, {
600
+ supported: false,
601
+ code: "unsupported_agent",
602
+ message: "The native provider does not support Pi host transport.",
603
+ });
604
+ assert.equal(statusRequests.length, 1, "typed Pi transport refusal must not retry STATUS without an agent");
605
+ assert.equal(statusRequests[0]?.agent, "pi");
606
+ assert.equal(fixture.starts.count, 0, "stale binding transport refusal must not start a review");
607
+ assert.deepEqual(fixture.answers, [], "stale binding transport refusal must not replay answer-consent");
799
608
  });
800
609
 
801
- test("extension reload gives the same candidate a fresh consent binding instead of replaying lost local state", async (t) => {
610
+ test("unavailable and ambiguous consent follow-ups never replay a consumed binding", async (t) => {
802
611
  const cwd = repository(t);
803
- const { native, startRequests } = relayedConsentNative(cwd);
804
- const beforeReload = await blockedConsent(runtime(native).controller, "consent-before-reload", headlessContext(cwd));
805
- const afterReload = await blockedConsent(runtime(native).controller, "consent-after-reload", headlessContext(cwd));
806
- assert.notEqual(afterReload.consent_binding, beforeReload.consent_binding);
807
- assert.equal(startRequests.length, 2, "reload loss requires a fresh native START instead of local replay");
808
- });
612
+ const unavailableFixture = consentNative(cwd);
613
+ delete unavailableFixture.native.answerConsent;
614
+ const unavailableRuntime = parityRuntime(unavailableFixture.native);
615
+ const unavailable = await beginConsent(unavailableRuntime, cwd);
616
+ await assert.rejects(
617
+ () => answerConsent(unavailableRuntime, cwd, unavailable.consent_binding, "declined"),
618
+ /consent follow-up is unavailable/,
619
+ );
809
620
 
810
- test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
621
+ const ambiguousFixture = consentNative(cwd);
622
+ let statusCalls = 0;
623
+ const targetStatus = ambiguousFixture.native.targetStatus!;
624
+ ambiguousFixture.native.targetStatus = async (request) => {
625
+ statusCalls += 1;
626
+ return await targetStatus(request);
627
+ };
628
+ ambiguousFixture.native.answerConsent = async () => {
629
+ throw Object.assign(new Error("ambiguous provider mutation"), { mutationOutcome: "unknown", nextAction: "review.status" });
630
+ };
631
+ const ambiguousRuntime = parityRuntime(ambiguousFixture.native);
632
+ const ambiguous = await beginConsent(ambiguousRuntime, cwd);
633
+ const outcome = await answerConsent(ambiguousRuntime, cwd, ambiguous.consent_binding, "granted");
634
+ assert.equal(outcome.operation, "answer-consent");
635
+ assert.ok(statusCalls >= 2, "ambiguous consent re-enters read-only STATUS after the initial START status");
636
+ const callsBeforeStaleReconciliation = statusCalls;
637
+ const stale = await answerConsent(ambiguousRuntime, cwd, ambiguous.consent_binding, "granted");
638
+ assert.equal(stale.operation, "answer-consent");
639
+ assertStaleConsentBinding(stale, ambiguous.consent_binding, "consent-binding-already-consumed");
640
+ assert.equal(statusCalls, callsBeforeStaleReconciliation + 1, "already-consumed binding reconciliation performs exactly one additional STATUS call");
641
+ });
642
+
643
+ test("concurrent answers atomically claim consent before review-mode gating and ignore a queued expiry callback", async (t) => {
811
644
  const cwd = repository(t);
812
- const { native, answers } = relayedConsentNative(cwd);
813
- let effective: "on" | "off" = "on";
814
- native.reviewMode = async (request) => {
815
- if (request.operation === "disable") effective = "off";
816
- if (request.operation === "enable") effective = "on";
817
- return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
645
+ const fixture = consentNative(cwd);
646
+ const candidateViews = new CandidateViewRegistry();
647
+ const cleanup = candidateViews.cleanup.bind(candidateViews);
648
+ let cleanupCalls = 0;
649
+ candidateViews.cleanup = (token) => {
650
+ cleanupCalls += 1;
651
+ cleanup(token);
818
652
  };
819
- const { controller, commands } = runtime(native);
820
- const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
821
- const command = commands.get("gentle:review-mode")!;
822
- await command.handler("disable", headlessContext(cwd));
823
- await command.handler("enable", headlessContext(cwd));
824
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
825
- assert.deepEqual(answers, []);
826
- });
827
-
828
- // Issue #264: an unused consent binding and the candidate view retained
829
- // exclusively for that binding must expire as one lifecycle unit before any
830
- // later START may reuse the view. Cleanup is synchronous with respect to the
831
- // observable TTL; the queued cleanup macrotask is a safety net, not the
832
- // authority, so a fresh-candidate retry after expiry must get a new binding
833
- // rather than tripping candidate-target-projection-drift on a stale view.
834
- const REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
835
-
836
- function fakeConsentClock(): { now: () => number; scheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void }; advance: (ms: number) => void; scheduled: Array<() => void> } {
837
- const start = Date.now();
838
- let nowMs = start;
839
653
  const scheduled: Array<() => void> = [];
840
- return {
841
- now: () => nowMs,
842
- // Never fires the callback: simulates a queued cleanup macrotask that
843
- // has not yet gotten a turn on the event loop.
844
- scheduleTimer: (callback) => { scheduled.push(callback); return { unref: () => {} }; },
845
- advance: (ms) => { nowMs = start + ms; },
846
- scheduled,
654
+ let releaseModeGate: (() => void) | undefined;
655
+ const modeGate = new Promise<void>((resolve) => { releaseModeGate = resolve; });
656
+ let modeCalls = 0;
657
+ const statusRequests: Array<{ agent?: string }> = [];
658
+ fixture.native.targetStatus = async (request) => {
659
+ statusRequests.push(request);
660
+ return startStatus(cwd);
661
+ };
662
+ let releaseProvider: (() => void) | undefined;
663
+ const providerGate = new Promise<void>((resolve) => { releaseProvider = resolve; });
664
+ const nativeAnswer = fixture.native.answerConsent!;
665
+ let nativeAnswerCalls = 0;
666
+ fixture.native.answerConsent = async (request) => {
667
+ nativeAnswerCalls += 1;
668
+ assert.equal(cleanupCalls, 0, "a queued expiry callback must not clean claimed candidate authority before the provider answer");
669
+ await providerGate;
670
+ return await nativeAnswer(request);
671
+ };
672
+ const runtime = parityRuntime(fixture.native, {
673
+ candidateViews,
674
+ scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; },
675
+ });
676
+ const blocked = await beginConsent(runtime, cwd);
677
+ fixture.native.reviewMode = async () => {
678
+ modeCalls += 1;
679
+ if (modeCalls === 1) await modeGate;
680
+ return {
681
+ operation: "status",
682
+ scope: "clone",
683
+ status: { global: "", cloneLocal: "", effective: "on", source: "default" },
684
+ };
847
685
  };
848
- }
849
-
850
- test("an expired unused binding prunes synchronously so a fresh-candidate retry gets a new binding, not candidate-target-projection-drift", async (t) => {
851
- const cwd = repository(t);
852
- const { native, startRequests } = relayedConsentNative(cwd);
853
- const clock = fakeConsentClock();
854
- const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
855
- // First START: consent-required, binding T1 retained with a queued cleanup timer.
856
- const first = await blockedConsent(controller, "expired-fresh-1", headlessContext(cwd));
857
- const firstBinding = first.consent_binding;
858
- assert.equal(clock.scheduled.length, 1, "the TTL cleanup macrotask is queued for T1");
859
- // The candidate changes: a later START on a FRESH candidate must not reuse T1.
860
- writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n");
861
- // Advance time to the exact TTL boundary without letting the queued cleanup macrotask fire.
862
- clock.advance(REVIEW_CONSENT_TTL_MS);
863
- assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
864
- const second = await execStart(controller, "expired-fresh-2", headlessContext(cwd));
865
- // The fix: a fresh consent-required binding, not candidate-target-projection-drift.
866
- assert.equal(second.status, "blocked");
867
- assert.equal(second.outcome, "native-review-consent-required", "the stale view must not trip candidate-target-projection-drift");
868
- assert.equal(typeof second.consent_binding, "string");
869
- assert.notEqual(second.consent_binding, firstBinding, "a fresh candidate gets a fresh binding, not the stale one");
870
- assert.equal(startRequests.length, 2, "native start was attempted for both candidates");
871
- assert.equal(clock.scheduled.length, 2, "the fresh binding queued its own cleanup macrotask");
872
- });
873
686
 
874
- test("an expired same-candidate consent request creates a fresh binding instead of replaying local state", async (t) => {
687
+ const first = answerConsent(runtime, cwd, blocked.consent_binding, "declined");
688
+ await new Promise<void>((resolve) => setImmediate(resolve));
689
+ assert.equal(modeCalls, 1, "the first answer pauses at the asynchronous review-mode gate");
690
+ const second = answerConsent(runtime, cwd, blocked.consent_binding, "declined");
691
+ await new Promise<void>((resolve) => setImmediate(resolve));
692
+ assert.equal(scheduled.length, 1);
693
+ scheduled[0]!();
694
+ assert.equal(cleanupCalls, 0, "a queued expiry callback is a harmless no-op after the answer claim");
695
+
696
+ releaseModeGate!();
697
+ releaseProvider!();
698
+ const [firstResult, secondResult] = await Promise.all([first, second]);
699
+ assert.equal(firstResult.outcome, "consent-declined-this-candidate");
700
+ assertStaleConsentBinding(secondResult, blocked.consent_binding, "consent-binding-already-consumed");
701
+ assert.equal(nativeAnswerCalls, 1, "only the atomically claimed answer may invoke native answer-consent");
702
+ assert.equal(statusRequests.length, 2, "the losing concurrent answer performs one STATUS reconciliation after the initial START status");
703
+ assert.equal(statusRequests[1]?.agent, "pi", "the losing answer keeps Pi host transport for STATUS reconciliation");
704
+ assert.equal(cleanupCalls, 1, "only the answered binding cleans candidate authority");
705
+ });
706
+
707
+ test("timer-cleaned and synchronously pruned consent bindings remain expired within their session", async (t) => {
875
708
  const cwd = repository(t);
876
- const { native, startRequests } = relayedConsentNative(cwd);
877
- const clock = fakeConsentClock();
878
- const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
879
- const first = await blockedConsent(controller, "same-candidate-expired-1", headlessContext(cwd));
880
- clock.advance(REVIEW_CONSENT_TTL_MS);
881
- const second = await blockedConsent(controller, "same-candidate-expired-2", headlessContext(cwd));
709
+ const fixture = consentNative(cwd);
710
+ const registry = new PendingReviewConsentRegistry();
711
+ const start = 1_000;
712
+ let now = start;
713
+ const scheduled: Array<() => void> = [];
714
+ const runtime = parityRuntime(fixture.native, {
715
+ pendingReviewConsentRegistry: registry,
716
+ now: () => now,
717
+ scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; },
718
+ });
719
+ const first = await beginConsent(runtime, cwd);
720
+ const reused = await beginConsent(runtime, cwd);
721
+ assert.equal(reused.consent_binding, first.consent_binding);
722
+ assert.equal(scheduled.length, 1);
723
+ now += 10 * 60 * 1000;
724
+ // The real TTL callback drops the frozen candidate immediately. Its
725
+ // session-local disposition remains only to classify this stale answer.
726
+ scheduled[0]!();
727
+ const timerExpired = await answerConsent(runtime, cwd, first.consent_binding, "declined");
728
+ assert.equal(timerExpired.operation, "answer-consent");
729
+ assertStaleConsentBinding(timerExpired, first.consent_binding, "consent-binding-expired");
730
+ assert.match(String((timerExpired.diagnostics as { message?: unknown }).message), /expired after 10 minutes/);
731
+ const second = await beginConsent(runtime, cwd);
882
732
  assert.notEqual(second.consent_binding, first.consent_binding);
883
- assert.equal(startRequests.length, 2, "expiry requires a fresh native START instead of local replay");
884
- assert.equal(clock.scheduled.length, 2, "the fresh binding queues its own cleanup macrotask");
885
- });
886
-
887
- test("a non-expired same-candidate consent request reuses the same binding instead of creating a new one", async (t) => {
888
- const cwd = repository(t);
889
- const { native } = relayedConsentNative(cwd);
890
- const clock = fakeConsentClock();
891
- const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
892
- const first = await blockedConsent(controller, "dedup-1", headlessContext(cwd));
893
- // Same candidate, same consent, still within TTL: the existing binding is reused.
894
- const second = await blockedConsent(controller, "dedup-2", headlessContext(cwd));
895
- assert.equal(second.consent_binding, first.consent_binding, "a non-expired same-candidate request deduplicates the binding");
896
- assert.equal(clock.scheduled.length, 1, "no new cleanup macrotask is queued when the binding is reused");
897
- });
898
-
899
- test("an expired consent binding is rejected on answer-consent even before the queued cleanup macrotask fires", async (t) => {
900
- const cwd = repository(t);
901
- const { native } = relayedConsentNative(cwd);
902
- const clock = fakeConsentClock();
903
- const { controller } = runtime(native, recordReviewConsentLatch, { now: clock.now, scheduleTimer: clock.scheduleTimer });
904
- const blocked = await blockedConsent(controller, "expired-answer-1", headlessContext(cwd));
905
- // Advance to the exact TTL boundary without firing the queued cleanup macrotask.
906
- clock.advance(REVIEW_CONSENT_TTL_MS);
907
- assert.equal(clock.scheduled.length, 1, "the queued cleanup macrotask has not fired");
908
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
909
- assert.equal(clock.scheduled.length, 1, "rejection was synchronous from the TTL observation, not from a fired macrotask");
910
- });
911
-
912
- test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
913
- const cwd = repository(t);
914
- const { native, answers } = relayedConsentNative(cwd);
915
- const { controller } = runtime(native);
916
- for (const id of ["decline-one", "decline-two"]) {
917
- const blocked = await blockedConsent(controller, id, headlessContext(cwd));
918
- const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
919
- assert.equal(result.outcome, "consent-declined-this-candidate");
920
- assert.equal(result.lineage_created, false);
921
- assert.equal("actor_binding" in result, false);
922
- assert.equal("result" in result, false);
923
- assert.equal(readReviewConsentLatch(cwd), false);
924
- }
925
- assert.deepEqual(answers, ["declined", "declined"]);
926
- });
927
-
928
- // Upstream gentle-ai.review-integration.consent/v3 consent is per-candidate:
929
- // the clone-local latch records only that the one-time question was already
930
- // put to the user, never that any later candidate is pre-approved. A fresh
931
- // START with the latch already recorded must still relay the complete
932
- // blocking envelope, and only an explicit ANSWER_CONSENT may resolve it.
933
- test("a pre-recorded consent latch never suppresses a later candidate's consent envelope or auto-answers it", async (t) => {
934
- const cwd = repository(t);
935
- recordReviewConsentLatch(cwd);
936
- assert.equal(readReviewConsentLatch(cwd), true);
937
- const { native, answers, startRequests } = relayedConsentNative(cwd);
938
- const { controller } = runtime(native);
939
- const blocked = await blockedConsent(controller, "consent-latched-fresh-start", headlessContext(cwd));
940
- assert.deepEqual(blocked.consent, candidateConsent(cwd).raw, "the full consent envelope is relayed despite the pre-recorded latch");
941
- assert.deepEqual(answers, [], "the latch must never auto-answer a later candidate's consent");
942
- assert.equal(blocked.lineage_created, false);
943
- assert.equal(startRequests.length, 1);
944
- const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
945
- assert.deepEqual(answers, ["granted"], "explicit ANSWER_CONSENT is still required to proceed");
946
- assert.ok(result.result);
947
- });
733
+ assert.equal(scheduled.length, 2);
734
+ assertStaleConsentBinding(await answerConsent(runtime, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-expired");
948
735
 
949
- test("low-risk zero-lens START remains silent", async (t) => {
950
- const cwd = repository(t);
951
- const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
952
- const { controller } = runtime(native);
953
- let selected = false;
954
- const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
955
- const result = await execStart(controller, "consent-low-risk", ctx);
956
- assert.equal(selected, false);
957
- assert.ok(result.result);
958
- });
959
-
960
- // ---------------------------------------------------------------------------
961
- // Phase 5 (tier/hint/delivery passthrough): mapNativeStartResult renders
962
- // risk_tier/risk_evidence/hint verbatim from the native start result, with
963
- // zero local derivation (Design Decision #8, organic-rdd-parity).
964
- // ---------------------------------------------------------------------------
965
-
966
- test("mapNativeStartResult passes risk_evidence through verbatim as the native phrase array, alongside the unmodified risk_tier passthrough", async (t) => {
967
- const cwd = repository(t);
968
- const { native } = fakeOrganicNative({ riskEvidence: ["shell scripting in .github/workflows/deploy.yml"] });
969
- const { controller } = runtime(native);
970
- const result = await execStart(controller, "risk-evidence-passthrough", confirmContext(cwd, true));
971
- const rendered = result.result as Record<string, unknown>;
972
- assert.equal(rendered.risk_tier, "high", "risk_tier is native's riskLevel, verbatim, with no local recomputation");
973
- assert.deepEqual(rendered.risk_evidence, ["shell scripting in .github/workflows/deploy.yml"]);
974
- });
736
+ // No second callback runs: START synchronously prunes this unused binding
737
+ // before it can reuse the frozen candidate, and its answer stays typed expiry.
738
+ now += 10 * 60 * 1000;
739
+ const third = await beginConsent(runtime, cwd);
740
+ assert.notEqual(third.consent_binding, second.consent_binding);
741
+ assertStaleConsentBinding(await answerConsent(runtime, cwd, second.consent_binding, "declined"), second.consent_binding, "consent-binding-expired");
975
742
 
976
- test("mapNativeStartResult surfaces the empty-candidate hint verbatim and omits risk_evidence when the native result carries none", async (t) => {
977
- const cwd = repository(t);
978
- const { native } = fakeOrganicNative({
979
- lensesRequired: false,
980
- riskEvidence: undefined,
981
- hint: "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref <commit> naming the base to compare against",
982
- });
983
- const { controller } = runtime(native);
984
- const result = await execStart(controller, "hint-passthrough", headlessContext(cwd));
985
- const rendered = result.result as Record<string, unknown>;
986
- assert.equal(rendered.hint, "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref <commit> naming the base to compare against");
987
- assert.equal(rendered.risk_evidence, undefined, "no local risk_evidence is ever fabricated when the native result omits it");
743
+ const reloaded = parityRuntime(fixture.native, { pendingReviewConsentRegistry: new PendingReviewConsentRegistry() });
744
+ assertStaleConsentBinding(await answerConsent(reloaded, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-unknown");
745
+ const afterReload = await beginConsent(reloaded, cwd);
746
+ assert.notEqual(afterReload.consent_binding, third.consent_binding);
747
+ assert.equal(fixture.starts.count, 5);
988
748
  });
989
749
 
990
- test("mapNativeStartResult never fabricates risk_evidence or hint when the native result omits both", async (t) => {
750
+ test("native START maps only provider facts and omits absent evidence", async (t) => {
991
751
  const cwd = repository(t);
992
- const { native } = fakeOrganicNative({ riskEvidence: undefined });
993
- const { controller } = runtime(native);
994
- const result = await execStart(controller, "no-evidence-no-hint", confirmContext(cwd, true));
752
+ const nextTransition = {
753
+ kind: "execute" as const,
754
+ reasonCode: "review_status_required",
755
+ execute: {
756
+ operation: "review.status",
757
+ arguments: [{ name: "lineage", value: "closed-lineage", token: "--lineage=closed-lineage" }],
758
+ preconditions: [],
759
+ binding: { targetIdentity: `sha256:${"d".repeat(64)}` },
760
+ },
761
+ };
762
+ const native = {
763
+ targetStatus: async () => startStatus(cwd),
764
+ start: async () => ({
765
+ lineageId: "closed-lineage",
766
+ state: "approved",
767
+ riskLevel: "low",
768
+ selectedLenses: [],
769
+ changedFiles: 0,
770
+ changedLines: 0,
771
+ correctionBudget: 0,
772
+ action: "closed",
773
+ lensesRequired: false,
774
+ riskReasons: [],
775
+ nextTransition,
776
+ hint: "provider empty-candidate hint",
777
+ }),
778
+ } as unknown as NativeReviewCli;
779
+ const result = await __testing.executeReviewControllerOperation({ operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native);
995
780
  const rendered = result.result as Record<string, unknown>;
781
+ assert.equal(rendered.hint, "provider empty-candidate hint");
996
782
  assert.equal(rendered.risk_evidence, undefined);
997
- assert.equal(rendered.hint, undefined);
783
+ assert.equal(rendered.next_transition, nextTransition);
998
784
  });
999
785
 
1000
- test("gentle:review-mode command reports status, disables, and enables through explicit user invocation", async (t) => {
786
+ test("consent completion stays authoritative when local latch recording fails", async (t) => {
1001
787
  const cwd = repository(t);
1002
- const { native } = fakeOrganicNative({ reviewModeEffective: "off" });
1003
- const { commands } = runtime(native);
1004
- const command = commands.get("gentle:review-mode");
1005
- assert.ok(command, "gentle:review-mode must be registered");
788
+ const fixture = consentNative(cwd);
1006
789
  const notices: Array<{ message: string; type?: string }> = [];
1007
- await command!.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
1008
- assert.ok(notices.at(-1)?.message.includes("off"));
1009
- });
1010
-
1011
- // Ground-truthed against a real gentle-ai build: with the global source off,
1012
- // `review mode enable --scope clone` exits 0, reports operation "enable", and
1013
- // leaves effective "off" — because cloneLocalRDDOverrideValue maps "on" onto
1014
- // "inherit" and a clone-local override may only ever disable. Pi hard-codes
1015
- // --scope clone by design, so this request can never succeed; reporting the
1016
- // bare status would let the user believe /gentle:review-mode enable is the way
1017
- // back on when it is a dead end.
1018
- test("gentle:review-mode: an enable that cannot take effect says so and names the command that can", async (t) => {
1019
- const cwd = repository(t);
1020
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
1021
- const { commands } = runtime(native);
1022
- const command = commands.get("gentle:review-mode")!;
1023
- const notices: Array<{ message: string; type?: string }> = [];
1024
- await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
1025
- const notice = notices.at(-1)!;
1026
- assert.equal(notice.type, "warning", "a request that did not take effect is not an informational result");
1027
- assert.equal(
1028
- notice.message,
1029
- "receipt-driven development: off (decided by global)\nThat did not turn reviews back on: /gentle:review-mode only sets clone scope, and a clone-local setting can never override a global off. Run `gentle-ai review mode enable --scope=global` to turn them back on.",
1030
- );
1031
- });
1032
-
1033
- test("gentle:review-mode: an enable that does take effect keeps the existing informational report", async (t) => {
1034
- const cwd = repository(t);
1035
- const { native } = fakeOrganicNative({ reviewModeEffective: "on", reviewModeSource: "default" });
1036
- const { commands } = runtime(native);
1037
- const command = commands.get("gentle:review-mode")!;
1038
- const notices: Array<{ message: string; type?: string }> = [];
1039
- await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
1040
- assert.deepEqual(notices, [{ message: "receipt-driven development: on (decided by default)", type: "info" }]);
1041
- });
1042
-
1043
- test("gentle:review-mode command reports unavailability without throwing when the capability is dark", async (t) => {
1044
- const cwd = repository(t);
1045
- const { native } = fakeOrganicNative({ reviewModeCapable: false });
1046
- const { commands } = runtime(native);
1047
- const command = commands.get("gentle:review-mode")!;
1048
- const notices: Array<{ message: string; type?: string }> = [];
1049
- await command.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
1050
- assert.equal(notices.length, 1);
790
+ const runtime = parityRuntime(fixture.native, {
791
+ writeConsentLatch: () => { throw new Error("injected latch failure"); },
792
+ });
793
+ const blocked = await beginConsent(runtime, cwd);
794
+ const completed = await answerConsent(runtime, cwd, blocked.consent_binding, "granted");
795
+ assert.equal((completed.result as { lineage_id?: string }).lineage_id, "consent-lineage");
796
+ assert.deepEqual(fixture.answers, ["granted"]);
797
+ assert.deepEqual(notices, []);
798
+ // The extension handler owns UI reporting; the controller's successful native
799
+ // result remains authoritative even when the local best-effort latch fails.
1051
800
  });