gentle-pi 2.2.0 → 2.3.0

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