gentle-pi 4.2.0 → 4.4.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 (334) hide show
  1. package/README.md +340 -138
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/design-canvas/canvas.css +648 -0
  6. package/assets/design-canvas/canvas.js +1497 -0
  7. package/assets/design-canvas/index.html +88 -0
  8. package/assets/design-canvas/inspector.js +290 -0
  9. package/assets/orchestrator-delegation-reference.md +163 -0
  10. package/assets/orchestrator-delegation.md +25 -176
  11. package/assets/orchestrator-tracking.md +1 -1
  12. package/assets/orchestrator-verification.md +14 -8
  13. package/assets/orchestrator-writer.md +5 -1
  14. package/assets/orchestrator.md +11 -11
  15. package/bin/engram-guard +1 -1
  16. package/bin/gentle-shell.mjs +237 -139
  17. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  19. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  20. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  21. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  22. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  23. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  24. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  25. package/docs/assets/diagrams/measured-routing.svg +49 -0
  26. package/docs/assets/diagrams/native-review.svg +52 -12
  27. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  28. package/docs/assets/diagrams/permission-modes.svg +51 -0
  29. package/docs/assets/features/audio-notifications.png +0 -0
  30. package/docs/assets/features/command-palette.png +0 -0
  31. package/docs/bundled-install.md +65 -30
  32. package/docs/delegated-verification.md +16 -8
  33. package/docs/gentle-agents-activity.md +16 -6
  34. package/docs/gentle-music.md +61 -0
  35. package/docs/gentle-shell-uninstall.md +8 -1
  36. package/docs/gentle-shell.md +80 -9
  37. package/docs/install-wizard.md +37 -6
  38. package/docs/permission-modes.md +8 -3
  39. package/docs/readme-reference.md +120 -28
  40. package/docs/session-profile-format.md +26 -0
  41. package/docs/voice.md +259 -0
  42. package/docs/yolo-mode.md +38 -10
  43. package/extensions/ask-user-choice.ts +4 -1
  44. package/extensions/codegraph-tools.ts +68 -6
  45. package/extensions/gentle-agents.ts +270 -48
  46. package/extensions/gentle-ai.ts +490 -113
  47. package/extensions/gentle-design.ts +158 -0
  48. package/extensions/gentle-herdr-activity.ts +2 -3
  49. package/extensions/gentle-music.ts +289 -0
  50. package/extensions/gentle-shell.ts +359 -85
  51. package/extensions/gentle-todo.ts +43 -9
  52. package/extensions/gentle-voice.ts +11 -0
  53. package/extensions/quiet-tools.ts +1 -1
  54. package/extensions/session-scratch.ts +384 -0
  55. package/extensions/skill-registry.ts +63 -8
  56. package/extensions/startup-banner.ts +2 -3
  57. package/lib/agent-home.ts +8 -3
  58. package/lib/agent-profiles.ts +26 -0
  59. package/lib/agents-config.ts +35 -0
  60. package/lib/agents-protocol.ts +60 -1
  61. package/lib/agents-runner.ts +107 -9
  62. package/lib/agents-session-transport.ts +101 -6
  63. package/lib/agents-view.ts +3 -1
  64. package/lib/agents-widget.ts +14 -1
  65. package/lib/auto-permission-memory.ts +48 -18
  66. package/lib/auto-permission-runtime.ts +21 -6
  67. package/lib/bounded-writer-admission.ts +51 -0
  68. package/lib/clipboard-image.ts +147 -0
  69. package/lib/command-palette-catalog.ts +2 -4
  70. package/lib/commit-slice-signal.ts +100 -0
  71. package/lib/context-backstop.ts +120 -0
  72. package/lib/delivered-test-lint.ts +80 -0
  73. package/lib/design-canvas-server.ts +947 -0
  74. package/lib/design-comments.ts +437 -0
  75. package/lib/design-handoffs.ts +313 -0
  76. package/lib/design-open-browser.ts +58 -0
  77. package/lib/design-sources.ts +120 -0
  78. package/lib/destructive-command-guard.ts +120 -9
  79. package/lib/external-editor.ts +31 -0
  80. package/lib/gauge-theme.ts +146 -0
  81. package/lib/gentle-ai-binary.ts +28 -7
  82. package/lib/gentle-design.ts +513 -0
  83. package/lib/gentle-shell-bundled.ts +146 -36
  84. package/lib/gentle-shell-import-pi.ts +233 -0
  85. package/lib/gentle-shell-launcher.ts +154 -15
  86. package/lib/gentle-shell-startup-update.ts +61 -9
  87. package/lib/gentle-shell-uninstall.ts +51 -2
  88. package/lib/herdr-activity.ts +24 -0
  89. package/lib/image-attachments.ts +150 -0
  90. package/lib/inprocess-reviewer.ts +33 -2
  91. package/lib/nan-provider.ts +3 -0
  92. package/lib/native-review-cli.ts +20 -5
  93. package/lib/notification-audio-native.ts +149 -13
  94. package/lib/notification-audio.ts +4 -0
  95. package/lib/notification-pulse-worker.ts +82 -18
  96. package/lib/notification-service.ts +5 -1
  97. package/lib/orchestrator-discovery.ts +5 -1
  98. package/lib/orchestrator-presence.ts +87 -8
  99. package/lib/orchestrator-scope.ts +31 -3
  100. package/lib/orchestrator-state.ts +11 -3
  101. package/lib/permission-mode-policy.ts +2 -2
  102. package/lib/provider-contract-bundle.ts +99 -53
  103. package/lib/review-candidate-view-owner.ts +29 -19
  104. package/lib/review-candidate-view.ts +36 -2
  105. package/lib/review-host-relay.ts +11 -2
  106. package/lib/review-reminder-receipt.ts +24 -1
  107. package/lib/review-risk-assessment.ts +43 -38
  108. package/lib/review-session-standing-permission.ts +3 -1
  109. package/lib/session-profile-append-controller.ts +713 -0
  110. package/lib/session-profile-disk-reader.ts +182 -0
  111. package/lib/session-profile-freeze.ts +30 -1
  112. package/lib/session-profile-persistence.ts +15 -7
  113. package/lib/session-scratch.ts +523 -0
  114. package/lib/session-worktree-registry.ts +7 -0
  115. package/lib/shell-bar.ts +29 -28
  116. package/lib/shell-changes-view.ts +80 -13
  117. package/lib/shell-changes.ts +81 -5
  118. package/lib/shell-gauge.ts +49 -6
  119. package/lib/shell-prompt.ts +4 -4
  120. package/lib/shell-sidebar-layout.ts +26 -6
  121. package/lib/shell-todo.ts +24 -5
  122. package/lib/shell-usage-view.ts +5 -1
  123. package/lib/shell-usage.ts +7 -7
  124. package/lib/verifier-admission.ts +30 -0
  125. package/lib/vim-editor-adapter.ts +11 -8
  126. package/lib/voice-artifacts.ts +188 -0
  127. package/lib/voice-command.ts +576 -0
  128. package/lib/voice-install.ts +455 -0
  129. package/lib/voice-platform.ts +99 -0
  130. package/lib/voice-push-to-talk.ts +262 -0
  131. package/lib/voice-session.ts +460 -0
  132. package/lib/voice-settings.ts +50 -0
  133. package/lib/voice-ui.ts +158 -0
  134. package/lib/yolo-session-policy.ts +58 -22
  135. package/lib/yolo-temp-scratch.ts +69 -0
  136. package/package.json +1 -2
  137. package/runtime/gentle-ai-binary.mjs +28 -7
  138. package/runtime/gentle-shell-bundled.mjs +145 -35
  139. package/runtime/gentle-shell-import-pi.mjs +234 -0
  140. package/runtime/gentle-shell-launcher.mjs +152 -13
  141. package/runtime/gentle-shell-startup-update.mjs +61 -9
  142. package/runtime/gentle-shell-uninstall.mjs +51 -2
  143. package/runtime/native-review-cli.mjs +19 -4
  144. package/runtime/review-risk-assessment.mjs +43 -38
  145. package/scripts/build-runtime-modules.mjs +1 -0
  146. package/scripts/bundled-engram-migration.mjs +119 -0
  147. package/scripts/gentle-ai-installer.mjs +10 -10
  148. package/scripts/installer-downloads.mjs +7 -7
  149. package/scripts/installer-preflight.mjs +5 -3
  150. package/scripts/installer-server.mjs +14 -4
  151. package/scripts/installer-windows.mjs +37 -7
  152. package/scripts/main-channel.mjs +94 -19
  153. package/scripts/pi-update.mjs +34 -6
  154. package/scripts/run-test-suite.mjs +5 -0
  155. package/scripts/verify-package-files.mjs +43 -13
  156. package/scripts/voice-worker.cjs +476 -0
  157. package/skills/design/SKILL.md +110 -0
  158. package/skills/gentle-ai/SKILL.md +3 -3
  159. package/skills/work-unit-commits/SKILL.md +5 -3
  160. package/tests/agent-profiles.test.ts +24 -0
  161. package/tests/agent-risk-escalation.test.ts +37 -10
  162. package/tests/agents-config.test.ts +61 -0
  163. package/tests/agents-fake-child.ts +8 -1
  164. package/tests/agents-protocol.test.ts +32 -0
  165. package/tests/agents-runner.test.ts +485 -7
  166. package/tests/agents-session-transport.test.ts +53 -3
  167. package/tests/ask-user-choice.test.ts +30 -0
  168. package/tests/auto-mode.test.ts +17 -0
  169. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  170. package/tests/auto-permission-runtime.test.ts +85 -1
  171. package/tests/autonomous-guard.test.ts +142 -0
  172. package/tests/bounded-writer-admission.test.ts +11 -3
  173. package/tests/bundled-install.test.ts +3 -3
  174. package/tests/card-content-policy.test.ts +19 -9
  175. package/tests/child-safety.test.ts +39 -0
  176. package/tests/clipboard-image.test.ts +114 -0
  177. package/tests/codegraph-tools.test.ts +123 -2
  178. package/tests/command-palette.test.ts +15 -5
  179. package/tests/commit-slice-signal.test.ts +162 -0
  180. package/tests/context-backstop.test.ts +225 -0
  181. package/tests/delegate-for-reason-contract.test.ts +92 -24
  182. package/tests/delivered-test-lint.test.ts +105 -0
  183. package/tests/design-canvas-assets.test.ts +710 -0
  184. package/tests/design-canvas-server.test.ts +1096 -0
  185. package/tests/design-comments.test.ts +201 -0
  186. package/tests/design-handoffs.test.ts +282 -0
  187. package/tests/design-open-browser.test.ts +70 -0
  188. package/tests/design-sources.test.ts +132 -0
  189. package/tests/destructive-command-guard.test.ts +85 -1
  190. package/tests/engram-safety.test.ts +132 -57
  191. package/tests/external-editor.test.ts +84 -0
  192. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  193. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  194. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  195. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  196. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  197. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  198. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  199. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  200. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  201. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  202. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  203. package/tests/gauge-theme.test.ts +120 -0
  204. package/tests/gentle-agents.test.ts +645 -21
  205. package/tests/gentle-ai-binary.test.ts +1 -1
  206. package/tests/gentle-ai-child-guards.test.ts +61 -0
  207. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  208. package/tests/gentle-ai-installer.test.ts +51 -51
  209. package/tests/gentle-ai.test.ts +292 -13
  210. package/tests/gentle-design.test.ts +1313 -0
  211. package/tests/gentle-music.test.ts +335 -0
  212. package/tests/gentle-shell-bin.test.ts +163 -0
  213. package/tests/gentle-shell-bundled.test.ts +392 -176
  214. package/tests/gentle-shell-import-pi.test.ts +262 -0
  215. package/tests/gentle-shell-launcher.test.ts +107 -2
  216. package/tests/gentle-shell-startup-update.test.ts +77 -1
  217. package/tests/gentle-shell-uninstall.test.ts +89 -0
  218. package/tests/gentle-shell.test.ts +225 -17
  219. package/tests/gentle-todo.test.ts +190 -0
  220. package/tests/headless-delivery-question.test.ts +58 -0
  221. package/tests/herdr-activity.test.ts +34 -1
  222. package/tests/image-attachments.test.ts +119 -0
  223. package/tests/image-paste-chips.test.ts +236 -0
  224. package/tests/inprocess-reviewer.test.ts +55 -2
  225. package/tests/installer-bundles.test.ts +12 -6
  226. package/tests/installer-posix-bootstrap.test.ts +8 -8
  227. package/tests/installer-preflight.test.ts +16 -0
  228. package/tests/installer-server.test.ts +10 -2
  229. package/tests/installer-windows-bootstrap.test.ts +94 -7
  230. package/tests/installer-windows-diagnostics.test.ts +111 -0
  231. package/tests/main-channel-upgrade.test.ts +232 -2
  232. package/tests/main-channel.test.ts +140 -21
  233. package/tests/nan-provider.test.ts +36 -0
  234. package/tests/native-review-capability-contract.test.ts +17 -4
  235. package/tests/native-review-parity.test.ts +23 -2
  236. package/tests/no-self-pause-contract.test.ts +5 -2
  237. package/tests/notification-audio-native.test.ts +128 -9
  238. package/tests/notification-customize.test.ts +17 -5
  239. package/tests/odd-integration.test.ts +5 -1
  240. package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
  241. package/tests/odd-routing-contract.test.ts +115 -30
  242. package/tests/orchestrator-budget.test.ts +22 -8
  243. package/tests/orchestrator-discovery.test.ts +29 -0
  244. package/tests/orchestrator-presence.test.ts +100 -2
  245. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  246. package/tests/orchestrator-scope.test.ts +80 -0
  247. package/tests/orchestrator-state.test.ts +29 -0
  248. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  249. package/tests/package-files.test.ts +34 -0
  250. package/tests/package-manifest.test.ts +24 -20
  251. package/tests/permission-mode-policy.test.ts +10 -1
  252. package/tests/pi-update-launcher.test.ts +21 -2
  253. package/tests/pi-update.test.ts +99 -4
  254. package/tests/primary-session-prompt.test.ts +101 -0
  255. package/tests/provider-contract-bundle.test.ts +118 -0
  256. package/tests/provider-contract-mirror.test.ts +24 -0
  257. package/tests/quiet-tool-rendering.test.ts +23 -0
  258. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  259. package/tests/rdd-status-line.test.ts +79 -0
  260. package/tests/review-agent-end-preflight.test.ts +6 -0
  261. package/tests/review-candidate-view.test.ts +49 -0
  262. package/tests/review-contract-prompt.test.ts +5 -2
  263. package/tests/review-controller-native-routing.test.ts +14 -1
  264. package/tests/review-controller.test.ts +1 -1
  265. package/tests/review-host-relay-routing.test.ts +147 -3
  266. package/tests/review-host-relay.test.ts +9 -2
  267. package/tests/review-json-arguments.test.ts +20 -3
  268. package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
  269. package/tests/review-owner-identity.test.ts +132 -0
  270. package/tests/review-relay-transport-agent.test.ts +26 -0
  271. package/tests/review-reminder-receipt.test.ts +31 -1
  272. package/tests/review-risk-assessment.test.ts +163 -76
  273. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  274. package/tests/review-session-standing-permission.test.ts +27 -1
  275. package/tests/run-test-suite.test.ts +15 -0
  276. package/tests/runtime-harness.mjs +16 -6
  277. package/tests/session-changes-shell.test.ts +1 -1
  278. package/tests/session-changes.test.ts +2 -1
  279. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  280. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  281. package/tests/session-profile-append-controller.test.ts +731 -0
  282. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  283. package/tests/session-profile-disk-reader.test.ts +311 -0
  284. package/tests/session-profile-freeze.test.ts +23 -0
  285. package/tests/session-scratch.test.ts +1011 -0
  286. package/tests/session-subject-routing-contract.test.ts +2 -1
  287. package/tests/session-worktree-registry.test.ts +13 -1
  288. package/tests/shell-bar.test.ts +21 -2
  289. package/tests/shell-changes-view.test.ts +137 -1
  290. package/tests/shell-changes.test.ts +148 -1
  291. package/tests/shell-gauge.test.ts +42 -0
  292. package/tests/shell-prompt.test.ts +3 -3
  293. package/tests/shell-render-cache.test.ts +134 -1
  294. package/tests/shell-sidebar-layout.test.ts +50 -0
  295. package/tests/short-check-routing-contract.test.ts +1 -1
  296. package/tests/skill-collision-prefixes.test.ts +3 -1
  297. package/tests/skill-registry-watcher.test.ts +17 -5
  298. package/tests/skill-registry.test.ts +47 -3
  299. package/tests/small-task-inline-contract.test.ts +37 -0
  300. package/tests/standalone-config-home.test.ts +324 -0
  301. package/tests/startup-banner.test.ts +5 -4
  302. package/tests/startup-home-repo.test.ts +34 -0
  303. package/tests/support/orchestrator-modules.ts +1 -0
  304. package/tests/task-size-routing-contract.test.ts +70 -10
  305. package/tests/test-scratch-contract.test.ts +102 -0
  306. package/tests/test-strength-contract.test.ts +36 -1
  307. package/tests/tool-batching-contract.test.ts +64 -0
  308. package/tests/verification-proportionality.test.ts +46 -0
  309. package/tests/verifier-admission.test.ts +154 -0
  310. package/tests/verify-agent-contract.test.ts +8 -8
  311. package/tests/voice-command.test.ts +773 -0
  312. package/tests/voice-install.test.ts +323 -0
  313. package/tests/voice-platform.test.ts +176 -0
  314. package/tests/voice-push-to-talk.test.ts +444 -0
  315. package/tests/voice-session.test.ts +434 -0
  316. package/tests/voice-settings.test.ts +49 -0
  317. package/tests/voice-ui.test.ts +97 -0
  318. package/tests/voice-worker.test.ts +445 -0
  319. package/tests/weak-model-economy-prompt.test.ts +32 -0
  320. package/tests/windows-hidden-processes.test.ts +30 -2
  321. package/tests/writer-edit-surface-scope.test.ts +3 -0
  322. package/tests/writer-feature-document-admission.test.ts +156 -0
  323. package/tests/yolo-customize.test.ts +5 -1
  324. package/tests/yolo-mode.test.ts +19 -1
  325. package/tests/yolo-session-policy-capture.test.ts +104 -0
  326. package/tests/yolo-temp-scratch.test.ts +71 -0
  327. package/scripts/mirror-odd-routing.mjs +0 -242
  328. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  329. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  330. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  331. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  332. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  333. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  334. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -589,6 +589,56 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
