gentle-pi 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,257 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { applySavedModelConfig, readModelConfig, readModelConfigAsync } from "../extensions/gentle-ai.ts";
7
+
8
+ test("model routing authority normalizes and preserves sync/async source status", async (t) => {
9
+ const loaded = await import("../lib/model-routing-authority.ts").then(
10
+ (module) => ({ module, error: undefined }),
11
+ (error) => ({ module: undefined, error }),
12
+ );
13
+ assert.ok(
14
+ loaded.module,
15
+ `shared model routing authority must load: ${String(loaded.error)}`,
16
+ );
17
+ const authority = loaded.module;
18
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-routing-authority-"));
19
+ const globalDir = join(root, "global");
20
+ const projectDir = join(root, "project");
21
+ const projectConfigDir = join(projectDir, ".pi", "gentle-ai");
22
+ const agentsDir = join(root, "agents");
23
+ mkdirSync(globalDir, { recursive: true });
24
+ mkdirSync(projectConfigDir, { recursive: true });
25
+ mkdirSync(agentsDir, { recursive: true });
26
+ t.after(() => rmSync(root, { recursive: true, force: true }));
27
+
28
+ assert.equal(authority.normalizeModelId(" openai/gpt-5 "), "openai/gpt-5");
29
+ assert.equal(authority.normalizeModelId("bad model"), undefined);
30
+ assert.deepEqual(authority.normalizeRoutingEntry(" inherit "), { model: "inherit" });
31
+ assert.deepEqual(
32
+ authority.normalizeRoutingEntry({ model: " anthropic/opus ", thinking: "high" }),
33
+ { model: "anthropic/opus", thinking: "high" },
34
+ );
35
+ assert.deepEqual(authority.normalizeRoutingEntry(null), undefined);
36
+ assert.deepEqual(
37
+ authority.normalizeModelConfig({
38
+ worker: " openai/gpt-5 ",
39
+ clear: {},
40
+ "not valid": "ignored",
41
+ nullValue: null,
42
+ }),
43
+ { worker: { model: "openai/gpt-5" }, clear: {} },
44
+ );
45
+
46
+ const missingPath = join(globalDir, "missing.json");
47
+ assert.deepEqual(authority.readModelConfigFile(missingPath), { status: "missing" });
48
+ assert.deepEqual(await authority.readModelConfigFileAsync(missingPath), { status: "missing" });
49
+
50
+ const validGlobalPath = join(globalDir, "valid.json");
51
+ writeFileSync(
52
+ validGlobalPath,
53
+ JSON.stringify({ worker: "openai/gpt-5", reviewer: { thinking: "medium" }, "not valid": "openai/gpt-4" }),
54
+ );
55
+ const validSync = authority.readModelConfigFile(validGlobalPath);
56
+ const validAsync = await authority.readModelConfigFileAsync(validGlobalPath);
57
+ assert.deepEqual(validSync, {
58
+ status: "valid",
59
+ config: {
60
+ worker: { model: "openai/gpt-5" },
61
+ reviewer: { model: undefined, thinking: "medium" },
62
+ "not valid": { model: "openai/gpt-4" },
63
+ },
64
+ });
65
+ assert.deepEqual(validAsync, validSync);
66
+
67
+ const invalidGlobalPath = join(globalDir, "invalid.json");
68
+ writeFileSync(invalidGlobalPath, "[]");
69
+ assert.deepEqual(authority.readModelConfigFile(invalidGlobalPath), {
70
+ status: "invalid",
71
+ path: invalidGlobalPath,
72
+ });
73
+ assert.deepEqual(await authority.readModelConfigFileAsync(invalidGlobalPath), {
74
+ status: "invalid",
75
+ path: invalidGlobalPath,
76
+ });
77
+
78
+ const projectPath = join(projectConfigDir, "models.json");
79
+ writeFileSync(projectPath, JSON.stringify({ project: "google/gemini" }));
80
+ assert.deepEqual(
81
+ authority.readSavedModelConfig(missingPath, projectPath),
82
+ { status: "valid", config: { project: { model: "google/gemini" } } },
83
+ );
84
+ assert.deepEqual(
85
+ await authority.readSavedModelConfigAsync(missingPath, projectPath),
86
+ await authority.readSavedModelConfig(missingPath, projectPath),
87
+ );
88
+ assert.deepEqual(authority.readSavedModelConfig(invalidGlobalPath, projectPath), {
89
+ status: "invalid",
90
+ path: invalidGlobalPath,
91
+ });
92
+ assert.deepEqual(await authority.readSavedModelConfigAsync(invalidGlobalPath, projectPath), {
93
+ status: "invalid",
94
+ path: invalidGlobalPath,
95
+ });
96
+
97
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
98
+ process.env.GENTLE_PI_CONFIG_HOME = globalDir;
99
+ t.after(() => {
100
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
101
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
102
+ });
103
+ writeFileSync(projectPath, JSON.stringify({ project: "google/gemini" }));
104
+ assert.deepEqual(readModelConfig(projectDir), { project: { model: "google/gemini" } });
105
+ assert.deepEqual(await readModelConfigAsync(projectDir), readModelConfig(projectDir));
106
+
107
+ writeFileSync(projectPath, "[]");
108
+ assert.deepEqual(authority.readModelConfigFile(projectPath), { status: "invalid", path: projectPath });
109
+ assert.deepEqual(await authority.readModelConfigFileAsync(projectPath), { status: "invalid", path: projectPath });
110
+ assert.deepEqual(readModelConfig(projectDir), {});
111
+ assert.deepEqual(await readModelConfigAsync(projectDir), {});
112
+
113
+ writeFileSync(join(globalDir, "models.json"), JSON.stringify({ global: "openai/gpt-5" }));
114
+ assert.deepEqual(readModelConfig(projectDir), { global: { model: "openai/gpt-5" } });
115
+ assert.deepEqual(await readModelConfigAsync(projectDir), readModelConfig(projectDir));
116
+
117
+ writeFileSync(join(globalDir, "models.json"), "[]");
118
+ assert.deepEqual(readModelConfig(projectDir), {});
119
+ assert.deepEqual(await readModelConfigAsync(projectDir), {});
120
+ });
121
+
122
+ test("saved-routing apply fails closed for invalid project and global sources", async (t) => {
123
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-routing-apply-"));
124
+ const configHome = join(root, "global");
125
+ const projectConfigDir = join(root, ".pi", "gentle-ai");
126
+ const projectAgentsDir = join(root, ".pi", "agents");
127
+ const projectProfileDir = join(root, ".pi");
128
+ const agentHome = join(root, "agent-home");
129
+ const agentHomeAgentsDir = join(agentHome, "agents");
130
+ const agentHomeSubagentsDir = join(agentHome, "subagents");
131
+ mkdirSync(configHome, { recursive: true });
132
+ mkdirSync(projectConfigDir, { recursive: true });
133
+ mkdirSync(projectAgentsDir, { recursive: true });
134
+ mkdirSync(projectProfileDir, { recursive: true });
135
+ mkdirSync(agentHomeAgentsDir, { recursive: true });
136
+ mkdirSync(agentHomeSubagentsDir, { recursive: true });
137
+ t.after(() => rmSync(root, { recursive: true, force: true }));
138
+
139
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
140
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
141
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
142
+ process.env.GENTLE_PI_AGENT_HOME = agentHome;
143
+ t.after(() => {
144
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
145
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
146
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
147
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
148
+ });
149
+
150
+ const agentPath = join(projectAgentsDir, "worker.md");
151
+ writeFileSync(agentPath, "---\nname: worker\ndescription: Worker\n---\nbody\n");
152
+ const profilePath = join(projectProfileDir, "subagents.json");
153
+ const profileBytes = JSON.stringify({
154
+ unrelated: { keep: true },
155
+ model_profiles: { worker: { model: "existing/model", effort: "low" } },
156
+ }, null, 2) + "\n";
157
+ writeFileSync(profilePath, profileBytes);
158
+ const context = { cwd: root } as Parameters<typeof applySavedModelConfig>[0];
159
+ const projectPath = join(projectConfigDir, "models.json");
160
+ const globalPath = join(configHome, "models.json");
161
+ let mutatorCalls = 0;
162
+ const applyConfig = async () => {
163
+ mutatorCalls += 1;
164
+ return { updated: 0, skipped: 0 };
165
+ };
166
+
167
+ for (const projectValue of ["{", "[]", "null"] as const) {
168
+ writeFileSync(projectPath, projectValue);
169
+ const before = statSync(profilePath);
170
+ const result = await applySavedModelConfig(context, applyConfig);
171
+ assert.deepEqual(result, { updated: 0, skipped: 0, invalidPath: projectPath });
172
+ assert.equal(mutatorCalls, 0);
173
+ assert.equal(readFileSync(profilePath, "utf8"), profileBytes);
174
+ assert.equal(statSync(profilePath).mtimeMs, before.mtimeMs);
175
+ }
176
+
177
+ writeFileSync(globalPath, "[]");
178
+ writeFileSync(projectPath, JSON.stringify({ worker: "new/model" }));
179
+ const before = statSync(profilePath);
180
+ const result = await applySavedModelConfig(context, applyConfig);
181
+ assert.deepEqual(result, { updated: 0, skipped: 0, invalidPath: globalPath });
182
+ assert.equal(mutatorCalls, 0);
183
+ assert.equal(readFileSync(profilePath, "utf8"), profileBytes);
184
+ assert.equal(statSync(profilePath).mtimeMs, before.mtimeMs);
185
+ });
186
+
187
+ test("saved-routing apply preserves missing, valid, null, inherit, and omission behavior", async (t) => {
188
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-routing-apply-valid-"));
189
+ const configHome = join(root, "global");
190
+ const projectConfigDir = join(root, ".pi", "gentle-ai");
191
+ const projectAgentsDir = join(root, ".pi", "agents");
192
+ const projectProfileDir = join(root, ".pi");
193
+ const agentHome = join(root, "agent-home");
194
+ const agentHomeAgentsDir = join(agentHome, "agents");
195
+ const agentHomeSubagentsDir = join(agentHome, "subagents");
196
+ mkdirSync(configHome, { recursive: true });
197
+ mkdirSync(projectConfigDir, { recursive: true });
198
+ mkdirSync(projectAgentsDir, { recursive: true });
199
+ mkdirSync(projectProfileDir, { recursive: true });
200
+ mkdirSync(agentHomeAgentsDir, { recursive: true });
201
+ mkdirSync(agentHomeSubagentsDir, { recursive: true });
202
+ t.after(() => rmSync(root, { recursive: true, force: true }));
203
+
204
+ const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
205
+ const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME;
206
+ process.env.GENTLE_PI_CONFIG_HOME = configHome;
207
+ process.env.GENTLE_PI_AGENT_HOME = agentHome;
208
+ t.after(() => {
209
+ if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
210
+ else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
211
+ if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME;
212
+ else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome;
213
+ });
214
+
215
+ const agentPath = join(projectAgentsDir, "worker.md");
216
+ writeFileSync(agentPath, "---\nname: worker\ndescription: Worker\n---\nbody\n");
217
+ const profilePath = join(projectProfileDir, "subagents.json");
218
+ const initialProfile = {
219
+ unrelated: { keep: true },
220
+ model_profiles: { worker: { model: "existing/model", effort: "low" } },
221
+ };
222
+ writeFileSync(profilePath, `${JSON.stringify(initialProfile, null, 2)}\n`);
223
+ const context = { cwd: root } as Parameters<typeof applySavedModelConfig>[0];
224
+ const projectPath = join(projectConfigDir, "models.json");
225
+
226
+ const missing = await applySavedModelConfig(context);
227
+ assert.equal(missing.invalidPath, undefined);
228
+ assert.deepEqual(JSON.parse(readFileSync(profilePath, "utf8")), initialProfile);
229
+
230
+ writeFileSync(projectPath, JSON.stringify({ worker: "inherit" }));
231
+ const valid = await applySavedModelConfig(context);
232
+ assert.equal(valid.invalidPath, undefined);
233
+ const validProfile = JSON.parse(readFileSync(profilePath, "utf8")) as Record<string, any>;
234
+ assert.deepEqual(validProfile.unrelated, initialProfile.unrelated);
235
+ assert.deepEqual(validProfile.model_profiles.worker, { model: "inherit" });
236
+ assert.match(readFileSync(agentPath, "utf8"), /model: inherit\n/);
237
+ assert.doesNotMatch(readFileSync(agentPath, "utf8"), /thinking:/);
238
+
239
+ const afterValidBytes = readFileSync(profilePath, "utf8");
240
+ const afterValid = statSync(profilePath);
241
+ writeFileSync(projectPath, JSON.stringify({ worker: null }));
242
+ const nullEntry = await applySavedModelConfig(context);
243
+ assert.equal(nullEntry.invalidPath, undefined);
244
+ assert.equal(readFileSync(profilePath, "utf8"), afterValidBytes);
245
+ assert.equal(statSync(profilePath).mtimeMs, afterValid.mtimeMs);
246
+ assert.doesNotMatch(readFileSync(agentPath, "utf8"), /model: null/);
247
+
248
+ let mutatorCalls = 0;
249
+ const applyConfig = async () => {
250
+ mutatorCalls += 1;
251
+ return { updated: 0, skipped: 0 };
252
+ };
253
+ writeFileSync(projectPath, JSON.stringify({ worker: "new/model" }));
254
+ const injected = await applySavedModelConfig(context, applyConfig);
255
+ assert.equal(injected.invalidPath, undefined);
256
+ assert.equal(mutatorCalls, 1);
257
+ });
@@ -80,8 +80,81 @@ test("2.2.2 repeats 2.2.1 because the lane Pi speaks still did not move", () =>
80
80
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.1"] as Record<string, boolean>);
81
81
  });
