gentle-pi 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -1,848 +1,800 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, 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,
12
+ NATIVE_REVIEW_MODE_OPERATION,
13
+ NATIVE_REVIEW_OPERATION,
11
14
  NativeReviewCliError,
12
- NativeReviewConsentBindingError,
15
+ NativeReviewCliV216,
16
+ NativeReviewIntegrationError,
13
17
  NativeReviewConsentRequiredError,
14
- NativeReviewCliV213 as NativeReviewCliV213Production,
18
+ nativeRiskEvidencePhrases,
15
19
  normalizeNativeReviewCwd,
16
- setNativeCliContractForTesting,
17
- type ExecFileAdapter,
18
20
  type NativeReviewCli,
19
- type NativeReviewConsentAnswer,
20
- type NativeReviewConsentAnswerRequest,
21
- type NativeStartRequest,
21
+ type ExecFileAdapter,
22
22
  } from "../lib/native-review-cli.ts";
23
- import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
24
- import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
25
- import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
26
-
27
- // Queued-adapter clients never execute a real process; default to a fixed
28
- // absolute package-local path so these tests do not depend on an installed
29
- // binary.
30
- class NativeReviewCliV213 extends NativeReviewCliV213Production {
31
- constructor(...parameters: ConstructorParameters<typeof NativeReviewCliV213Production>) {
32
- const [adapter, executable, ...rest] = parameters;
33
- super(adapter, executable ?? "/package/.gentle-ai/gentle-ai", ...rest);
34
- }
35
- }
23
+ import {
24
+ decodeReviewConsentV3,
25
+ decodeReviewFailureV2,
26
+ decodeReviewLastEventClosureV1,
27
+ decodeReviewStartV3,
28
+ type ReviewStatusV3,
29
+ } from "../lib/review-integration-v2.ts";
36
30
 
37
- interface QueuedResult {
38
- stdout: string;
39
- stderr?: string;
40
- exitCode?: number;
41
- timedOut?: boolean;
42
- signal?: NodeJS.Signals | null;
43
- outputLimitExceeded?: boolean;
31
+ const CAPTURED_FIXTURES = join(process.cwd(), "tests", "fixtures", "devbinary");
32
+
33
+ function captured(name: string): unknown {
34
+ return JSON.parse(readFileSync(join(CAPTURED_FIXTURES, name), "utf8"));
44
35
  }
45
36
 
46
- function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string }> } {
47
- 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[]> = [];
48
50
  return {
49
51
  calls,
50
52
  adapter: async (request) => {
51
- calls.push(request);
52
- const result = results.shift();
53
- if (!result) throw new Error("unexpected native invocation");
54
- return {
55
- stdout: result.stdout,
56
- stderr: result.stderr ?? "",
57
- exitCode: result.exitCode ?? 0,
58
- signal: result.signal ?? null,
59
- timedOut: result.timedOut ?? false,
60
- outputLimitExceeded: result.outputLimitExceeded ?? false,
61
- };
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 };
62
57
  },
63
58
  };
64
59
  }
65
60
 
66
- // A synthetic testing-only version whose capability row (in the testing
67
- // overlay, never in the shipped NATIVE_CLI_CONTRACTS table) reports every
68
- // organic-parity column true, so decode paths that are dark for every real
69
- // shipped version including the pinned 2.1.11 — can still be exercised.
70
- const CAPABLE_VERSION = "9.9.9";
71
- const CAPABLE_VERSION_LINE = { stdout: `gentle-ai ${CAPABLE_VERSION}\n` };
72
- const DARK_VERSION = { stdout: "gentle-ai 2.1.11\n" };
73
-
74
- test.beforeEach(() => {
75
- setNativeCliContractForTesting(CAPABLE_VERSION, {
76
- start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true,
77
- reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true,
78
- mode: true, riskEvidence: true, hint: true, delivery: true,
79
- });
80
- });
81
- test.afterEach(() => {
82
- setNativeCliContractForTesting(CAPABLE_VERSION, undefined);
83
- });
84
-
85
- 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" }) };
86
-
87
- function reviewModeStatusBody(effective: "on" | "off", overrides: Record<string, unknown> = {}): Record<string, unknown> {
88
- 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({
89
70
  schema: "gentle-ai.review-mode/v1",
90
- operation: "status",
91
- scope: "both",
92
- status: {
93
- schema: "gentle-ai.rdd-mode-status/v1",
94
- global: "",
95
- clone_local: "",
96
- effective,
97
- source: "default",
98
- ...overrides,
99
- },
100
- };
71
+ operation,
72
+ scope: "clone",
73
+ status: { schema: "gentle-ai.rdd-mode-status/v1", global, clone_local: cloneLocal, effective, source, ...(reach === undefined ? {} : { reach }) },
74
+ });
101
75
  }
102
76
 
