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,26 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, rm, symlink } from "node:fs/promises";
2
+ import { mkdtempSync, readFileSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
+ decodeReviewConsentV3,
8
+ } from "../lib/review-integration-v2.ts";
9
+ import {
10
+ NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES,
7
11
  NATIVE_REVIEW_ERROR_CODE,
8
12
  NativeReviewCliError,
9
- NativeReviewCliV213 as NativeReviewCliV213Production,
13
+ NativeReviewCliV216,
10
14
  createNodeExecFileAdapter,
11
15
  type ExecFileAdapter,
12
- type NativeStartRequest,
13
16
  } from "../lib/native-review-cli.ts";
14
17
 
15
- // The queued-adapter unit tests never execute a real process; default to a fixed
16
- // absolute package-local path so they do not depend on an installed binary
17
- // (for example while a re-pinned release's digests are still pending).
18
- class NativeReviewCliV213 extends NativeReviewCliV213Production {
19
- constructor(...parameters: ConstructorParameters<typeof NativeReviewCliV213Production>) {
20
- const [adapter, executable, ...rest] = parameters;
21
- super(adapter, executable ?? "/package/.gentle-ai/gentle-ai", ...rest);
22
- }
23
- }
18
+ const fixture = (name: string): Record<string, unknown> => JSON.parse(
19
+ readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", name), "utf8"),
20
+ ) as Record<string, unknown>;
24
21
 
25
22
  interface QueuedResult {
26
23
  stdout: string;
@@ -31,14 +28,18 @@ interface QueuedResult {
31
28
  outputLimitExceeded?: boolean;
32
29
  }
33
30
 
34
- function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number }> } {
35
- const calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number }> = [];
31
+ function queuedAdapter(results: readonly QueuedResult[]): {
32
+ adapter: ExecFileAdapter;
33
+ calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined }>;
34
+ } {
35
+ const queue = [...results];
36
+ const calls: Array<{ file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined }> = [];
36
37
  return {
37
38
  calls,
38
39
  adapter: async (request) => {
39
- calls.push(request);
40
- const result = results.shift();
41
- if (!result) throw new Error("unexpected native invocation");
40
+ calls.push({ file: request.file, arguments: request.arguments, cwd: request.cwd, timeoutMs: request.timeoutMs });
41
+ const result = queue.shift();
42
+ if (result === undefined) throw new Error("unexpected native invocation");
42
43
  return {
43
44
  stdout: result.stdout,
44
45
  stderr: result.stderr ?? "",
@@ -51,1014 +52,693 @@ function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; cal
51
52
  };
52
53
  }
53
54
 
54
- const VERSION = { stdout: "gentle-ai 2.1.4\n" };
55
- const STATUS_VERSION = { stdout: "gentle-ai 2.1.5\n" };
56
- const VERSION_219 = { stdout: "gentle-ai 2.1.9\n" };
57
- 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" }) };
58
- const REVIEW_STATUS = {
59
- stdout: JSON.stringify({
60
- schema: "gentle-ai.review-authority-status/v1",
61
- operation: "review/status",
62
- repository: "C:\\repo with spaces",
63
- complete: true,
64
- authoritative: true,
65
- status: "clean",
66
- entries: [],
67
- locks: [],
68
- diagnostics: [],
69
- }),
70
- };
71
-
72
- test("native START supports every declared version with the START capability and requires the workspace projection", async () => {
73
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
74
- assert.equal((await new NativeReviewCliV213(queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, projection: "workspace" }) }]).adapter).start({ cwd: "/repo" })).lineageId, "lineage-1");
75
- assert.equal((await new NativeReviewCliV213(queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...start, projection: "workspace" }) }]).adapter).start({ cwd: "/repo" })).lineageId, "lineage-1");
76
- const missing = Object.fromEntries(Object.entries(start).filter(([key]) => key !== "projection"));
77
- for (const body of [missing, { ...start, projection: "repository" }]) {
78
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
79
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE);
80
- }
81
- });
55
+ function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
56
+ return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
57
+ }
82
58
 
83
- test("native client re-verifies the pinned version before every operation and uses argv without a shell", async () => {
84
- const queue = queuedAdapter([VERSION, START, VERSION, START, VERSION, START, VERSION, START]);
85
- const client = new NativeReviewCliV213(queue.adapter);
86
- await client.start({ cwd: "/repo with spaces" });
87
- await client.start({ cwd: "/repo with spaces", baseRef: "origin/main", committedOnly: true });
88
- await client.start({ cwd: "/repo with spaces", policyPath: "/repo with spaces/.gentle-ai/policies/team policy.json" });
89
- await client.start({ cwd: "/repo with spaces", policyHash: "legacy-policy" } as unknown as { cwd: string; policyPath?: string });
90
- assert.deepEqual(queue.calls.map((call) => call.arguments), [
91
- ["version"],
92
- ["review", "start", "--cwd", "/repo with spaces"],
93
- ["version"],
94
- ["review", "start", "--cwd", "/repo with spaces", "--base-ref", "origin/main", "--committed-only"],
95
- ["version"],
96
- ["review", "start", "--cwd", "/repo with spaces", "--policy", "/repo with spaces/.gentle-ai/policies/team policy.json"],
97
- ["version"],
98
- ["review", "start", "--cwd", "/repo with spaces"],
59
+ test("negotiated STATUS accepts the pinned v5 receipt before routing its transition", async () => {
60
+ const status = fixture("status-v5.captured.json");
61
+ const queue = queuedAdapter([{ stdout: JSON.stringify(status) }]);
62
+ const result = await client(queue.adapter).targetStatus({ cwd: "/repo", lineageId: "review-status-fixture", agent: "pi" });
63
+ assert.deepEqual(result.receipt, { status: "not_applicable" });
64
+ assert.equal(result.nextTransition?.kind, "collect");
65
+ assert.deepEqual(queue.calls[0]?.arguments, [
66
+ "review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
67
+ "--projection", "workspace", "--lineage", "review-status-fixture", "--agent", "pi", "--next-transition",
99
68
  ]);
100
- assert.equal(queue.calls.every((call) => call.cwd === "/repo with spaces"), true);
69
+ assert.equal(queue.calls[0]?.timeoutMs, 30_000);
101
70
  });
102
71
 
103
- test("native START normalizes null selected lenses only for low-risk no-lens responses", async () => {
104
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
105
- const valid = queuedAdapter([VERSION, {
106
- stdout: JSON.stringify({
107
- ...start,
108
- risk_level: "low",
109
- selected_lenses: null,
110
- lenses_required: false,
111
- }),
112
- }]);
113
- assert.deepEqual((await new NativeReviewCliV213(valid.adapter).start({ cwd: "/repo" })).selectedLenses, []);
72
+ test("a final reviewer capture returns its native last-event closure without a follow-up command", async () => {
73
+ const closure = fixture("last-event-capture-result-approved.captured.json");
74
+ const queue = queuedAdapter([{ stdout: JSON.stringify(closure) }]);
75
+ const result = await client(queue.adapter).captureResult({
76
+ argumentTokens: ["--repository-context=rctx1_" + "a".repeat(64), "--lineage=review-c7c923a031112dd7"],
77
+ resultDocument: "{\"subject_hash\":\"sha256:captured\"}",
78
+ });
114
79
 
115
- for (const scenario of [
116
- { risk_level: "low", lenses_required: true },
117
- { risk_level: "medium", lenses_required: false },
118
- { risk_level: "high", lenses_required: false },
119
- ]) {
120
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, ...scenario, selected_lenses: null }) }]);
121
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), NativeReviewCliError);
122
- }
80
+ assert.equal("operation" in result && result.operation, "review/capture-result");
81
+ assert.deepEqual(queue.calls[0]?.arguments.slice(0, 4), ["review", "capture-result", "--repository-context=rctx1_" + "a".repeat(64), "--lineage=review-c7c923a031112dd7"]);
82
+ assert.equal(queue.calls.length, 1);
83
+ assert.equal(queue.calls[0]?.arguments.includes("--contract"), false);
123
84
  });
124
85
 
125
- test("native START action/lenses_required matrix accepts only authoritative dispatch combinations", async () => {
126
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
127
- const valid = [
128
- { action: "created", lenses_required: true, risk_level: "medium", selected_lenses: ["review-reliability"] },
129
- { action: "created", lenses_required: false, risk_level: "low", selected_lenses: [] },
130
- { action: "resumed", lenses_required: true, risk_level: "medium", selected_lenses: ["review-reliability"], state: "reviewing" },
131
- { action: "resumed", lenses_required: false, risk_level: "medium", selected_lenses: ["review-reliability"], state: "correction_required" },
132
- { action: "reuse-receipt", lenses_required: false, risk_level: "high", selected_lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], state: "approved" },
133
- { action: "blocked-scope-action", lenses_required: false, risk_level: "low", selected_lenses: [] },
134
- ] as const;
135
- for (const scenario of valid) {
136
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, ...scenario }) }]);
137
- const result = await new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" });
138
- assert.equal(result.action, scenario.action);
139
- assert.equal(result.lensesRequired, scenario.lenses_required);
140
- assert.deepEqual(result.selectedLenses, scenario.selected_lenses);
141
- }
142
- for (const scenario of [
143
- { action: "created", lenses_required: false, risk_level: "medium", selected_lenses: [] },
144
- { action: "created", lenses_required: false, risk_level: "low", selected_lenses: ["review-reliability"] },
145
- { action: "reuse-receipt", lenses_required: true, risk_level: "medium", selected_lenses: ["review-reliability"] },
146
- { action: "blocked-scope-action", lenses_required: true, risk_level: "medium", selected_lenses: ["review-reliability"] },
147
- { action: "resumed", lenses_required: true, risk_level: "medium", selected_lenses: ["review-reliability"], state: "correction_required" },
148
- ] as const) {
149
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, ...scenario }) }]);
150
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), NativeReviewCliError);
151
- }
152
- });
153
-
154
- test("native START binds every risk tier to canonical unique lenses before controller use", async () => {
155
- const start = JSON.parse(START.stdout) as Record<string, unknown>;
156
- const high = ["review-risk", "review-resilience", "review-readability", "review-reliability"];
157
- const valid = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, risk_level: "high", selected_lenses: high, lenses_required: false, action: "resumed", state: "validating" }) }]);
158
- assert.deepEqual((await new NativeReviewCliV213(valid.adapter).start({ cwd: "/repo" })).selectedLenses, high);
159
- for (const scenario of [
160
- { risk_level: "low", selected_lenses: [], lenses_required: true }, { risk_level: "medium", selected_lenses: ["review-risk", "review-reliability"], lenses_required: true },
161
- { risk_level: "medium", selected_lenses: ["review-reliability", "review-reliability"], lenses_required: true },
162
- { risk_level: "high", selected_lenses: high.slice(0, 3), lenses_required: true }, { risk_level: "high", selected_lenses: [...high, "review-risk"], lenses_required: true },
163
- { action: "created", risk_level: "high", selected_lenses: high, lenses_required: false },
164
- { action: "resumed", state: "approved", risk_level: "medium", selected_lenses: ["review-reliability"], lenses_required: true },
165
- ] as const) {
166
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...start, ...scenario }) }]);
167
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), NativeReviewCliError);
168
- }
169
- });
86
+ test("correction-plan capture substitutes only the provider slot and closes natively", async () => {
87
+ const closure = fixture("last-event-capture-correction-plan.captured.json");
88
+ const queue = queuedAdapter([{ stdout: JSON.stringify(closure) }]);
89
+ const result = await client(queue.adapter).captureCorrectionPlan({
90
+ argumentTokens: ["--lineage=review-c7c923a031112dd7", "--correction-lines={{value}}"],
91
+ correctionLines: 7,
92
+ cwd: "/repo",
93
+ });
170
94
 
171
- test("long-lived native client rejects a replaced incompatible executable before another operation", async () => {
172
- const queue = queuedAdapter([VERSION, START, { stdout: "gentle-ai 2.1.0\n" }]);
173
- const client = new NativeReviewCliV213(queue.adapter);
174
- await client.start({ cwd: "/repo" });
95
+ assert.equal(result.operation, "review.capture-correction-plan");
96
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "capture-correction-plan", "--lineage=review-c7c923a031112dd7", "--correction-lines=7"]);
175
97
  await assert.rejects(
176
- () => client.start({ cwd: "/repo" }),
177
- (error: unknown) => error instanceof NativeReviewCliError
178
- && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE
179
- && error.operation === "version",
98
+ () => client(queuedAdapter([]).adapter).captureCorrectionPlan({ argumentTokens: ["--a={{value}}", "--b={{value}}"], correctionLines: 1, cwd: "/repo" }),
99
+ /CAPTURE_CORRECTION_PLAN requires exactly one/,
180
100
  );
181
- assert.deepEqual(queue.calls.map((call) => call.arguments), [
182
- ["version"],
183
- ["review", "start", "--cwd", "/repo"],
184
- ["version"],
185
- ]);
186
101
  });