82
82
 
83
+ test("2.2.3 repeats 2.2.2 because Pi's consumed capability columns did not move", () => {
84
+ // Ground-truthed against the released v2.2.3 binary: the v2 lane reports
85
+ // protocol 2.0, the same eight negotiated operations, and the same START
86
+ // fields represented by this table.
87
+ const contract = NATIVE_CLI_CONTRACTS["2.2.3"] as Record<string, boolean>;
88
+ assert.equal(contract.mode, true);
89
+ assert.equal(contract.delivery, true);
90
+ assert.equal(contract.riskEvidence, false);
91
+ assert.equal(contract.hint, false);
92
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.2"] as Record<string, boolean>);
93
+ });
94
+
95
+ test("2.4.0 repeats 2.2.3 because the START envelope Pi consumes still omits risk_evidence and hint", () => {
96
+ // Ground-truthed against the published v2.4.0 linux_amd64 release binary
97
+ // (sha256:0be4467... , captured 2026-08-17), not against a main build:
98
+ //
99
+ // mode `gentle-ai review mode status --json` still answers
100
+ // `gentle-ai.rdd-mode-status/v1`. The RESOLVED value moved --
101
+ // an unconfigured install now reports effective "off" with
102
+ // source "default" instead of "on" -- but that is a decision
103
+ // the envelope carries, not a capability it lost.
104
+ // delivery `review validate --gate pre-commit` still answers
105
+ // `gentle-ai.review-gate-result/v1` with
106
+ // `delivery: "disabled/unmanaged"` at exit 0. Under opt-in RDD
107
+ // this is now the DEFAULT delivery answer for an install that
108
+ // never enabled review, so the column matters more, not less.
109
+ //
110
+ // riskEvidence and hint stay dark for exactly the reason the 2.2.0 comment
111
+ // gives, re-proven rather than inherited: the negotiated
112
+ // `gentle-ai.review-integration.start/v3` envelope captured from this
113
+ // binary carries `risk_reasons: [{ code: "non_executable_only" }]` and no
114
+ // `risk_evidence` and no `hint`. `risk_evidence` does appear in v2.4.0, but
115
+ // only on the `gentle-ai.review-integration.consent/v3` blocking envelope,
116
+ // which is a different envelope on a different branch of START and is not
117
+ // what these columns describe.
118
+ const contract = NATIVE_CLI_CONTRACTS["2.4.0"] as Record<string, boolean>;
119
+ assert.equal(contract.mode, true);
120
+ assert.equal(contract.delivery, true);
121
+ assert.equal(contract.riskEvidence, false);
122
+ assert.equal(contract.hint, false);
123
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.3"] as Record<string, boolean>);
124
+ });
125
+
126
+ test("2.5.0-rc.3 repeats 2.4.0 because the negotiated lane Pi consumes is unchanged", () => {
127
+ // Ground-truthed by driving the exact v2.5.0-rc.3 tagged build through the
128
+ // gentle-ai-bench driven journey corpus (exit 0), which exercises the
129
+ // start/status/capture/validate/mode/delivery lifecycles Pi consumes. The
130
+ // lane now advertises capabilities/v2.3 and the reviewing START is the
131
+ // `start/v4` continuation envelope that #499 already decodes; the closed
132
+ // fields Pi reads did not change. riskEvidence and hint stay dark: still
133
+ // not proven to reach the negotiated START path Pi consumes.
134
+ const contract = NATIVE_CLI_CONTRACTS["2.5.0-rc.3"] as Record<string, boolean>;
135
+ assert.equal(contract.riskEvidence, false);
136
+ assert.equal(contract.hint, false);
137
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.4.0"] as Record<string, boolean>);
138
+ });
139
+
140
+ test("2.5.0 repeats 2.5.0-rc.3 because the stable lane Pi consumes is unchanged", () => {
141
+ // Ground-truthed against the published v2.5.0 binary installed from its
142
+ // signed archive: the v2 lane advertises capabilities/v2.4 and answers
143
+ // status/v6, consent/v3, and the `start/v4` continuation, all already
144
+ // decoded. The closed fields Pi reads did not change between rc.3 and
145
+ // stable. riskEvidence and hint stay dark: still not proven to reach the
146
+ // negotiated START path Pi consumes.
147
+ const contract = NATIVE_CLI_CONTRACTS["2.5.0"] as Record<string, boolean>;
148
+ assert.equal(contract.riskEvidence, false);
149
+ assert.equal(contract.hint, false);
150
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.5.0-rc.3"] as Record<string, boolean>);
151
+ });
152
+
83
153
  test("no shipped version key was added beyond the pin bump", () => {
84
154
  // Rows are promises to consumers, so a new key only ever appears in a
85
- // dedicated commit alongside a pin bump, never as a side effect.
86
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2"]);
155
+ // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
156
+ // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
157
+ // so they get no row: a row asserts ground truth measured against a binary
158
+ // Pi actually ran, and the table only has to be ascending, not gapless.
159
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0"]);
87
160
  });