103
- test("reviewMode requires the mode capability and fails closed for a version without it", async () => {
104
- const queue = queuedAdapter([DARK_VERSION]);
105
- await assert.rejects(
106
- () => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" }),
107
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
108
- );
109
- });
110
-
111
- test("reviewMode status uses the exact fixed argv and decodes the effective mode", async () => {
112
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
113
- const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
114
- assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", "/repo", "--json"]);
115
- assert.equal(result.status.effective, "on");
116
- assert.equal(result.scope, "both");
117
- });
118
-
119
- test("reviewMode canonicalizes an existing repository cwd before the version probe and status argv", async (t) => {
120
- if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
121
- const repository = mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-cwd-"));
122
- const alias = `${repository}-alias`;
123
- symlinkSync(repository, alias, "dir");
124
- t.after(() => { rmSync(alias, { force: true }); rmSync(repository, { recursive: true, force: true }); });
125
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { global: "off", source: "global" })) }]);
126
- 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 });
127
80
  assert.equal(result.status.effective, "off");
128
- assert.equal(queue.calls.every((call) => call.cwd === repository), true);
129
- 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"]);
130
83
  });
131
84
 
132
- test("native review cwd normalization unifies Git Bash and drive-form Windows paths", () => {
133
- const expected = "C:\\Users\\Alan\\worktree B";
134
- assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "win32"), expected);
135
- assert.equal(normalizeNativeReviewCwd("c:/Users/Alan/worktree B", "win32"), expected);
136
- 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"]]);
137
91
  });
138
92
 
139
- test("reviewMode status decodes an off effective mode with its deciding source", async () => {
140
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
141
- const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
142
- assert.equal(result.status.effective, "off");
143
- assert.equal(result.status.source, "clone_local");
144
- assert.equal(result.status.cloneLocal, "off");
145
- assert.equal(result.status.revision, "sha256:deadbeef");
93
+ test("review mode rejects a response with a foreign operation discriminator", async () => {
94
+ const queue = queuedAdapter([mode("enable", "on", "global")]);
95
+ await assert.rejects(
96
+ () => new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
97
+ /schema incompatible/,
98
+ );
146
99
  });
147
100
 
148
- test("reviewMode enable and disable pass --scope clone and mutate without a timeout", async () => {
149
- const queue = queuedAdapter([
150
- 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" } }) },
151
- 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" } }) },
152
- ]);
153
- const client = new NativeReviewCliV213(queue.adapter);
154
- const disabled = await client.reviewMode({ cwd: "/repo", operation: "disable" });
155
- const enabled = await client.reviewMode({ cwd: "/repo", operation: "enable" });
156
- assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "disable", "--cwd", "/repo", "--scope", "clone", "--json"]);
157
- assert.deepEqual(queue.calls[3]?.arguments, ["review", "mode", "enable", "--cwd", "/repo", "--scope", "clone", "--json"]);
158
- assert.equal(disabled.status.effective, "off");
159
- assert.equal(enabled.status.effective, "on");
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");
160
105
  });
161
106
 
162
- test("reviewMode rejects a response whose operation discriminator does not match the request", async () => {
163
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("on")) }]);
164
- await assert.rejects(
165
- () => new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "disable" }),
166
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
167
- );
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"]);
168
114
  });
169
115
 
170
- test("native START decodes optional riskEvidence (a phrase array, matching gentle-ai's []string wire shape) and hint only when present", async () => {
171
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
172
- 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." }) }]);
173
- const result = await new NativeReviewCliV213(withEvidence.adapter).start({ cwd: "/repo" });
174
- assert.deepEqual(result.riskEvidence, ["service credentials in .env.example"]);
175
- assert.equal(result.hint, "no reviewable candidate: every changed path is documentation.");
176
-
177
- const withoutEvidence = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: START.stdout }]);
178
- const bare = await new NativeReviewCliV213(withoutEvidence.adapter).start({ cwd: "/repo" });
179
- assert.equal(bare.riskEvidence, undefined);
180
- assert.equal(bare.hint, undefined);
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");
181
120
  });
182
121
 
183
- test("native START rejects a scalar risk_evidence: the wire shape is always a phrase array, even with one phrase", async () => {
184
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
185
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify({ ...start, risk_evidence: "not an array" }) }]);
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);
127
+ }
128
+ const malformed = queuedAdapter([mode("status", "off", "clone_local", "future")]);
186
129
  await assert.rejects(
187
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
130
+ () => new NativeReviewCliV216(malformed.adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: process.cwd(), operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }),
188
131
  (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
189
132
  );