187
102
 
188
- test("native START rejects invalid committed-range combinations before any adapter invocation", async () => {
189
- for (const baseRef of ["", " ", " origin/main", "origin/main ", "origin\0main", "origin\nmain", "origin\rmain", "origin\tmain", "origin\u007fmain", 42, [], {}]) {
190
- const queue = queuedAdapter([]);
191
- const request = { cwd: "/repo", baseRef, committedOnly: true } as unknown as NativeStartRequest;
192
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start(request), TypeError);
193
- assert.equal(queue.calls.length, 0);
194
- }
195
- for (const request of [
196
- { cwd: "/repo", baseRef: "origin/main" },
197
- { cwd: "/repo", baseRef: "origin/main", committedOnly: false },
198
- { cwd: "/repo", committedOnly: true },
199
- { cwd: "/repo", committedOnly: false },
200
- { cwd: "/repo", baseRef: "origin/main", committedOnly: "true" },
201
- ] as const) {
202
- const queue = queuedAdapter([]);
203
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start(request as NativeStartRequest), TypeError);
204
- assert.equal(queue.calls.length, 0);
205
- }
206
- });
103
+ test("provider-owned refuter and targeted-validator vectors accept only their mapped terminal closures", async () => {
104
+ const captures = [
105
+ {
106
+ captureOperation: "review.capture-refuter",
107
+ closure: fixture("last-event-capture-refuter-approved.captured.json"),
108
+ closureOperation: "review.capture-refuter",
109
+ commandOperation: "capture-refuter",
110
+ },
111
+ {
112
+ captureOperation: "review.capture-validation",
113
+ closure: fixture("last-event-capture-validation-approved.captured.json"),
114
+ closureOperation: "review/capture-validation",
115
+ commandOperation: "capture-validation",
116
+ },
117
+ ] as const;
207
118
 
208
- test("native client accepts known versions only when they support the requested capability", async () => {
209
- const accepted = queuedAdapter([VERSION_219, START]);
210
- assert.equal((await new NativeReviewCliV213(accepted.adapter).start({ cwd: "/repo" })).lineageId, "lineage-1");
211
- for (const rejectedVersion of ["2.1.1", "2.1.3"]) {
212
- const incompatible = queuedAdapter([{ stdout: `gentle-ai ${rejectedVersion}\n` }]);
213
- await assert.rejects(
214
- () => new NativeReviewCliV213(incompatible.adapter).start({ cwd: "/repo" }),
215
- (error: unknown) => error instanceof NativeReviewCliError
216
- && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
217
- );
119
+ for (const capture of captures) {
120
+ const queue = queuedAdapter([{ stdout: JSON.stringify(capture.closure) }]);
121
+ const result = await client(queue.adapter).captureProviderRole({
122
+ captureOperation: capture.captureOperation,
123
+ argumentTokens: ["--repository-context=rctx1_" + "a".repeat(64), "--agent=pi", "--execute=true"],
124
+ cwd: "/repo",
125
+ });
126
+ assert.equal("operation" in result && result.operation, capture.closureOperation);
127
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", capture.commandOperation, "--repository-context=rctx1_" + "a".repeat(64), "--agent=pi", "--execute=true"]);
218
128
  }
219
- const malformed = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...JSON.parse(await fixture("validate-allow")), allowed: false }) }]);
129
+
220
130
  await assert.rejects(
221
- () => new NativeReviewCliV213(malformed.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }),
222
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
131
+ () => client(queuedAdapter([]).adapter).captureProviderRole({ captureOperation: "review.capture-result", argumentTokens: ["--agent=pi"], cwd: "/repo" }),
132
+ /CAPTURE_PROVIDER_ROLE supports only/,
223
133
  );
224
134
  });
225
135
 