589
589
  assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
590
590
  });
591
591
 
592
+ test("a theme switch repaints rail sections whose digest did not change", (t) => {
593
+ const f = fixture();
594
+ // Pi hands extensions a live theme proxy and announces a switch only through
595
+ // ui.invalidate(), which never reaches the rail inside the layout tree.
596
+ let code = 31;
597
+ const live = { fg: (_color: string, text: string) => `\x1b[${code}m${text}\x1b[39m`, bold: (text: string) => text };
598
+ for (const key of ["footer", "agents", "todo"]) {
599
+ sidebarPart(f.tui, key, { render: () => [key], invalidate() {} }, {
600
+ digest: () => "stable",
601
+ render: () => [live.fg("accent", `${key} card`)],
602
+ invalidate() {},
603
+ });
604
+ }
605
+ t.after(installSidebar(f.tui, live));
606
+
607
+ const before = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
608
+ for (const key of ["footer", "agents", "todo"]) assert.ok(before.includes(`\x1b[31m${key} card`), `${key} paints with the first theme`);
609
+
610
+ code = 32;
611
+ const after = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
612
+ for (const key of ["footer", "agents", "todo"]) {
613
+ assert.ok(after.includes(`\x1b[32m${key} card`), `${key} repaints with the new theme`);
614
+ assert.ok(!after.includes(`\x1b[31m${key} card`), `${key} drops the old theme`);
615
+ }
616
+ });
617
+
618
+ test("structural cache misses skip the whole-rail digest pass", (t) => {
619
+ const f = fixture();
620
+ let digests = 0;
621
+ sidebarPart(f.tui, "todo", { render: () => ["Todo"], invalidate() {} }, {
622
+ digest: () => { digests++; return "stable"; },
623
+ render: () => [`Todo ${digests}`],
624
+ invalidate() {},
625
+ });
626
+ t.after(installSidebar(f.tui, theme));
627
+
628
+ // Every frame evaluates the digest exactly once: the whole-rail comparison
629
+ // reuses it for the section cache instead of calling each digest twice per
630
+ // prepare (issue #1681).
631
+ renderLayoutFrame(f.root, 140, 20, () => {});
632
+ const prepared = digests;
633
+ assert.equal(prepared, 1, "preparing the rail evaluates the digest once");
634
+ renderLayoutFrame(f.root, 140, 20, () => {});
635
+ assert.equal(digests, prepared + 1, "a cache hit compares the digest once");
636
+ renderLayoutFrame(f.root, 141, 20, () => {});
637
+ assert.equal(digests, prepared + 2, "a structural miss re-prepares with a single digest pass");
638
+ renderLayoutFrame(f.root, 140, 20, () => {});
639
+ assert.equal(digests, prepared + 3, "returning to the cached structure stays on a single digest pass");
640
+ });
641
+
592
642
  test("reuses final sidebar presentation until a relevant invalidation", (t) => {
593
643
  const f = fixture();
594
644
  sidebarPart(f.tui, "todo", { render: () => Array.from({ length: 10 }, (_, index) => `Todo ${index}`), invalidate() {} });
@@ -36,7 +36,7 @@ for (const [name, text] of Object.entries({ registeredDescription: background.de
36
36
  test("inline still means the same parent, and the public background API stays asynchronous", () => {
37
37
  assert.ok(core.includes("Small path: inline (parent; `bash_background` policy);"));
38
38
  assert.ok(guide.includes("Inline means the same parent, not necessarily synchronous bash"));
39
- assert.ok(core.includes("run the focused test and the suite inline, once each"));
39
+ assert.ok(core.includes("run the focused test and the suite inline in one command"));
40
40
  assert.match(background.description, /return a job id at once/);
41
41
  assert.match(background.description, /notified once, automatically/);
42
42
  assert.match(background.description, /do not poll, sleep, or delegate a subagent to wait/);
@@ -25,7 +25,9 @@ const PREFIXED_NAMES: Record<string, string> = {
25
25
  "work-unit-commits": "gentle-ai-work-unit-commits",
26
26
  };
27
27
 
28
- const UNPREFIXED_DIRS = ["gentle-ai"];
28
+ // gentle-pi-owned skills that gentle-ai does not install keep their own
29
+ // gentle- namespace, so they need no gentle-ai- prefix to avoid a collision.
30
+ const UNPREFIXED_DIRS = ["gentle-ai", "design"];
29
31
 
30
32
  for (const [dir, expectedName] of Object.entries(PREFIXED_NAMES)) {
31
33
  test(`skills/${dir}/SKILL.md frontmatter name is prefixed`, () => {
@@ -16,7 +16,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
16
16
  import assert from "node:assert/strict";
17
17
  import fs from "node:fs";
18
18
  import { syncBuiltinESMExports } from "node:module";
19
- import { join } from "node:path";
19
+ import { join, sep } from "node:path";
20
20
  const root = ${JSON.stringify(root)};
21
21
  const cwd = join(root, "workspace");
22
22
  const home = join(root, "home");
@@ -52,6 +52,8 @@ for (const code of ["ENOENT", "EMFILE"]) {
52
52
  });
53
53
  const ctx = { cwd, hasUI: true, ui: { notify: (message, level) => notices.push({ message, level }) } };
54
54
  const waitFor = async (predicate) => {
55
+ // Must stay well below the parent's spawnSync timeout so a missed
56
+ // outcome fails with this assertion instead of an opaque ETIMEDOUT.
55
57
  const deadline = Date.now() + 5000;
56
58
  while (!predicate() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 25));
57
59
  assert.ok(predicate(), "timed out waiting for watcher outcome");
@@ -66,18 +68,28 @@ for (const code of ["ENOENT", "EMFILE"]) {
66
68
  if (code === "ENOENT" && process.platform !== "darwin" && process.platform !== "win32") {
67
69
  // Force the transient missing-directory race at Node's real recursive
68
70
  // readdir boundary, after watcher creation, not in our refresh callback.
71
+ // Older Node rescans the parent folder; Node 24.22+ resolves new files
72
+ // via lstat and only readdirs a newly added directory, so trigger with
73
+ // a directory and fail whichever readdir lands under assets.
69
74
  const nativeReaddir = fs.readdirSync;
70
75
  let reachedRescan = false;
71
76
  fs.readdirSync = function (path, options) {
72
- if (path === assets) {
77
+ if (typeof path === "string" && (path === assets || path.startsWith(assets + sep))) {
73
78
  fs.readdirSync = nativeReaddir;
74
79
  reachedRescan = true;
75
80
  throw Object.assign(new Error("ENOENT: transient skill assets disappeared"), { code, syscall: "scandir", path });
76
81
  }
77
82
  return nativeReaddir(path, options);
78
83
  };
79
- fs.writeFileSync(join(assets, "trigger.txt"), "trigger directory rescan");
80
- await waitFor(() => reachedRescan);
84
+ fs.mkdirSync(join(assets, "trigger"));
85
+ // The rescan race needs live recursive-watch events. Where the OS
86
+ // cannot deliver them (an exhausted inotify budget, some WSL2
87
+ // setups), waiting the full waitFor budget is pointless: settle
88
+ // briefly for the natural race, then exercise the public error
89
+ // listener directly, as the manual fallback below always did.
90
+ const raceDeadline = Date.now() + 2000;
91
+ while (!reachedRescan && Date.now() < raceDeadline) await new Promise((resolve) => setTimeout(resolve, 25));
92
+ fs.readdirSync = nativeReaddir;
81
93
  // Newer Node versions swallow rescan ENOENT themselves. Still
82
94
  // exercise our public error listener without requiring Node's bug.
83
95
  if (!notices.some(({ message, level }) => level === "warning" && message.includes(code))) {
@@ -112,7 +124,7 @@ for (const code of ["ENOENT", "EMFILE"]) {
112
124
  timeout: 15_000,
113
125
  env: { ...process.env, GIT_CEILING_DIRECTORIES: root },
114
126
  });
115
- assert.equal(child.error, undefined, child.error?.message);
127
+ assert.equal(child.error, undefined, `${child.error?.message}\n${child.stderr}`);
116
128
  assert.equal(child.status, 0, child.stderr);
117
129
  assert.match(child.stdout, new RegExp(`SURVIVED ${code}`));
118
130
  });
@@ -120,28 +120,61 @@ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", async
120
120
  );
121
121
  });
122
122
 
123
- test("findSkillFiles scans one skill directory level only", async () => {
123
+ test("findSkillFiles scans flat skills and category-nested skills without descending further", async () => {
124
124
  const root = join(tmpdir(), `gentle-pi-shallow-${Date.now()}`);
125
125
  const skillPath = join(root, "docs", "SKILL.md");
126
126
  const nestedSkillPath = join(root, "fixtures", "nested", "SKILL.md");
127
+ const deeperSkillPath = join(root, "fixtures", "nested", "deeper", "SKILL.md");
127
128
  mkdirSync(dirname(skillPath), { recursive: true });
128
129
  mkdirSync(dirname(nestedSkillPath), { recursive: true });
130
+ mkdirSync(dirname(deeperSkillPath), { recursive: true });
129
131
  writeFileSync(skillPath, "---\nname: docs\ndescription: Docs.\n---\n");
130
132
  writeFileSync(nestedSkillPath, "---\nname: nested\ndescription: Nested fixture.\n---\n");
133
+ writeFileSync(deeperSkillPath, "---\nname: deeper\ndescription: Deeper fixture.\n---\n");
131
134
 
132
- assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
135
+ assert.deepEqual(await __testing.findSkillFiles(root), [skillPath, nestedSkillPath]);
133
136
  });
134
137
 
135
- test("findSkillFiles follows symlinked skill directories", async (t) => {
138
+ test("findSkillFiles keeps direct skills ahead of alphabetically earlier nested collisions", async () => {
139
+ const cwd = join(tmpdir(), `gentle-pi-precedence-${Date.now()}`);
140
+ const directSkillPath = join(cwd, "skills", "zeta", "SKILL.md");
141
+ const nestedSkillPath = join(cwd, "skills", "alpha", "duplicate", "SKILL.md");
142
+ mkdirSync(dirname(directSkillPath), { recursive: true });
143
+ mkdirSync(dirname(nestedSkillPath), { recursive: true });
144
+ writeFileSync(directSkillPath, "---\nname: duplicate\ndescription: Direct skill.\n---\n");
145
+ writeFileSync(nestedSkillPath, "---\nname: duplicate\ndescription: Nested skill.\n---\n");
146
+
147
+ await __testing.regenerateRegistry(cwd, true);
148
+ const registry = readFileSync(join(cwd, ".atl", "skill-registry.md"), "utf8");
149
+ assert.match(registry, new RegExp(directSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
150
+ assert.doesNotMatch(registry, new RegExp(nestedSkillPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
151
+ });
152
+
153
+ test("findSkillFiles does not descend into excluded categories", async () => {
154
+ const root = join(tmpdir(), `gentle-pi-excluded-category-${Date.now()}`);
155
+ for (const category of ["_shared", "skill-registry", "sdd-fixtures"]) {
156
+ const nestedSkillPath = join(root, category, "hidden", "SKILL.md");
157
+ mkdirSync(dirname(nestedSkillPath), { recursive: true });
158
+ writeFileSync(nestedSkillPath, "---\nname: hidden\ndescription: Hidden skill.\n---\n");
159
+ }
160
+
161
+ assert.deepEqual(await __testing.findSkillFiles(root), []);
162
+ });
163
+
164
+ test("findSkillFiles follows direct symlinked skills but not symlinked categories", async (t) => {
136
165
  const root = join(tmpdir(), `gentle-pi-symlink-root-${Date.now()}`);
137
166
  const realSkillDir = join(tmpdir(), `gentle-pi-symlink-target-${Date.now()}`);
167
+ const categoryTarget = join(tmpdir(), `gentle-pi-symlink-category-${Date.now()}`);
138
168
  const linkedSkillDir = join(root, "linked");
139
169
  const skillPath = join(linkedSkillDir, "SKILL.md");
140
170
  mkdirSync(root, { recursive: true });
141
171
  mkdirSync(realSkillDir, { recursive: true });
172
+ mkdirSync(join(categoryTarget, "nested"), { recursive: true });
142
173
  writeFileSync(join(realSkillDir, "SKILL.md"), "---\nname: linked\ndescription: Linked skill.\n---\n");
174
+ writeFileSync(join(categoryTarget, "nested", "SKILL.md"), "---\nname: nested\ndescription: Nested skill.\n---\n");
143
175
  try {
144
176
  symlinkSync(realSkillDir, linkedSkillDir, "dir");
177
+ symlinkSync(categoryTarget, join(root, "category"), "dir");
145
178
  } catch (error) {
146
179
  t.skip(`symlink creation unavailable: ${error instanceof Error ? error.message : String(error)}`);
147
180
  return;
@@ -150,6 +183,12 @@ test("findSkillFiles follows symlinked skill directories", async (t) => {
150
183
  assert.deepEqual(await __testing.findSkillFiles(root), [skillPath]);
151
184
  });
152
185
 
186
+ test("only missing skill-file errors allow category scanning", () => {
187
+ assert.equal(__testing.isMissingSkillFileError({ code: "ENOENT" }), true);
188
+ assert.equal(__testing.isMissingSkillFileError({ code: "ENOTDIR" }), true);
189
+ assert.equal(__testing.isMissingSkillFileError({ code: "EACCES" }), false);
190
+ });
191
+
153
192
  test("skill registry watchers close on shutdown", async () => {
154
193
  const root = join(tmpdir(), `gentle-pi-watchers-${Date.now()}`);
155
194
  const skillPath = join(root, "skills", "docs", "SKILL.md");
@@ -453,6 +492,11 @@ test("registered watcher protects registry tracked after a cache hit", async (t)
453
492
  while (runtime.notices.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
454
493
  assert.equal(readFileSync(fixture.registry, "utf8"), before);
455
494
  assert.ok(runtime.notices.some(({ message, level }) => level === "warning" && message.includes("/skill-registry:refresh")));
495
+ // The poll re-asks the same tracked-target state every interval: the
496
+ // warning stays a one-shot per unchanged state instead of repeating.
497
+ runtime.notices.length = 0;
498
+ await new Promise((resolve) => setTimeout(resolve, 3000));
499
+ assert.deepEqual(runtime.notices, []);
456
500
  assert.ok(!runtime.notices.some(({ message }) => message.includes("refreshed")));
457
501
  });
458
502
 
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import { __testing } from "../extensions/gentle-ai.ts";
6
+
7
+ // Pain-point iteration S15. Bench evidence (pps14 x5): one run read "Two unrelated wishes" as separate
8
+ // deliverables, classified a two-feature request as large (branch, feature document, session state), and
9
+ // started two writers for ~100-line units: USD 0.55 against 0.20 for the run that stayed inline. Blind round 4
10
+ // also found delivered tests that pin `git show <baseline commit>`, so the differential check had leaked into
11
+ // committed tests that fail in another clone. These are instruction-delivery contracts, not proof of adherence.
12
+
13
+ const read = (path: string): string => readFileSync(join(import.meta.dirname, "..", path), "utf8");
14
+
15
+ test("S15 (a): separate deliverables are ones the user wants apart, not several features asked at once", () => {
16
+ const core = read("assets/orchestrator.md");
17
+ assert.ok(
18
+ core.includes("deliverables the user wants apart (never several features asked at once)"),
19
+ "Task Size must define separate deliverables",
20
+ );
21
+ });
22
+
23
+ test("S15 (b): parallel writers never fire for units of about 100 changed lines or less", () => {
24
+ const card = read("assets/orchestrator-delegation.md");
25
+ assert.ok(
26
+ card.includes("Units of about 100 changed lines or less always run inline: writing them costs less than starting workers."),
27
+ "the Writer trigger must keep small units inline",
28
+ );
29
+ });
30
+
31
+ test("S15 (c): the differential check runs in scratch and is never committed as a test", () => {
32
+ const clause = "a copy of the base commit in a `mktemp -d` scratch directory, never committed as a test; committed tests never read git history or a fixed commit";
33
+ const harness = __testing.buildGentlePrompt("neutral");
34
+ assert.ok(harness.includes(clause), "the always-on test-first policy must keep the differential check in scratch");
35
+ assert.ok(!harness.includes("(`git stash` or a copy of HEAD)"), "the harness must not suggest git stash or HEAD copies");
36
+ assert.ok(read("assets/agents/gentle-ai-worker.md").includes(clause), "the worker must keep the differential check in scratch");
37
+ });
@@ -0,0 +1,324 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { chmodSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import test from "node:test";
8
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
9
+ import { gentleAiDevBinaryRegistrationPath, gentleAiIsolationEnv } from "../lib/gentle-ai-binary.ts";
10
+ import { GENTLE_SHELL_CONFIG_HOME_ENV, setBundledInstallForTesting } from "../lib/gentle-shell-bundled.ts";
11
+ import { buildPiInvocation, migrateStandaloneConfig, standaloneConfigHome, type ResolvedHome } from "../lib/gentle-shell-launcher.ts";
12
+ import { createActiveProfileReader } from "../extensions/gentle-shell.ts";
13
+ import { readBannerConfig } from "../extensions/startup-banner.ts";
14
+
15
+ // #2201 (S1, S1a): the standalone launcher's isolated home keeps Gentle Shell's
16
+ // config next to its agent dir (~/.gentle-shell/gentle-ai), not in the shared
17
+ // ~/.pi/gentle-ai that a Pi install with gentle-pi also uses.
18
+
19
+ const posixOnly = { skip: process.platform === "win32" };
20
+ const binPath = fileURLToPath(new URL("../bin/gentle-shell.mjs", import.meta.url));
21
+ const MARKER = "GENTLE_SHELL_CONFIG_HOME";
22
+
23
+ function scratch(): string {
24
+ return realpathSync(mkdtempSync(join(tmpdir(), "standalone-config-home-")));
25
+ }
26
+
27
+ function launch(home: ResolvedHome, baseEnv: Record<string, string | undefined> = {}, bundled = false) {
28
+ return buildPiInvocation({
29
+ runtime: { kind: "path", command: "fake-pi", args: [] },
30
+ home,
31
+ packageRoot: "/pkg",
32
+ declaration: undefined,
33
+ takeOver: false,
34
+ otherPackagePaths: [],
35
+ passthrough: [],
36
+ baseEnv,
37
+ homedir: "/os-home",
38
+ cwd: "/cwd",
39
+ bundled,
40
+ });
41
+ }
42
+
43
+ function isolated(dir = "/os-home/.gentle-shell/agent"): { home: ResolvedHome } {
44
+ return { home: { mode: "isolated", dir, source: "default" } };
45
+ }
46
+
47
+ test("the marker is exported under its launcher-owned name, never GENTLE_PI_CONFIG_HOME", () => {
48
+ assert.equal(GENTLE_SHELL_CONFIG_HOME_ENV, MARKER);
49
+ });
50
+
51
+ test("config home precedence: explicit GENTLE_PI_CONFIG_HOME > launcher marker > ~/.pi/gentle-ai; empty counts as unset", () => {
52
+ const home = scratch();
53
+ const env = (extra: Record<string, string>) => ({ HOME: home, USERPROFILE: home, ...extra });
54
+ const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
55
+ process.env.HOME = home;
56
+ process.env.USERPROFILE = home;
57
+ try {
58
+ assert.equal(gentlePiConfigHome(env({})), join(home, ".pi", "gentle-ai"));
59
+ assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai" })), "/shell/gentle-ai");
60
+ assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "/explicit" })), "/explicit");
61
+ assert.equal(gentlePiConfigHome(env({ [MARKER]: "/shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "" })), "/shell/gentle-ai");
62
+ assert.equal(gentlePiConfigHome(env({ [MARKER]: "" })), join(home, ".pi", "gentle-ai"));
63
+ assert.equal(gentlePiConfigHome(env({ [MARKER]: "relative/gentle-ai" })), join(home, ".pi", "gentle-ai"), "a relative marker is ignored");
64
+ } finally {
65
+ for (const [key, value] of Object.entries(previous)) {
66
+ if (value === undefined) delete process.env[key];
67
+ else process.env[key] = value;
68
+ }
69
+ }
70
+ });
71
+
72
+ test("a bundled prefix still outranks the explicit override and the marker", (t) => {
73
+ const prefix = scratch();
74
+ setBundledInstallForTesting({ root: prefix });
75
+ t.after(() => setBundledInstallForTesting(undefined));
76
+ assert.equal(
77
+ gentlePiConfigHome({ GENTLE_SHELL_PREFIX: prefix, GENTLE_PI_CONFIG_HOME: "/explicit", [MARKER]: "/shell/gentle-ai" }),
78
+ join(prefix, "config", "gentle-ai"),
79
+ );
80
+ });
81
+
82
+ test("the banner and profile readers follow the same resolver, including an empty GENTLE_PI_CONFIG_HOME", async (t) => {
83
+ const root = scratch();
84
+ const config = join(root, "shell", "gentle-ai");
85
+ mkdirSync(config, { recursive: true });
86
+ writeFileSync(join(config, "banner.json"), JSON.stringify({ showTextLogo: false }));
87
+ writeFileSync(join(config, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "fast", profiles: { fast: {} } }));
88
+ const previous = { marker: process.env[MARKER], explicit: process.env.GENTLE_PI_CONFIG_HOME };
89
+ process.env[MARKER] = config;
90
+ process.env.GENTLE_PI_CONFIG_HOME = "";
91
+ t.after(() => {
92
+ if (previous.marker === undefined) delete process.env[MARKER];
93
+ else process.env[MARKER] = previous.marker;
94
+ if (previous.explicit === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
95
+ else process.env.GENTLE_PI_CONFIG_HOME = previous.explicit;
96
+ });
97
+ assert.equal((await readBannerConfig()).showTextLogo, false);
98
+ assert.equal(createActiveProfileReader({ [MARKER]: config, GENTLE_PI_CONFIG_HOME: "" })(), "fast");
99
+ });
100
+
101
+ test("the launcher gives Pi the marker only in its isolated home", () => {
102
+ const isolatedLaunch = launch(isolated().home);
103
+ assert.equal(isolatedLaunch.env[MARKER], "/os-home/.gentle-shell/gentle-ai");
104
+ assert.equal(isolatedLaunch.env.GENTLE_PI_CONFIG_HOME, undefined, "GENTLE_PI_CONFIG_HOME is never exported to Pi");
105
+ assert.equal(standaloneConfigHome(isolated().home), "/os-home/.gentle-shell/gentle-ai");
106
+ // A custom GENTLE_SHELL_HOME's parent may be anyone's folder (~/myagent ->
107
+ // ~/gentle-ai), so only a .gentle-shell/agent home gets its own config folder.
108
+ assert.equal(launch(isolated("/custom/shell/agent").home).env[MARKER], undefined, "no marker beside a custom GENTLE_SHELL_HOME");
109
+ assert.equal(standaloneConfigHome(isolated("/os-home/myagent").home), undefined, "never the generic ~/gentle-ai");
110
+ assert.equal(standaloneConfigHome(isolated("/data/.gentle-shell/agent").home), "/data/.gentle-shell/gentle-ai");
111
+ });
112
+
113
+ test("--link, a custom --home and a bundled launch keep today's config home and drop an inherited marker", () => {
114
+ const inherited = { [MARKER]: "/outer/gentle-ai" };
115
+ const link = launch({ mode: "link", dir: "/os-home/.pi/agent", source: "flag" }, inherited);
116
+ const path = launch({ mode: "path", dir: "/custom/home", source: "flag" }, inherited);
117
+ const bundled = launch(isolated().home, inherited, true);
118
+ for (const invocation of [link, path, bundled]) assert.equal(MARKER in invocation.env, false);
119
+ assert.equal(standaloneConfigHome({ mode: "link", dir: "/os-home/.pi/agent", source: "flag" }), undefined);
120
+ assert.equal(standaloneConfigHome({ mode: "path", dir: "/custom/home", source: "flag" }), undefined);
121
+ });
122
+
123
+ test("Gentle AI processes started from a standalone isolated session get the bundled-style config pair", () => {
124
+ const env = gentleAiIsolationEnv({ PATH: "/bin", [MARKER]: "/os-home/.gentle-shell/gentle-ai" });
125
+ assert.equal(env.GENTLE_AI_PI_CONFIG_DIR, "/os-home/.gentle-shell/gentle-ai");
126
+ assert.equal(env.GENTLE_PI_CONFIG_HOME, "/os-home/.gentle-shell", "Gentle AI 4.0.0 reads it as the parent of its gentle-ai folder");
127
+ assert.equal(env.PATH, "/bin");
128
+ assert.equal(env.GENTLE_AI_HOME, undefined, "standalone keeps Gentle AI's own state where it was");
129
+ });
130
+
131
+ test("without the marker, or with an explicit GENTLE_PI_CONFIG_HOME, Gentle AI's environment is unchanged", () => {
132
+ const plain = { PATH: "/bin" };
133
+ assert.equal(gentleAiIsolationEnv(plain), plain);
134
+ const explicit = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: "/explicit", [MARKER]: "/os-home/.gentle-shell/gentle-ai" };
135
+ assert.equal(gentleAiIsolationEnv(explicit), explicit);
136
+ });
137
+
138
+ test("the main-channel dev-binary registration ignores the marker and an empty GENTLE_PI_CONFIG_HOME", () => {
139
+ assert.equal(
140
+ gentleAiDevBinaryRegistrationPath({ env: { [MARKER]: "/os-home/.gentle-shell/gentle-ai", GENTLE_PI_CONFIG_HOME: "" }, home: "/os-home" }),
141
+ join("/os-home", ".pi", "gentle-ai", "dev-binary.json"),
142
+ );
143
+ assert.equal(gentleAiDevBinaryRegistrationPath({ env: { GENTLE_PI_CONFIG_HOME: "/explicit" }, home: "/os-home" }), join("/explicit", "dev-binary.json"));
144
+ });
145
+
146
+ // --- migration -----------------------------------------------------------------
147
+
148
+ // The same real filesystem bin/gentle-shell.mjs gives migrateStandaloneConfig.
149
+ const fs = {
150
+ kind: (path: string) => {
151
+ try {
152
+ const stat = lstatSync(path);
153
+ return stat.isDirectory() ? "dir" as const : stat.isFile() ? "file" as const : "other" as const;
154
+ } catch {
155
+ return "missing" as const;
156
+ }
157
+ },
158
+ list: (path: string) => readdirSync(path),
159
+ mkdirPrivate: (path: string) => { mkdirSync(path, { recursive: true, mode: 0o700 }); },
160
+ copyNew: (from: string, to: string) => copyFileSync(from, to, constants.COPYFILE_EXCL),
161
+ };
162
+
163
+ function migrationWorld() {
164
+ const root = scratch();
165
+ const source = join(root, "home", ".pi", "gentle-ai");
166
+ const target = join(root, "home", ".gentle-shell", "gentle-ai");
167
+ mkdirSync(source, { recursive: true });
168
+ mkdirSync(dirname(target), { recursive: true });
169
+ writeFileSync(join(source, "banner.json"), "{\"showTextLogo\":false}\n");
170
+ writeFileSync(join(source, "profiles.json"), "{\"active\":\"fast\"}\n");
171
+ writeFileSync(join(source, "dev-binary.json"), "{}\n");
172
+ writeFileSync(join(source, "channel.json"), "{}\n");
173
+ mkdirSync(join(source, "main", "gentle-ai"), { recursive: true });
174
+ writeFileSync(join(source, "main", "gentle-ai", "marker"), "x");
175
+ return { root, source, target };
176
+ }
177
+
178
+ function snapshotTree(dir: string): Record<string, string> {
179
+ const out: Record<string, string> = {};
180
+ for (const name of readdirSync(dir)) {
181
+ const path = join(dir, name);
182
+ const stat = lstatSync(path);
183
+ if (stat.isDirectory()) for (const [key, value] of Object.entries(snapshotTree(path))) out[join(name, key)] = value;
184
+ else out[name] = stat.isSymbolicLink() ? "symlink" : `${stat.mode}:${stat.mtimeMs}:${readFileSync(path, "utf8")}`;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ test("migration copies the regular files once into a new 0700 folder and never changes the source", posixOnly, () => {
190
+ const w = migrationWorld();
191
+ symlinkSync(join(w.source, "banner.json"), join(w.source, "linked.json"));
192
+ const before = snapshotTree(w.source);
193
+ const result = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
194
+ assert.deepEqual(readdirSync(w.target).sort(), ["banner.json", "profiles.json"]);
195
+ assert.equal(readFileSync(join(w.target, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
196
+ assert.equal(statSync(w.target).mode & 0o777, 0o700);
197
+ assert.deepEqual(snapshotTree(w.source), before);
198
+ assert.equal(result.lines.length, 1);
199
+ assert.match(result.lines[0], new RegExp(`${w.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*${w.target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
200
+ });
201
+
202
+ test("migration skips the main-channel files and does nothing once the folder exists", posixOnly, () => {
203
+ const w = migrationWorld();
204
+ migrateStandaloneConfig({ source: w.source, target: w.target, fs });
205
+ assert.equal(existsSync(join(w.target, "dev-binary.json")), false);
206
+ assert.equal(existsSync(join(w.target, "channel.json")), false);
207
+ assert.equal(existsSync(join(w.target, "main")), false);
208
+ writeFileSync(join(w.target, "banner.json"), "{\"color\":\"cyan\"}\n");
209
+ writeFileSync(join(w.source, "vim.json"), "{}\n");
210
+ const again = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
211
+ assert.deepEqual(again.lines, []);
212
+ assert.equal(readFileSync(join(w.target, "banner.json"), "utf8"), "{\"color\":\"cyan\"}\n", "never overwrites");
213
+ assert.equal(existsSync(join(w.target, "vim.json")), false, "runs once");
214
+ });
215
+
216
+ test("without ~/.pi/gentle-ai the migration only creates the empty 0700 folder, silently", posixOnly, () => {
217
+ const root = scratch();
218
+ const target = join(root, ".gentle-shell", "gentle-ai");
219
+ const result = migrateStandaloneConfig({ source: join(root, ".pi", "gentle-ai"), target, fs });
220
+ assert.deepEqual(result.lines, []);
221
+ assert.deepEqual(readdirSync(target), []);
222
+ assert.equal(statSync(target).mode & 0o777, 0o700);
223
+ });
224
+
225
+ test("a failed copy warns and keeps whatever was copied; it never throws", posixOnly, () => {
226
+ const w = migrationWorld();
227
+ chmodSync(join(w.source, "profiles.json"), 0o000);
228
+ if (spawnSync("cat", [join(w.source, "profiles.json")]).status === 0) return; // running as root: nothing is unreadable
229
+ const result = migrateStandaloneConfig({ source: w.source, target: w.target, fs });
230
+ chmodSync(join(w.source, "profiles.json"), 0o644);
231
+ assert.deepEqual(readdirSync(w.target), ["banner.json"]);
232
+ assert.equal(result.lines.length, 1);
233
+ assert.match(result.lines[0], /could not copy/);
234
+ assert.match(result.lines[0], /profiles\.json/);
235
+ const blocked = scratch();
236
+ writeFileSync(join(blocked, "not-a-dir"), "");
237
+ const failed = migrateStandaloneConfig({ source: w.source, target: join(blocked, "not-a-dir", "gentle-ai"), fs });
238
+ assert.equal(failed.lines.length, 1);
239
+ assert.match(failed.lines[0], /could not/);
240
+ });
241
+
242
+ // --- the real launcher -----------------------------------------------------------
243
+
244
+ function writeFakePi(path: string) {
245
+ writeFileSync(path, [
246
+ "#!/usr/bin/env node",
247
+ "const args = process.argv.slice(2);",
248
+ "if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
249
+ `console.log(JSON.stringify({ marker: process.env.${MARKER} ?? null, explicit: process.env.GENTLE_PI_CONFIG_HOME ?? null }));`,
250
+ "",
251
+ ].join("\n"));
252
+ chmodSync(path, 0o755);
253
+ }
254
+
255
+ function writeFakeGentleAi(path: string) {
256
+ writeFileSync(path, [
257
+ "#!/usr/bin/env node",
258
+ "import { mkdirSync, writeFileSync } from 'node:fs';",
259
+ "import { homedir } from 'node:os';",
260
+ "import { join } from 'node:path';",
261
+ "for (const dir of [process.env.GENTLE_AI_PI_CONFIG_DIR, join(homedir(), '.pi', 'gentle-ai')]) {",
262
+ " if (!dir) continue;",
263
+ " mkdirSync(dir, { recursive: true });",
264
+ " writeFileSync(join(dir, 'persona.json'), JSON.stringify({ mode: 'gentleman' }));",
265
+ "}",
266
+ "process.stdout.write(JSON.stringify({ dir: process.env.GENTLE_AI_PI_CONFIG_DIR ?? null, parent: process.env.GENTLE_PI_CONFIG_HOME ?? null }) + '\\n');",
267
+ "",
268
+ ].join("\n"));
269
+ chmodSync(path, 0o755);
270
+ }
271
+
272
+ function launcherWorld() {
273
+ const root = scratch();
274
+ const home = join(root, "home");
275
+ mkdirSync(join(home, ".pi", "gentle-ai"), { recursive: true });
276
+ writeFileSync(join(home, ".pi", "gentle-ai", "banner.json"), "{\"showTextLogo\":false}\n");
277
+ const pi = join(root, "fake-pi.mjs");
278
+ writeFakePi(pi);
279
+ const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, GENTLE_SHELL_PI: pi, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
280
+ const run = (args: string[], extra: NodeJS.ProcessEnv = {}) => spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env: { ...env, ...extra } });
281
+ return { root, home, env, run, shellConfig: join(home, ".gentle-shell", "gentle-ai") };
282
+ }
283
+
284
+ test("a standalone isolated launch migrates ~/.pi/gentle-ai and points Pi at ~/.gentle-shell/gentle-ai", posixOnly, () => {
285
+ const w = launcherWorld();
286
+ const result = w.run(["list"]);
287
+ assert.equal(result.status, 0, result.stderr);
288
+ assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)!), { marker: w.shellConfig, explicit: null });
289
+ assert.equal(readFileSync(join(w.shellConfig, "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
290
+ assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "banner.json"), "utf8"), "{\"showTextLogo\":false}\n");
291
+ assert.equal(result.stderr.split("\n").filter((line) => line.includes(w.shellConfig) && line.includes("copied")).length, 1);
292
+ const second = w.run(["list"]);
293
+ assert.doesNotMatch(second.stderr, /copied/);
294
+ });
295
+
296
+ test("--link launches keep ~/.pi/gentle-ai: no marker and no migration", posixOnly, () => {
297
+ const w = launcherWorld();
298
+ mkdirSync(join(w.home, ".pi", "agent"), { recursive: true });
299
+ const result = w.run(["--link", "list"], { [MARKER]: "/outer/gentle-ai" });
300
+ assert.equal(result.status, 0, result.stderr);
301
+ assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)!), { marker: null, explicit: null });
302
+ assert.equal(existsSync(w.shellConfig), false);
303
+ });
304
+
305
+ test("an explicit GENTLE_PI_CONFIG_HOME still wins: it reaches Pi and no migration runs", posixOnly, () => {
306
+ const w = launcherWorld();
307
+ const result = w.run(["list"], { GENTLE_PI_CONFIG_HOME: join(w.root, "explicit") });
308
+ assert.equal(result.status, 0, result.stderr);
309
+ assert.equal(JSON.parse(result.stdout.trim().split("\n").at(-1)!).explicit, join(w.root, "explicit"));
310
+ assert.equal(existsSync(w.shellConfig), false);
311
+ });
312
+
313
+ test("standalone setup gives Gentle AI the config pair and keeps both persona files unchanged", posixOnly, () => {
314
+ const w = launcherWorld();
315
+ const gentleAi = join(w.root, "fake-gentle-ai.mjs");
316
+ writeFakeGentleAi(gentleAi);
317
+ writeFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "{\"mode\":\"neutral\"}");
318
+ const result = w.run(["setup"], { GENTLE_SHELL_GENTLE_AI_BIN: gentleAi });
319
+ assert.equal(result.status, 0, result.stderr);
320
+ const seen = JSON.parse(result.stdout.split("\n").find((line) => line.includes("\"dir\""))!);
321
+ assert.deepEqual(seen, { dir: w.shellConfig, parent: join(w.home, ".gentle-shell") });
322
+ assert.equal(readFileSync(join(w.home, ".pi", "gentle-ai", "persona.json"), "utf8"), "{\"mode\":\"neutral\"}");
323
+ assert.equal(readFileSync(join(w.shellConfig, "persona.json"), "utf8"), "{\"mode\":\"neutral\"}", "the migrated copy is restored too");
324
+ });
@@ -462,19 +462,20 @@ test("delegated children never paint the startup banner", async (t) => {
462
462
 
463
463
  // S9: the config home each of these reads resolves, per case. The OS home is
464
464
  // a temporary folder in every case, so no case can reach the real one.
465
- // "unset" and "empty" are exactly as before: an empty GENTLE_PI_CONFIG_HOME
466
- // names the current folder. "bundled" stands in for this package running from
465
+ // "unset" and "empty" both name ~/.pi/gentle-ai: an empty GENTLE_PI_CONFIG_HOME
466
+ // counts as unset (#2201; it used to name the current folder). "bundled" stands in for this package running from
467
467
  // a bundled install (testing overlay) whose prefix the launcher passed; the
468
468
  // user's own GENTLE_PI_CONFIG_HOME is then not this install's.
469
469
  type ConfigHomeCase = "unset" | "empty" | "bundled";
470
470
  function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHomeCase): { env: NodeJS.ProcessEnv; dir: string; userConfig: string } {
471
471
  const base = realpathSync(mkdtempSync(join(tmpdir(), `config-home-${kind}-`)));
472
- const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX };
472
+ const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, GENTLE_PI_CONFIG_HOME: process.env.GENTLE_PI_CONFIG_HOME, GENTLE_SHELL_PREFIX: process.env.GENTLE_SHELL_PREFIX, GENTLE_SHELL_CONFIG_HOME: process.env.GENTLE_SHELL_CONFIG_HOME };
473
473
  const cwd = process.cwd();
474
474
  process.env.HOME = join(base, "home");
475
475
  process.env.USERPROFILE = join(base, "home");
476
476
  delete process.env.GENTLE_PI_CONFIG_HOME;
477
477
  delete process.env.GENTLE_SHELL_PREFIX;
478
+ delete process.env.GENTLE_SHELL_CONFIG_HOME;
478
479
  t.after(() => {
479
480
  process.chdir(cwd);
480
481
  setBundledInstallForTesting(undefined);
@@ -489,7 +490,7 @@ function configHomeCase(t: { after: (fn: () => void) => void }, kind: ConfigHome
489
490
  if (kind === "empty") {
490
491
  mkdirSync(join(base, "cwd"));
491
492
  process.chdir(join(base, "cwd"));
492
- return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "cwd"), userConfig };
493
+ return { env: { GENTLE_PI_CONFIG_HOME: "" }, dir: join(base, "home", ".pi", "gentle-ai"), userConfig };
493
494
  }
494
495
  const prefix = join(base, "prefix");
495
496
  mkdirSync(prefix);