190
- });
191
-
192
- test("native VALIDATE decodes the disabled/unmanaged delivery alternate discriminator at exit 0", async () => {
193
- const body = {
194
- schema: "gentle-ai.review-gate-result/v1",
195
- result: "invalidated",
196
- allowed: false,
197
- action: "repository-policy",
198
- reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
199
- // gentle-ai's RDDDeliveryDisabledUnmanaged is a single literal
200
- // "disabled/unmanaged" never split into two separate enum values.
201
- delivery: "disabled/unmanaged",
202
- 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 },
203
- };
204
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
205
- const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" });
206
- assert.equal(result.result, "invalidated");
207
- assert.equal(result.allowed, false);
208
- assert.equal(result.action, "repository-policy");
209
- assert.equal(result.delivery, "disabled/unmanaged");
210
- });
211
-
212
- test("native VALIDATE rejects a split disabled-only or unmanaged-only delivery value: the wire literal is always the combined string", async () => {
213
- for (const delivery of ["disabled", "unmanaged"]) {
214
- const body = {
215
- schema: "gentle-ai.review-gate-result/v1", result: "invalidated", allowed: false, action: "repository-policy",
216
- reason: "receipt-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy",
217
- delivery,
218
- 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 },
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 };
219
146
  };
220
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
221
- await assert.rejects(
222
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
223
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
224
- `delivery ${JSON.stringify(delivery)} must still fail closed`,
225
- );
147
+ await new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai").reviewMode({ cwd: alias, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS });
148
+ assert.deepEqual(observed, [root]);
226
149
  }
227
150
  });
228
151
 
229
- test("native VALIDATE without delivery keeps the strict exit-code/action pairing unchanged", async () => {
230
- const body = {
231
- schema: "gentle-ai.review-gate-result/v1",
232
- result: "invalidated",
233
- allowed: false,
234
- action: "repository-policy",
235
- reason: "should require exit 1 because delivery is absent",
236
- 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 },
237
- };
238
- // Without `delivery`, an "invalidated" result with exit 0 is not the
239
- // alternate discriminator shape and must still fail closed.
240
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(body), exitCode: 0 }]);
241
- await assert.rejects(
242
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "pre-commit" }),
243
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
244
- );
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/);
245
155
  });
246
156
 
247
- test("START tolerates the exact review-consent-skipped stderr line only when the mode capability is true", async () => {
248
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
249
- 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.";
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
+ }
250
166
 
251
- const tolerated = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr: notice }]);
252
- const result = await new NativeReviewCliV213(tolerated.adapter).start({ cwd: "/repo" });
253
- assert.equal(result.lineageId, "lineage-1");
167
+ interface RegisteredCommand {
168
+ handler: (args: string, context: ExtensionContext) => Promise<void>;
169
+ }
254
170
 
255
- // Same exact stderr text, but the negotiated version's `mode` capability is
256
- // false (the pinned 2.1.11 row), so the notice must still fail closed.
257
- const notTolerated = queuedAdapter([DARK_VERSION, { stdout: JSON.stringify(start), stderr: notice }]);
258
- await assert.rejects(
259
- () => new NativeReviewCliV213(notTolerated.adapter).start({ cwd: "/repo" }),
260
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
261
- );
262
- });
171
+ interface RegisteredEvent {
172
+ (event: unknown, context: ExtensionContext): Promise<unknown> | unknown;
173
+ }
263
174
 
264
- test("START rejects near-miss, prefixed, or extra-line stderr even when the mode capability is true", async () => {
265
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
266
- 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.";
267
- for (const stderr of [
268
- `prefix: ${notice}`,
269
- `${notice}\nextra line`,
270
- notice.slice(0, -1),
271
- ]) {
272
- const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(start), stderr }]);
273
- await assert.rejects(
274
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
275
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
276
- `stderr ${JSON.stringify(stderr)} must still fail closed`,
277
- );
278
- }
279
- });
175
+ interface ParityRuntime {
176
+ controller: RegisteredControllerTool;
177
+ commands: Map<string, RegisteredCommand>;
178
+ events: Map<string, RegisteredEvent>;
179
+ }
280
180
 
281
- // ---------------------------------------------------------------------------
282
- // Phase 3 (kill switch) + Phase 4 (consent) integration through the
283
- // `gentle_review` controller tool and the `gentle:review-mode` command,
284
- // against a fake NativeReviewCli (no binary needed).
285
- // ---------------------------------------------------------------------------
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
+ }
286
188
 
287
- function git(cwd: string, ...args: string[]): string {
288
- return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
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 };
289
208
  }
290
209
 
291
210
  function repository(t: test.TestContext): string {
292
- const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-organic-parity-"));
293
- // A materialized (retained) candidate view chmods its worktree read-only
294
- // (0o444/0o555) to prevent tampering while lenses run; restore write
295
- // permission recursively before cleanup so a retained-but-never-cleaned-up
296
- // candidate view does not leak an unremovable temp directory.
211
+ const cwd = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-native-parity-")));
297
212
  t.after(() => {
298
- try { execFileSync("chmod", ["-R", "u+w", cwd]); } catch { /* best effort */ }
213
+ try { execFileSync("chmod", ["-R", "u+w", cwd], { stdio: "ignore" }); } catch { /* best effort */ }
299
214
  rmSync(cwd, { recursive: true, force: true });
300
215
  });
301
- git(cwd, "init", "-b", "main");
302
- git(cwd, "config", "user.email", "tests@example.com");
303
- git(cwd, "config", "user.name", "Tests");
216
+ execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
304
217
  writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
305
- git(cwd, "add", ".");
306
- 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" });
307
220
  writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
308
221
  return cwd;
309
222
  }
