gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,626 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { createGentleAiExtension, __testing } from "../extensions/gentle-ai.ts";
5
+ import {
6
+ NATIVE_REVIEW_ERROR_CODE,
7
+ NATIVE_REVIEW_MODE_SOURCE,
8
+ NativeReviewCliError,
9
+ NativeReviewCliV216,
10
+ type ExecFileAdapter,
11
+ type NativeReviewCli,
12
+ } from "../lib/native-review-cli.ts";
13
+ import {
14
+ decodeReviewAssessmentV1,
15
+ isSmallWriterProfile,
16
+ resolveWriterProfile,
17
+ verificationPlan,
18
+ REVIEW_ASSESSMENT_SCHEMA,
19
+ VERIFICATION_TIER,
20
+ RDD_LINE,
21
+ WRITER_PROFILE,
22
+ NATIVE_REVIEW_OUTCOME,
23
+ type VerificationTier,
24
+ type RddLine,
25
+ type WriterProfile,
26
+ type NativeReviewOutcome,
27
+ } from "../lib/review-risk-assessment.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // gentle-pi#662: decoder for the native `gentle-ai review assess` envelope
31
+ // (gentle-ai#4295, landing in parallel -- stubbed here, never invoked as a
32
+ // real process).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function validEnvelope(overrides: Record<string, unknown> = {}): Record<string, unknown> {
36
+ return {
37
+ schema: REVIEW_ASSESSMENT_SCHEMA,
38
+ risk: "medium",
39
+ reasons: [{ code: "touches-auth-path", path: "lib/auth.ts", detail: "matches a high-risk path token" }],
40
+ changed_paths: 2,
41
+ changed_lines: 14,
42
+ candidate: { kind: "current-changes" },
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ test("decodeReviewAssessmentV1 accepts a well-formed gentle-ai.review-assessment/v1 envelope", () => {
48
+ const decoded = decodeReviewAssessmentV1(validEnvelope());
49
+ assert.equal(decoded.schema, REVIEW_ASSESSMENT_SCHEMA);
50
+ assert.equal(decoded.risk, "medium");
51
+ assert.deepEqual(decoded.reasons, [{ code: "touches-auth-path", path: "lib/auth.ts", detail: "matches a high-risk path token" }]);
52
+ assert.equal(decoded.changedPaths, 2);
53
+ assert.equal(decoded.changedLines, 14);
54
+ assert.deepEqual(decoded.candidate, { kind: "current-changes", baseRef: undefined });
55
+ });
56
+
57
+ test("decodeReviewAssessmentV1 accepts a base-diff candidate with base_ref", () => {
58
+ const decoded = decodeReviewAssessmentV1(validEnvelope({ candidate: { kind: "base-diff", base_ref: "origin/main" } }));
59
+ assert.deepEqual(decoded.candidate, { kind: "base-diff", baseRef: "origin/main" });
60
+ });
61
+
62
+ test("decodeReviewAssessmentV1 accepts passive and high risk values", () => {
63
+ assert.equal(decodeReviewAssessmentV1(validEnvelope({ risk: "passive", reasons: [] })).risk, "passive");
64
+ assert.equal(decodeReviewAssessmentV1(validEnvelope({ risk: "high" })).risk, "high");
65
+ });
66
+
67
+ test("decodeReviewAssessmentV1 rejects a wrong schema", () => {
68
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ schema: "gentle-ai.review-assessment/v2" })), TypeError);
69
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ schema: undefined })), TypeError);
70
+ });
71
+
72
+ test("decodeReviewAssessmentV1 rejects an unrecognized risk value", () => {
73
+ for (const risk of ["low", "critical", "", 1, null, undefined]) {
74
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ risk })), TypeError, `risk ${JSON.stringify(risk)} must be rejected`);
75
+ }
76
+ });
77
+
78
+ test("decodeReviewAssessmentV1 rejects a malformed shape", () => {
79
+ assert.throws(() => decodeReviewAssessmentV1(null), TypeError);
80
+ assert.throws(() => decodeReviewAssessmentV1("gentle-ai.review-assessment/v1"), TypeError);
81
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ reasons: "none" })), TypeError);
82
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ reasons: [{ code: "x" }] })), TypeError);
83
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ changed_paths: -1 })), TypeError);
84
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ changed_lines: 1.5 })), TypeError);
85
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ candidate: { kind: "unknown-kind" } })), TypeError);
86
+ assert.throws(() => decodeReviewAssessmentV1(validEnvelope({ candidate: { kind: "current-changes", base_ref: "" } })), TypeError);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // verificationPlan: every (rdd, risk, profile) combination from gentle-pi#662.
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const RISKS: readonly VerificationTier[] = [VERIFICATION_TIER.PASSIVE, VERIFICATION_TIER.MEDIUM, VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE];
94
+ const RDD_LINES: readonly RddLine[] = [RDD_LINE.ON, RDD_LINE.OFF, RDD_LINE.UNKNOWN];
95
+ const PROFILES: readonly WriterProfile[] = [WRITER_PROFILE.SMALL, WRITER_PROFILE.LARGE];
96
+ const NON_CLOSED_OUTCOMES: readonly NativeReviewOutcome[] = [NATIVE_REVIEW_OUTCOME.DECLINED, NATIVE_REVIEW_OUTCOME.UNAVAILABLE, NATIVE_REVIEW_OUTCOME.UNKNOWN];
97
+
98
+ test("verificationPlan: rdd on + closed, passive risk -> structural readback only regardless of writer profile", () => {
99
+ for (const writerProfile of PROFILES) {
100
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.PASSIVE, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED });
101
+ assert.equal(plan.structuralReadbackOnly, true);
102
+ assert.equal(plan.writerSelfVerification, false);
103
+ assert.equal(plan.independentVerifier, false);
104
+ }
105
+ });
106
+
107
+ test("verificationPlan: rdd on + closed, medium/high/unassessable risk -> writer self-verification, no independent verifier", () => {
108
+ for (const risk of [VERIFICATION_TIER.MEDIUM, VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE]) {
109
+ for (const writerProfile of PROFILES) {
110
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED });
111
+ assert.equal(plan.writerSelfVerification, true, `rdd on+closed, risk ${risk}, profile ${writerProfile}`);
112
+ assert.equal(plan.structuralReadbackOnly, false);
113
+ assert.equal(plan.independentVerifier, false, `the closed native review is the independent check under rdd on for risk ${risk}`);
114
+ }
115
+ }
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // gentle-pi#668: the `on` branch holds only while the native review reaches a
120
+ // terminal (`closed`) outcome for this candidate. A decline, an unavailable
121
+ // review, or an unknown/omitted outcome falls back to the exact same
122
+ // risk-gated path as `off` -- declining a review is candidate-scoped and
123
+ // never lowers the bar below the RDD-off path.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ test("verificationPlan: rdd on + non-closed outcome behaves exactly like rdd off for every risk/profile combination", () => {
127
+ for (const outcome of NON_CLOSED_OUTCOMES) {
128
+ for (const risk of RISKS) {
129
+ for (const writerProfile of PROFILES) {
130
+ const off = verificationPlan({ rddLine: RDD_LINE.OFF, risk, writerProfile });
131
+ const onFallback = verificationPlan({ rddLine: RDD_LINE.ON, risk, writerProfile, nativeReviewOutcome: outcome });
132
+ assert.equal(onFallback.writerSelfVerification, off.writerSelfVerification, `outcome ${outcome}, risk ${risk}, profile ${writerProfile}`);
133
+ assert.equal(onFallback.structuralReadbackOnly, off.structuralReadbackOnly, `outcome ${outcome}, risk ${risk}, profile ${writerProfile}`);
134
+ assert.equal(onFallback.independentVerifier, off.independentVerifier, `outcome ${outcome}, risk ${risk}, profile ${writerProfile}`);
135
+ }
136
+ }
137
+ }
138
+ });
139
+
140
+ test("verificationPlan: an omitted nativeReviewOutcome under rdd on defaults to unknown (fail closed), exactly like rdd off", () => {
141
+ for (const risk of RISKS) {
142
+ for (const writerProfile of PROFILES) {
143
+ const off = verificationPlan({ rddLine: RDD_LINE.OFF, risk, writerProfile });
144
+ const omitted = verificationPlan({ rddLine: RDD_LINE.ON, risk, writerProfile });
145
+ assert.equal(omitted.writerSelfVerification, off.writerSelfVerification);
146
+ assert.equal(omitted.structuralReadbackOnly, off.structuralReadbackOnly);
147
+ assert.equal(omitted.independentVerifier, off.independentVerifier);
148
+ }
149
+ }
150
+ });
151
+
152
+ test("verificationPlan: on+closed+medium+large -> no verifier", () => {
153
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.LARGE, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED });
154
+ assert.equal(plan.independentVerifier, false);
155
+ assert.equal(plan.writerSelfVerification, true);
156
+ });
157
+
158
+ test("verificationPlan: on+declined+medium+large -> no verifier (medium, large)", () => {
159
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.LARGE, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.DECLINED });
160
+ assert.equal(plan.independentVerifier, false);
161
+ assert.equal(plan.writerSelfVerification, true);
162
+ });
163
+
164
+ test("verificationPlan: on+declined+medium+small -> verifier", () => {
165
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.SMALL, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.DECLINED });
166
+ assert.equal(plan.independentVerifier, true);
167
+ });
168
+
169
+ test("verificationPlan: on+declined+high -> verifier", () => {
170
+ for (const writerProfile of PROFILES) {
171
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.HIGH, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.DECLINED });
172
+ assert.equal(plan.independentVerifier, true);
173
+ }
174
+ });
175
+
176
+ test("verificationPlan: on+unavailable+high -> verifier", () => {
177
+ for (const writerProfile of PROFILES) {
178
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.HIGH, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.UNAVAILABLE });
179
+ assert.equal(plan.independentVerifier, true);
180
+ }
181
+ });
182
+
183
+ test("verificationPlan: on+unknown (omitted)+high -> verifier", () => {
184
+ for (const writerProfile of PROFILES) {
185
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.HIGH, writerProfile });
186
+ assert.equal(plan.independentVerifier, true);
187
+ }
188
+ });
189
+
190
+ test("verificationPlan: on+declined+passive -> structural readback", () => {
191
+ for (const writerProfile of PROFILES) {
192
+ const plan = verificationPlan({ rddLine: RDD_LINE.ON, risk: VERIFICATION_TIER.PASSIVE, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.DECLINED });
193
+ assert.equal(plan.structuralReadbackOnly, true);
194
+ assert.equal(plan.writerSelfVerification, false);
195
+ assert.equal(plan.independentVerifier, false);
196
+ }
197
+ });
198
+
199
+ test("verificationPlan: off+closed+high -> verifier (outcome ignored)", () => {
200
+ for (const writerProfile of PROFILES) {
201
+ const plan = verificationPlan({ rddLine: RDD_LINE.OFF, risk: VERIFICATION_TIER.HIGH, writerProfile, nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED });
202
+ assert.equal(plan.independentVerifier, true);
203
+ assert.equal(plan.writerSelfVerification, true);
204
+ }
205
+ });
206
+
207
+ test("verificationPlan: off/unknown lines ignore nativeReviewOutcome entirely", () => {
208
+ for (const rddLine of [RDD_LINE.OFF, RDD_LINE.UNKNOWN]) {
209
+ for (const risk of RISKS) {
210
+ for (const writerProfile of PROFILES) {
211
+ const withoutOutcome = verificationPlan({ rddLine, risk, writerProfile });
212
+ for (const outcome of Object.values(NATIVE_REVIEW_OUTCOME)) {
213
+ const withOutcome = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome: outcome });
214
+ assert.deepEqual(withOutcome, withoutOutcome, `rddLine ${rddLine}, risk ${risk}, profile ${writerProfile}, outcome ${outcome}`);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ });
220
+
221
+ for (const rddLine of [RDD_LINE.OFF, RDD_LINE.UNKNOWN]) {
222
+ test(`verificationPlan: rdd ${rddLine}, passive risk -> structural readback only, no verifier, no tests`, () => {
223
+ for (const writerProfile of PROFILES) {
224
+ const plan = verificationPlan({ rddLine, risk: VERIFICATION_TIER.PASSIVE, writerProfile });
225
+ assert.equal(plan.structuralReadbackOnly, true);
226
+ assert.equal(plan.writerSelfVerification, false);
227
+ assert.equal(plan.independentVerifier, false);
228
+ }
229
+ });
230
+
231
+ test(`verificationPlan: rdd ${rddLine}, medium risk -> independent verifier only for a small writer profile`, () => {
232
+ const large = verificationPlan({ rddLine, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.LARGE });
233
+ assert.equal(large.writerSelfVerification, true);
234
+ assert.equal(large.structuralReadbackOnly, false);
235
+ assert.equal(large.independentVerifier, false);
236
+
237
+ const small = verificationPlan({ rddLine, risk: VERIFICATION_TIER.MEDIUM, writerProfile: WRITER_PROFILE.SMALL });
238
+ assert.equal(small.writerSelfVerification, true);
239
+ assert.equal(small.structuralReadbackOnly, false);
240
+ assert.equal(small.independentVerifier, true, "the small-model bias raises medium to high for verification purposes");
241
+ });
242
+
243
+ test(`verificationPlan: rdd ${rddLine}, high or unassessable risk -> writer self-verification plus independent verifier, always`, () => {
244
+ for (const risk of [VERIFICATION_TIER.HIGH, VERIFICATION_TIER.UNASSESSABLE]) {
245
+ for (const writerProfile of PROFILES) {
246
+ const plan = verificationPlan({ rddLine, risk, writerProfile });
247
+ assert.equal(plan.writerSelfVerification, true, `rdd ${rddLine}, risk ${risk}, profile ${writerProfile}`);
248
+ assert.equal(plan.structuralReadbackOnly, false);
249
+ assert.equal(plan.independentVerifier, true, `rdd ${rddLine}, risk ${risk}, profile ${writerProfile}`);
250
+ }
251
+ }
252
+ });
253
+ }
254
+
255
+ test("verificationPlan: an unknown rdd line never lowers a tier relative to off", () => {
256
+ for (const risk of RISKS) {
257
+ for (const writerProfile of PROFILES) {
258
+ const off = verificationPlan({ rddLine: RDD_LINE.OFF, risk, writerProfile });
259
+ const unknown = verificationPlan({ rddLine: RDD_LINE.UNKNOWN, risk, writerProfile });
260
+ assert.equal(unknown.writerSelfVerification, off.writerSelfVerification);
261
+ assert.equal(unknown.structuralReadbackOnly, off.structuralReadbackOnly);
262
+ assert.equal(unknown.independentVerifier, off.independentVerifier);
263
+ }
264
+ }
265
+ });
266
+
267
+ test("verificationPlan: reason is a non-empty distinctive string for every branch", () => {
268
+ for (const rddLine of RDD_LINES) {
269
+ for (const risk of RISKS) {
270
+ for (const writerProfile of PROFILES) {
271
+ const plan = verificationPlan({ rddLine, risk, writerProfile });
272
+ assert.ok(plan.reason.length > 20, `reason too short for ${rddLine}/${risk}/${writerProfile}`);
273
+ }
274
+ }
275
+ }
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Small-writer-profile predicate.
280
+ // ---------------------------------------------------------------------------
281
+
282
+ test("isSmallWriterProfile: true when the resolved effort is low", () => {
283
+ assert.equal(isSmallWriterProfile({ thinking: "low" }), true);
284
+ });
285
+
286
+ test("isSmallWriterProfile: true when the resolved model id carries mini as a whole token, case-insensitively", () => {
287
+ assert.equal(isSmallWriterProfile({ model: { id: "gpt-5-mini" } }), true);
288
+ assert.equal(isSmallWriterProfile({ model: { id: "Claude-Mini-Fast" } }), true);
289
+ assert.equal(isSmallWriterProfile({ model: { id: "openai/gpt-5.4-mini" } }), true, "a path-delimited mini token must still match");
290
+ assert.equal(isSmallWriterProfile({ model: { id: "o4-mini" } }), true);
291
+ assert.equal(isSmallWriterProfile({ model: { id: "claude-mini" } }), true);
292
+ assert.equal(isSmallWriterProfile({ model: { id: "mini-high" } }), true, "mini at the start of the id must still match");
293
+ });
294
+
295
+ test("isSmallWriterProfile: false for a model id where mini is only a bare substring (gemini)", () => {
296
+ assert.equal(isSmallWriterProfile({ model: { id: "gemini" } }), false, "gemini must never match on the mini substring");
297
+ assert.equal(isSmallWriterProfile({ model: { id: "gemini-2.5-pro" } }), false);
298
+ assert.equal(isSmallWriterProfile({ model: { id: "gemini-2.5-flash" } }), false);
299
+ });
300
+
301
+ test("isSmallWriterProfile: false for a large model with medium/high effort", () => {
302
+ assert.equal(isSmallWriterProfile({ model: { id: "claude-opus-4" }, thinking: "high" }), false);
303
+ assert.equal(isSmallWriterProfile({ model: { id: "claude-sonnet-5" }, thinking: "medium" }), false);
304
+ assert.equal(isSmallWriterProfile({ model: { id: "gemini-2.5-pro" }, thinking: "high" }), false);
305
+ });
306
+
307
+ test("isSmallWriterProfile: true (fail closed) when the profile is undefined, empty, or carries only an unrecognized effort with no model id", () => {
308
+ assert.equal(isSmallWriterProfile(undefined), true, "an unknown/omitted profile must fail closed to small, not default to large");
309
+ assert.equal(isSmallWriterProfile({}), true);
310
+ assert.equal(isSmallWriterProfile({ thinking: undefined }), true, "matches how the omitted-input caller shape resolves (thinking key present but undefined)");
311
+ });
312
+
313
+ test("isSmallWriterProfile: false when effort alone is known and not low, even without a model id", () => {
314
+ assert.equal(isSmallWriterProfile({ thinking: "high" }), false, "an explicitly recorded non-low effort is a known-large signal, not an unknown profile");
315
+ });
316
+
317
+ test("resolveWriterProfile maps the predicate to the small/large verificationPlan input", () => {
318
+ assert.equal(resolveWriterProfile({ thinking: "low" }), WRITER_PROFILE.SMALL);
319
+ assert.equal(resolveWriterProfile({ model: { id: "o-mini" } }), WRITER_PROFILE.SMALL);
320
+ assert.equal(resolveWriterProfile({ model: { id: "claude-sonnet-5" }, thinking: "high" }), WRITER_PROFILE.LARGE);
321
+ assert.equal(resolveWriterProfile({ model: { id: "gemini-2.5-pro" }, thinking: "high" }), WRITER_PROFILE.LARGE, "gemini must resolve large, never small on the substring");
322
+ });
323
+
324
+ test("resolveWriterProfile: an unknown or omitted profile fails closed to small, never large", () => {
325
+ assert.equal(resolveWriterProfile(undefined), WRITER_PROFILE.SMALL);
326
+ assert.equal(resolveWriterProfile({}), WRITER_PROFILE.SMALL);
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Tool-level fail-closed path: the `gentle_review` tool's `assess` operation
331
+ // (`extensions/gentle-ai.ts`, gentle-pi#662) must treat a native CLI without
332
+ // the `assess` verb (an older binary) or a rejected `assess` call (a process
333
+ // failure) the same way -- risk "unassessable", which `verificationPlan`
334
+ // treats as `high`. `assess` is exposed as a `gentle_review` operation, not a
335
+ // dedicated tool, so the fixed `gentle_*` tool registry stays unchanged.
336
+
337
+ function reviewControllerTool(nativeReviewCli: Partial<NativeReviewCli> | null): { execute: (id: string, params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<{ content: readonly { type: string; text: string }[]; details: unknown }> } {
338
+ const tools = new Map<string, any>();
339
+ const pi = {
340
+ on() {},
341
+ registerCommand() {},
342
+ registerTool(tool: { name: string }) {
343
+ tools.set(tool.name, tool);
344
+ },
345
+ } as unknown as ExtensionAPI;
346
+ createGentleAiExtension({ nativeReviewCli: nativeReviewCli as NativeReviewCli | null })(pi);
347
+ const tool = tools.get("gentle_review");
348
+ assert.ok(tool, "gentle_review must be registered");
349
+ return tool;
350
+ }
351
+
352
+ const ctx = { cwd: process.cwd() } as ExtensionContext;
353
+
354
+ test("gentle_review assess: an older binary without the assess verb fails closed to high", async () => {
355
+ const nativeReviewCli: Partial<NativeReviewCli> = {
356
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
357
+ // assess intentionally absent: pre-gentle-ai#4295 binary.
358
+ };
359
+ const tool = reviewControllerTool(nativeReviewCli);
360
+ const result = await tool.execute("call-1", { operation: "assess" }, undefined, undefined, ctx);
361
+ const details = result.details as { risk: string; rddLine: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
362
+ assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
363
+ assert.equal(details.rddLine, "off");
364
+ assert.equal(details.plan.writerSelfVerification, true);
365
+ assert.equal(details.plan.independentVerifier, true);
366
+ assert.equal(details.plan.structuralReadbackOnly, false);
367
+ const highEquivalent = verificationPlan({ rddLine: "off", risk: VERIFICATION_TIER.HIGH, writerProfile: WRITER_PROFILE.LARGE });
368
+ assert.equal(details.plan.writerSelfVerification, highEquivalent.writerSelfVerification, "unassessable must verify exactly like high");
369
+ assert.equal(details.plan.independentVerifier, highEquivalent.independentVerifier, "unassessable must verify exactly like high");
370
+ assert.equal(details.plan.structuralReadbackOnly, highEquivalent.structuralReadbackOnly, "unassessable must verify exactly like high");
371
+ assert.equal(JSON.parse(result.content[0].text).risk, VERIFICATION_TIER.UNASSESSABLE);
372
+ });
373
+
374
+ test("gentle_review assess: a rejected assess call fails closed to high, and RDD status read failure fails closed to unknown", async () => {
375
+ const nativeReviewCli: Partial<NativeReviewCli> = {
376
+ reviewMode: async () => {
377
+ throw new Error("native review mode is unavailable");
378
+ },
379
+ assess: async () => {
380
+ throw new Error("native process failed");
381
+ },
382
+ };
383
+ const tool = reviewControllerTool(nativeReviewCli);
384
+ const result = await tool.execute("call-2", { operation: "assess" }, undefined, undefined, ctx);
385
+ const details = result.details as { risk: string; rddLine: string; reasons: readonly { code: string }[]; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
386
+ assert.equal(details.risk, VERIFICATION_TIER.UNASSESSABLE);
387
+ assert.equal(details.rddLine, "unknown", "an unresolved RDD status must fail closed to unknown, never on or off");
388
+ assert.equal(details.reasons[0]?.code, "native-assess-unavailable");
389
+ assert.equal(details.plan.writerSelfVerification, true);
390
+ assert.equal(details.plan.independentVerifier, true);
391
+ });
392
+
393
+ test("gentle_review assess: a successful native assessment is reflected directly in the returned plan", async () => {
394
+ const nativeReviewCli: Partial<NativeReviewCli> = {
395
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
396
+ assess: async () => ({
397
+ schema: REVIEW_ASSESSMENT_SCHEMA,
398
+ risk: "passive",
399
+ reasons: [],
400
+ changedPaths: 1,
401
+ changedLines: 3,
402
+ candidate: { kind: "current-changes", baseRef: undefined },
403
+ }),
404
+ };
405
+ const tool = reviewControllerTool(nativeReviewCli);
406
+ const result = await tool.execute("call-3", { operation: "assess" }, undefined, undefined, ctx);
407
+ const details = result.details as { risk: string; rddLine: string; plan: { structuralReadbackOnly: boolean } };
408
+ assert.equal(details.risk, "passive");
409
+ assert.equal(details.rddLine, "on");
410
+ assert.equal(details.plan.structuralReadbackOnly, true);
411
+ });
412
+
413
+ test("gentle_review assess: baseRef without committedOnly is rejected", async () => {
414
+ const tool = reviewControllerTool({});
415
+ await assert.rejects(
416
+ () => tool.execute("call-4", { operation: "assess", input: JSON.stringify({ baseRef: "origin/main" }) }, undefined, undefined, ctx),
417
+ /committedOnly/,
418
+ );
419
+ });
420
+
421
+ test("gentle_review assess: writerModelId/writerEffort in input select the writer profile, and an omitted profile fails closed to small", async () => {
422
+ const nativeReviewCli: Partial<NativeReviewCli> = {
423
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "off", cloneLocal: "off", effective: "off", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
424
+ assess: async () => ({
425
+ schema: REVIEW_ASSESSMENT_SCHEMA,
426
+ risk: "medium",
427
+ reasons: [],
428
+ changedPaths: 1,
429
+ changedLines: 10,
430
+ candidate: { kind: "current-changes", baseRef: undefined },
431
+ }),
432
+ };
433
+ const tool = reviewControllerTool(nativeReviewCli);
434
+
435
+ // Omitted profile + medium + off -> fails closed to small -> independent
436
+ // verifier true (this is the fix for the fail-open finding: an omitted
437
+ // profile must never resolve as permissively as a known large model).
438
+ const omitted = await tool.execute("call-5", { operation: "assess" }, undefined, undefined, ctx);
439
+ assert.equal((omitted.details as { writerProfile: string }).writerProfile, "small");
440
+ assert.equal((omitted.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, true, "an omitted writer profile must fail closed to small, not default to large");
441
+
442
+ // Explicit large profile + medium + off -> independent verifier false.
443
+ const explicitLarge = await tool.execute(
444
+ "call-6",
445
+ { operation: "assess", input: JSON.stringify({ writerModelId: "claude-sonnet-5", writerEffort: "high" }) },
446
+ undefined,
447
+ undefined,
448
+ ctx,
449
+ );
450
+ assert.equal((explicitLarge.details as { writerProfile: string }).writerProfile, "large");
451
+ assert.equal((explicitLarge.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false, "an explicitly recorded large profile must not be forced into the small-model bias");
452
+
453
+ const small = await tool.execute("call-7", { operation: "assess", input: JSON.stringify({ writerEffort: "low" }) }, undefined, undefined, ctx);
454
+ assert.equal((small.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, true, "a low-effort writer profile must trigger the small-model bias");
455
+
456
+ // A gemini model id must resolve large, never small on the bare "mini" substring.
457
+ const gemini = await tool.execute("call-8", { operation: "assess", input: JSON.stringify({ writerModelId: "gemini-2.5-pro", writerEffort: "high" }) }, undefined, undefined, ctx);
458
+ assert.equal((gemini.details as { writerProfile: string }).writerProfile, "large", "gemini-2.5-pro must never be tiered as a small model");
459
+ assert.equal((gemini.details as { plan: { independentVerifier: boolean } }).plan.independentVerifier, false);
460
+ });
461
+
462
+ test("gentle_review assess: an explicit nativeReviewOutcome:\"declined\" input falls back to the risk-gated plan even when RDD is on (gentle-pi#668)", async () => {
463
+ const nativeReviewCli: Partial<NativeReviewCli> = {
464
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
465
+ assess: async () => ({
466
+ schema: REVIEW_ASSESSMENT_SCHEMA,
467
+ risk: "high",
468
+ reasons: [],
469
+ changedPaths: 3,
470
+ changedLines: 40,
471
+ candidate: { kind: "current-changes", baseRef: undefined },
472
+ }),
473
+ };
474
+ const tool = reviewControllerTool(nativeReviewCli);
475
+ const result = await tool.execute("call-9", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "declined" }) }, undefined, undefined, ctx);
476
+ const details = result.details as { risk: string; rddLine: string; outcome_source: string; plan: { writerSelfVerification: boolean; independentVerifier: boolean; structuralReadbackOnly: boolean } };
477
+ assert.equal(details.risk, "high");
478
+ assert.equal(details.rddLine, "on", "the rendered RDD line still reads on -- only the verification plan falls back");
479
+ assert.equal(details.outcome_source, "explicit");
480
+ assert.equal(details.plan.writerSelfVerification, true);
481
+ assert.equal(details.plan.independentVerifier, true, "a declined review for this candidate must re-enable the risk-gated independent verifier");
482
+ assert.equal(details.plan.structuralReadbackOnly, false);
483
+ });
484
+
485
+ test("gentle_review assess: an unrecognized nativeReviewOutcome value is rejected", async () => {
486
+ const tool = reviewControllerTool({});
487
+ await assert.rejects(
488
+ () => tool.execute("call-10", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "approved" }) }, undefined, undefined, ctx),
489
+ /nativeReviewOutcome/,
490
+ );
491
+ });
492
+
493
+ // gentle-pi#668 correction: keyed per candidate (repository + target
494
+ // identity), never repository alone; `closed` is never written to the memo.
495
+ function assessOnNativeCli(currentTargetIdentity: () => string): Partial<NativeReviewCli> {
496
+ return {
497
+ reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }),
498
+ assess: async () => ({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "high", reasons: [], changedPaths: 1, changedLines: 5, candidate: { kind: "current-changes", baseRef: undefined } }),
499
+ targetStatus: (async () => ({ applicability: "current_target", targetIdentity: currentTargetIdentity() })) as NativeReviewCli["targetStatus"],
500
+ };
501
+ }
502
+
503
+ test("gentle_review assess: derivation is bound to the exact candidate recorded, closed can only ever be passed explicitly (gentle-pi#668 correction)", async (t) => {
504
+ t.after(() => __testing.clearNativeReviewOutcomeMemoForTesting());
505
+ __testing.clearNativeReviewOutcomeMemoForTesting();
506
+ let current = "target-a";
507
+ const tool = reviewControllerTool(assessOnNativeCli(() => current));
508
+
509
+ // Nothing recorded for candidate A yet -> unknown, risk-gated.
510
+ const before = (await tool.execute("call-11", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
511
+ assert.equal(before.nativeReviewOutcome, "unknown");
512
+ assert.equal(before.outcome_source, "unknown");
513
+ assert.equal(before.plan.independentVerifier, true);
514
+
515
+ // Candidate A recorded declined -> assess for A derives it.
516
+ __testing.recordNativeReviewOutcome(ctx.cwd, "target-a", "declined");
517
+ const forA = (await tool.execute("call-12", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
518
+ assert.equal(forA.nativeReviewOutcome, "declined");
519
+ assert.equal(forA.outcome_source, "derived");
520
+ assert.equal(forA.plan.independentVerifier, true, "a derived decline re-enables the independent verifier for the matching candidate");
521
+
522
+ // Candidate B (different current target) never inherits A's decline --
523
+ // and since closed is never written, an acknowledged A can never leak a
524
+ // closed derivation into B either.
525
+ current = "target-b";
526
+ const forB = (await tool.execute("call-13", { operation: "assess" }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean } };
527
+ assert.equal(forB.nativeReviewOutcome, "unknown", "candidate B must never inherit candidate A's recorded outcome");
528
+ assert.equal(forB.outcome_source, "unknown");
529
+ assert.equal(forB.plan.independentVerifier, true);
530
+
531
+ // Explicit input always wins, and is the only way to reach "closed".
532
+ const closed = (await tool.execute("call-14", { operation: "assess", input: JSON.stringify({ nativeReviewOutcome: "closed" }) }, undefined, undefined, ctx)).details as { nativeReviewOutcome: string; outcome_source: string; plan: { independentVerifier: boolean; writerSelfVerification: boolean } };
533
+ assert.equal(closed.nativeReviewOutcome, "closed");
534
+ assert.equal(closed.outcome_source, "explicit");
535
+ assert.equal(closed.plan.writerSelfVerification, true);
536
+ assert.equal(closed.plan.independentVerifier, false, "an explicit closed outcome restores the on-path: no separate verifier");
537
+ });
538
+
539
+ test("gentle_review assess never requires a lineageId (unlike most other operations)", async () => {
540
+ const tool = reviewControllerTool({});
541
+ // Would throw "Review controller requires a lineageId" if ASSESS were not
542
+ // exempted from that check.
543
+ await assert.doesNotReject(() => tool.execute("call-7", { operation: "assess" }, undefined, undefined, ctx));
544
+ });
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Native reader (`NativeReviewCliV216.assess`, `lib/native-review-cli.ts`):
548
+ // mirrors reviewMode's wiring -- bounded subprocess, typed decode, fail closed
549
+ // on a non-zero exit or an "unknown command" older binary.
550
+ // ---------------------------------------------------------------------------
551
+
552
+ interface QueuedResult {
553
+ stdout: string;
554
+ stderr?: string;
555
+ exitCode?: number;
556
+ }
557
+
558
+ function queuedAdapter(results: readonly QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ arguments: readonly string[]; cwd: string }> } {
559
+ const queue = [...results];
560
+ const calls: Array<{ arguments: readonly string[]; cwd: string }> = [];
561
+ return {
562
+ calls,
563
+ adapter: async (request) => {
564
+ calls.push({ arguments: request.arguments, cwd: request.cwd });
565
+ const result = queue.shift();
566
+ if (result === undefined) throw new Error("unexpected native invocation");
567
+ return { stdout: result.stdout, stderr: result.stderr ?? "", exitCode: result.exitCode ?? 0, signal: null, timedOut: false, outputLimitExceeded: false };
568
+ },
569
+ };
570
+ }
571
+
572
+ function nativeClient(adapter: ExecFileAdapter): NativeReviewCliV216 {
573
+ return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024);
574
+ }
575
+
576
+ test("native assess: decodes a well-formed envelope and sends the exact plain-versioned argv", async () => {
577
+ const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "medium", reasons: [], changed_paths: 1, changed_lines: 2, candidate: { kind: "current-changes" } }) }]);
578
+ const result = await nativeClient(queue.adapter).assess!({ cwd: process.cwd() });
579
+ assert.equal(result.risk, "medium");
580
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "assess", "--cwd", process.cwd(), "--json"]);
581
+ });
582
+
583
+ test("native assess: passes baseRef/committedOnly through as --base-ref and --committed-only", async () => {
584
+ const queue = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "high", reasons: [], changed_paths: 5, changed_lines: 500, candidate: { kind: "base-diff", base_ref: "origin/main" } }) }]);
585
+ await nativeClient(queue.adapter).assess!({ cwd: process.cwd(), baseRef: "origin/main", committedOnly: true });
586
+ assert.deepEqual(queue.calls[0]?.arguments, ["review", "assess", "--cwd", process.cwd(), "--base-ref", "origin/main", "--committed-only", "--json"]);
587
+ });
588
+
589
+ test("native assess: baseRef requires explicit committedOnly acknowledgement", async () => {
590
+ const queue = queuedAdapter([]);
591
+ await assert.rejects(() => nativeClient(queue.adapter).assess!({ cwd: process.cwd(), baseRef: "origin/main" }), TypeError);
592
+ assert.equal(queue.calls.length, 0, "an invalid request must never reach the subprocess");
593
+ });
594
+
595
+ test("native assess: a non-zero exit (an older binary reporting an unknown command) fails closed with a native error, never a synthesized result", async () => {
596
+ // An older binary without the `assess` verb reports its "unknown command"
597
+ // diagnostic on stderr with nothing on stdout, or writes the same message
598
+ // to stdout instead -- either way the wrapper rejects rather than
599
+ // returning a synthesized envelope; the specific error code depends only
600
+ // on which stream carried the message.
601
+ const emptyStdout = queuedAdapter([{ stdout: "", stderr: "unknown command \"assess\" for \"gentle-ai review\"", exitCode: 1 }]);
602
+ await assert.rejects(
603
+ () => nativeClient(emptyStdout.adapter).assess!({ cwd: process.cwd() }),
604
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT,
605
+ );
606
+
607
+ const textOnStdout = queuedAdapter([{ stdout: "unknown command \"assess\" for \"gentle-ai review\"", exitCode: 1 }]);
608
+ await assert.rejects(
609
+ () => nativeClient(textOnStdout.adapter).assess!({ cwd: process.cwd() }),
610
+ (error: unknown) => error instanceof NativeReviewCliError && [NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON, NATIVE_REVIEW_ERROR_CODE.NON_ZERO].includes(error.code),
611
+ );
612
+ });
613
+
614
+ test("native assess: a wrong schema or unrecognized risk value fails closed as schema-incompatible", async () => {
615
+ const wrongSchema = queuedAdapter([{ stdout: JSON.stringify({ schema: "gentle-ai.review-assessment/v2", risk: "medium", reasons: [], changed_paths: 0, changed_lines: 0, candidate: { kind: "current-changes" } }) }]);
616
+ await assert.rejects(
617
+ () => nativeClient(wrongSchema.adapter).assess!({ cwd: process.cwd() }),
618
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
619
+ );
620
+
621
+ const badRisk = queuedAdapter([{ stdout: JSON.stringify({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "critical", reasons: [], changed_paths: 0, changed_lines: 0, candidate: { kind: "current-changes" } }) }]);
622
+ await assert.rejects(
623
+ () => nativeClient(badRisk.adapter).assess!({ cwd: process.cwd() }),
624
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE,
625
+ );
626
+ });