gentle-pi 2.1.2 → 2.2.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 (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,195 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { mkdtemp } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import test from "node:test";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
10
+ import {
11
+ GENTLE_AI_DEV_BINARY_ENV,
12
+ gentleAiDevBinaryRegistrationPath,
13
+ setGentleAiDevBinaryEnvironmentForTesting,
14
+ } from "../lib/gentle-ai-binary.ts";
15
+
16
+ // Loud surfacing for the dev-binary override: while an override is active,
17
+ // every diagnostic surface must say so, name the exact binary, its live
18
+ // version, and its content digest — the maintainer must never wonder which
19
+ // gentle-ai actually answered.
20
+
21
+ interface CommandRegistration {
22
+ handler: (args: string, ctx: ExtensionContext) => Promise<void>;
23
+ }
24
+
25
+ function harness(): { pi: ExtensionAPI; commands: Map<string, CommandRegistration> } {
26
+ const commands = new Map<string, CommandRegistration>();
27
+ const pi = {
28
+ on() {},
29
+ registerCommand(name: string, registration: CommandRegistration) {
30
+ commands.set(name, registration);
31
+ },
32
+ registerTool() {},
33
+ } as unknown as ExtensionAPI;
34
+ return { pi, commands };
35
+ }
36
+
37
+ function contextFor(cwd: string, notifications: Array<{ message: string; severity: string }>): ExtensionContext {
38
+ return {
39
+ cwd,
40
+ hasUI: true,
41
+ ui: {
42
+ notify(message: string, severity: string) {
43
+ notifications.push({ message, severity });
44
+ },
45
+ },
46
+ } as unknown as ExtensionContext;
47
+ }
48
+
49
+ async function withDevOverride<T>(callback: (state: { devBinary: string; sha256: string; home: string }) => Promise<T>): Promise<T> {
50
+ const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-"));
51
+ const bin = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-bin-"));
52
+ const devBinary = join(bin, "gentle-ai");
53
+ writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+surface'\n");
54
+ chmodSync(devBinary, 0o755);
55
+ const sha256 = createHash("sha256").update(readFileSync(devBinary)).digest("hex");
56
+ setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: devBinary }, home });
57
+ try {
58
+ return await callback({ devBinary, sha256, home });
59
+ } finally {
60
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
61
+ }
62
+ }
63
+
64
+ test("gentle:doctor and gentle:status surface the active dev-binary override loudly", async () => {
65
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
66
+ process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-"));
67
+ try {
68
+ await withDevOverride(async ({ devBinary, sha256 }) => {
69
+ const { pi, commands } = harness();
70
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
71
+ const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-"));
72
+ const expected = `Gentle AI dev binary override active (unpinned, field-test only): ${devBinary} 9.9.9-dev+surface sha256:${sha256.slice(0, 16)}`;
73
+ for (const command of ["gentle:doctor", "gentle:status"]) {
74
+ const notifications: Array<{ message: string; severity: string }> = [];
75
+ await commands.get(command)!.handler("", contextFor(cwd, notifications));
76
+ assert.equal(notifications.length, 1, command);
77
+ assert.ok(notifications[0]!.message.includes(expected), `${command}: ${notifications[0]!.message}`);
78
+ }
79
+ });
80
+ } finally {
81
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
82
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
83
+ }
84
+ });
85
+
86
+ test("without an override the surfaces stay silent about dev binaries", async () => {
87
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
88
+ process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-"));
89
+ const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-"));
90
+ setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home });
91
+ try {
92
+ const { pi, commands } = harness();
93
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
94
+ const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-"));
95
+ for (const command of ["gentle:doctor", "gentle:status"]) {
96
+ const notifications: Array<{ message: string; severity: string }> = [];
97
+ await commands.get(command)!.handler("", contextFor(cwd, notifications));
98
+ assert.equal(notifications.length, 1, command);
99
+ assert.doesNotMatch(notifications[0]!.message, /dev binary/i, command);
100
+ }
101
+ } finally {
102
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
103
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
104
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
105
+ }
106
+ });
107
+
108
+ test("an invalid override is surfaced as a failure, never silently ignored", async () => {
109
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
110
+ process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-"));
111
+ const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-"));
112
+ setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: "/nonexistent/gentle-ai" }, home });
113
+ try {
114
+ const { pi, commands } = harness();
115
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
116
+ const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-"));
117
+ const notifications: Array<{ message: string; severity: string }> = [];
118
+ await commands.get("gentle:doctor")!.handler("", contextFor(cwd, notifications));
119
+ assert.equal(notifications.length, 1);
120
+ assert.match(notifications[0]!.message, /fail: Gentle AI dev binary override/);
121
+ assert.match(notifications[0]!.message, new RegExp(GENTLE_AI_DEV_BINARY_ENV));
122
+ } finally {
123
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
124
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
125
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
126
+ }
127
+ });
128
+
129
+ test("gentle:dev-binary registers, reports, and clears the persistent override", async () => {
130
+ const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-"));
131
+ const bin = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-bin-"));
132
+ const devBinary = join(bin, "gentle-ai");
133
+ writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+register'\n");
134
+ chmodSync(devBinary, 0o755);
135
+ setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home });
136
+ try {
137
+ const { pi, commands } = harness();
138
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
139
+ const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-"));
140
+ const command = commands.get("gentle:dev-binary");
141
+ assert.ok(command, "gentle:dev-binary command is registered");
142
+ const registrationPath = gentleAiDevBinaryRegistrationPath({ env: {}, home });
143
+
144
+ let notifications: Array<{ message: string; severity: string }> = [];
145
+ await command!.handler("status", contextFor(cwd, notifications));
146
+ assert.match(notifications[0]!.message, /no dev binary override/i);
147
+
148
+ notifications = [];
149
+ await command!.handler(devBinary, contextFor(cwd, notifications));
150
+ assert.equal(existsSync(registrationPath), true);
151
+ assert.match(notifications[0]!.message, /dev binary override active \(unpinned, field-test only\)/);
152
+ assert.ok(notifications[0]!.message.includes(devBinary));
153
+
154
+ notifications = [];
155
+ await command!.handler("relative/gentle-ai", contextFor(cwd, notifications));
156
+ assert.equal(notifications[0]!.severity, "error");
157
+
158
+ notifications = [];
159
+ await command!.handler("off", contextFor(cwd, notifications));
160
+ assert.equal(existsSync(registrationPath), false);
161
+ assert.match(notifications[0]!.message, /removed|cleared/i);
162
+ } finally {
163
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
164
+ }
165
+ });
166
+
167
+ test("session start announces the active override once, loudly", async () => {
168
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
169
+ process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-"));
170
+ try {
171
+ await withDevOverride(async ({ devBinary, sha256 }) => {
172
+ const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
173
+ const pi = {
174
+ on(name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
175
+ handlers.set(name, handler);
176
+ },
177
+ registerCommand() {},
178
+ registerTool() {},
179
+ } as unknown as ExtensionAPI;
180
+ createGentleAiExtension({ nativeReviewCli: null })(pi);
181
+ const sessionStart = handlers.get("session_start");
182
+ assert.equal(typeof sessionStart, "function");
183
+ const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-"));
184
+ const notifications: Array<{ message: string; severity: string }> = [];
185
+ await sessionStart!({}, contextFor(cwd, notifications));
186
+ const expected = `Gentle AI dev binary override active (unpinned, field-test only): ${devBinary} 9.9.9-dev+surface sha256:${sha256.slice(0, 16)}`;
187
+ const announcement = notifications.find((entry) => entry.message.includes(expected));
188
+ assert.ok(announcement, JSON.stringify(notifications));
189
+ assert.equal(announcement!.severity, "warning");
190
+ });
191
+ } finally {
192
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
193
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
194
+ }
195
+ });
@@ -0,0 +1,336 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { mkdtemp } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import test from "node:test";
8
+ import {
9
+ GENTLE_AI_DEV_BINARY_ENV,
10
+ GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE,
11
+ GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA,
12
+ GentleAiDevBinaryOverrideError,
13
+ PackageLocalGentleAiBinaryMissingError,
14
+ gentleAiDevBinaryRegistrationPath,
15
+ registerGentleAiDevBinary,
16
+ resolveGentleAiBinary,
17
+ resolveGentleAiDevBinaryOverride,
18
+ setGentleAiDevBinaryEnvironmentForTesting,
19
+ unregisterGentleAiDevBinary,
20
+ type GentleAiDevBinaryEnvironment,
21
+ } from "../lib/gentle-ai-binary.ts";
22
+ import {
23
+ NATIVE_REVIEW_ERROR_CODE,
24
+ NativeReviewCliError,
25
+ NativeReviewCliV214,
26
+ NativeReviewCliV216,
27
+ clearNativeReviewCapabilitiesCacheForTesting,
28
+ type ExecFileAdapter,
29
+ } from "../lib/native-review-cli.ts";
30
+
31
+ // Dev-binary override, maintainer field-test lane. The pinned supply-chain
32
+ // resolver stays byte-identical while neither the env var nor the persistent
33
+ // registration file is present; every override failure is a typed refusal that
34
+ // names its origin, never a silent fallback to the pin (silently running the
35
+ // pinned release while the maintainer believes he is testing main is the worst
36
+ // outcome this contract exists to prevent).
37
+
38
+ const PLATFORM = "linux";
39
+
40
+ function sha256(path: string): string {
41
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
42
+ }
43
+
44
+ async function scratch(prefix: string): Promise<string> {
45
+ return mkdtemp(join(tmpdir(), prefix));
46
+ }
47
+
48
+ function writeDevBinary(directory: string, contents = "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+field'\n"): string {
49
+ const path = join(directory, "gentle-ai");
50
+ writeFileSync(path, contents);
51
+ chmodSync(path, 0o755);
52
+ return path;
53
+ }
54
+
55
+ function environment(home: string, env: Record<string, string | undefined> = {}): GentleAiDevBinaryEnvironment {
56
+ return { env, home };
57
+ }
58
+
59
+ function writeRegistration(home: string, contents: string): string {
60
+ const path = gentleAiDevBinaryRegistrationPath(environment(home));
61
+ mkdirSync(join(home, ".pi", "gentle-ai"), { recursive: true });
62
+ writeFileSync(path, contents);
63
+ return path;
64
+ }
65
+
66
+ function registrationDocument(path: string): string {
67
+ return `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`;
68
+ }
69
+
70
+ const isOverrideError = (origin: string | RegExp) => (error: unknown): boolean =>
71
+ error instanceof GentleAiDevBinaryOverrideError
72
+ && error.code === GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE
73
+ && (typeof origin === "string" ? error.message.includes(origin) : origin.test(error.message));
74
+
75
+ test("the env override resolves the dev binary with a fresh sha256", async () => {
76
+ const home = await scratch("gentle-pi-dev-home-");
77
+ const bin = await scratch("gentle-pi-dev-bin-");
78
+ const devBinary = writeDevBinary(bin);
79
+ const env = environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: devBinary });
80
+ const override = resolveGentleAiDevBinaryOverride(env, PLATFORM);
81
+ assert.equal(override?.source, "env");
82
+ assert.equal(override?.path, devBinary);
83
+ assert.equal(override?.sha256, sha256(devBinary));
84
+ const packageRoot = await scratch("gentle-pi-dev-pkg-");
85
+ assert.equal(resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), devBinary);
86
+ });
87
+
88
+ test("with no env var and no registration file, pinned resolution is byte-identical", async () => {
89
+ const home = await scratch("gentle-pi-dev-home-");
90
+ const packageRoot = await scratch("gentle-pi-dev-pkg-");
91
+ const env = environment(home);
92
+ assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM), undefined);
93
+ // An empty env value means unset, exactly like the pinned path today.
94
+ assert.equal(resolveGentleAiDevBinaryOverride(environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: "" }), PLATFORM), undefined);
95
+ assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), PackageLocalGentleAiBinaryMissingError);
96
+ });
97
+
98
+ test("a relative path, symlink, or non-executable env override is a typed refusal naming the env var", async () => {
99
+ const home = await scratch("gentle-pi-dev-home-");
100
+ const bin = await scratch("gentle-pi-dev-bin-");
101
+ const devBinary = writeDevBinary(bin);
102
+ const packageRoot = await scratch("gentle-pi-dev-pkg-");
103
+ const symlinked = join(bin, "gentle-ai-link");
104
+ symlinkSync(devBinary, symlinked);
105
+ const nonExecutable = join(bin, "gentle-ai-noexec");
106
+ writeFileSync(nonExecutable, "#!/bin/sh\n");
107
+ chmodSync(nonExecutable, 0o644);
108
+ for (const value of ["relative/gentle-ai", symlinked, nonExecutable, join(bin, "missing-gentle-ai")]) {
109
+ const env = environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: value });
110
+ assert.throws(() => resolveGentleAiDevBinaryOverride(env, PLATFORM), isOverrideError(GENTLE_AI_DEV_BINARY_ENV), value);
111
+ // Never a silent fallback to the pin.
112
+ assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), isOverrideError(GENTLE_AI_DEV_BINARY_ENV), value);
113
+ }
114
+ });
115
+
116
+ test("the registration file resolves the dev binary and the env var wins over it", async () => {
117
+ const home = await scratch("gentle-pi-dev-home-");
118
+ const bin = await scratch("gentle-pi-dev-bin-");
119
+ const registered = writeDevBinary(bin);
120
+ writeRegistration(home, registrationDocument(registered));
121
+ const fileOnly = resolveGentleAiDevBinaryOverride(environment(home), PLATFORM);
122
+ assert.equal(fileOnly?.source, "registration");
123
+ assert.equal(fileOnly?.path, registered);
124
+ const envDirectory = await scratch("gentle-pi-dev-bin-");
125
+ const envBinary = writeDevBinary(envDirectory, "#!/bin/sh\necho 'gentle-ai 9.9.10-dev'\n");
126
+ const both = resolveGentleAiDevBinaryOverride(environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: envBinary }), PLATFORM);
127
+ assert.equal(both?.source, "env");
128
+ assert.equal(both?.path, envBinary);
129
+ });
130
+
131
+ test("GENTLE_PI_CONFIG_HOME relocates the registration file", async () => {
132
+ const home = await scratch("gentle-pi-dev-home-");
133
+ const configHome = await scratch("gentle-pi-dev-config-");
134
+ const env = environment(home, { GENTLE_PI_CONFIG_HOME: configHome });
135
+ assert.equal(gentleAiDevBinaryRegistrationPath(env), join(configHome, "dev-binary.json"));
136
+ assert.equal(gentleAiDevBinaryRegistrationPath(environment(home)), join(home, ".pi", "gentle-ai", "dev-binary.json"));
137
+ });
138
+
139
+ test("a malformed or unresolvable registration is a typed refusal naming the file, never the pin", async () => {
140
+ const bin = await scratch("gentle-pi-dev-bin-");
141
+ const packageRoot = await scratch("gentle-pi-dev-pkg-");
142
+ const devBinary = writeDevBinary(bin);
143
+ const cases = [
144
+ "not json at all",
145
+ `${JSON.stringify({ schema: "gentle-pi.dev-binary/v0", path: devBinary })}\n`,
146
+ `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: devBinary, sha256: "0".repeat(64) })}\n`,
147
+ `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA })}\n`,
148
+ `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: "relative/gentle-ai" })}\n`,
149
+ `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: join(bin, "missing-gentle-ai") })}\n`,
150
+ `${JSON.stringify([devBinary])}\n`,
151
+ ];
152
+ for (const contents of cases) {
153
+ const home = await scratch("gentle-pi-dev-home-");
154
+ const registrationPath = writeRegistration(home, contents);
155
+ const env = environment(home);
156
+ assert.throws(() => resolveGentleAiDevBinaryOverride(env, PLATFORM), isOverrideError(registrationPath), contents);
157
+ assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), isOverrideError(registrationPath), contents);
158
+ }
159
+ });
160
+
161
+ test("replacing the registered binary is picked up on the next resolution with the new sha256", async () => {
162
+ const home = await scratch("gentle-pi-dev-home-");
163
+ const bin = await scratch("gentle-pi-dev-bin-");
164
+ const devBinary = writeDevBinary(bin, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build1'\n");
165
+ writeRegistration(home, registrationDocument(devBinary));
166
+ const env = environment(home);
167
+ const first = resolveGentleAiDevBinaryOverride(env, PLATFORM);
168
+ writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build2'\n");
169
+ chmodSync(devBinary, 0o755);
170
+ const second = resolveGentleAiDevBinaryOverride(env, PLATFORM);
171
+ assert.equal(first?.sha256, createHash("sha256").update("#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build1'\n").digest("hex"));
172
+ assert.equal(second?.sha256, createHash("sha256").update("#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build2'\n").digest("hex"));
173
+ assert.notEqual(first?.sha256, second?.sha256);
174
+ });
175
+
176
+ test("register and unregister write and delete the strict registration document", async () => {
177
+ const home = await scratch("gentle-pi-dev-home-");
178
+ const bin = await scratch("gentle-pi-dev-bin-");
179
+ const devBinary = writeDevBinary(bin);
180
+ const env = environment(home);
181
+ const registered = registerGentleAiDevBinary(devBinary, env, PLATFORM);
182
+ assert.equal(registered.registrationPath, gentleAiDevBinaryRegistrationPath(env));
183
+ assert.equal(readFileSync(registered.registrationPath, "utf8"), registrationDocument(devBinary));
184
+ assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM)?.path, devBinary);
185
+ assert.throws(() => registerGentleAiDevBinary("relative/gentle-ai", env, PLATFORM), isOverrideError(/relative\/gentle-ai/));
186
+ assert.equal(unregisterGentleAiDevBinary(env), true);
187
+ assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM), undefined);
188
+ assert.equal(unregisterGentleAiDevBinary(env), false);
189
+ });
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Version gate: dev mode accepts the binary's reported version with the latest
193
+ // known contract row as the capability floor; pinned mode stays byte-identical.
194
+ // ---------------------------------------------------------------------------
195
+
196
+ interface QueuedResult { stdout: string; stderr?: string; exitCode?: number }
197
+
198
+ function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[] }> } {
199
+ const calls: Array<{ file: string; arguments: readonly string[] }> = [];
200
+ return {
201
+ calls,
202
+ adapter: async (request) => {
203
+ calls.push({ file: request.file, arguments: request.arguments });
204
+ const result = results.shift();
205
+ if (!result) throw new Error("unexpected native invocation");
206
+ return { stdout: result.stdout, stderr: result.stderr ?? "", exitCode: result.exitCode ?? 0, signal: null, timedOut: false, outputLimitExceeded: false };
207
+ },
208
+ };
209
+ }
210
+
211
+ 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" }) };
212
+ const DEV_VERSION_OUTPUTS = ["gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery\n", "gentle-ai 9.9.9\n"];
213
+
214
+ async function withDevEnvironment<T>(callback: () => Promise<T>): Promise<T> {
215
+ const home = await scratch("gentle-pi-dev-home-");
216
+ const bin = await scratch("gentle-pi-dev-bin-");
217
+ const devBinary = writeDevBinary(bin);
218
+ setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: devBinary }, home });
219
+ try {
220
+ return await callback();
221
+ } finally {
222
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
223
+ }
224
+ }
225
+
226
+ async function withPinnedEnvironment<T>(callback: () => Promise<T>): Promise<T> {
227
+ const home = await scratch("gentle-pi-dev-home-");
228
+ setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home });
229
+ try {
230
+ return await callback();
231
+ } finally {
232
+ setGentleAiDevBinaryEnvironmentForTesting(undefined);
233
+ }
234
+ }
235
+
236
+ test("pinned mode still refuses an unknown reported version", async () => {
237
+ await withPinnedEnvironment(async () => {
238
+ for (const stdout of DEV_VERSION_OUTPUTS) {
239
+ const queue = queuedAdapter([{ stdout }]);
240
+ await assert.rejects(
241
+ () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" }),
242
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
243
+ stdout,
244
+ );
245
+ }
246
+ });
247
+ });
248
+
249
+ test("dev mode accepts the reported version and floors capabilities at the latest known contract row", async () => {
250
+ await withDevEnvironment(async () => {
251
+ for (const stdout of DEV_VERSION_OUTPUTS) {
252
+ const queue = queuedAdapter([{ stdout }, START]);
253
+ const result = await new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" });
254
+ assert.equal(result.lineageId, "lineage-1", stdout);
255
+ }
256
+ });
257
+ });
258
+
259
+ test("dev mode never rescues a pinned-mode unparseable version banner", async () => {
260
+ await withDevEnvironment(async () => {
261
+ const queue = queuedAdapter([{ stdout: "definitely not a version banner\n" }]);
262
+ await assert.rejects(
263
+ () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" }),
264
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
265
+ );
266
+ });
267
+ });
268
+
269
+ // Captured 2026-08-15 from /home/gentleman/.cargo/bin/gentle-ai reporting
270
+ // "gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery" (a gentle-ai main
271
+ // line dev build) via `gentle-ai review capabilities --contract
272
+ // gentle-ai.review-integration/v2` in a scratch git repository.
273
+ const CAPABILITIES_V22 = readFileSync(join(import.meta.dirname, "fixtures", "devbinary", "capabilities-v2.2.captured.json"), "utf8");
274
+ const CAPTURED_EXECUTABLE_DIGEST = "ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1";
275
+ const STATUS_V5 = readFileSync(join(import.meta.dirname, "fixtures", "devbinary", "status-v5.captured.json"), "utf8");
276
+ const FORECAST_STDERR = "Forecast horizon: partial\nstep 1: collect; reason_code=empty_candidate_base_ref_required; description=empty candidate base ref required\nRe-query STATUS after completing this partial head.\n";
277
+
278
+ function v216(adapter: ExecFileAdapter, digest: string): NativeReviewCliV216 {
279
+ return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 16 * 1024 * 1024, async () => undefined, () => digest);
280
+ }
281
+
282
+ test("pinned mode refuses a dev-version capabilities envelope on package version equality", async () => {
283
+ clearNativeReviewCapabilitiesCacheForTesting();
284
+ await withPinnedEnvironment(async () => {
285
+ const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }]);
286
+ await assert.rejects(
287
+ () => v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).capabilities({ cwd: "/repo" }),
288
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE,
289
+ );
290
+ });
291
+ });
292
+
293
+ test("dev mode negotiates the captured v2.2 capabilities envelope without the pin equality", async () => {
294
+ clearNativeReviewCapabilitiesCacheForTesting();
295
+ await withDevEnvironment(async () => {
296
+ const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }]);
297
+ const capabilities = await v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).capabilities({ cwd: "/repo" });
298
+ assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery");
299
+ });
300
+ });
301
+
302
+ test("dev mode tolerates the provider's forecast narration on negotiated STATUS stderr and still refuses other stderr", async () => {
303
+ await withDevEnvironment(async () => {
304
+ clearNativeReviewCapabilitiesCacheForTesting();
305
+ const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }, { stdout: STATUS_V5, stderr: FORECAST_STDERR }]);
306
+ const status = await v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).targetStatus({ cwd: "/repo", projection: "workspace" });
307
+ assert.equal(status.action, "start");
308
+ assert.equal(status.forecast?.horizon, "partial");
309
+ clearNativeReviewCapabilitiesCacheForTesting();
310
+ const noisy = queuedAdapter([{ stdout: CAPABILITIES_V22 }, { stdout: STATUS_V5, stderr: "panic: something else\n" }]);
311
+ await assert.rejects(
312
+ () => v216(noisy.adapter, CAPTURED_EXECUTABLE_DIGEST).targetStatus({ cwd: "/repo", projection: "workspace" }),
313
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
314
+ );
315
+ clearNativeReviewCapabilitiesCacheForTesting();
316
+ });
317
+ });
318
+
319
+ test("pinned mode still refuses forecast narration stderr on negotiated STATUS", async () => {
320
+ await withPinnedEnvironment(async () => {
321
+ clearNativeReviewCapabilitiesCacheForTesting();
322
+ // A pinned-version capabilities envelope cannot be captured from the dev
323
+ // binary; assert one layer down instead: with the dev override absent the
324
+ // forecast narration is not in any tolerated stderr set, so the negotiated
325
+ // STATUS invocation itself reports UNEXPECTED_STDERR. Capability
326
+ // negotiation is bypassed by pre-seeding the cache through a dev-mode run
327
+ // being impossible here, so this test drives the legacy client, whose
328
+ // stderr discipline is the same code path.
329
+ const queue = queuedAdapter([{ stdout: "gentle-ai 2.1.5\n" }, { stdout: STATUS_V5, stderr: FORECAST_STDERR }]);
330
+ await assert.rejects(
331
+ () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewStatus({ cwd: "/repo" }),
332
+ (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR,
333
+ );
334
+ clearNativeReviewCapabilitiesCacheForTesting();
335
+ });
336
+ });