310
223
 
311
- const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
312
- schema: "gentle-ai.review-authority-repair-assessment/v1",
313
- status: "unsupported",
314
- counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 },
315
- supportedOperations: ["review/complete-fix", "review/validate-fix"],
316
- authorizationSchema: "gentle-ai.review-repair-authorization/v1",
317
- };
318
-
319
- function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
320
- const sha = `sha256:${"a".repeat(64)}`;
321
- const candidate = new CandidateViewRegistry().create({ contributorRoot: cwd });
322
- const tree = candidate.candidateTree;
323
- const baseTree = candidate.baseTree;
324
- const paths = candidate.paths;
325
- candidate.cleanup();
326
- const projection = {
327
- schema: "gentle-ai.review-integration.projection/v1" as const,
328
- kind: "current-changes" as const,
329
- projection: "workspace" as const,
330
- baseTree,
331
- initialReviewTree: tree,
332
- currentCandidateTree: tree,
333
- pathsDigest: sha,
334
- paths,
335
- intendedUntracked: [],
336
- intendedUntrackedProof: sha,
337
- initialSnapshotIdentity: sha,
338
- currentSnapshotIdentity: sha,
339
- };
340
- const rawRepair = {
341
- schema: UNSUPPORTED_REPAIR_ASSESSMENT.schema,
342
- status: UNSUPPORTED_REPAIR_ASSESSMENT.status,
343
- counts: {
344
- lineages: 0, compact_lineages: 0, legacy_lineages: 0, events: 0, bytes: 0,
345
- eligible_candidates: 0, unsupported_lineages: 0, conflicts: 0,
346
- },
347
- supported_operations: UNSUPPORTED_REPAIR_ASSESSMENT.supportedOperations,
348
- authorization_schema: UNSUPPORTED_REPAIR_ASSESSMENT.authorizationSchema,
349
- };
350
- return {
351
- contract: "gentle-ai.review-integration/v2",
352
- applicability: "unrelated",
353
- receipt: { status: "not_applicable" },
354
- action: "start",
355
- replayability: "not_replayable",
356
- targetIdentity: sha,
357
- projection,
358
- repair: UNSUPPORTED_REPAIR_ASSESSMENT,
359
- candidates: [],
360
- raw: {
361
- schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
362
- applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
363
- repair: rawRepair,
364
- 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 },
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
+ },
365
248
  candidates: [],
366
- },
367
- };
368
- }
369
-
370
- interface FakeOrganicNativeOptions {
371
- reviewModeCapable?: boolean;
372
- reviewModeEffective?: "on" | "off";
373
- reviewModeSource?: "default" | "global" | "clone_local";
374
- reviewModeThrows?: boolean;
375
- lensesRequired?: boolean;
376
- riskEvidence?: readonly string[];
377
- hint?: string;
378
- }
379
-
380
- function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: NativeReviewCli; state: { startCalls: number } } {
381
- const state = { startCalls: 0 };
382
- const reviewModeCapable = options.reviewModeCapable ?? true;
383
- const native = {
384
- async start() {
385
- state.startCalls += 1;
386
- return {
387
- lineageId: "native-lineage", state: "reviewing", riskLevel: "high",
388
- selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"],
389
- changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created",
390
- lensesRequired: options.lensesRequired ?? true,
391
- ...(options.riskEvidence === undefined ? {} : { riskEvidence: options.riskEvidence }),
392
- ...(options.hint === undefined ? {} : { hint: options.hint }),
393
- };
394
- },
395
- async finalize(): Promise<never> { throw new Error("finalize not used in this test"); },
396
- async validate(): Promise<never> { throw new Error("validate not used in this test"); },
397
- async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
398
- async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
399
- async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
400
- async targetStatus(request: { cwd: string }) {
401
- return unrelatedStartTargetStatus(request.cwd);
402
- },
403
- ...(reviewModeCapable
404
- ? {
405
- async reviewMode(request: { operation: string }) {
406
- if (options.reviewModeThrows === true) throw new Error("native review mode process failed");
407
- const effective = options.reviewModeEffective ?? "on";
408
- const source = options.reviewModeSource ?? "default";
409
- return {
410
- operation: request.operation,
411
- scope: "both",
412
- status: {
413
- global: source === "global" ? effective : "",
414
- cloneLocal: source === "clone_local" && effective === "off" ? "off" : "",
415
- effective,
416
- source,
417
- },
418
- };
419
- },
420
- }
421
- : {}),
422
- } as unknown as NativeReviewCli;
423
- return { native, state };
424
- }
425
-
426
- interface RegisteredTool {
427
- execute: (
428
- toolCallId: string,
429
- params: unknown,
430
- signal: AbortSignal | undefined,
431
- onUpdate: undefined,
432
- ctx: ExtensionContext,
433
- ) => Promise<{ details?: unknown }>;
249
+ raw: { schema: "gentle-ai.review-integration.status/v5" },
250
+ } as unknown as ReviewStatusV3;
251
+ } finally {
252
+ candidates.cleanup(candidate.token);
253
+ }
434
254
  }