226
- test("native SDD status accepts an optional non-negative correction budget and preserves legacy absence", async () => {
227
- const source = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
228
- const remediationState = source.remediationState as Record<string, unknown>;
229
- for (const correctionBudget of [undefined, 0, 17]) {
230
- const queue = queuedAdapter([VERSION_219, {
231
- stdout: JSON.stringify({
232
- ...source,
233
- remediationState: {
234
- ...remediationState,
235
- ...(correctionBudget === undefined ? {} : { correctionBudget }),
236
- },
237
- }),
238
- }]);
239
- const status = await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" });
240
- assert.equal((status.remediationState as Record<string, unknown>).correctionBudget, correctionBudget);
241
- }
242
- for (const correctionBudget of [-1, 1.5]) {
243
- const queue = queuedAdapter([VERSION_219, { stdout: JSON.stringify({ ...source, remediationState: { ...remediationState, correctionBudget } }) }]);
244
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }), NativeReviewCliError);
245
- }
246
- });
247
-
248
- test("version process failures retain their typed failure code", async () => {
249
- for (const result of [
250
- { stdout: "", timedOut: true, code: NATIVE_REVIEW_ERROR_CODE.TIMEOUT },
251
- { stdout: "", exitCode: 2, code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO },
252
- { stdout: "", signal: "SIGTERM" as NodeJS.Signals, code: NATIVE_REVIEW_ERROR_CODE.SIGNAL },
253
- { stdout: "", outputLimitExceeded: true, code: NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT },
254
- ]) {
255
- const queue = queuedAdapter([result]);
256
- await assert.rejects(
257
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
258
- (error: unknown) => error instanceof NativeReviewCliError && error.code === result.code && error.operation === "version",
259
- );
260
- }
261
- });
262
-
263
- test("native mutation uncertainty requires target status before any replay decision", async () => {
264
- const queue = queuedAdapter([VERSION, { stdout: "", timedOut: true }]);
136
+ test("malformed closure output remains a typed schema failure and never authorizes a retry", async () => {
137
+ const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result" }) }]);
265
138
  await assert.rejects(
266
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
139
+ () => client(queue.adapter).captureResult({ argumentTokens: ["--repository-context=rctx1_" + "a".repeat(64)], resultDocument: "{}" }),
267
140
  (error: unknown) => error instanceof NativeReviewCliError
268
- && error.code === NATIVE_REVIEW_ERROR_CODE.TIMEOUT
269
- && error.mutationOutcome === "unknown"
270
- && error.nextAction === "review.status",
141
+ && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
142
+ && error.mutationOutcome === "unknown",
271
143
  );
144
+ assert.equal(queue.calls.length, 1);
272
145
  });
273
146
 
274
- test("native mutating commands omit the automatic timeout while preserving output caps", async () => {
275
- const queue = queuedAdapter([
276
- VERSION,
277
- START,
278
- VERSION,
279
- { stdout: await fixture("finalize") },
280
- VERSION,
281
- { stdout: await fixture("bind-sdd") },
282
- ]);
283
- const client = new NativeReviewCliV213(queue.adapter, "/package/.gentle-ai/v2.1.4/gentle-ai", 321, 654);
284
- await client.start({ cwd: "/repo" });
285
- await client.finalize({ cwd: "/repo", lineageId: "lineage-1" });
286
- await client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" });
287
- assert.deepEqual(queue.calls.map((call) => call.timeoutMs), [321, undefined, 321, undefined, 321, undefined]);
288
- assert.deepEqual(queue.calls.map((call) => call.maxBufferBytes), [654, 654, 654, 654, 654, 654]);
289
- });
290
-
291
- test("native read-only commands and version checks retain the automatic timeout", async () => {
292
- const queue = queuedAdapter([VERSION, { stdout: await fixture("validate-allow") }]);
293
- await new NativeReviewCliV213(queue.adapter, "/package/.gentle-ai/v2.1.4/gentle-ai", 321).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" });
294
- assert.deepEqual(queue.calls.map((call) => call.timeoutMs), [321, 321]);
295
- });
147
+ function currentStatusFixture(): Record<string, unknown> {
148
+ return fixture("status-v5.captured.json");
149
+ }
296
150
 
297
- test("native process failures retain bounded sanitized process diagnostics and parsed denial evidence", async () => {
298
- const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
299
- const queue = queuedAdapter([VERSION, {
300
- stdout: JSON.stringify(denial),
301
- stderr: "token=super-secret\nBearer another-secret\n".repeat(2_000),
302
- exitCode: 2,
303
- }]);
304
- await assert.rejects(
305
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply" }),
306
- (error: unknown) => {
307
- if (!(error instanceof NativeReviewCliError)) return false;
308
- const diagnostics = (error as unknown as { diagnostics?: Record<string, unknown> }).diagnostics;
309
- return error.code === NATIVE_REVIEW_ERROR_CODE.NON_ZERO
310
- && diagnostics?.operation === "review/validate"
311
- && diagnostics.exit_code === 2
312
- && diagnostics.stderr !== undefined
313
- && !String(diagnostics.stderr).includes("super-secret")
314
- && String(diagnostics.stderr).length <= 4_096
315
- && (diagnostics.denial as Record<string, unknown> | undefined)?.result === "scope-changed";
151
+ function negotiatedStartStatus(targetIdentity: string, tokens: readonly string[]): Record<string, unknown> {
152
+ const status = currentStatusFixture();
153
+ status.target_identity = targetIdentity;
154
+ const projection = status.projection as Record<string, unknown>;
155
+ projection.initial_snapshot_identity = targetIdentity;
156
+ projection.current_snapshot_identity = targetIdentity;
157
+ status.next_transition = {
158
+ kind: "execute",
159
+ reason_code: "review_start_required",
160
+ execute: {
161
+ operation: "review.start",
162
+ arguments: tokens.map((token) => {
163
+ const separator = token.indexOf("=");
164
+ return { name: token.slice(2, separator), value: token.slice(separator + 1), token };
165
+ }),
166
+ preconditions: [],
167
+ binding: { target_identity: targetIdentity },
316
168
  },
317
- );
318
- });
169
+ };
170
+ return status;
171
+ }
319
172
 
320
- test("native diagnostics deterministically redact OSC/control sequences and quoted JSON secret values", async () => {
321
- const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
322
- const secretDiagnostic = [
323
- "\u001b]8;;https://example.invalid/token\u0007click\u001b]8;;\u0007",
324
- "\u001bPprivate-control\u001b\\",
325
- '{"token":"token-value","PASSWORD":"password-value","secret":"secret-value","api_key":"key-value","apiKey":"camel-key","authorization":"authorization-value","cookie":"cookie-value","private_key":"private-value"}',
326
- ].join("\n");
327
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(denial), stderr: secretDiagnostic, exitCode: 2 }]);
328
- await assert.rejects(
329
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply" }),
330
- (error: unknown) => {
331
- if (!(error instanceof NativeReviewCliError)) return false;
332
- const diagnostics = error.diagnostics as unknown as Record<string, unknown>;
333
- const stderr = String(diagnostics.stderr);
334
- return error.code === NATIVE_REVIEW_ERROR_CODE.NON_ZERO
335
- && !/[\u0000-\u001f\u007f]/.test(stderr)
336
- && !["token-value", "password-value", "secret-value", "key-value", "camel-key", "authorization-value", "cookie-value", "private-value", ].some((secret) => stderr.includes(secret))
337
- && (diagnostics.denial as Record<string, unknown> | undefined)?.action === "create-new-lineage";
173
+ const TARGET = `sha256:${"b".repeat(64)}`;
174
+ const REPOSITORY_CONTEXT = `rctx1_${"c".repeat(64)}`;
175
+
176
+ function reviewingStartV4(targetIdentity: string): Record<string, unknown> {
177
+ const start = fixture("start-v3-consent-granted.captured.json");
178
+ start.schema = "gentle-ai.review-integration.start/v4";
179
+ (start.repository_context as Record<string, unknown>).target_identity = targetIdentity;
180
+ for (const subject of start.artifact_subjects as Record<string, unknown>[]) subject.target_identity = targetIdentity;
181
+ const baseTree = start.base_tree as string;
182
+ start.next_transition = {
183
+ kind: "execute",
184
+ reason_code: "review_status_required",
185
+ execute: {
186
+ operation: "review.status",
187
+ arguments: [
188
+ { name: "contract", value: "gentle-ai.review-integration/v2", token: "--contract=gentle-ai.review-integration/v2" },
189
+ { name: "next-transition", value: "true", token: "--next-transition=true" },
190
+ { name: "lineage", value: start.lineage_id, token: `--lineage=${start.lineage_id}` },
191
+ { name: "agent", value: "pi", token: "--agent=pi" },
192
+ { name: "base-ref", value: baseTree, token: `--base-ref=${baseTree}` },
193
+ { name: "committed-only", value: "true", token: "--committed-only=true" },
194
+ ],
195
+ preconditions: [],
196
+ binding: { target_identity: targetIdentity },
338
197
  },
339
- );
340
- });
198
+ };
199
+ const execute = (start.next_transition as Record<string, unknown>).execute as Record<string, unknown>;
200
+ execute.selector_arguments = (execute.arguments as Record<string, unknown>[]).slice(-2).map((argument) => ({ ...argument }));
201
+ return start;
202
+ }
341
203
 
342
- test("structured denial fields redact token-shaped secrets and obey the 1 KiB field limit", async () => {
343
- const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
344
- const context = denial.context as Record<string, unknown>;
345
- const nested = context.denial as Record<string, unknown>;
346
- const secret = "GITHUB_TOKEN=github-secret ACCESS_TOKEN=access-secret access_token=lower-secret CUSTOM_TOKEN=custom-secret";
347
- const queue = queuedAdapter([VERSION, {
348
- stdout: JSON.stringify({ ...denial, reason: `${secret} ${"x".repeat(2_000)}`, context: { ...context, denial: { ...nested, stage: secret, code: secret } } }),
349
- stderr: "denied",
350
- exitCode: 2,
351
- }]);
352
- await assert.rejects(
353
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply" }),
354
- (error: unknown) => {
355
- if (!(error instanceof NativeReviewCliError)) return false;
356
- const denial = error.diagnostics.denial;
357
- return denial !== undefined
358
- && [denial.reason, denial.denial?.stage, denial.denial?.code].every((field) => field !== undefined && field.length <= 1_024)
359
- && !JSON.stringify(denial).includes("secret");
360
- },
361
- );
204
+ test("negotiated STATUS forwards its exact ordered workspace, base, lineage, and untracked selection argv", async () => {
205
+ const queue = queuedAdapter([{ stdout: JSON.stringify(currentStatusFixture()) }]);
206
+ const result = await client(queue.adapter).targetStatus({
207
+ cwd: "/repo with spaces",
208
+ projection: "staged",
209
+ baseRef: "origin/main",
210
+ committedOnly: true,
211
+ lineageId: "review-current",
212
+ agent: "pi",
213
+ untrackedScope: "select",
214
+ expectedUntrackedInventory: "inventory-sha256",
215
+ intendedUntracked: ["new.txt", "nested/other.txt"],
216
+ });
217
+ assert.equal(result.action, "start");
218
+ assert.deepEqual(queue.calls[0]?.arguments, [
219
+ "review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo with spaces",
220
+ "--projection", "staged", "--untracked-scope=select", "--expected-untracked-inventory=inventory-sha256",
221
+ "--intended-untracked=new.txt", "--intended-untracked=nested/other.txt", "--base-ref", "origin/main", "--committed-only",
222
+ "--lineage", "review-current", "--agent", "pi", "--next-transition",
223
+ ]);
224
+ assert.equal(queue.calls[0]?.timeoutMs, 30_000);
362
225
  });
363
226
 
364
- test("native validate redacts and bounds returned structured-denial fields", async () => {
365
- const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
366
- const context = denial.context as Record<string, unknown>;
367
- const nested = context.denial as Record<string, unknown>;
368
- const secret = "GITHUB_TOKEN=github-secret ACCESS_TOKEN=access-secret access_token=lower-secret CUSTOM_TOKEN=custom-secret";
369
- const queue = queuedAdapter([VERSION, {
370
- stdout: JSON.stringify({
371
- ...denial,
372
- reason: `${secret} ${"x".repeat(2_000)}`,
373
- context: { ...context, denial: { ...nested, stage: secret, code: secret } },
374
- }),
375
- stderr: "Error: review gate denied: scope-changed\n",
376
- exitCode: 1,
377
- }]);
378
- const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply" });
379
- const returned = result.gateContext.raw.denial as Record<string, string>;
380
- assert.equal(result.reason.length <= 1_024, true);
381
- assert.equal(returned.stage.length <= 1_024, true);
382
- assert.equal(returned.code.length <= 1_024, true);
383
- assert.equal(JSON.stringify({ reason: result.reason, returned }).includes("secret"), false);
227
+ test("negotiated STATUS emits the explicit committed selector argv", async () => {
228
+ const queue = queuedAdapter([{ stdout: JSON.stringify(currentStatusFixture()) }]);
229
+ await client(queue.adapter).targetStatus({
230
+ cwd: "/repo",
231
+ baseRef: "refs/heads/main",
232
+ committedOnly: true,
233
+ });
234
+ assert.deepEqual(queue.calls[0]?.arguments, [
235
+ "review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
236
+ "--projection", "workspace", "--base-ref", "refs/heads/main", "--committed-only", "--next-transition",
237
+ ]);
384
238
  });
385
239
 
386
- test("native diagnostics reject non-canonical or extra structured-denial fields", async () => {
387
- const published = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
388
- const context = published.context as Record<string, unknown>;
389
- for (const body of [
390
- { ...published, unexpected: true },
391
- { ...published, context: { ...context, unexpected: true } },
392
- { ...published, context: { ...context, denial: { ...(context.denial as Record<string, unknown>), unexpected: true } } },
393
- { ...published, action: " create-new-lineage" },
394
- { ...published, reason: "reason " },
240
+ test("negotiated STATUS rejects malformed committed selectors before launching a process", async () => {
241
+ for (const request of [
242
+ { cwd: "/repo", baseRef: "", committedOnly: true },
243
+ { cwd: "/repo", baseRef: 42, committedOnly: true },
244
+ { cwd: "/repo", committedOnly: true },
245
+ { cwd: "/repo", baseRef: "refs/heads/main" },
246
+ { cwd: "/repo", baseRef: "refs/heads/main", committedOnly: false },
247
+ { cwd: "/repo", baseRef: "refs/heads/main", committedOnly: "true" },
395
248
  ]) {
396
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body), stderr: "denied", exitCode: 2 }]);
397
- await assert.rejects(
398
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply" }),
399
- (error: unknown) => error instanceof NativeReviewCliError
400
- && error.code === NATIVE_REVIEW_ERROR_CODE.NON_ZERO
401
- && error.diagnostics.denial === undefined,
402
- );
249
+ const queue = queuedAdapter([]);
250
+ await assert.rejects(() => client(queue.adapter).targetStatus(request), TypeError);
251
+ assert.equal(queue.calls.length, 0);
403
252
  }
404
253
  });
405
254
 
406
- test("native validate requires a strict allow body", async () => {
407
- const queue = queuedAdapter([VERSION, { stdout: await fixture("validate-allow") }]);
408
- const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" });
409
- assert.equal(result.allowed, true);
410
- assert.equal(result.action, "continue");
411
- assert.equal(result.gateContext.lineageId, "issue136-contract-runtime");
412
- });
413
-
414
- test("native validate requires the returned gate context to equal the requested gate", async () => {
415
- const published = JSON.parse(await fixture("validate-allow")) as Record<string, unknown>;
416
- for (const gate of ["", "pre-push"]) {
417
- const queue = queuedAdapter([VERSION, {
418
- stdout: JSON.stringify({
419
- ...published,
420
- context: { ...(published.context as Record<string, unknown>), gate },
421
- }),
422
- }]);
423
- await assert.rejects(
424
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }),
425
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
426
- );
427
- }
428
- const denial = JSON.parse(await fixture("validate-deny")) as Record<string, unknown>;
429
- const mismatch = queuedAdapter([VERSION, {
430
- stdout: JSON.stringify({
431
- ...denial,
432
- context: { ...(denial.context as Record<string, unknown>), gate: "pre-push" },
433
- }),
434
- stderr: "Error: review gate denied: scope-changed\n",
435
- exitCode: 1,
436
- }]);
255
+ test("negotiated STATUS rejects malformed untracked selection before launching a process", async () => {
256
+ const queue = queuedAdapter([]);
437
257
  await assert.rejects(
438
- () => new NativeReviewCliV213(mismatch.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }),
439
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
258
+ () => client(queue.adapter).targetStatus({ cwd: "/repo", untrackedScope: "select", expectedUntrackedInventory: "inventory", intendedUntracked: ["../escape"] }),
259
+ /unique repository-relative paths/,
440
260
  );
261
+ assert.equal(queue.calls.length, 0);
441
262
  });
442
263
 
443
- test("native validate decodes published structured denials from exit code 1", async () => {
444
- const published = JSON.parse(await fixture("validate-deny-empty-context")) as Record<string, unknown>;
445
- for (const [result, action] of [
446
- ["scope-changed", "create-new-lineage"],
447
- ["invalidated", "explicit-maintainer-action"],
448
- ["escalated", "stop"],
449
- ] as const) {
450
- const queue = queuedAdapter([VERSION, {
451
- stdout: JSON.stringify({ ...published, result, action }),
452
- stderr: `Error: review gate denied: ${result}\n`,
453
- exitCode: 1,
454
- }]);
455
- const denial = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" });
456
- assert.deepEqual({ result: denial.result, allowed: denial.allowed, action: denial.action }, { result, allowed: false, action });
457
- assert.equal(denial.gateContext.raw.gate, "");
458
- }
459
- });
460
-
461
- test("native validate keeps malformed and unexpected nonzero exits typed", async () => {
462
- const denial = await fixture("validate-deny");
463
- for (const scenario of [
464
- { result: { stdout: "", exitCode: 1 }, code: NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT },
465
- { result: { stdout: "{", exitCode: 1 }, code: NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON },
466
- { result: { stdout: denial, exitCode: 2 }, code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO },
467
- { result: { stdout: denial, exitCode: 1, timedOut: true }, code: NATIVE_REVIEW_ERROR_CODE.TIMEOUT },
468
- { result: { stdout: denial, exitCode: 1, signal: "SIGTERM" as NodeJS.Signals }, code: NATIVE_REVIEW_ERROR_CODE.SIGNAL },
469
- ]) {
470
- const queue = queuedAdapter([VERSION, scenario.result]);
471
- await assert.rejects(
472
- () => new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }),
473
- (error: unknown) => error instanceof NativeReviewCliError && error.code === scenario.code && error.operation === "review/validate",
474
- );
475
- }
476
- const unavailable: ExecFileAdapter = async (request) => {
477
- if (request.arguments[0] === "version") return { stdout: VERSION.stdout, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
478
- throw Object.assign(new Error("spawn"), { code: "ENOENT" });
264
+ test("negotiated STATUS rejects an unsupported provider execute operation before argv synthesis", async () => {
265
+ const status = currentStatusFixture();
266
+ status.next_transition = {
267
+ kind: "execute",
268
+ reason_code: "future_operation",
269
+ execute: { operation: "review.dispose-result", arguments: [], preconditions: [], binding: { target_identity: TARGET } },
479
270
  };
271
+ const queue = queuedAdapter([{ stdout: JSON.stringify(status) }]);
480
272
  await assert.rejects(
481
- () => new NativeReviewCliV213(unavailable).validate({ cwd: "/repo", gate: "post-apply" }),
482
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE,
273
+ () => client(queue.adapter).targetStatus({ cwd: "/repo" }),
274
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION,
483
275
  );
276
+ assert.equal(queue.calls.length, 1);
484
277
  });
485
278
 
486
-
487
- test("native decoders reject every one-field schema mutation", async () => {
488
- const operations = [
489
- { fixtureName: "start", invoke: (client: NativeReviewCliV213) => client.start({ cwd: "/repo", lineageId: "lineage-1" }) },
490
- { fixtureName: "finalize", optionalKeys: ["receipt_path"], invoke: (client: NativeReviewCliV213) => client.finalize({ cwd: "/repo", lineageId: "lineage-1" }) },
491
- { fixtureName: "validate-allow", invoke: (client: NativeReviewCliV213) => client.validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }) },
492
- { fixtureName: "bind-sdd", invoke: (client: NativeReviewCliV213) => client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" }) },
493
- { fixtureName: "sdd-status", optionalKeys: ["reviewGate", "reviewTransaction", "phaseInstructions"], invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
494
- { fixtureName: "sdd-status-engram", optionalKeys: ["reviewGate", "reviewTransaction", "phaseInstructions"], invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
495
- ];
496
- for (const operation of operations) {
497
- const fixtureBody = JSON.parse(await fixture(operation.fixtureName)) as Record<string, unknown>;
498
- for (const [key, value] of Object.entries(fixtureBody)) {
499
- const missing = { ...fixtureBody };
500
- delete missing[key];
501
- for (const mutated of [...(operation.optionalKeys?.includes(key) ? [] : [missing]), { ...fixtureBody, [key]: typeof value === "string" ? 1 : "wrong-type" }]) {
502
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(mutated) }]);
503
- await assert.rejects(() => operation.invoke(new NativeReviewCliV213(queue.adapter)), NativeReviewCliError, `${operation.fixtureName}.${key}`);
504
- }
505
- }
506
- }
507
- });
508
-
509
- test("native decoders reject nested mutations and unknown enums", async () => {
510
- const validate = JSON.parse(await fixture("validate-allow")) as Record<string, unknown>;
511
- const bind = JSON.parse(await fixture("bind-sdd")) as Record<string, unknown>;
512
- const status = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
513
- const start = JSON.parse(await fixture("start")) as Record<string, unknown>;
514
- const finalization = JSON.parse(await fixture("finalize")) as Record<string, unknown>;
515
- const cases = [
516
- { body: { ...start, risk_level: "unknown" }, invoke: (client: NativeReviewCliV213) => client.start({ cwd: "/repo" }) },
517
- { body: { ...start, selected_lenses: ["unknown"] }, invoke: (client: NativeReviewCliV213) => client.start({ cwd: "/repo" }) },
518
- { body: { ...finalization, state: "unknown" }, invoke: (client: NativeReviewCliV213) => client.finalize({ cwd: "/repo" }) },
519
- { body: { ...validate, context: { ...(validate.context as Record<string, unknown>), extra: true } }, invoke: (client: NativeReviewCliV213) => client.validate({ cwd: "/repo", gate: "post-apply" }) },
520
- { body: { ...bind, gate_context: { ...(bind.gate_context as Record<string, unknown>), candidate_tree: 1 } }, invoke: (client: NativeReviewCliV213) => client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" }) },
521
- { body: { ...status, nextRecommended: "unknown" }, invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
522
- { body: { ...status, actionContext: { ...(status.actionContext as Record<string, unknown>), allowedEditRoots: [1] } }, invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
523
- { body: { ...status, reviewGate: { ...(status.reviewGate as Record<string, unknown>), result: "deny" } }, invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
524
- { body: { ...status, reviewTransaction: { ...(status.reviewTransaction as Record<string, unknown>), mode: "ordinary" } }, invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
525
- { body: { ...status, reviewTransaction: { ...(status.reviewTransaction as Record<string, unknown>), snapshot: { ...((status.reviewTransaction as Record<string, unknown>).snapshot as Record<string, unknown>), extra: true } } }, invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
279
+ test("native START queries STATUS then executes only the provider-rendered ordered transition", async () => {
280
+ const tokens = [
281
+ "--contract=gentle-ai.review-integration/v2", "--cwd=/repo", `--target=${TARGET}`,
282
+ "--projection=workspace", "--agent=pi", "--consent=relay",
526
283
  ];
527
- for (const item of cases) {
528
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(item.body) }]);
529
- await assert.rejects(() => item.invoke(new NativeReviewCliV213(queue.adapter)), NativeReviewCliError);
530
- }
531
- });
532
-
533
- test("native decoders reject every nested response-field mutation", async () => {
534
- const cases = [
535
- { fixtureName: "validate-allow", nestedKey: "context", optionalNestedKeys: ["store_revision", "genesis_revision", "chain_identity", "bundle_digest"], invoke: (client: NativeReviewCliV213) => client.validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" }) },
536
- { fixtureName: "bind-sdd", nestedKey: "gate_context", optionalNestedKeys: ["genesis_revision", "chain_identity", "bundle_digest"], invoke: (client: NativeReviewCliV213) => client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" }) },
537
- { fixtureName: "sdd-status", nestedKey: "actionContext", invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
538
- { fixtureName: "sdd-status", nestedKey: "reviewGate", invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
539
- { fixtureName: "sdd-status", nestedKey: "reviewTransaction", invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
540
- { fixtureName: "sdd-status-engram", nestedKey: "artifacts", invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }) },
541
- ];
542
- for (const item of cases) {
543
- const body = JSON.parse(await fixture(item.fixtureName)) as Record<string, Record<string, unknown>>;
544
- for (const [field, value] of Object.entries(body[item.nestedKey]!)) {
545
- const missingNested = { ...body[item.nestedKey] };
546
- delete missingNested[field];
547
- const mutations = [
548
- ...(item.optionalNestedKeys?.includes(field) ? [] : [missingNested]),
549
- { ...body[item.nestedKey], [field]: typeof value === "string" ? 1 : "wrong-type" },
550
- { ...body[item.nestedKey], extra: true },
551
- ];
552
- for (const nested of mutations) {
553
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify({ ...body, [item.nestedKey]: nested }) }]);
554
- await assert.rejects(() => item.invoke(new NativeReviewCliV213(queue.adapter)), NativeReviewCliError, `${item.fixtureName}.${item.nestedKey}.${field}`);
555
- }
556
- }
557
- }
284
+ const queue = queuedAdapter([
285
+ { stdout: JSON.stringify(negotiatedStartStatus(TARGET, tokens)) },
286
+ { stdout: JSON.stringify(fixture("start-v3-zero-lens-closed.captured.json")) },
287
+ ]);
288
+ const result = await client(queue.adapter).start({ cwd: "/repo", targetIdentity: TARGET });
289
+ assert.equal(result.action, "closed");
290
+ assert.equal(result.hint, undefined);
291
+ assert.deepEqual(queue.calls.map((call) => call.arguments), [
292
+ ["review", "status", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo", "--projection", "workspace", "--agent", "pi", "--next-transition"],
293
+ ["review", "start", ...tokens],
294
+ ]);
558
295
  });
559
296
 
560
- test("native process failures are typed and never authorize mutation", async () => {
561
- const cases: Array<{ result?: QueuedResult; throws?: Error; code: string }> = [
562
- { throws: Object.assign(new Error("spawn"), { code: "ENOENT" }), code: NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE },
563
- { result: { stdout: "", timedOut: true }, code: NATIVE_REVIEW_ERROR_CODE.TIMEOUT },
564
- { result: { stdout: "", signal: "SIGTERM" }, code: NATIVE_REVIEW_ERROR_CODE.SIGNAL },
565
- { result: { stdout: "", exitCode: 2 }, code: NATIVE_REVIEW_ERROR_CODE.NON_ZERO },
566
- { result: { stdout: START.stdout, stderr: "unexpected" }, code: NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR },
567
- { result: { stdout: "", outputLimitExceeded: true }, code: NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT },
568
- { result: { stdout: "" }, code: NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT },
569
- { result: { stdout: "{" }, code: NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON },
297
+ test("native START relays provider-owned intended-untracked selection and transition argv verbatim", async () => {
298
+ const selectionValue = JSON.stringify(["new.ts", "nested/other.ts"]);
299
+ const selectionTokens = ["--selection-kind=untracked", "--selection-json={{value}}", "--selection-proof=provider-issued"];
300
+ const startTokens = [
301
+ "--contract=gentle-ai.review-integration/v2", "--cwd=/repo", `--target=${TARGET}`,
302
+ "--projection=workspace", "--agent=pi", "--consent=relay",
570
303
  ];
571
- for (const scenario of cases) {
572
- const adapter: ExecFileAdapter = async (request) => {
573
- if (request.arguments[0] === "version") return { ...VERSION, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
574
- if (scenario.throws) throw scenario.throws;
575
- return { stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false, ...scenario.result! };
576
- };
577
- await assert.rejects(
578
- () => new NativeReviewCliV213(adapter).start({ cwd: "/repo" }),
579
- (error: unknown) => error instanceof NativeReviewCliError && error.code === scenario.code && error.mutationOutcome === "unknown" && error.nextAction === "review.status",
580
- );
581
- }
582
- });
583
-
584
- test("finalize stages every optional document privately and cleans it after failures", async () => {
585
- const observed: string[] = [];
586
- let nativeCall = 0;
587
- const adapter: ExecFileAdapter = async (request) => {
588
- nativeCall += 1;
589
- if (nativeCall === 1) return { ...VERSION, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
590
- for (const argument of request.arguments) if (argument.includes("gentle-ai-finalize-")) observed.push(argument);
591
- return { stdout: "{", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
592
- };
593
- await assert.rejects(
594
- () => new NativeReviewCliV213(adapter).finalize({
595
- cwd: "/repo",
596
- lensResults: [{ lens: "review-risk", document: { id: "risk" } }],
597
- refuterDocument: { id: "refuter" },
598
- validationDocument: { id: "validation" },
599
- evidenceDocument: "evidence",
600
- }),
601
- NativeReviewCliError,
602
- );
603
- assert.equal(observed.filter((argument) => argument.endsWith(".json")).length, 3);
604
- await Promise.all(observed.filter((argument) => argument.endsWith(".json")).map(async (path) => assert.rejects(() => import("node:fs/promises").then(({ stat }) => stat(path)))));
605
- });
606
- test("native review status uses the anticipated v2.1.5 contract, preserves Windows paths, and never reports mutation", async () => {
607
- const queue = queuedAdapter([STATUS_VERSION, REVIEW_STATUS]);
608
- const result = await new NativeReviewCliV213(queue.adapter).reviewStatus({ cwd: "C:\\repo with spaces" });
609
- assert.equal(result.status, "clean");
610
- assert.equal(result.complete, true);
611
- assert.equal(result.authoritative, true);
304
+ const queue = queuedAdapter([
305
+ { stdout: JSON.stringify(negotiatedStartStatus(TARGET, startTokens)) },
306
+ { stdout: JSON.stringify(fixture("start-v3-zero-lens-closed.captured.json")) },
307
+ ]);
308
+ await client(queue.adapter).start({
309
+ cwd: "/repo",
310
+ targetIdentity: TARGET,
311
+ intendedUntrackedSelection: { argumentTokens: selectionTokens, value: selectionValue },
312
+ });
612
313
  assert.deepEqual(queue.calls.map((call) => call.arguments), [
613
- ["version"],
614
- ["review", "status", "--cwd", "C:\\repo with spaces"],
314
+ ["review", "status", "--cwd", "/repo", "--selection-kind=untracked", `--selection-json=${selectionValue}`, "--selection-proof=provider-issued"],
315
+ ["review", "start", ...startTokens],
615
316
  ]);
616
- assert.equal(queue.calls.length, 2);
617
-
618
- const wrongRepository = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), repository: "C:\\other repository" }) }]);
619
- await assert.rejects(
620
- () => new NativeReviewCliV213(wrongRepository.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
621
- (error: unknown) => error instanceof NativeReviewCliError
622
- && error.code === NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH
623
- && error.operation === "review/status"
624
- && error.mutationOutcome === "none",
625
- );
626
-
627
- const incomplete = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), complete: false, authoritative: false, status: "invalid" }) }]);
628
- assert.equal((await new NativeReviewCliV213(incomplete.adapter).reviewStatus({ cwd: "C:\\repo with spaces" })).complete, false);
629
-
630
- const populated = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), status: "active", entries: [{ version: "compact-v2", lineage_id: "lineage", path: "C:\\repo\\.git\\gentle-ai", status: "active", revision: "r1", recovery: { predecessor_lineage_id: "old", predecessor_revision: "r0", disposition: "scope_changed", reason: "scope", actor: "maintainer", recovered_at: "2026-07-14T00:00:00Z" }, problems: ["stale"] }], locks: [{ version: "compact-v2", path: "C:\\repo\\.git\\gentle-ai\\LOCK", status: "owned", owner: { schema: "gentle-ai.review-store-lock/v1", owner_id: "owner", pid: 1, host: "host", acquired_at: "2026-07-14T00:00:00Z" } }], diagnostics: [{ path: "C:\\repo\\.git\\gentle-ai", problem: "stale" }] }) }]);
631
- const decoded = await new NativeReviewCliV213(populated.adapter).reviewStatus({ cwd: "C:\\repo with spaces" });
632
- assert.deepEqual(decoded.entries[0]?.recovery?.predecessorLineageId, "old");
633
- assert.deepEqual(decoded.locks[0]?.owner?.ownerId, "owner");
634
- assert.deepEqual(decoded.diagnostics, [{ path: "C:\\repo\\.git\\gentle-ai", problem: "stale" }]);
635
-
636
- const unsupportedLockSchema = queuedAdapter([STATUS_VERSION, {
637
- stdout: JSON.stringify({
638
- ...JSON.parse(REVIEW_STATUS.stdout),
639
- locks: [{
640
- version: "compact-v2",
641
- path: "C:\\repo\\.git\\gentle-ai\\LOCK",
642
- status: "owned",
643
- owner: { schema: "unexpected", owner_id: "owner", pid: 2_147_483_647, host: "host", acquired_at: "2026-07-14T00:00:00Z" },
644
- }],
645
- }),
646
- }]);
647
- await assert.rejects(
648
- () => new NativeReviewCliV213(unsupportedLockSchema.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
649
- (error: unknown) => error instanceof NativeReviewCliError
650
- && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
651
- && error.operation === "review/status"
652
- && error.mutationOutcome === "none",
653
- );
654
-
655
- for (const body of [
656
- { ...JSON.parse(REVIEW_STATUS.stdout), complete: false, authoritative: true },
657
- { ...JSON.parse(REVIEW_STATUS.stdout), entries: [{ version: "compact-v2", lineage_id: "lineage", path: "C:\\repo\\.git\\gentle-ai", status: "active", problems: [], unexpected: true }] },
658
- { ...JSON.parse(REVIEW_STATUS.stdout), status: "unknown" },
659
- ]) {
660
- const malformed = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify(body) }]);
661
- await assert.rejects(
662
- () => new NativeReviewCliV213(malformed.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
663
- (error: unknown) => error instanceof NativeReviewCliError
664
- && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
665
- && error.operation === "review/status"
666
- && error.mutationOutcome === "none",
667
- );
317
+ for (const flag of ["--untracked-scope", "--expected-untracked-inventory", "--intended-untracked", "--contract", "--agent", "--projection"]) {
318
+ assert.equal(queue.calls[0]?.arguments.includes(flag), false, flag);
668
319
  }
669
- });
670
-
671
- test("native review status decodes and retains the v2.2.2 compact snapshot identity", async () => {
672
- const snapshotIdentity = "sha256:0586c3d40c3acf3db92214ddaf07afe44ad862518b48d608ae4157377ab2f3cc";
673
- const entry = {
674
- version: "compact-v2",
675
- lineage_id: "issue-136-v2-1-2-final",
676
- path: "C:\\repo with spaces\\.git\\gentle-ai\\review-transactions\\v2\\issue-136-v2-1-2-final",
677
- status: "active",
678
- state: "reviewing",
679
- revision: "sha256:8103efb360d10d1717a4d22e38c8b75074f99680ed6a5eb3ab40e0ed9fb69931",
680
- snapshot_identity: snapshotIdentity,
681
- problems: [],
682
- };
683
- const status = { ...JSON.parse(REVIEW_STATUS.stdout), status: "active", entries: [entry] };
684
- const queue = queuedAdapter([{ stdout: "gentle-ai 2.2.2\n" }, { stdout: JSON.stringify(status) }]);
685
- const decoded = await new NativeReviewCliV213(queue.adapter).reviewStatus({ cwd: "C:\\repo with spaces" });
686
- assert.equal(decoded.entries[0]?.snapshotIdentity, snapshotIdentity);
687
-
688
- const malformed = queuedAdapter([{ stdout: "gentle-ai 2.2.2\n" }, { stdout: JSON.stringify({ ...status, entries: [{ ...entry, snapshot_identity: snapshotIdentity.slice("sha256:".length) }] }) }]);
689
- await assert.rejects(
690
- () => new NativeReviewCliV213(malformed.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
691
- (error: unknown) => error instanceof NativeReviewCliError
692
- && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
693
- && error.operation === "review/status"
694
- && error.mutationOutcome === "none",
695
- );
696
- });
697
320
 
698
- test("native review status decodes 2.1.8 released lock residue and keeps unknown lock statuses fail-closed", async () => {
699
- // gentle-ai 2.1.8 leaves review-transactions/v2/LOCK behind after ORDINARY
700
- // successful operations and reports it as {"status":"released"} without
701
- // owner metadata (issue #184; reproduced empirically against the system
702
- // binary). The decoder must accept it or reviewStatus dead-ends with
703
- // schema-incompatible on every repository that ever completed a review.
704
- //
705
- // Lock status stays a CLOSED enum extended by exactly `released` — unlike
706
- // the cause_category widening in review-integration-v2 (diagnostic
707
- // metadata nothing routes on), lock status routes blocking behavior in the
708
- // controller, so an unknown future status must keep failing closed rather
709
- // than being silently classified as blocking or non-blocking.
710
- const releasedLock = { version: "compact-v2", path: "C:\\repo\\.git\\gentle-ai\\review-transactions\\v2\\LOCK", status: "released" };
711
- const released = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), status: "approved", locks: [releasedLock] }) }]);
712
- const decoded = await new NativeReviewCliV213(released.adapter).reviewStatus({ cwd: "C:\\repo with spaces" });
713
- assert.equal(decoded.status, "approved");
714
- assert.deepEqual(decoded.locks, [{ version: "compact-v2", path: "C:\\repo\\.git\\gentle-ai\\review-transactions\\v2\\LOCK", status: "released" }]);
715
-
716
- // Residual dead-owner metadata may still accompany a released entry.
717
- const releasedWithOwner = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), locks: [{ ...releasedLock, owner: { schema: "gentle-ai.review-store-lock/v1", owner_id: "dead-owner", pid: 1, host: "host", acquired_at: "2026-07-14T00:00:00Z" } }] }) }]);
718
- assert.equal((await new NativeReviewCliV213(releasedWithOwner.adapter).reviewStatus({ cwd: "C:\\repo with spaces" })).locks[0]?.owner?.ownerId, "dead-owner");
719
-
720
- for (const status of ["Released", "stale", "unknown-future-status", ""]) {
721
- const malformed = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), locks: [{ ...releasedLock, status }] }) }]);
321
+ for (const argumentTokens of [["--selection-json=value"], ["--selection-json={{value}}", "--duplicate={{value}}"], ["--selection-json={{value}}{{value}}"]]) {
322
+ const rejected = queuedAdapter([]);
722
323
  await assert.rejects(
723
- () => new NativeReviewCliV213(malformed.adapter).reviewStatus({ cwd: "C:\\repo with spaces" }),
724
- (error: unknown) => error instanceof NativeReviewCliError
725
- && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE
726
- && error.operation === "review/status"
727
- && error.mutationOutcome === "none",
324
+ () => client(rejected.adapter).start({ cwd: "/repo", targetIdentity: TARGET, intendedUntrackedSelection: { argumentTokens, value: selectionValue } }),
325
+ /exactly one provider-issued \{\{value\}\} token/,
728
326
  );
327
+ assert.equal(rejected.calls.length, 0);
729
328
  }
