gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,722 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test, { after } from "node:test";
6
+ import {
7
+ AgentProfileError,
8
+ bootstrapProfilesFile,
9
+ buildProfileListItems,
10
+ createProfile,
11
+ deleteProfile,
12
+ duplicateProfile,
13
+ emptyProfilesFile,
14
+ formatOrchestratorSelection,
15
+ formatProfileSummaryLines,
16
+ formatRoutingRow,
17
+ isValidProfileName,
18
+ normalizeProfilesFile,
19
+ parseProfileExportText,
20
+ parseProfileExportTextWithDrops,
21
+ parseProfilesFileText,
22
+ PROFILE_EXPORT_KIND,
23
+ PROFILE_EXPORT_VERSION,
24
+ PROFILE_ORCHESTRATOR_KEY,
25
+ PROFILES_KIND,
26
+ PROFILES_VERSION,
27
+ profileExportPath,
28
+ profileRoleEntries,
29
+ profileRoutingRows,
30
+ profilesFilePath,
31
+ readProfileOrchestrator,
32
+ readProfilesFileResult,
33
+ renameProfile,
34
+ routingColumnWidths,
35
+ serializeProfileExport,
36
+ serializeProfilesFile,
37
+ setActiveProfile,
38
+ summarizeProfile,
39
+ updateProfile,
40
+ writeProfilesFileSync,
41
+ } from "../lib/agent-profiles.ts";
42
+ import type { AgentModelConfig } from "../lib/model-routing-authority.ts";
43
+
44
+ // Agent-model profiles: pure store, summary, and export logic for the
45
+ // /gentle:profiles panel. These tests perform zero filesystem access — every
46
+ // tested function operates on strings and plain objects.
47
+
48
+ const CONFIG: AgentModelConfig = {
49
+ explore: { model: "openai-codex/gpt-5.6-terra", thinking: "low" },
50
+ design: { model: "anthropic/claude-sonnet-4", thinking: "high" },
51
+ };
52
+
53
+ function profilesText(profiles: unknown, overrides: Record<string, unknown> = {}): string {
54
+ return JSON.stringify({
55
+ kind: PROFILES_KIND,
56
+ version: PROFILES_VERSION,
57
+ profiles,
58
+ ...overrides,
59
+ });
60
+ }
61
+
62
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
63
+ return Object.prototype.hasOwnProperty.call(record, key);
64
+ }
65
+
66
+
67
+ test("isValidProfileName accepts slug names and rejects reserved and malformed names", () => {
68
+ assert.equal(isValidProfileName("current"), true);
69
+ assert.equal(isValidProfileName("deep-work"), true);
70
+ assert.equal(isValidProfileName("Team.Prod_2"), true);
71
+ assert.equal(isValidProfileName("a"), true);
72
+ assert.equal(isValidProfileName("x".repeat(64)), true);
73
+ assert.equal(isValidProfileName(""), false);
74
+ assert.equal(isValidProfileName("-leading"), false);
75
+ assert.equal(isValidProfileName(".hidden"), false);
76
+ assert.equal(isValidProfileName("has space"), false);
77
+ assert.equal(isValidProfileName("bad/name"), false);
78
+ assert.equal(isValidProfileName("x".repeat(65)), false);
79
+ assert.equal(isValidProfileName(42), false);
80
+ assert.equal(isValidProfileName(undefined), false);
81
+ assert.equal(isValidProfileName("__proto__"), false);
82
+ assert.equal(isValidProfileName("constructor"), false);
83
+ assert.equal(isValidProfileName("prototype"), false);
84
+ });
85
+
86
+ test("emptyProfilesFile returns a versioned empty store", () => {
87
+ assert.deepEqual(emptyProfilesFile(), {
88
+ kind: PROFILES_KIND,
89
+ version: PROFILES_VERSION,
90
+ active: undefined,
91
+ profiles: {},
92
+ });
93
+ });
94
+
95
+ test("parseProfilesFileText rejects malformed JSON without throwing", () => {
96
+ assert.deepEqual(parseProfilesFileText("{not json"), { status: "invalid" });
97
+ assert.deepEqual(parseProfilesFileText(""), { status: "invalid" });
98
+ assert.deepEqual(parseProfilesFileText("[1, 2]"), { status: "invalid" });
99
+ assert.deepEqual(parseProfilesFileText('"profiles"'), { status: "invalid" });
100
+ });
101
+
102
+ test("parseProfilesFileText never accepts a wrong kind or version", () => {
103
+ assert.deepEqual(
104
+ parseProfilesFileText(profilesText({ team: CONFIG }, { kind: "other.kind" })),
105
+ { status: "invalid" },
106
+ );
107
+ assert.deepEqual(
108
+ parseProfilesFileText(profilesText({ team: CONFIG }, { version: 2 })),
109
+ { status: "invalid" },
110
+ );
111
+ assert.deepEqual(
112
+ parseProfilesFileText(profilesText({ team: CONFIG }, { version: "1" })),
113
+ { status: "invalid" },
114
+ );
115
+ });
116
+
117
+ test("parseProfilesFileText rejects a file whose profiles are not a record", () => {
118
+ assert.deepEqual(parseProfilesFileText(profilesText([CONFIG])), { status: "invalid" });
119
+ });
120
+
121
+ test("parseProfilesFileText accepts a valid store and drops profiles with invalid names", () => {
122
+ const parsed = parseProfilesFileText(
123
+ profilesText(
124
+ {
125
+ "has space": CONFIG,
126
+ "__proto__": { explore: { model: "m/x" } },
127
+ constructor: { explore: { model: "m/x" } },
128
+ team: CONFIG,
129
+ },
130
+ { active: "has space" },
131
+ ),
132
+ );
133
+ assert.deepEqual(parsed, {
134
+ status: "valid",
135
+ file: {
136
+ kind: PROFILES_KIND,
137
+ version: PROFILES_VERSION,
138
+ active: undefined,
139
+ profiles: { team: CONFIG },
140
+ },
141
+ });
142
+ });
143
+
144
+ test("parseProfilesFileText accepts legacy string routing entries and drops broken agents", () => {
145
+ const parsed = parseProfilesFileText(
146
+ profilesText({
147
+ team: {
148
+ explore: "openai-codex/gpt-5.6-terra",
149
+ "bad agent": { model: "m/x" },
150
+ empty: {},
151
+ },
152
+ }),
153
+ );
154
+ assert.deepEqual(parsed, {
155
+ status: "valid",
156
+ file: {
157
+ kind: PROFILES_KIND,
158
+ version: PROFILES_VERSION,
159
+ active: undefined,
160
+ profiles: {
161
+ team: {
162
+ explore: { model: "openai-codex/gpt-5.6-terra" },
163
+ empty: {},
164
+ },
165
+ },
166
+ },
167
+ });
168
+ });
169
+
170
+ test("serializeProfilesFile round-trips through parseProfilesFileText with a trailing newline", () => {
171
+ const file = setActiveProfile(createProfile(emptyProfilesFile(), "team", CONFIG), "team");
172
+ const text = serializeProfilesFile(file);
173
+ assert.ok(text.endsWith("}\n"));
174
+ assert.deepEqual(parseProfilesFileText(text), { status: "valid", file });
175
+ });
176
+
177
+ test("serializeProfilesFile omits the active field when no profile is active", () => {
178
+ const text = serializeProfilesFile(createProfile(emptyProfilesFile(), "team", CONFIG));
179
+ assert.ok(!text.includes('"active"'));
180
+ });
181
+
182
+ test("createProfile adds a profile without mutating the input", () => {
183
+ const file = emptyProfilesFile();
184
+ const before = structuredClone(file);
185
+ const next = createProfile(file, "team", CONFIG);
186
+ assert.deepEqual(file, before);
187
+ assert.deepEqual(next.profiles.team, CONFIG);
188
+ assert.equal(hasOwn(next.profiles, "team"), true);
189
+ });
190
+
191
+ test("createProfile clones the given config so later edits cannot leak", () => {
192
+ const config: AgentModelConfig = { explore: { model: "m/x" } };
193
+ const next = createProfile(emptyProfilesFile(), "team", config);
194
+ next.profiles.team.explore.model = "changed";
195
+ assert.equal(config.explore.model, "m/x");
196
+ });
197
+
198
+ test("createProfile throws typed errors on invalid and duplicate names", () => {
199
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
200
+ assert.throws(
201
+ () => createProfile(file, "bad name", CONFIG),
202
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
203
+ );
204
+ assert.throws(
205
+ () => createProfile(file, "constructor", CONFIG),
206
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
207
+ );
208
+ assert.throws(
209
+ () => createProfile(file, "team", CONFIG),
210
+ (error: unknown) => error instanceof AgentProfileError && error.code === "duplicate_name",
211
+ );
212
+ });
213
+
214
+ test("updateProfile replaces the config without mutating the input and throws when missing", () => {
215
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
216
+ const before = structuredClone(file);
217
+ const next = updateProfile(file, "team", { explore: { model: "m/y" } });
218
+ assert.deepEqual(file, before);
219
+ assert.deepEqual(next.profiles.team, { explore: { model: "m/y" } });
220
+ assert.throws(
221
+ () => updateProfile(file, "missing", CONFIG),
222
+ (error: unknown) => error instanceof AgentProfileError && error.code === "missing_profile",
223
+ );
224
+ assert.throws(
225
+ () => updateProfile(file, "__proto__", CONFIG),
226
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
227
+ );
228
+ });
229
+
230
+ test("duplicateProfile copies the config under a new name", () => {
231
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
232
+ const next = duplicateProfile(file, "team", "team-2");
233
+ assert.deepEqual(next.profiles["team-2"], CONFIG);
234
+ assert.deepEqual(next.profiles.team, CONFIG);
235
+ assert.equal(next.active, undefined);
236
+ assert.throws(
237
+ () => duplicateProfile(file, "missing", "other"),
238
+ (error: unknown) => error instanceof AgentProfileError && error.code === "missing_profile",
239
+ );
240
+ assert.throws(
241
+ () => duplicateProfile(file, "team", "team"),
242
+ (error: unknown) => error instanceof AgentProfileError && error.code === "duplicate_name",
243
+ );
244
+ assert.throws(
245
+ () => duplicateProfile(file, "team", "bad name"),
246
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
247
+ );
248
+ });
249
+
250
+ test("renameProfile carries the active marker over and keeps the input untouched", () => {
251
+ let file = createProfile(emptyProfilesFile(), "team", CONFIG);
252
+ file = setActiveProfile(file, "team");
253
+ const before = structuredClone(file);
254
+ const next = renameProfile(file, "team", "deep-work");
255
+ assert.deepEqual(file, before);
256
+ assert.equal(hasOwn(next.profiles, "team"), false);
257
+ assert.deepEqual(next.profiles["deep-work"], CONFIG);
258
+ assert.equal(next.active, "deep-work");
259
+ });
260
+
261
+ test("renameProfile leaves the active marker alone for other profiles", () => {
262
+ let file = createProfile(emptyProfilesFile(), "team", CONFIG);
263
+ file = createProfile(file, "other", {});
264
+ file = setActiveProfile(file, "other");
265
+ const next = renameProfile(file, "team", "deep-work");
266
+ assert.equal(next.active, "other");
267
+ });
268
+
269
+ test("renameProfile throws typed errors for missing, duplicate, and invalid names", () => {
270
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
271
+ assert.throws(
272
+ () => renameProfile(file, "missing", "other"),
273
+ (error: unknown) => error instanceof AgentProfileError && error.code === "missing_profile",
274
+ );
275
+ assert.throws(
276
+ () => renameProfile(file, "team", "team"),
277
+ (error: unknown) => error instanceof AgentProfileError && error.code === "duplicate_name",
278
+ );
279
+ assert.throws(
280
+ () => renameProfile(file, "team", "bad name"),
281
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
282
+ );
283
+ });
284
+
285
+ test("deleteProfile removes a profile but refuses to delete the active one", () => {
286
+ let file = createProfile(emptyProfilesFile(), "team", CONFIG);
287
+ file = createProfile(file, "other", {});
288
+ const before = structuredClone(file);
289
+ const next = deleteProfile(file, "team");
290
+ assert.deepEqual(file, before);
291
+ assert.equal(hasOwn(next.profiles, "team"), false);
292
+ assert.deepEqual(next.profiles.other, {});
293
+
294
+ file = setActiveProfile(file, "team");
295
+ assert.throws(
296
+ () => deleteProfile(file, "team"),
297
+ (error: unknown) => error instanceof AgentProfileError && error.code === "active_profile",
298
+ );
299
+ assert.throws(
300
+ () => deleteProfile(file, "missing"),
301
+ (error: unknown) => error instanceof AgentProfileError && error.code === "missing_profile",
302
+ );
303
+ });
304
+
305
+ test("setActiveProfile marks an existing profile and throws otherwise", () => {
306
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
307
+ assert.equal(setActiveProfile(file, "team").active, "team");
308
+ assert.throws(
309
+ () => setActiveProfile(file, "missing"),
310
+ (error: unknown) => error instanceof AgentProfileError && error.code === "missing_profile",
311
+ );
312
+ assert.throws(
313
+ () => setActiveProfile(file, "prototype"),
314
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
315
+ );
316
+ });
317
+
318
+ test("summarizeProfile groups roles per model and sorts by count desc then model asc", () => {
319
+ const summary = summarizeProfile({
320
+ gamma: { model: "a/one" },
321
+ alpha: { model: "b/two" },
322
+ beta: { model: "b/two" },
323
+ delta: { model: "a/one" },
324
+ echo: { model: "a/one" },
325
+ });
326
+ assert.deepEqual(summary, {
327
+ models: [
328
+ { model: "a/one", count: 3, roles: ["delta", "echo", "gamma"] },
329
+ { model: "b/two", count: 2, roles: ["alpha", "beta"] },
330
+ ],
331
+ total: 5,
332
+ });
333
+ });
334
+
335
+ test("summarizeProfile labels model-less entries as inherit and handles empty configs", () => {
336
+ assert.deepEqual(summarizeProfile({ solo: { thinking: "high" } }), {
337
+ models: [{ model: "inherit", count: 1, roles: ["solo"] }],
338
+ total: 1,
339
+ });
340
+ assert.deepEqual(summarizeProfile({}), { models: [], total: 0 });
341
+ });
342
+
343
+ test("profile export round-trips through serialize and parse", () => {
344
+ const text = serializeProfileExport("team", CONFIG);
345
+ assert.ok(text.endsWith("}\n"));
346
+ assert.deepEqual(parseProfileExportText(text), { name: "team", config: CONFIG });
347
+ });
348
+
349
+ test("profile export parse rejects malformed, wrong-kind, wrong-version, and invalid-name payloads", () => {
350
+ assert.equal(parseProfileExportText("{nope"), undefined);
351
+ assert.equal(
352
+ parseProfileExportText(JSON.stringify({ kind: "other", version: PROFILE_EXPORT_VERSION, name: "team", config: CONFIG })),
353
+ undefined,
354
+ );
355
+ assert.equal(
356
+ parseProfileExportText(JSON.stringify({ kind: PROFILE_EXPORT_KIND, version: 99, name: "team", config: CONFIG })),
357
+ undefined,
358
+ );
359
+ assert.equal(
360
+ parseProfileExportText(JSON.stringify({ kind: PROFILE_EXPORT_KIND, version: PROFILE_EXPORT_VERSION, config: CONFIG })),
361
+ undefined,
362
+ );
363
+ assert.equal(
364
+ parseProfileExportText(JSON.stringify({ kind: PROFILE_EXPORT_KIND, version: PROFILE_EXPORT_VERSION, name: "bad name", config: CONFIG })),
365
+ undefined,
366
+ );
367
+ assert.equal(
368
+ parseProfileExportText(JSON.stringify({ kind: PROFILE_EXPORT_KIND, version: PROFILE_EXPORT_VERSION, name: "team", config: "nope" })),
369
+ undefined,
370
+ );
371
+ });
372
+
373
+ test("serializeProfileExport throws on an invalid profile name", () => {
374
+ assert.throws(
375
+ () => serializeProfileExport("bad name", CONFIG),
376
+ (error: unknown) => error instanceof AgentProfileError && error.code === "invalid_name",
377
+ );
378
+ });
379
+
380
+ test("profile export parse names dropped routing entries without dropping the profile", () => {
381
+ const text = JSON.stringify({
382
+ kind: PROFILE_EXPORT_KIND,
383
+ version: PROFILE_EXPORT_VERSION,
384
+ name: "team",
385
+ config: {
386
+ explore: { model: "m/x" },
387
+ "bad agent": { model: "m/x" },
388
+ broken: "not an entry",
389
+ },
390
+ });
391
+ const parsed = parseProfileExportTextWithDrops(text);
392
+ assert.deepEqual(parsed, {
393
+ name: "team",
394
+ config: { explore: { model: "m/x", thinking: undefined } },
395
+ droppedAgents: ["bad agent", "broken"],
396
+ });
397
+ });
398
+
399
+ test("normalizeProfilesFile validates shape, drops invalid entries, and reports them", () => {
400
+ assert.equal(normalizeProfilesFile("nope"), undefined);
401
+ assert.equal(normalizeProfilesFile({ kind: PROFILES_KIND, version: PROFILES_VERSION }), undefined);
402
+ assert.equal(normalizeProfilesFile({ kind: "other", version: PROFILES_VERSION, profiles: {} }), undefined);
403
+ assert.equal(normalizeProfilesFile({ kind: PROFILES_KIND, version: 3, profiles: {} }), undefined);
404
+
405
+ const normalized = normalizeProfilesFile(
406
+ JSON.parse(
407
+ profilesText(
408
+ {
409
+ "bad name": CONFIG,
410
+ broken: "not a record",
411
+ team: { explore: { model: "m/x" }, "bad agent": { model: "m/y" } },
412
+ },
413
+ { active: "team" },
414
+ ),
415
+ ),
416
+ );
417
+ assert.deepEqual(normalized, {
418
+ file: {
419
+ kind: PROFILES_KIND,
420
+ version: PROFILES_VERSION,
421
+ active: "team",
422
+ profiles: { team: { explore: { model: "m/x", thinking: undefined } } },
423
+ },
424
+ drops: {
425
+ droppedProfiles: ["bad name", "broken"],
426
+ droppedAgents: [{ profile: "team", agent: "bad agent" }],
427
+ },
428
+ });
429
+ });
430
+
431
+ test("normalizeProfilesFile drops an active marker that points nowhere", () => {
432
+ const normalized = normalizeProfilesFile(
433
+ JSON.parse(profilesText({ team: CONFIG }, { active: "missing" })),
434
+ );
435
+ assert.equal(normalized?.file.active, undefined);
436
+ assert.equal(normalized?.drops.droppedProfiles.length, 0);
437
+ });
438
+
439
+ test("bootstrapProfilesFile seeds a current profile, active only when routing is non-empty", () => {
440
+ const empty = bootstrapProfilesFile({});
441
+ assert.deepEqual(empty, {
442
+ kind: PROFILES_KIND,
443
+ version: PROFILES_VERSION,
444
+ active: undefined,
445
+ profiles: { current: {} },
446
+ });
447
+ const seeded = bootstrapProfilesFile(CONFIG);
448
+ assert.deepEqual(seeded, {
449
+ kind: PROFILES_KIND,
450
+ version: PROFILES_VERSION,
451
+ active: "current",
452
+ profiles: { current: CONFIG },
453
+ });
454
+ const config: AgentModelConfig = structuredClone(CONFIG);
455
+ bootstrapProfilesFile(config);
456
+ assert.deepEqual(config, CONFIG);
457
+ });
458
+
459
+ test("buildProfileListItems shows the name, active marker, and role count", () => {
460
+ let file = createProfile(emptyProfilesFile(), "team", CONFIG);
461
+ file = createProfile(file, "idle", {});
462
+ file = setActiveProfile(file, "team");
463
+ assert.deepEqual(buildProfileListItems(file), [
464
+ { id: "team", label: "team (active)", description: "2 roles" },
465
+ { id: "idle", label: "idle", description: "0 roles" },
466
+ ]);
467
+ });
468
+
469
+ test("formatProfileSummaryLines renders counts, models, and roles", () => {
470
+ assert.deepEqual(
471
+ formatProfileSummaryLines({
472
+ models: [
473
+ { model: "a/one", count: 3, roles: ["delta", "echo", "gamma"] },
474
+ { model: "b/two", count: 1, roles: ["alpha"] },
475
+ ],
476
+ total: 4,
477
+ }),
478
+ [
479
+ "3 agents → a/one: delta, echo, gamma",
480
+ "1 agent → b/two: alpha",
481
+ ],
482
+ );
483
+ assert.deepEqual(formatProfileSummaryLines({ models: [], total: 0 }), [
484
+ "No routing entries — every agent inherits its default model.",
485
+ ]);
486
+ });
487
+
488
+ // ---- Triangulation: adversarial and alternate cases ----
489
+
490
+ test("parseProfilesFileText drops a __proto__ profile key without polluting the object", () => {
491
+ const text = '{"kind":"gentle-pi.agent_model_profiles","version":1,"profiles":{"__proto__":{"explore":{"model":"m/x"}},"team":{"explore":{"model":"m/x"}}}}';
492
+ const parsed = parseProfilesFileText(text);
493
+ assert.equal(parsed.status, "valid");
494
+ if (parsed.status !== "valid") return;
495
+ assert.deepEqual(Object.keys(parsed.file.profiles), ["team"]);
496
+ assert.equal(({} as Record<string, unknown>).explore, undefined);
497
+ assert.equal(Object.getPrototypeOf(parsed.file.profiles), Object.prototype);
498
+ });
499
+
500
+ test("serialize then parse is idempotent across repeated round-trips", () => {
501
+ let file = createProfile(emptyProfilesFile(), "team", CONFIG);
502
+ file = setActiveProfile(file, "team");
503
+ const first = serializeProfilesFile(file);
504
+ const reparsed = parseProfilesFileText(first);
505
+ assert.equal(reparsed.status, "valid");
506
+ if (reparsed.status !== "valid") return;
507
+ assert.equal(serializeProfilesFile(reparsed.file), first);
508
+ });
509
+
510
+ test("deleteProfile removes the last profile and leaves a valid empty store", () => {
511
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
512
+ const next = deleteProfile(file, "team");
513
+ assert.deepEqual(next.profiles, {});
514
+ assert.equal(next.active, undefined);
515
+ assert.deepEqual(parseProfilesFileText(serializeProfilesFile(next)), {
516
+ status: "valid",
517
+ file: next,
518
+ });
519
+ });
520
+
521
+ test("profile names accept slug punctuation but reject unicode letters", () => {
522
+ assert.equal(isValidProfileName("a.b-c_d"), true);
523
+ assert.equal(isValidProfileName("café"), false);
524
+ assert.equal(isValidProfileName("1up"), true);
525
+ });
526
+
527
+ test("summarizeProfile breaks count ties by model name ascending", () => {
528
+ const summary = summarizeProfile({
529
+ one: { model: "z/last" },
530
+ two: { model: "a/first" },
531
+ });
532
+ assert.deepEqual(
533
+ summary.models.map((model) => model.model),
534
+ ["a/first", "z/last"],
535
+ );
536
+ });
537
+
538
+ test("duplicateProfile deep-clones so copy edits cannot leak into the source", () => {
539
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
540
+ const next = duplicateProfile(file, "team", "copy");
541
+ const copyEntry = next.profiles.copy.explore;
542
+ if (!copyEntry) throw new Error("copy entry missing");
543
+ copyEntry.model = "changed";
544
+ assert.equal(file.profiles.team.explore?.model, "openai-codex/gpt-5.6-terra");
545
+ });
546
+
547
+ test("renameProfile preserves the profile order in the store", () => {
548
+ let file = createProfile(emptyProfilesFile(), "alpha", {});
549
+ file = createProfile(file, "beta", CONFIG);
550
+ const next = renameProfile(file, "alpha", "delta");
551
+ assert.deepEqual(Object.keys(next.profiles), ["delta", "beta"]);
552
+ });
553
+
554
+ test("an empty profile exports and imports cleanly", () => {
555
+ const text = serializeProfileExport("empty", {});
556
+ assert.deepEqual(parseProfileExportTextWithDrops(text), {
557
+ name: "empty",
558
+ config: {},
559
+ droppedAgents: [],
560
+ });
561
+ });
562
+
563
+ test("normalizeProfilesFile reports an active marker that names no profile", () => {
564
+ const normalized = normalizeProfilesFile({
565
+ kind: PROFILES_KIND,
566
+ version: PROFILES_VERSION,
567
+ active: "gone",
568
+ profiles: { team: CONFIG },
569
+ });
570
+ assert.equal(normalized?.file.active, undefined);
571
+ assert.equal(normalized?.drops.droppedActive, "gone");
572
+ });
573
+
574
+ // ---- Filesystem wrappers ----
575
+ //
576
+ // These two wrappers are the only part of the module that touches disk, so they
577
+ // run against a real temporary directory instead of a mock: the atomic replace
578
+ // cannot be proven without a filesystem, and the failure modes that matter here
579
+ // (absent file, unreadable JSON, dropped entries, missing parent directories) are
580
+ // exactly the ones a mock would hide.
581
+
582
+ const root = mkdtempSync(join(tmpdir(), "gentle-agent-profiles-"));
583
+ after(() => rmSync(root, { recursive: true, force: true }));
584
+
585
+ test("readProfilesFileResult reports a missing store without throwing", () => {
586
+ assert.deepEqual(readProfilesFileResult(join(root, "absent.json")), { status: "missing" });
587
+ });
588
+
589
+ test("readProfilesFileResult reports malformed JSON as invalid", () => {
590
+ const path = join(root, "malformed.json");
591
+ writeFileSync(path, "{ not json");
592
+ assert.deepEqual(readProfilesFileResult(path), { status: "invalid" });
593
+ });
594
+
595
+ test("readProfilesFileResult rejects a foreign kind or version", () => {
596
+ const path = join(root, "foreign.json");
597
+ writeFileSync(path, JSON.stringify({ kind: "other", version: 1, profiles: {} }));
598
+ assert.deepEqual(readProfilesFileResult(path), { status: "invalid" });
599
+ });
600
+
601
+ test("readProfilesFileResult returns normalized profiles together with their drops", () => {
602
+ const path = join(root, "drops.json");
603
+ writeFileSync(
604
+ path,
605
+ JSON.stringify({
606
+ kind: PROFILES_KIND,
607
+ version: PROFILES_VERSION,
608
+ active: "missing-profile",
609
+ profiles: { team: CONFIG, "bad name": CONFIG },
610
+ }),
611
+ );
612
+ const result = readProfilesFileResult(path);
613
+ if (result.status !== "valid") throw new Error(`expected valid, got ${result.status}`);
614
+ assert.deepEqual(Object.keys(result.file.profiles), ["team"]);
615
+ assert.equal(result.file.active, undefined);
616
+ assert.deepEqual(result.drops.droppedProfiles, ["bad name"]);
617
+ assert.equal(result.drops.droppedActive, "missing-profile");
618
+ });
619
+
620
+ test("writeProfilesFileSync creates missing parent directories", () => {
621
+ const path = join(root, "nested", "deeper", "profiles.json");
622
+ writeProfilesFileSync(path, createProfile(emptyProfilesFile(), "team", CONFIG));
623
+ assert.equal(readProfilesFileResult(path).status, "valid");
624
+ });
625
+
626
+ test("writeProfilesFileSync replaces the store and leaves no temp file behind", () => {
627
+ const path = join(root, "atomic.json");
628
+ const first = createProfile(emptyProfilesFile(), "alpha", CONFIG);
629
+ writeProfilesFileSync(path, first);
630
+ writeProfilesFileSync(path, createProfile(first, "beta", CONFIG));
631
+ const round = readProfilesFileResult(path);
632
+ if (round.status !== "valid") throw new Error(`expected valid, got ${round.status}`);
633
+ assert.deepEqual(Object.keys(round.file.profiles), ["alpha", "beta"]);
634
+ assert.deepEqual(readdirSync(root).filter((entry) => entry.includes(".tmp")), []);
635
+ });
636
+
637
+ test("writeProfilesFileSync reports the operation error and still removes the temp file", () => {
638
+ const occupied = join(root, "occupied");
639
+ mkdirSync(occupied, { recursive: true });
640
+ assert.throws(() =>
641
+ writeProfilesFileSync(occupied, createProfile(emptyProfilesFile(), "team", CONFIG)),
642
+ );
643
+ assert.deepEqual(readdirSync(root).filter((entry) => entry.includes(".tmp")), []);
644
+ });
645
+
646
+ test("writeProfilesFileSync reports a serialization failure, skips the rename, and cleans up", () => {
647
+ // The write path fails before the rename, so this proves the reported error is
648
+ // the operation's, that the destination is never touched once a failure is
649
+ // recorded, and that cleanup still runs.
650
+ const path = join(root, "serialize.json");
651
+ const file = createProfile(emptyProfilesFile(), "team", CONFIG);
652
+ const circular: Record<string, unknown> = {};
653
+ circular.self = circular;
654
+ (file.profiles.team as Record<string, unknown>).explore = circular;
655
+ assert.throws(() => writeProfilesFileSync(path, file), /circular/i);
656
+ assert.equal(existsSync(path), false);
657
+ assert.deepEqual(readdirSync(root).filter((entry) => entry.includes(".tmp")), []);
658
+ });
659
+
660
+ test("the path helpers resolve both stores inside the config home", () => {
661
+ const home = join(root, "config-home");
662
+ assert.equal(profilesFilePath(home), join(home, "profiles.json"));
663
+ assert.equal(profileExportPath(home), join(home, "profiles.export.json"));
664
+ });
665
+
666
+ test("the orchestrator key is never counted or listed as an agent role", () => {
667
+ const config: AgentModelConfig = {
668
+ [PROFILE_ORCHESTRATOR_KEY]: { model: "nan/glm5.3", thinking: "high" },
669
+ alpha: { model: "a/one" },
670
+ beta: { model: "a/one" },
671
+ };
672
+ assert.deepEqual(summarizeProfile(config), {
673
+ models: [{ model: "a/one", count: 2, roles: ["alpha", "beta"] }],
674
+ total: 2,
675
+ });
676
+ const file = createProfile(emptyProfilesFile(), "team", config);
677
+ assert.deepEqual(buildProfileListItems(file).map((item) => item.description), ["2 roles"]);
678
+ assert.deepEqual(profileRoleEntries(config).map(([name]) => name), ["alpha", "beta"]);
679
+ assert.deepEqual(readProfileOrchestrator(config), { model: "nan/glm5.3", thinking: "high" });
680
+ });
681
+
682
+ test("the orchestrator helper treats an absent or empty entry as undefined", () => {
683
+ assert.equal(readProfileOrchestrator({}), undefined);
684
+ assert.equal(readProfileOrchestrator({ alpha: { model: "a/one" } }), undefined);
685
+ assert.equal(readProfileOrchestrator({ [PROFILE_ORCHESTRATOR_KEY]: {} }), undefined);
686
+ });
687
+
688
+ test("profileRoutingRows lists one aligned row per agent, in a stable order", () => {
689
+ const rows = profileRoutingRows({
690
+ [PROFILE_ORCHESTRATOR_KEY]: { model: "nan/glm5.3" },
691
+ zeta: { model: "nan/deepseek-v4-flash", thinking: "high" },
692
+ alpha: { thinking: "high" },
693
+ mid: { model: "nan/qwen3.8-flash" },
694
+ });
695
+ assert.deepEqual(rows, [
696
+ { agent: "alpha", model: "inherit", thinking: "high" },
697
+ { agent: "mid", model: "nan/qwen3.8-flash", thinking: "inherit" },
698
+ { agent: "zeta", model: "nan/deepseek-v4-flash", thinking: "high" },
699
+ ]);
700
+ assert.deepEqual(profileRoutingRows({}), []);
701
+
702
+ const widths = routingColumnWidths(rows, [{ agent: "a-much-longer-agent-name", model: "m/x", thinking: "low" }]);
703
+ assert.deepEqual(widths, { agent: "a-much-longer-agent-name".length, model: "nan/deepseek-v4-flash".length });
704
+ assert.equal(
705
+ formatRoutingRow(rows[0], widths),
706
+ `${"alpha".padEnd(widths.agent)} ${"inherit".padEnd(widths.model)} high`,
707
+ );
708
+ assert.equal(
709
+ formatRoutingRow(rows[2], widths),
710
+ `${"zeta".padEnd(widths.agent)} ${"nan/deepseek-v4-flash".padEnd(widths.model)} high`,
711
+ );
712
+ });
713
+
714
+ test("formatOrchestratorSelection separates set, inherit, and inherit-with-thinking", () => {
715
+ assert.equal(formatOrchestratorSelection(undefined), "inherit");
716
+ assert.equal(formatOrchestratorSelection({ thinking: "high" }), "inherit · high");
717
+ assert.equal(formatOrchestratorSelection({ model: "nan/glm5.3" }), "nan/glm5.3");
718
+ assert.equal(
719
+ formatOrchestratorSelection({ model: "nan/glm5.3", thinking: "max" }),
720
+ "nan/glm5.3 · max",
721
+ );
722
+ });