435
255
 
436
- interface RegisteredCommandFixture {
437
- handler: (args: string, ctx: ExtensionContext) => Promise<void>;
256
+ function context(cwd: string, sessionId = "parity-session", notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
257
+ return {
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;
438
263
  }
439
264
 
440
- interface RegisteredEventFixture {
441
- (event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
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>;
442
268
  }
443
269
 
444
- function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
445
- const tools = new Map<string, RegisteredTool>();
446
- const commands = new Map<string, RegisteredCommandFixture>();
447
- const events = new Map<string, RegisteredEventFixture>();
448
- const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
449
- createGentleAiExtension(dependencies)({
450
- on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
451
- registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
452
- registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
453
- } as unknown as ExtensionAPI);
454
- const controller = tools.get("gentle_review");
455
- assert.ok(controller);
456
- return { controller: controller!, commands, events };
457
- }
458
-
459
- function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
460
- return { cwd, hasUI: false, ui: { notify: (message: string, type?: string) => { notices.push({ message, type }); } } } as unknown as ExtensionContext;
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") {
286
+ return {
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" },
295
+ };
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
+ };
461
317
  }
462
318
 
463
- function confirmContext(cwd: string, answer: boolean): ExtensionContext {
464
- 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;
465
324
  }
466
325
 
467
- function throwingConfirmContext(cwd: string): ExtensionContext {
468
- 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);
469
328
  }
470
329
 
471
- const START_ORDINARY = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) };
472
-
473
- async function execStart(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
474
- const { details } = await controller.execute(id, START_ORDINARY, undefined, undefined, ctx);
475
- 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");
476
346
  }
477
347
 
478
- test("kill-switch: effective off returns a non-failure skipped envelope and never calls native start", async (t) => {
479
- const cwd = repository(t);
480
- const { native, state } = fakeOrganicNative({ reviewModeEffective: "off" });
481
- const { controller } = runtime(native);
482
- const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
483
- assert.equal(result.status, "skipped");
484
- assert.equal(result.outcome, "review-mode-disabled");
485
- assert.equal(result.delivery, "disabled/unmanaged");
486
- assert.equal(result.mutation_performed, false);
487
- assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
488
- });
489
-
490
- // Parity with gentle-ai's RDDDisabledError.Error()
491
- // (internal/reviewtransaction/rdd_mode.go): a refusal names the situation, the
492
- // source that actually decided, and a continuation scoped to that source. Pi
493
- // never blocks here the envelope is a non-failure skip — but it must not
494
- // throw away which source decided, nor leave the caller without a way back on.
495
- test("kill-switch: a clone-local off names the deciding source and the Pi command that turns reviews back on", async (t) => {
496
- const cwd = repository(t);
497
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "clone_local" });
498
- const { controller } = runtime(native);
499
- const result = await execStart(controller, "kill-switch-clone-local", headlessContext(cwd));
500
- assert.equal(result.status, "skipped");
501
- assert.equal(result.outcome, "review-mode-disabled");
502
- assert.equal(result.mode_source, "clone_local");
503
- assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the clone_local mode source keeps it off");
504
- assert.equal(result.next_action, "Run /gentle:review-mode enable to turn reviews back on for this clone.");
505
- });
506
-
507
- // gentle-ai maps RDDModeSourceGlobal onto `--scope=global`, and a clone-local
508
- // override may only ever disable (cloneLocalRDDOverrideValue rejects RDDModeOn).
509
- // Pi's own /gentle:review-mode always passes --scope clone, so it CANNOT clear a
510
- // global off. Naming it here would be naming a dead end, so the continuation has
511
- // to be the native command that actually resolves it.
512
- test("kill-switch: a global off names the native global-scope command, never Pi's clone-scope one", async (t) => {
513
- const cwd = repository(t);
514
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
515
- const { controller } = runtime(native);
516
- const result = await execStart(controller, "kill-switch-global", headlessContext(cwd));
517
- assert.equal(result.mode_source, "global");
518
- assert.equal(result.reason, "receipt-driven development is disabled: start is skipped because the global mode source keeps it off");
519
- assert.equal(
520
- result.next_action,
521
- "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,
522
366
  );
523
- 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");
524
- });
525
-
526
- // Parity with reviewModeScopeForSource: the default source expresses no opinion
527
- // and can never be what keeps reviews off, so it gets no guessed continuation.
528
- test("kill-switch: an off with the default source names no continuation rather than guessing one", async (t) => {
529
- const cwd = repository(t);
530
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "default" });
531
- const { controller } = runtime(native);
532
- const result = await execStart(controller, "kill-switch-default", headlessContext(cwd));
533
- assert.equal(result.mode_source, "default");
534
- assert.equal(result.next_action, undefined);
367
+ assert.equal(failed.outcome, "native-operation-failed");
535
368
  });