730
329
  });
731
330
 
732
- test("native review status accepts a canonical POSIX symlink repository identity", async (t) => {
733
- if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
734
- const repository = await mkdtemp(join(tmpdir(), "gentle-pi-native-status-"));
735
- const alias = `${repository}-alias`;
736
- await symlink(repository, alias);
737
- t.after(async () => { await rm(alias); await rm(repository, { recursive: true }); });
738
- const queue = queuedAdapter([STATUS_VERSION, { stdout: JSON.stringify({ ...JSON.parse(REVIEW_STATUS.stdout), repository }) }]);
739
- assert.equal((await new NativeReviewCliV213(queue.adapter).reviewStatus({ cwd: alias })).repository, repository);
740
- });
741
-
742
- test("native review status keeps v2.1.4 truthfully unsupported", async () => {
743
- const queue = queuedAdapter([VERSION]);
744
- await assert.rejects(
745
- () => new NativeReviewCliV213(queue.adapter).reviewStatus({ cwd: "/repo" }),
746
- (error: unknown) => error instanceof NativeReviewCliError
747
- && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE
748
- && error.operation === "version"
749
- && error.mutationOutcome === "none",
750
- );
751
- assert.deepEqual(queue.calls.map((call) => call.arguments), [["version"]]);
331
+ test("native START retains the provider-owned START/v4 status transition in exact argument order", async () => {
332
+ const tokens = [
333
+ "--contract=gentle-ai.review-integration/v2", "--cwd=/repo", `--target=${TARGET}`,
334
+ "--projection=workspace", "--agent=pi", "--consent=relay",
335
+ ];
336
+ const start = reviewingStartV4(TARGET);
337
+ const queue = queuedAdapter([
338
+ { stdout: JSON.stringify(negotiatedStartStatus(TARGET, tokens)) },
339
+ { stdout: JSON.stringify(start) },
340
+ ]);
341
+ const result = await client(queue.adapter).start({ cwd: "/repo", targetIdentity: TARGET });
342
+ assert.deepEqual(result.nextTransition?.execute?.arguments.map((argument) => argument.token), [
343
+ "--contract=gentle-ai.review-integration/v2",
344
+ "--next-transition=true",
345
+ `--lineage=${result.lineageId}`,
346
+ "--agent=pi",
347
+ `--base-ref=${start.base_tree}`,
348
+ "--committed-only=true",
349
+ ]);
350
+ assert.deepEqual(result.nextTransition?.execute?.selectorArguments?.map((argument) => argument.token), [
351
+ `--base-ref=${start.base_tree}`,
352
+ "--committed-only=true",
353
+ ]);
354
+ assert.deepEqual(queue.calls[1]?.arguments, ["review", "start", ...tokens]);
752
355
  });
753
356
 
754
- async function fixture(name: string): Promise<string> {
755
- return readFile(new URL(`./fixtures/native-review-cli/v2.1.3/${name}.json`, import.meta.url), "utf8");
756
- }
757
-
758
- test("finalize ignores injected cleanup failures after native completion", async () => {
759
- for (const native of [{ stdout: await fixture("finalize") }, { stdout: "{" }]) {
760
- let cleanupAttempts = 0;
761
- const queue = queuedAdapter([VERSION, native]);
762
- const client = new NativeReviewCliV213(
763
- queue.adapter,
764
- "/package/.gentle-ai/v2.1.3/gentle-ai",
765
- 30_000,
766
- 1024 * 1024,
767
- async () => {
768
- cleanupAttempts += 1;
769
- throw new Error("cleanup failed");
770
- },
771
- );
772
- const finalize = () => client.finalize({ cwd: "/repo", lensResults: [{ lens: "review-risk", document: { id: "risk" } }] });
773
- if (native.stdout === "{") {
774
- await assert.rejects(finalize, (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON && error.mutationOutcome === "unknown");
775
- } else {
776
- assert.match((await finalize()).storeRevision, /^sha256:[0-9a-f]{64}$/);
777
- }
778
- assert.equal(cleanupAttempts, 1);
357
+ test("native consent completion decodes START/v4 and replays its provider argv unchanged", async () => {
358
+ const rawConsent = fixture("consent-v3.captured.json");
359
+ rawConsent.agent = "pi";
360
+ for (const choice of rawConsent.choices as Record<string, unknown>[]) {
361
+ choice.invocation = String(choice.invocation)
362
+ .replace(/--cwd \S+ --target/, "--cwd /repo --target")
363
+ .replace(" --consent ", " --agent pi --consent ");
779
364
  }
365
+ const consent = decodeReviewConsentV3(rawConsent, "pi");
366
+ const queue = queuedAdapter([{ stdout: JSON.stringify(reviewingStartV4(consent.targetIdentity)) }]);
367
+ const answered = await client(queue.adapter).answerConsent({ cwd: "/repo", consent, answer: "granted" });
368
+ assert.equal(answered.kind, "started");
369
+ if (answered.kind !== "started") throw new Error("expected started consent result");
370
+ assert.equal(answered.start.nextTransition?.execute?.operation, "review.status");
371
+ assert.deepEqual(queue.calls[0]?.arguments, consent.choices[0].invocation.split(" ").slice(1));
780
372
  });
781
373
 
782
- test("finalize cleanup survives every native exit path", async () => {
783
- for (const result of [
784
- { stdout: await fixture("finalize") },
785
- { stdout: await fixture("finalize"), exitCode: 1 },
786
- { stdout: "", timedOut: true },
787
- { stdout: "{" },
374
+ test("native START refuses invalid committed-range and target bindings before STATUS", async () => {
375
+ for (const request of [
376
+ { cwd: "/repo", baseRef: "origin/main" },
377
+ { cwd: "/repo", committedOnly: true },
378
+ { cwd: "/repo", baseRef: " origin/main", committedOnly: true },
379
+ { cwd: "/repo", targetIdentity: "not-a-sha" },
788
380
  ]) {
789
- const staged: string[] = [];
790
- let call = 0;
791
- const adapter: ExecFileAdapter = async (request) => {
792
- call += 1;
793
- if (call === 1) return { ...VERSION, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
794
- for (const argument of request.arguments) if (argument.includes("gentle-ai-finalize-")) staged.push(argument);
795
- return { stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false, ...result };
796
- };
797
- const finalize = () => new NativeReviewCliV213(adapter).finalize({ cwd: "/repo", lensResults: [{ lens: "review-risk", document: { id: "risk" } }], refuterDocument: { id: "refuter" }, validationDocument: { id: "validation" }, evidenceDocument: "evidence" });
798
- if (result.exitCode === 1 || result.timedOut || result.stdout === "{") await assert.rejects(finalize, NativeReviewCliError);
799
- else await finalize();
800
- await Promise.all(staged.filter((path) => path.endsWith(".json")).map(async (path) => assert.rejects(() => import("node:fs/promises").then(({ stat }) => stat(path)))));
381
+ const queue = queuedAdapter([]);
382
+ await assert.rejects(() => client(queue.adapter).start(request), TypeError);
383
+ assert.equal(queue.calls.length, 0);
801
384
  }
802
385
  });
803
386
 
804
- test("native client rejects historical v2.1.3 START while decoding compatible fixtures", async () => {
805
- const queue = queuedAdapter([
806
- VERSION,
807
- { stdout: await fixture("start") },
808
- VERSION,
809
- { stdout: await fixture("finalize") },
810
- VERSION,
811
- { stdout: await fixture("validate-allow") },
812
- VERSION,
813
- { stdout: await fixture("bind-sdd") },
814
- VERSION,
815
- { stdout: await fixture("sdd-status") },
816
- ]);
817
- const client = new NativeReviewCliV213(queue.adapter);
818
- await assert.rejects(() => client.start({ cwd: "/repo", lineageId: "lineage-1" }), NativeReviewCliError);
819
- const finalized = await client.finalize({ cwd: "/repo", lineageId: "lineage-1" });
820
- assert.match(finalized.storeRevision, /^sha256:[0-9a-f]{64}$/);
821
- assert.equal(finalized.action, "validate delivery with gentle-ai review validate --gate <gate>");
822
- assert.equal((await client.validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" })).allowed, true);
823
- assert.match((await client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" })).revision, /^sha256:[0-9a-f]{64}$/);
824
- assert.equal((await client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" })).ready, true);
387
+ test("capture-result stages a private input and preserves provider tokens without adding a contract", async () => {
388
+ const queue = queuedAdapter([{ stdout: JSON.stringify(fixture("last-event-capture-result-approved.captured.json")) }]);
389
+ const result = await client(queue.adapter).captureResult({
390
+ argumentTokens: [`--repository-context=${REPOSITORY_CONTEXT}`, "--lineage=review-c7c923a031112dd7", "--target=sha256:captured"],
391
+ resultDocument: "{\"subject_hash\":\"sha256:captured\"}",
392
+ });
393
+ assert.equal("operation" in result && result.operation, "review/capture-result");
394
+ assert.deepEqual(queue.calls[0]?.arguments.slice(0, 5), ["review", "capture-result", `--repository-context=${REPOSITORY_CONTEXT}`, "--lineage=review-c7c923a031112dd7", "--target=sha256:captured"]);
395
+ assert.equal(queue.calls[0]?.arguments.includes("--contract"), false);
396
+ assert.equal(queue.calls[0]?.arguments.at(-2), "--input");
397
+ assert.equal(queue.calls[0]?.timeoutMs, undefined);
825
398
  });
826
399
 
827
- test("native SDD readiness requires an unblocked post-review action with published allow evidence", async () => {
828
- const openspec = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
829
- const engram = JSON.parse(await fixture("sdd-status-engram")) as Record<string, unknown>;
830
- const actions = ["apply", "verify", "remediate", "archive", "review", "resolve-review", "resolve-blockers", "sdd-new", "select-change", "propose", "spec", "design", "tasks"] as const;
831
- for (const source of [openspec, engram]) {
832
- for (const nextRecommended of actions) {
833
- const body = {
834
- ...source,
835
- nextRecommended,
836
- blockedReasons: [],
837
- reviewGate: { result: "allow", reason: "current bound authority allows delivery" },
838
- };
839
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
840
- assert.equal(
841
- (await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" })).ready,
842
- nextRecommended === "verify" || nextRecommended === "archive",
843
- `${source.artifactStore as string}:${nextRecommended}`,
844
- );
845
- }
846
- }
847
-
848
- for (const body of [
849
- { ...openspec, nextRecommended: "verify", blockedReasons: [], reviewGate: undefined },
850
- { ...openspec, nextRecommended: "archive", blockedReasons: ["stale authority"], reviewGate: { result: "allow", reason: "allow before drift" } },
851
- { ...openspec, nextRecommended: "verify", blockedReasons: [], reviewGate: { result: "scope-changed", reason: "candidate changed" } },
852
- { ...openspec, nextRecommended: "archive", blockedReasons: [], reviewGate: { result: "invalidated", reason: "authority is stale" } },
400
+ test("capture-result rejects an empty document and a conflicting repository context before launch", async () => {
401
+ for (const request of [
402
+ { argumentTokens: [`--repository-context=${REPOSITORY_CONTEXT}`], resultDocument: "" },
403
+ { argumentTokens: [`--repository-context=${REPOSITORY_CONTEXT}`], resultDocument: "{}", cwd: "/repo" },
853
404
  ]) {
854
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
855
- assert.equal((await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" })).ready, false);
405
+ const queue = queuedAdapter([]);
406
+ await assert.rejects(() => client(queue.adapter).captureResult(request), TypeError);
407
+ assert.equal(queue.calls.length, 0);
856
408
  }
857
409
  });
858
410
 
859
- // Phase 13.13 (migrate-review-integration-v2): with the kill switch off,
860
- // gentle-ai's gate result can never become "allow" (delivery follows ordinary
861
- // repository policy instead), so gating `ready` on result==="allow" alone
862
- // deadlocked readiness forever. `delivery` also arrived on the reviewGate
863
- // struct itself (json:"delivery,omitempty") without a corresponding optional
864
- // key on the decoder, so that payload was previously rejected outright rather
865
- // than merely read as not-ready.
866
- test("native SDD readiness unblocks on disabled/unmanaged delivery instead of deadlocking on a permanently non-allow gate result", async () => {
867
- const openspec = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
868
-
869
- for (const nextRecommended of ["verify", "archive"] as const) {
870
- const body = {
871
- ...openspec,
872
- nextRecommended,
873
- blockedReasons: [],
874
- reviewGate: { result: "invalidated", reason: "review-driven development is disabled and no receipt governs this candidate, so delivery follows ordinary repository policy", delivery: "disabled/unmanaged" },
875
- };
876
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
877
- const status = await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" });
878
- assert.equal(status.ready, true, `${nextRecommended} must be ready under disabled/unmanaged delivery even though result is not "allow"`);
411
+ test("terminal correction and provider-role captures preserve unknown mutation failure semantics", async () => {
412
+ for (const run of [
413
+ (review: NativeReviewCliV216) => review.captureCorrectionPlan({ argumentTokens: ["--lineage=review-c7c923a031112dd7", "--correction-lines={{value}}"], correctionLines: 1, cwd: "/repo" }),
414
+ (review: NativeReviewCliV216) => review.captureProviderRole({ captureOperation: "review.capture-refuter", argumentTokens: ["--lineage=review-c7c923a031112dd7"], cwd: "/repo" }),
415
+ ]) {
416
+ const queue = queuedAdapter([{ stdout: "", timedOut: true }]);
417
+ await assert.rejects(
418
+ () => run(client(queue.adapter)),
419
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.TIMEOUT && error.mutationOutcome === "unknown" && error.nextAction === "review.status",
420
+ );
879
421
  }
880
-
881
- // Blocked reasons still take priority over a legitimate unmanaged delivery.
882
- const stillBlocked = {
883
- ...openspec, nextRecommended: "verify", blockedReasons: ["stale authority"],
884
- reviewGate: { result: "invalidated", reason: "delivery follows ordinary repository policy", delivery: "disabled/unmanaged" },
885
- };
886
- const blockedQueue = queuedAdapter([VERSION, { stdout: JSON.stringify(stillBlocked) }]);
887
- assert.equal((await new NativeReviewCliV213(blockedQueue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" })).ready, false);
888
-
889
- // An unrecognized delivery value must still fail closed (exact enum, not a
890
- // free string).
891
- const unknownDelivery = {
892
- ...openspec, nextRecommended: "verify", blockedReasons: [],
893
- reviewGate: { result: "invalidated", reason: "delivery follows ordinary repository policy", delivery: "unmanaged" },
894
- };
895
- const unknownQueue = queuedAdapter([VERSION, { stdout: JSON.stringify(unknownDelivery) }]);
896
- await assert.rejects(
897
- () => new NativeReviewCliV213(unknownQueue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }),
898
- (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
899
- );
900
422
  });
901
423
 
902
- test("native client decodes the exact v2.1.3 Engram artifact map", async () => {
903
- const queue = queuedAdapter([VERSION, { stdout: await fixture("sdd-status-engram") }]);
904
- const status = await new NativeReviewCliV213(queue.adapter).sddStatus({ cwd: "/repo", change: "native-review-authority-parity" });
905
- assert.equal(status.artifactStore, "engram");
906
- assert.equal(status.artifacts.reviewPolicy, "done");
907
- assert.equal(status.ready, false);
424
+ test("read-only STATUS process failures remain typed and never claim mutation", async () => {
425
+ for (const [result, code] of [
426
+ [{ stdout: "", outputLimitExceeded: true }, NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT],
427
+ [{ stdout: "", timedOut: true }, NATIVE_REVIEW_ERROR_CODE.TIMEOUT],
428
+ [{ stdout: "", signal: "SIGTERM" as NodeJS.Signals }, NATIVE_REVIEW_ERROR_CODE.SIGNAL],
429
+ [{ stdout: "not-json" }, NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON],
430
+ ] as const) {
431
+ const queue = queuedAdapter([result]);
432
+ await assert.rejects(
433
+ () => client(queue.adapter).targetStatus({ cwd: "/repo" }),
434
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === code && error.mutationOutcome === "none",
435
+ );
436
+ }
908
437
  });
909
438
 
910
- test("native client decodes the exact published non-allow result and rejects stale aliases", async () => {
911
- const queue = queuedAdapter([VERSION, { stdout: await fixture("validate-deny"), stderr: "Error: review gate denied: scope-changed\n", exitCode: 1 }]);
912
- const result = await new NativeReviewCliV213(queue.adapter).validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" });
913
- assert.equal(result.result, "scope-changed");
914
- assert.equal(result.allowed, false);
915
- assert.equal(result.action, "create-new-lineage");
916
-
917
- for (const stale of [
918
- { schema: "gentle-ai.review-gate-result/v1", result: "deny", allowed: false, action: "blocked", reason: "stale", gate_context: {} },
919
- { schema: "gentle-ai.review-sdd-binding/v1", repository: "repo", change: "native-review-authority-parity", path: "openspec/changes/native-review-authority-parity", lineage_id: "issue136-contract-runtime", authority_revision: "revision", receipt_hash: "receipt", binding_revision: "binding", gate_context: {} },
439
+ test("read-only STATUS rejects unexpected stderr and nonzero failures without decoding authority", async () => {
440
+ for (const result of [
441
+ { stdout: JSON.stringify(currentStatusFixture()), stderr: "unexpected provider noise" },
442
+ { stdout: JSON.stringify({ schema: "gentle-ai.review-integration.failure/v2" }), exitCode: 1 },
920
443
  ]) {
921
- const staleQueue = queuedAdapter([VERSION, { stdout: JSON.stringify(stale) }]);
922
- const client = new NativeReviewCliV213(staleQueue.adapter);
444
+ const queue = queuedAdapter([result]);
923
445
  await assert.rejects(
924
- () => "result" in stale
925
- ? client.validate({ cwd: "/repo", gate: "post-apply", lineageId: "issue136-contract-runtime" })
926
- : client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "issue136-contract-runtime", expectedBindingRevision: "" }),
927
- NativeReviewCliError,
446
+ () => client(queue.adapter).targetStatus({ cwd: "/repo" }),
447
+ (error: unknown) => error instanceof NativeReviewCliError && error.mutationOutcome === "none",
928
448
  );
929
449
  }
930
450
  });
931
451
 
932
- test("native client rejects mutations, trailing JSON, and process uncertainty", async () => {
933
- const start = JSON.parse(await fixture("start")) as Record<string, unknown>;
934
- for (const body of [
935
- {},
936
- { ...start, extra: true },
937
- { ...start, changed_lines: Number.MAX_SAFE_INTEGER + 1 },
938
- ]) {
939
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(body) }]);
940
- await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), NativeReviewCliError);
941
- }
942
- const trailing = queuedAdapter([VERSION, { stdout: `${await fixture("start")} {}` }]);
943
- await assert.rejects(() => new NativeReviewCliV213(trailing.adapter).start({ cwd: "/repo" }), NativeReviewCliError);
944
- for (const result of [
945
- { stdout: "", stderr: "missing", exitCode: 1 },
946
- { stdout: await fixture("start"), stderr: "warning" },
947
- { stdout: "", timedOut: true },
452
+ test("negotiated STATUS accepts only exact provider forecast narration on stderr", async () => {
453
+ const narration = [
454
+ "Forecast horizon: partial",
455
+ "step 1: collect; reason_code=provider_refuter_required; description=run the provider-owned refuter",
456
+ "Re-query STATUS after completing this partial head.",
457
+ ].join("\n");
458
+ const accepted = queuedAdapter([{ stdout: JSON.stringify(currentStatusFixture()), stderr: narration }]);
459
+ assert.equal((await client(accepted.adapter).targetStatus({ cwd: "/repo" })).action, "start");
460
+
461
+ for (const stderr of [
462
+ "Forecast horizon: unknown",
463
+ `prefix ${narration}`,
464
+ `${narration}\nunexpected provider noise`,
948
465
  ]) {
949
- const queue = queuedAdapter([VERSION, result]);
466
+ const rejected = queuedAdapter([{ stdout: JSON.stringify(currentStatusFixture()), stderr }]);
950
467
  await assert.rejects(
951
- () => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }),
952
- (error: unknown) => error instanceof NativeReviewCliError && error.mutationOutcome === "unknown",
468
+ () => client(rejected.adapter).targetStatus({ cwd: "/repo" }),
469
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
953
470
  );
954
471
  }
955
472
  });
956
473
 
957
- test("native client rejects extra fields in finalize, bind, and bound SDD status fixtures", async () => {
958
- const finalize = JSON.parse(await fixture("finalize")) as Record<string, unknown>;
959
- const bind = JSON.parse(await fixture("bind-sdd")) as Record<string, unknown>;
960
- const status = JSON.parse(await fixture("sdd-status")) as Record<string, unknown>;
961
- const cases = [
962
- { invoke: (client: NativeReviewCliV213) => client.finalize({ cwd: "/repo", lineageId: "lineage-1" }), body: { ...finalize, extra: true } },
963
- { invoke: (client: NativeReviewCliV213) => client.bindSdd({ cwd: "/repo", change: "native-review-authority-parity", lineage: "lineage-1", expectedBindingRevision: "" }), body: { ...bind, extra: true } },
964
- { invoke: (client: NativeReviewCliV213) => client.sddStatus({ cwd: "/repo", change: "native-review-authority-parity" }), body: { ...status, extra: true } },
965
- ];
966
- for (const item of cases) {
967
- const queue = queuedAdapter([VERSION, { stdout: JSON.stringify(item.body) }]);
968
- await assert.rejects(() => item.invoke(new NativeReviewCliV213(queue.adapter)), NativeReviewCliError);
969
- }
474
+ test("native client requires an absolute package-local executable before any invocation", () => {
475
+ assert.throws(() => new NativeReviewCliV216(queuedAdapter([]).adapter, "gentle-ai"), /absolute package-local executable/);
476
+ assert.throws(() => new NativeReviewCliV216(queuedAdapter([]).adapter, "relative/gentle-ai"), /absolute package-local executable/);
970
477
  });
971
478
 
972
- test("native finalize stages ordered private result documents and removes them after decoding", async () => {
973
- const observed: Array<{ flag: string; file: string; mode: number; content: string }> = [];
974
- let finalizeArguments: readonly string[] = [];
975
- let call = 0;
976
- const adapter: ExecFileAdapter = async (request) => {
977
- call += 1;
978
- if (call === 1) return { ...VERSION, stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
979
- finalizeArguments = request.arguments;
980
- for (let index = 0; index < request.arguments.length; index += 1) {
981
- if (["--result", "--refuter", "--validation", "--evidence"].includes(request.arguments[index]!)) {
982
- const path = request.arguments[index + 1]!;
983
- const { readFile, stat } = await import("node:fs/promises");
984
- observed.push({ flag: request.arguments[index]!, file: path, mode: (await stat(path)).mode & 0o777, content: await readFile(path, "utf8") });
985
- }
986
- }
987
- return { stdout: await fixture("finalize"), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
988
- };
989
- await new NativeReviewCliV213(adapter).finalize({
990
- cwd: "/repo",
991
- lineageId: "lineage-1",
992
- lensResults: [{ lens: "review-risk", document: { lens: "risk", findings: [], evidence: ["complete candidate reviewed"] } }],
993
- refuterDocument: { results: [{ finding_id: "RISK-001", outcome: "inconclusive", proof_refs: ["differential-test:candidate still fails"] }] },
994
- validationDocument: { original_criteria: { passed: false, evidence: ["acceptance still fails"] }, correction_regression: { passed: true, evidence: ["regression suite passes"] }, follow_ups: [{ observation: "Track the remaining failure", proof_refs: ["differential-test:candidate still fails"] }] },
995
- evidenceDocument: " focused verification failed\n\n",
996
- failed: true,
479
+ // O012 migrated from the retired per-operation version probe. V216 executes
480
+ // negotiated argv directly; this test proves the argv boundary without
481
+ // resurrecting NativeReviewCliV213 or capability/version negotiation.
482
+ test("native client executes current negotiated argv without a shell", async () => {
483
+ const literal = "$GENTLE_PI_MUST_NOT_EXPAND";
484
+ const result = await createNodeExecFileAdapter()({
485
+ file: process.execPath,
486
+ arguments: ["-p", "process.argv[1]", literal],
487
+ cwd: process.cwd(),
488
+ timeoutMs: 30_000,
489
+ maxBufferBytes: 1024,
997
490
  });
998
- assert.deepEqual(observed.map((entry) => entry.mode), [0o600, 0o600, 0o600, 0o600]);
999
- assert.deepEqual(observed.map((entry) => entry.flag), ["--result", "--refuter", "--validation", "--evidence"]);
1000
- assert.deepEqual(observed.slice(0, 3).map((entry) => JSON.parse(entry.content)), [
1001
- { lens: "risk", findings: [], evidence: ["complete candidate reviewed"] },
1002
- { results: [{ finding_id: "RISK-001", outcome: "inconclusive", proof_refs: ["differential-test:candidate still fails"] }] },
1003
- { original_criteria: { passed: false, evidence: ["acceptance still fails"] }, correction_regression: { passed: true, evidence: ["regression suite passes"] }, follow_ups: [{ observation: "Track the remaining failure", proof_refs: ["differential-test:candidate still fails"] }] },
1004
- ]);
1005
- assert.equal(observed[3]?.content, " focused verification failed\n\n");
1006
- assert.equal(finalizeArguments.at(-1), "--failed");
1007
- await Promise.all(observed.map(async (entry) => assert.rejects(() => import("node:fs/promises").then(({ stat }) => stat(entry.file)))));
1008
- });
491
+ assert.equal(result.exitCode, 0);
492
+ assert.equal(result.stderr, "");
493
+ assert.equal(result.stdout.trim(), literal);
494
+ });
495
+
496
+ test("current STATUS preserves bounded output configuration and output-limit precedence", async () => {
497
+ const previous = process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
498
+ try {
499
+ for (const [environmentValue, expectedBuffer] of [[undefined, NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES], ["2097152", 2_097_152], ["invalid", NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES]] as const) {
500
+ if (environmentValue === undefined) delete process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
501
+ else process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES = environmentValue;
502
+ const calls: Array<{ timeoutMs: number | undefined; maxBufferBytes: number }> = [];
503
+ const adapter: ExecFileAdapter = async (request) => {
504
+ calls.push({ timeoutMs: request.timeoutMs, maxBufferBytes: request.maxBufferBytes });
505
+ return { stdout: JSON.stringify(currentStatusFixture()), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
506
+ };
507
+ await new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai").targetStatus({ cwd: "/repo" });
508
+ assert.deepEqual(calls, [{ timeoutMs: 30_000, maxBufferBytes: expectedBuffer }]);
509
+ }
510
+ } finally {
511
+ if (previous === undefined) delete process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
512
+ else process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES = previous;
513
+ }
1009
514
 
1010
- test("native finalize rejects only zero-length staged evidence before launch", async () => {
1011
- const queue = queuedAdapter([]);
515
+ const outputLimited = new NativeReviewCliV216(async () => ({
516
+ stdout: "", stderr: "", exitCode: 0, signal: "SIGTERM", timedOut: true, outputLimitExceeded: true,
517
+ }), "/package/.gentle-ai/gentle-ai");
1012
518
  await assert.rejects(
1013
- () => new NativeReviewCliV213(queue.adapter).finalize({ cwd: "/repo", evidenceDocument: "" }),
1014
- TypeError,
519
+ () => outputLimited.targetStatus({ cwd: "/repo" }),
520
+ (error: unknown) => error instanceof NativeReviewCliError
521
+ && error.code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT
522
+ && error.mutationOutcome === "none"
523
+ && error.diagnostics.timed_out === false
524
+ && error.diagnostics.output_limit_exceeded === true
525
+ && error.diagnostics.max_buffer_bytes === NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES
526
+ && error.diagnostics.configuration_hint === "Inspect native review state before any new START; GENTLE_PI_REVIEW_MAX_BUFFER_BYTES accepts a positive decimal up to 67108864.",
1015
527
  );
1016
- assert.equal(queue.calls.length, 0);
1017
528
  });
1018
529
 
1019
- test("native cancellation fails closed and preserves mutating ambiguity", async () => {
530
+ test("node execFile adapter passes AbortSignal to child_process", async () => {
531
+ const controller = new AbortController();
532
+ const pending = createNodeExecFileAdapter()({
533
+ file: process.execPath,
534
+ arguments: ["-e", "setTimeout(() => {}, 10_000)"],
535
+ cwd: process.cwd(),
536
+ timeoutMs: 30_000,
537
+ maxBufferBytes: 1024,
538
+ signal: controller.signal,
539
+ });
540
+ controller.abort();
541
+ await assert.rejects(pending, (error: unknown) => error instanceof Error && error.name === "AbortError");
542
+ });
543
+
544
+ test("capture-result receives the controller AbortSignal without an automatic mutation timeout", async () => {
545
+ const controller = new AbortController();
546
+ controller.abort();
547
+ let receivedSignal: AbortSignal | undefined;
548
+ let receivedTimeout: number | undefined;
1020
549
  const adapter: ExecFileAdapter = async (request) => {
1021
- if (request.arguments[0] === "version") return { stdout: "gentle-ai 2.1.4\n", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
550
+ receivedSignal = request.signal;
551
+ receivedTimeout = request.timeoutMs;
1022
552
  const error = new Error("cancelled");
1023
553
  error.name = "AbortError";
1024
554
  throw error;
1025
555
  };
1026
556
  await assert.rejects(
1027
- () => new NativeReviewCliV213(adapter).start({ cwd: "/repo" }),
557
+ () => new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai").captureResult({
558
+ argumentTokens: [`--repository-context=${REPOSITORY_CONTEXT}`],
559
+ resultDocument: "{}",
560
+ signal: controller.signal,
561
+ }),
1028
562
  (error: unknown) => error instanceof NativeReviewCliError
1029
563
  && error.code === NATIVE_REVIEW_ERROR_CODE.CANCELLED
1030
564
  && error.mutationOutcome === "unknown"
1031
565
  && error.nextAction === "review.status",
1032
566
  );
567
+ assert.equal(receivedSignal, controller.signal);
568
+ assert.equal(receivedTimeout, undefined);
1033
569
  });
1034
570
 
1035
- test("node execFile adapter passes AbortSignal to child_process", async () => {
1036
- const controller = new AbortController();
1037
- const pending = createNodeExecFileAdapter()({ file: process.execPath, arguments: ["-e", "setTimeout(() => {}, 10_000)"], cwd: process.cwd(), timeoutMs: 30_000, maxBufferBytes: 1024, signal: controller.signal });
1038
- controller.abort();
1039
- await assert.rejects(pending, (error: unknown) => error instanceof Error && error.name === "AbortError");
571
+ test("native review client leaves SDD status resolution to the local SDD engine", () => {
572
+ assert.equal("sddStatus" in client(queuedAdapter([]).adapter), false);
1040
573
  });
1041
574
 
1042
- test("native adapter receives the controller AbortSignal without an automatic mutation timeout", async () => {
1043
- const controller = new AbortController();
1044
- controller.abort();
1045
- let mutationTimeoutMs: number | undefined;
575
+ test("read-only authority inventory rejects a repository identity mismatch after decoding", async () => {
576
+ const queue = queuedAdapter([{ stdout: JSON.stringify({
577
+ schema: "gentle-ai.review-authority-status/v1", operation: "review/status", repository: "/foreign-repository",
578
+ complete: true, authoritative: true, status: "clean", entries: [], locks: [], diagnostics: [],
579
+ }) }]);
580
+ await assert.rejects(
581
+ () => client(queue.adapter).reviewStatus({ cwd: "/repo" }),
582
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH && error.mutationOutcome === "none",
583
+ );
584
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "status", "--cwd", "/repo"]);
585
+ });
586
+
587
+
588
+ test("current native process boundary preserves caps, timeout classes, and sanitized diagnostics", async () => {
589
+ const calls: Array<{ timeoutMs: number | undefined; maxBufferBytes: number }> = [];
1046
590
  const adapter: ExecFileAdapter = async (request) => {
1047
- if (request.arguments[0] === "version") return { stdout: "gentle-ai 2.1.4\n", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1048
- mutationTimeoutMs = request.timeoutMs;
1049
- if (request.signal?.aborted) {
1050
- const error = new Error("cancelled");
1051
- error.name = "AbortError";
1052
- throw error;
591
+ calls.push({ timeoutMs: request.timeoutMs, maxBufferBytes: request.maxBufferBytes });
592
+ if (request.arguments[1] === "capture-result") {
593
+ return { stdout: JSON.stringify(fixture("last-event-capture-result-approved.captured.json")), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1053
594
  }
1054
- throw new Error("missing AbortSignal");
595
+ return { stdout: JSON.stringify(currentStatusFixture()), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1055
596
  };
597
+ const review = new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 321, 654);
598
+ await review.targetStatus({ cwd: "/repo" });
599
+ await review.captureResult({ argumentTokens: [`--repository-context=${REPOSITORY_CONTEXT}`], resultDocument: "{}" });
600
+ assert.deepEqual(calls, [{ timeoutMs: 321, maxBufferBytes: 654 }, { timeoutMs: undefined, maxBufferBytes: 654 }]);
601
+
602
+ const diagnostics = queuedAdapter([{ stdout: JSON.stringify(currentStatusFixture()), stderr: "\u001b]8;;https://example.invalid/token\u0007token=super-secret\n" }]);
1056
603
  await assert.rejects(
1057
- () => new NativeReviewCliV213(adapter).start({ cwd: "/repo", signal: controller.signal }),
604
+ () => client(diagnostics.adapter).targetStatus({ cwd: "/repo" }),
1058
605
  (error: unknown) => error instanceof NativeReviewCliError
1059
- && error.code === NATIVE_REVIEW_ERROR_CODE.CANCELLED
1060
- && error.mutationOutcome === "unknown"
1061
- && error.nextAction === "review.status",
606
+ && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR
607
+ && !String(error.diagnostics.stderr).includes("super-secret")
608
+ && !/[\u0000-\u001f\u007f]/.test(String(error.diagnostics.stderr)),
609
+ );
610
+ });
611
+
612
+ test("current review STATUS retains compact snapshot and released-lock wire fields", async (t) => {
613
+ const repository = process.cwd();
614
+ const snapshotIdentity = `sha256:${"d".repeat(64)}`;
615
+ const body = {
616
+ schema: "gentle-ai.review-authority-status/v1",
617
+ operation: "review/status",
618
+ repository,
619
+ complete: true,
620
+ authoritative: true,
621
+ status: "active",
622
+ entries: [{ version: "compact-v2", lineage_id: "current-lineage", path: `${repository}/.git/gentle-ai`, status: "active", revision: `sha256:${"e".repeat(64)}`, snapshot_identity: snapshotIdentity, problems: [] }],
623
+ locks: [{ version: "compact-v2", path: `${repository}/.git/gentle-ai/LOCK`, status: "released" }],
624
+ diagnostics: [],
625
+ };
626
+ const queue = queuedAdapter([{ stdout: JSON.stringify(body) }]);
627
+ const status = await client(queue.adapter).reviewStatus({ cwd: repository });
628
+ assert.equal(status.entries[0]?.snapshotIdentity, snapshotIdentity);
629
+ assert.deepEqual(status.locks, [{ version: "compact-v2", path: `${repository}/.git/gentle-ai/LOCK`, status: "released" }]);
630
+ for (const lockStatus of ["Released", "stale", "future"]) {
631
+ const malformed = queuedAdapter([{ stdout: JSON.stringify({ ...body, locks: [{ ...body.locks[0], status: lockStatus }] }) }]);
632
+ await assert.rejects(
633
+ () => client(malformed.adapter).reviewStatus({ cwd: repository }),
634
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
635
+ );
636
+ }
637
+ if (process.platform !== "win32") {
638
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-native-status-"));
639
+ const alias = `${root}-alias`;
640
+ symlinkSync(root, alias, "dir");
641
+ t.after(() => {
642
+ rmSync(alias, { force: true });
643
+ rmSync(root, { recursive: true, force: true });
644
+ });
645
+ const canonical = queuedAdapter([{ stdout: JSON.stringify({ ...body, repository: root }) }]);
646
+ assert.equal((await client(canonical.adapter).reviewStatus({ cwd: alias })).repository, root);
647
+ }
648
+ });
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // acknowledge-approved — gentle-ai #3947 made the burn print one typed
652
+ // `gentle-ai.review-acknowledged/v1` envelope; every published release up to
653
+ // v2.5.0-rc.3 burns in silence. Both are a successful burn; anything else on
654
+ // stdout is not.
655
+ // ---------------------------------------------------------------------------
656
+
657
+ const ACKNOWLEDGED_LINEAGE = "review-3ec95251db75f626";
658
+ const ACKNOWLEDGED_TARGET = "sha256:b505dcd8d82395c053c9786935e11e0e235cbdecca4f1f46f98c768ea6248d3d";
659
+ const ACKNOWLEDGED_REVISION = "sha256:9732b1c3526bfecd3851093239241145c970cc126acea59bfaf14133214b60ee";
660
+ const ACKNOWLEDGEMENT_TOKENS = [`--cwd=/repo`, `--lineage=${ACKNOWLEDGED_LINEAGE}`, `--target=${ACKNOWLEDGED_TARGET}`, `--expected-revision=${ACKNOWLEDGED_REVISION}`, "--token=provider-issued-once"] as const;
661
+ const ACKNOWLEDGED_BINDING = { lineageId: ACKNOWLEDGED_LINEAGE, targetIdentity: ACKNOWLEDGED_TARGET, revision: ACKNOWLEDGED_REVISION } as const;
662
+
663
+ test("acknowledge-approved keeps accepting the pinned silent burn byte-for-byte", async () => {
664
+ const queue = queuedAdapter([{ stdout: "" }]);
665
+ const result = await client(queue.adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING });
666
+ assert.equal(result, undefined);
667
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "acknowledge-approved", ...ACKNOWLEDGEMENT_TOKENS]);
668
+ assert.equal(queue.calls[0]?.cwd, "/repo");
669
+ assert.equal(queue.calls[0]?.timeoutMs, undefined);
670
+ assert.equal(queue.calls.length, 1);
671
+ });
672
+
673
+ test("acknowledge-approved decodes the captured review-acknowledged/v1 burn envelope", async () => {
674
+ const raw = readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "review-acknowledged-v1.captured.json"), "utf8");
675
+ const queue = queuedAdapter([{ stdout: raw }]);
676
+ const result = await client(queue.adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING });
677
+ assert.equal(result?.schema, "gentle-ai.review-acknowledged/v1");
678
+ assert.deepEqual(
679
+ { lineageId: result?.lineageId, targetIdentity: result?.targetIdentity, consumedRevision: result?.consumedRevision, authority: result?.authority },
680
+ { lineageId: ACKNOWLEDGED_LINEAGE, targetIdentity: ACKNOWLEDGED_TARGET, consumedRevision: ACKNOWLEDGED_REVISION, authority: "burned" },
681
+ );
682
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "acknowledge-approved", ...ACKNOWLEDGEMENT_TOKENS]);
683
+ assert.equal(queue.calls.length, 1);
684
+
685
+ const unbound = await client(queuedAdapter([{ stdout: raw }]).adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo" });
686
+ assert.equal(unbound?.consumedRevision, ACKNOWLEDGED_REVISION);
687
+ });
688
+
689
+ test("acknowledge-approved fails closed on a foreign, drifted, or mismatched success body", async () => {
690
+ const raw = JSON.parse(readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "review-acknowledged-v1.captured.json"), "utf8")) as Record<string, unknown>;
691
+ const bodies: Array<[string, string, { lineageId: string; targetIdentity: string; revision: string }]> = [
692
+ ["a STATUS envelope", JSON.stringify(fixture("status-v5.captured.json")), ACKNOWLEDGED_BINDING],
693
+ ["a last-event closure", JSON.stringify(fixture("last-event-capture-result-approved.captured.json")), ACKNOWLEDGED_BINDING],
694
+ ["an envelope with an unknown field", JSON.stringify({ ...raw, receipt: {} }), ACKNOWLEDGED_BINDING],
695
+ ["an envelope naming another lineage", JSON.stringify(raw), { ...ACKNOWLEDGED_BINDING, lineageId: "review-other" }],
696
+ ["an envelope naming another revision", JSON.stringify(raw), { ...ACKNOWLEDGED_BINDING, revision: `sha256:${"c".repeat(64)}` }],
697
+ ["non-JSON output", "acknowledged\n", ACKNOWLEDGED_BINDING],
698
+ ];
699
+ for (const [name, stdout, binding] of bodies) {
700
+ const queue = queuedAdapter([{ stdout }]);
701
+ await assert.rejects(
702
+ () => client(queue.adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding }),
703
+ (error: unknown) => error instanceof NativeReviewCliError
704
+ && (error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE || error.code === NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON)
705
+ && error.mutationOutcome === "unknown",
706
+ name,
707
+ );
708
+ assert.equal(queue.calls.length, 1, name);
709
+ }
710
+ });
711
+
712
+ test("acknowledge-approved keeps its fail-closed stderr and typed refusal discipline around the envelope", async () => {
713
+ const raw = readFileSync(join(process.cwd(), "tests", "fixtures", "devbinary", "review-acknowledged-v1.captured.json"), "utf8");
714
+ await assert.rejects(
715
+ () => client(queuedAdapter([{ stdout: raw, stderr: "note: burned\n" }]).adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING }),
716
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
717
+ );
718
+ await assert.rejects(
719
+ () => client(queuedAdapter([{ stdout: "", stderr: "note: burned\n" }]).adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING }),
720
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
721
+ );
722
+ const failure = {
723
+ schema: "gentle-ai.review-integration.failure/v2",
724
+ contract: "gentle-ai.review-integration/v2",
725
+ operation: "review.capture-result",
726
+ phase: "pre_native",
727
+ code: "gate_scope_changed",
728
+ message: "the target scope changed since START",
729
+ mutation_outcome: "not_started",
730
+ authority_applicability: "current_target",
731
+ retry_safe: true,
732
+ replayability: "manual_action_required",
733
+ required_inputs: [],
734
+ next_action: "explicit-maintainer-action",
735
+ };
736
+ await assert.rejects(
737
+ () => client(queuedAdapter([{ stdout: JSON.stringify(failure), exitCode: 1 }]).adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING }),
738
+ (error: unknown) => error instanceof Error && error.name === "NativeReviewIntegrationError",
739
+ );
740
+ await assert.rejects(
741
+ () => client(queuedAdapter([{ stdout: "", stderr: "Error: approved acknowledgement names no live compact authority\n", exitCode: 1 }]).adapter).acknowledgeApproved({ argumentTokens: ACKNOWLEDGEMENT_TOKENS, cwd: "/repo", binding: ACKNOWLEDGED_BINDING }),
742
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT,
1062
743
  );
1063
- assert.equal(mutationTimeoutMs, undefined);
1064
744
  });