536
369
 
537
- test("kill-switch: capability-absent (no reviewMode) leaves today's path unchanged", 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) => {
538
405
  const cwd = repository(t);
539
- const { native } = fakeOrganicNative({ reviewModeCapable: false, lensesRequired: false });
540
- const { controller } = runtime(native);
541
- const result = await execStart(controller, "kill-switch-absent", headlessContext(cwd));
542
- assert.notEqual(result.status, "skipped");
543
- assert.ok(result.result);
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");
438
+
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);
544
454
  });
545
455
 
546
- 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) => {
547
457
  const cwd = repository(t);
548
- const { native } = fakeOrganicNative({ reviewModeThrows: true });
549
- const { controller } = runtime(native);
550
- const result = await execStart(controller, "kill-switch-error", headlessContext(cwd));
551
- assert.equal(result.status, "blocked");
552
- assert.equal(result.outcome, "native-operation-failed");
553
- });
554
-
555
- function candidateConsent(cwd: string): ReviewConsentV2 {
556
- const targetIdentity = `sha256:${"a".repeat(64)}`;
557
- const choices = [
558
- { 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` },
559
- { 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` },
560
- ] as const;
561
- 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" } };
562
- 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 };
563
- }
564
-
565
- function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
566
- const { native } = fakeOrganicNative();
567
- const consent = candidateConsent(cwd);
568
- const answers: NativeReviewConsentAnswer[] = [];
569
- const startRequests: NativeStartRequest[] = [];
570
- const answerRequests: NativeReviewConsentAnswerRequest[] = [];
571
- native.start = async (request) => {
572
- startRequests.push(request);
573
- 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"] },
574
463
  };
575
- native.answerConsent = async (request) => {
576
- answers.push(request.answer);
577
- answerRequests.push(request);
578
- 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" } };
579
- 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;
580
472
  };
581
- return { native, answers, startRequests, answerRequests };
582
- }
583
-
584
- async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
585
- const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
586
- return details as Record<string, unknown>;
587
- }
588
-
589
- async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
590
- const result = await execStart(controller, id, ctx);
591
- assert.equal(result.status, "blocked");
592
- assert.equal(result.outcome, "native-review-consent-required");
593
- assert.equal(typeof result.consent_binding, "string");
594
- return result;
595
- }
596
-
597
- 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) => {
598
489
  const cwd = repository(t);
599
- recordReviewConsentLatch(cwd);
600
- for (const ctx of [
601
- { cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
602
- headlessContext(cwd),
603
- ]) {
604
- const { native, answers } = relayedConsentNative(cwd);
605
- const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
606
- assert.deepEqual(result.consent, candidateConsent(cwd).raw);
607
- assert.deepEqual(answers, []);
608
- assert.equal(result.lineage_created, false);
609
- }
610
- });
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,
523
+ });
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");
611
527
 
612
- test("explicit consent follow-up grants or declines exactly once", async (t) => {
613
- const cwd = repository(t);
614
- for (const answer of ["granted", "declined"] as const) {
615
- const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
616
- const { controller } = runtime(native);
617
- const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
618
- const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
619
- assert.deepEqual(answers, [answer]);
620
- assert.equal(startRequests.length, 1);
621
- assert.equal(startRequests[0]?.cwd, cwd);
622
- assert.equal(startRequests[0]?.targetIdentity, candidateConsent(cwd).targetIdentity);
623
- assert.equal(startRequests[0]?.projection, "workspace");
624
- assert.equal(answerRequests.length, 1);
625
- assert.equal(answerRequests[0]?.cwd, cwd);
626
- assert.equal(answerRequests[0]?.consent.targetIdentity, startRequests[0]?.targetIdentity);
627
- if (answer === "granted") {
628
- const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
629
- assert.equal(actorBinding.workspace_root, cwd);
630
- assert.notEqual(actorBinding.candidate_root, cwd);
631
- } else {
632
- assert.equal(result.outcome, "consent-declined-this-candidate");
633
- assert.equal(result.lineage_created, false);
634
- assert.equal(result.actor_binding, undefined);
635
- }
636
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
637
- }
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");
638
532
  });
639
533
 
640
- // Issue #247: a local binding mismatch was indistinguishable from a provider
641
- // outage, so the reporter diagnosed a missing --cwd that Pi does forward.
642
- 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) => {
643
535
  const cwd = repository(t);
644
- const { native, answers } = relayedConsentNative(cwd);
645
- native.answerConsent = async () => {
646
- 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
+ );
647
548
  };
648
- const { controller } = runtime(native);
649
- const blocked = await blockedConsent(controller, "consent-binding", headlessContext(cwd));
650
- const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
651
- assert.equal(result.status, "blocked");
652
- assert.equal(result.outcome, "consent-binding-invalid");
653
- assert.deepEqual(result.diagnostics, { code: "consent-invocation-cwd-changed", message: "Native consent invocation repository binding changed" });
654
- assert.equal(result.native_invocation_attempted, false);
655
- assert.equal(result.lineage_created, false);
656
- assert.equal(result.mutation_performed, false);
657
- assert.equal(result.mutation_outcome, "none");
658
- assert.equal(result.next_action, "resolve-consent-binding");
659
- assert.deepEqual(answers, []);
660
- });
661
-
662
- test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
663
- const cwd = repository(t);
664
- const consent = candidateConsent(cwd);
665
- const { native, answers } = relayedConsentNative(cwd);
666
- native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
667
- const { controller } = runtime(native);
668
- const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
669
- await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
670
- 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/);
671
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
672
- await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
673
- const other = repository(t);
674
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
675
- const originalTarget = consent.targetIdentity;
676
- (consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
677
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
678
- (consent as { targetIdentity: string }).targetIdentity = originalTarget;
679
- (consent as { projection: string }).projection = "staged";
680
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
681
- (consent as { projection: string }).projection = "workspace";
682
- (consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
683
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
684
- assert.deepEqual(answers, []);
685
- });
686
549
 
687
- test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
688
- const cwd = repository(t);
689
- const { native } = relayedConsentNative(cwd);
690
- let statusCalls = 0;
691
- const targetStatus = native.targetStatus!;
692
- native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
693
- native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
694
- const { controller } = runtime(native);
695
- const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
696
- await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
697
- assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
698
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
699
- });
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");
700
562
 
701
- test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
702
- const cwd = repository(t);
703
- const { native, answers } = relayedConsentNative(cwd);
704
- const { controller, events } = runtime(native);
705
- const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
706
- const shutdown = events.get("session_shutdown");
707
- assert.ok(shutdown);
708
- await shutdown({}, headlessContext(cwd));
709
- await shutdown({}, headlessContext(cwd));
710
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
711
- assert.deepEqual(answers, []);
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");
712
570
  });
713
571
 
714
- test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
572
+ test("stale local consent bindings retain typed Pi transport refusal without an agentless retry", async (t) => {
715
573
  const cwd = repository(t);
716
- const { native, answers } = relayedConsentNative(cwd);
717
- let effective: "on" | "off" = "on";
718
- native.reviewMode = async (request) => {
719
- if (request.operation === "disable") effective = "off";
720
- if (request.operation === "enable") effective = "on";
721
- return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
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
+ }));
722
592
  };
723
- const { controller, commands } = runtime(native);
724
- const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
725
- const command = commands.get("gentle:review-mode")!;
726
- await command.handler("disable", headlessContext(cwd));
727
- await command.handler("enable", headlessContext(cwd));
728
- await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
729
- assert.deepEqual(answers, []);
730
- });
731
-
732
- test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
733
- const cwd = repository(t);
734
- const { native, answers } = relayedConsentNative(cwd);
735
- const { controller } = runtime(native);
736
- for (const id of ["decline-one", "decline-two"]) {
737
- const blocked = await blockedConsent(controller, id, headlessContext(cwd));
738
- const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
739
- assert.equal(result.outcome, "consent-declined-this-candidate");
740
- assert.equal(result.lineage_created, false);
741
- assert.equal(result.actor_binding, undefined);
742
- }
743
- assert.deepEqual(answers, ["declined", "declined"]);
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");
744
608
  });
745
609
 
746
- test("low-risk zero-lens START remains silent", async (t) => {
610
+ test("unavailable and ambiguous consent follow-ups never replay a consumed binding", async (t) => {
747
611
  const cwd = repository(t);
748
- const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
749
- const { controller } = runtime(native);
750
- let selected = false;
751
- const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
752
- const result = await execStart(controller, "consent-low-risk", ctx);
753
- assert.equal(selected, false);
754
- assert.ok(result.result);
755
- });
756
-
757
- // ---------------------------------------------------------------------------
758
- // Phase 5 (tier/hint/delivery passthrough): mapNativeStartResult renders
759
- // risk_tier/risk_evidence/hint verbatim from the native start result, with
760
- // zero local derivation (Design Decision #8, organic-rdd-parity).
761
- // ---------------------------------------------------------------------------
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
+ );
762
620
 
763
- test("mapNativeStartResult passes risk_evidence through verbatim as the native phrase array, alongside the unmodified risk_tier passthrough", 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) => {
764
644
  const cwd = repository(t);
765
- const { native } = fakeOrganicNative({ riskEvidence: ["shell scripting in .github/workflows/deploy.yml"] });
766
- const { controller } = runtime(native);
767
- const result = await execStart(controller, "risk-evidence-passthrough", confirmContext(cwd, true));
768
- const rendered = result.result as Record<string, unknown>;
769
- assert.equal(rendered.risk_tier, "high", "risk_tier is native's riskLevel, verbatim, with no local recomputation");
770
- assert.deepEqual(rendered.risk_evidence, ["shell scripting in .github/workflows/deploy.yml"]);
771
- });
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);
652
+ };
653
+ const scheduled: Array<() => void> = [];
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
+ };
685
+ };
772
686
 
773
- test("mapNativeStartResult surfaces the empty-candidate hint verbatim and omits risk_evidence when the native result carries none", 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) => {
774
708
  const cwd = repository(t);
775
- const { native } = fakeOrganicNative({
776
- lensesRequired: false,
777
- riskEvidence: undefined,
778
- 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",
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() {} }; },
779
718
  });
780
- const { controller } = runtime(native);
781
- const result = await execStart(controller, "hint-passthrough", headlessContext(cwd));
782
- const rendered = result.result as Record<string, unknown>;
783
- 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");
784
- assert.equal(rendered.risk_evidence, undefined, "no local risk_evidence is ever fabricated when the native result omits it");
785
- });
786
-
787
- test("mapNativeStartResult never fabricates risk_evidence or hint when the native result omits both", async (t) => {
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);
732
+ assert.notEqual(second.consent_binding, first.consent_binding);
733
+ assert.equal(scheduled.length, 2);
734
+ assertStaleConsentBinding(await answerConsent(runtime, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-expired");
735
+
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");
742
+
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);
748
+ });
749
+
750
+ test("native START maps only provider facts and omits absent evidence", async (t) => {
788
751
  const cwd = repository(t);
789
- const { native } = fakeOrganicNative({ riskEvidence: undefined });
790
- const { controller } = runtime(native);
791
- 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);
792
780
  const rendered = result.result as Record<string, unknown>;
781
+ assert.equal(rendered.hint, "provider empty-candidate hint");
793
782
  assert.equal(rendered.risk_evidence, undefined);
794
- assert.equal(rendered.hint, undefined);
795
- });
796
-
797
- test("gentle:review-mode command reports status, disables, and enables through explicit user invocation", async (t) => {
798
- const cwd = repository(t);
799
- const { native } = fakeOrganicNative({ reviewModeEffective: "off" });
800
- const { commands } = runtime(native);
801
- const command = commands.get("gentle:review-mode");
802
- assert.ok(command, "gentle:review-mode must be registered");
803
- const notices: Array<{ message: string; type?: string }> = [];
804
- await command!.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
805
- assert.ok(notices.at(-1)?.message.includes("off"));
806
- });
807
-
808
- // Ground-truthed against a real gentle-ai build: with the global source off,
809
- // `review mode enable --scope clone` exits 0, reports operation "enable", and
810
- // leaves effective "off" — because cloneLocalRDDOverrideValue maps "on" onto
811
- // "inherit" and a clone-local override may only ever disable. Pi hard-codes
812
- // --scope clone by design, so this request can never succeed; reporting the
813
- // bare status would let the user believe /gentle:review-mode enable is the way
814
- // back on when it is a dead end.
815
- test("gentle:review-mode: an enable that cannot take effect says so and names the command that can", async (t) => {
816
- const cwd = repository(t);
817
- const { native } = fakeOrganicNative({ reviewModeEffective: "off", reviewModeSource: "global" });
818
- const { commands } = runtime(native);
819
- const command = commands.get("gentle:review-mode")!;
820
- const notices: Array<{ message: string; type?: string }> = [];
821
- await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
822
- const notice = notices.at(-1)!;
823
- assert.equal(notice.type, "warning", "a request that did not take effect is not an informational result");
824
- assert.equal(
825
- notice.message,
826
- "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.",
827
- );
828
- });
829
-
830
- test("gentle:review-mode: an enable that does take effect keeps the existing informational report", async (t) => {
831
- const cwd = repository(t);
832
- const { native } = fakeOrganicNative({ reviewModeEffective: "on", reviewModeSource: "default" });
833
- const { commands } = runtime(native);
834
- const command = commands.get("gentle:review-mode")!;
835
- const notices: Array<{ message: string; type?: string }> = [];
836
- await command.handler("enable", headlessContext(cwd, notices) as unknown as ExtensionContext);
837
- assert.deepEqual(notices, [{ message: "receipt-driven development: on (decided by default)", type: "info" }]);
783
+ assert.equal(rendered.next_transition, nextTransition);
838
784
  });
839
785
 
840
- test("gentle:review-mode command reports unavailability without throwing when the capability is dark", async (t) => {
786
+ test("consent completion stays authoritative when local latch recording fails", async (t) => {
841
787
  const cwd = repository(t);
842
- const { native } = fakeOrganicNative({ reviewModeCapable: false });
843
- const { commands } = runtime(native);
844
- const command = commands.get("gentle:review-mode")!;
788
+ const fixture = consentNative(cwd);
845
789
  const notices: Array<{ message: string; type?: string }> = [];
846
- await command.handler("status", headlessContext(cwd, notices) as unknown as ExtensionContext);
847
- 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.
848
800
  });