gentle-pi 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,160 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import type { JobRecord } from "../lib/background-jobs.ts";
5
+ import { JobsView } from "../lib/jobs-view.ts";
6
+ import { stripAnsi } from "../lib/terminal-theme.ts";
7
+
8
+ const theme = { fg: (_role: string, text: string) => text };
9
+
10
+ function job(overrides: Partial<JobRecord> & Pick<JobRecord, "id">): JobRecord {
11
+ return {
12
+ kind: "command",
13
+ label: overrides.id,
14
+ command: `run ${overrides.id}`,
15
+ cwd: "/repo",
16
+ ownerSessionId: "s1",
17
+ outputPath: `/tmp/jobs/${overrides.id}.log`,
18
+ startedAt: 0,
19
+ status: "running",
20
+ tail: [],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ function harness(jobs: JobRecord[], rows = 14) {
26
+ const stopped: string[] = [];
27
+ let closed = 0;
28
+ let renders = 0;
29
+ const view = new JobsView({
30
+ theme,
31
+ rows: () => rows,
32
+ jobs: () => jobs,
33
+ now: () => 125_000,
34
+ onStop: (selected) => { stopped.push(selected.id); },
35
+ onClose: () => { closed += 1; },
36
+ requestRender: () => { renders += 1; },
37
+ });
38
+ const screen = (width = 100) => view.render(width).map(stripAnsi);
39
+ return { view, screen, stopped, closed: () => closed, renders: () => renders };
40
+ }
41
+
42
+ test("the split view lists this session's jobs beside the selected job's details and output tail", () => {
43
+ const h = harness([
44
+ job({ id: "job-1", label: "CI for PR 1834", command: "gh run watch 42 --exit-status", tail: ["check lint: pass", "check test: pending"] }),
45
+ job({ id: "job-2", label: "build", status: "exited", exitCode: 1, endedAt: 4_000 }),
46
+ ]);
47
+ try {
48
+ const lines = h.screen();
49
+ assert.equal(lines.length, 14, "the overlay fills the terminal height");
50
+ for (const line of lines) assert.equal(visibleWidth(line), 100, "every row spans the full width");
51
+ const text = lines.join("\n");
52
+ assert.match(lines[0]!, /Jobs · 1 running · 1 finished/);
53
+ assert.match(text, /◐ CI for PR 1834 +2m 5s/);
54
+ assert.match(text, /✗ build +4s/);
55
+ assert.match(text, /gh run watch 42 --exit-status/);
56
+ assert.match(text, /running/);
57
+ assert.match(text, /\/tmp\/jobs\/job-1\.log/);
58
+ assert.match(text, /check test: pending/);
59
+ assert.match(lines.at(-2)!, /s Stop/);
60
+ } finally { h.view.dispose(); }
61
+ });
62
+
63
+ test("j/k move the selection, s stops only a running job, and q closes", () => {
64
+ const h = harness([job({ id: "job-1" }), job({ id: "job-2", status: "exited", exitCode: 0, endedAt: 1_000, tail: ["done"] })]);
65
+ try {
66
+ h.view.handleInput("j");
67
+ const text = h.screen().join("\n");
68
+ assert.match(text, /exited with code 0/);
69
+ assert.match(text, /done/);
70
+ assert.doesNotMatch(h.screen().at(-2)!, /s Stop/, "a finished job offers no stop");
71
+ h.view.handleInput("s");
72
+ assert.deepEqual(h.stopped, []);
73
+ h.view.handleInput("k");
74
+ h.view.handleInput("s");
75
+ assert.deepEqual(h.stopped, ["job-1"]);
76
+ h.view.handleInput("q");
77
+ assert.equal(h.closed(), 1);
78
+ } finally { h.view.dispose(); }
79
+ });
80
+
81
+ test("jobs show running first and newest first within each group without changing source order", () => {
82
+ const jobs = [
83
+ job({ id: "old-finished", status: "exited", exitCode: 0, startedAt: 1_000, endedAt: 2_000 }),
84
+ job({ id: "old-running", startedAt: 3_000 }),
85
+ job({ id: "new-finished", status: "stopped", startedAt: 9_000, endedAt: 10_000 }),
86
+ job({ id: "new-running", startedAt: 5_000 }),
87
+ ];
88
+ const h = harness(jobs);
89
+ try {
90
+ const lines = h.screen();
91
+ const names = ["new-running", "old-running", "new-finished", "old-finished"];
92
+ const positions = names.map((name) => lines.findIndex((line) => line.includes(name)));
93
+ assert.ok(positions.every((index, i) => index > 0 && (i === 0 || index > positions[i - 1]!)));
94
+ h.view.handleInput("s");
95
+ assert.deepEqual(h.stopped, ["new-running"], "the default selection follows display order");
96
+ h.view.handleInput("j");
97
+ h.view.handleInput("s");
98
+ assert.deepEqual(h.stopped, ["new-running", "old-running"], "navigation follows the same sorted order");
99
+ jobs.push(job({ id: "latest-running", startedAt: 11_000 }));
100
+ assert.match(h.screen().join("\n"), /› ◐ old-running/, "a new job preserves selection by ID");
101
+ assert.deepEqual(jobs.map((entry) => entry.id), ["old-finished", "old-running", "new-finished", "new-running", "latest-running"]);
102
+ } finally { h.view.dispose(); }
103
+ });
104
+
105
+ test("initial selection survives new jobs and completion before any keyboard navigation", () => {
106
+ const first = job({ id: "job-A", startedAt: 1_000 });
107
+ const jobs = [first];
108
+ const h = harness(jobs);
109
+ try {
110
+ assert.match(h.screen().join("\n"), /› ◐ job-A/);
111
+ jobs.push(job({ id: "job-B", startedAt: 2_000 }));
112
+ assert.match(h.screen().join("\n"), /› ◐ job-A/, "a newly started job does not steal selection");
113
+ h.view.handleInput("s");
114
+ assert.deepEqual(h.stopped, ["job-A"], "stop still targets the initially selected job");
115
+ first.status = "exited";
116
+ first.exitCode = 0;
117
+ first.endedAt = 3_000;
118
+ assert.match(h.screen().join("\n"), /› ✓ job-A/, "completion does not steal selection");
119
+ assert.match(h.screen().join("\n"), /exited with code 0/);
120
+ h.view.handleInput("s");
121
+ assert.deepEqual(h.stopped, ["job-A"], "stop on a finished selection does not target another running job");
122
+ assert.deepEqual(jobs.map((entry) => entry.id), ["job-A", "job-B"]);
123
+ } finally { h.view.dispose(); }
124
+ });
125
+
126
+ test("an empty session says so", () => {
127
+ const h = harness([]);
128
+ try {
129
+ const text = h.screen().join("\n");
130
+ assert.match(text, /Jobs · 0 running · 0 finished/);
131
+ assert.match(text, /No background jobs in this session/);
132
+ } finally { h.view.dispose(); }
133
+ });
134
+
135
+ test("a narrow terminal shows the list, and tab toggles the selected job's details", () => {
136
+ const h = harness([job({ id: "job-1", label: "ci", command: "gh run watch 7", tail: ["waiting"] })]);
137
+ try {
138
+ let text = h.screen(40).join("\n");
139
+ assert.match(text, /◐ ci/);
140
+ assert.doesNotMatch(text, /gh run watch 7/);
141
+ h.view.handleInput("\t");
142
+ text = h.screen(40).join("\n");
143
+ assert.match(text, /gh run watch 7/);
144
+ assert.match(text, /waiting/);
145
+ h.view.handleInput("\u001b");
146
+ assert.equal(h.closed(), 0, "escape from details goes back to the list first");
147
+ assert.doesNotMatch(h.screen(40).join("\n"), /gh run watch 7/);
148
+ h.view.handleInput("\u001b");
149
+ assert.equal(h.closed(), 1);
150
+ } finally { h.view.dispose(); }
151
+ });
152
+
153
+ test("a monitor shows its kind and event count in the list and details", () => {
154
+ const h = harness([job({ id: "job-1", kind: "monitor", label: "errors", command: "tail -f app.log | grep ERROR", events: 3, tail: ["ERROR x"] })]);
155
+ try {
156
+ const text = h.screen().join("\n");
157
+ assert.match(text, /◉ errors/);
158
+ assert.match(text, /Status +running · .* · 3 events/);
159
+ } finally { h.view.dispose(); }
160
+ });
@@ -0,0 +1,395 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import * as fsPromises from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
8
+ import { requirements } from "../scripts/installer-preflight.mjs";
9
+
10
+ const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
11
+ const SHELL_SHA = "6e7e3a18f794223396527a54c7c36d19c7d236c6";
12
+ const NEW_AI = "2222222222222222222222222222222222222222";
13
+ const NEW_SHELL = "3333333333333333333333333333333333333333";
14
+ const posixOnly = { skip: process.platform === "win32" };
15
+ type Call = { command: string; argv: string[] };
16
+ type Invocation = { command: string; prefix: string[] } | null;
17
+
18
+ /** A sandboxed home with a pnpm- or npm-owned package root and fake network/processes.
19
+ * `which` finds `<bin>/<name><suffix>`; `invocation`, when given, is the host's adapter. */
20
+ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin",
21
+ bin = "/usr/bin", suffix = "", invocation = undefined as ((name: string) => Promise<Invocation>) | undefined, fs = fsPromises as typeof fsPromises,
22
+ migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined } = {}) {
23
+ // owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root.
24
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-")));
25
+ const home = join(root, "home");
26
+ const pnpmHome = join(root, "pnpm");
27
+ const npmRoot = join(root, "npm", "lib", "node_modules");
28
+ mkdirSync(npmRoot, { recursive: true });
29
+ const packageRoot = owner === "pnpm" ? join(pnpmHome, "global", "v11", "x", "node_modules", "gentle-pi")
30
+ : owner === "npm" ? join(npmRoot, "gentle-pi") : join(root, "checkout");
31
+ mkdirSync(packageRoot, { recursive: true });
32
+ mkdirSync(home);
33
+ const ctx = { env: { PNPM_HOME: pnpmHome }, home };
34
+ const calls: Call[] = [];
35
+ const lines: string[] = [];
36
+ const fetches: string[] = [];
37
+ const fetch = async (url: string) => {
38
+ fetches.push(url);
39
+ if (url === "https://registry.npmjs.org/gentle-pi/latest") return { ok: latest !== "", status: latest ? 200 : 503, json: async () => ({ version: latest }) };
40
+ if (url.endsWith("/gentle-ai/commits/main")) return { ok: true, status: 200, text: async () => commits.ai };
41
+ if (url.endsWith("/gentle-shell/commits/main")) return { ok: true, status: 200, text: async () => commits.shell };
42
+ if (url.startsWith("https://codeload.github.com/")) return { ok: true, status: 200, arrayBuffer: async () => new TextEncoder().encode("src").buffer };
43
+ throw new Error(`unexpected fetch ${url}`);
44
+ };
45
+ const roots: Call[] = [];
46
+ const run = async (command: string, argv: string[], options: { env?: Record<string, string>; cwd?: string }) => {
47
+ if (argv.slice(-2).join(" ") === "root -g") {
48
+ roots.push({ command, argv });
49
+ return command === "/usr/bin/npm" || command.endsWith("node.exe") ? { code: 0, stdout: `${npmRoot}\n` } : { code: 1, stdout: "" };
50
+ }
51
+ calls.push({ command, argv });
52
+ if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) {
53
+ mkdirSync(options.env!.GOBIN, { recursive: true });
54
+ for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
55
+ return { code: 0, stdout: "" };
56
+ }
57
+ if (argv[0] === "version" && command === `${bin}/go`) {
58
+ return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
59
+ }
60
+ if (argv[0] === "version") {
61
+ const sha = command.split("/").at(-2) ?? "";
62
+ return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
63
+ }
64
+ if (command === "tar" || command.endsWith("tar.exe")) {
65
+ writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } }));
66
+ return { code: 0, stdout: "" };
67
+ }
68
+ if (argv.includes("pack")) {
69
+ const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version;
70
+ writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz");
71
+ }
72
+ return { code: 0, stdout: "" };
73
+ };
74
+ const which = async (name: string) => (tools.includes(name) ? `${bin}/${name}${name === "go" ? "" : suffix}` : null);
75
+ const upgrade = (args: string[], currentVersion = "4.0.0") => runUpgrade({ args, ctx, platform, packageRoot, currentVersion,
76
+ adapters: { fetch, run, fs, which,
77
+ acquireGo: async () => { throw new Error("Unexpected Go acquisition in sandboxed upgrade fixture"); },
78
+ ...(invocation ? { invocation } : {}), ...(migrate ? { migrate } : {}) }, out: (line: string) => lines.push(line) });
79
+ const installs = () => calls.filter((call) => call.argv.some((arg) => ["add", "install"].includes(arg)) && !call.argv.some((arg) => arg.includes("cmd/gentle-ai@")))
80
+ .map((call) => `${call.command} ${call.argv.join(" ")}`);
81
+ const manager = (command?: string) => ownerManager({ ctx, platform, packageRoot, adapters: { fs, run, which, ...(invocation ? { invocation } : {}) },
82
+ ...(command === undefined ? {} : { command }) });
83
+ return { root, home, ctx, calls, roots, lines, fetches, upgrade, installs, manager, cleanup: () => rmSync(root, { recursive: true, force: true }) };
84
+ }
85
+
86
+ test("a release install that is already the latest release changes nothing", async () => {
87
+ const w = world({ latest: "4.0.0" });
88
+ try {
89
+ assert.equal(await w.upgrade([]), 0);
90
+ assert.deepEqual(w.lines, ["gentle-shell 4.0.0 is already the latest release."]);
91
+ assert.deepEqual(w.calls, []);
92
+ assert.deepEqual(w.fetches, ["https://registry.npmjs.org/gentle-pi/latest"]);
93
+ } finally { w.cleanup(); }
94
+ });
95
+
96
+ test("a release install updates to the latest release with the package manager that owns it", async () => {
97
+ for (const [owner, expected] of [["pnpm", "/usr/bin/pnpm add -g gentle-pi@4.1.0 --allow-build=gentle-pi"], ["npm", "/usr/bin/npm install -g gentle-pi@4.1.0"]]) {
98
+ const w = world({ owner });
99
+ try {
100
+ assert.equal(await w.upgrade([]), 0);
101
+ assert.deepEqual(w.installs(), [expected]);
102
+ assert.deepEqual(w.lines, ["Updated gentle-shell 4.0.0 to 4.1.0 (release)."]);
103
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "release" });
104
+ } finally { w.cleanup(); }
105
+ }
106
+ });
107
+
108
+ test("a pnpm upgrade adds only gentle-pi, in its own pnpm group: a Pi installed with pnpm is never read or replaced", async () => {
109
+ // A separate add keeps `pi update` (which replaces Pi's own group) from removing gentle-pi.
110
+ const w = world();
111
+ try {
112
+ assert.equal(await w.upgrade([]), 0);
113
+ assert.deepEqual(w.calls.map((call) => `${call.command} ${call.argv.join(" ")}`), ["/usr/bin/pnpm add -g gentle-pi@4.1.0 --allow-build=gentle-pi"]);
114
+ } finally { w.cleanup(); }
115
+ });
116
+
117
+ test("a Gentle Shell that neither pnpm nor npm owns, such as an npm-linked checkout, is never reinstalled", async () => {
118
+ const w = world({ owner: "linked" });
119
+ try {
120
+ await assert.rejects(w.upgrade([]), (error: MainChannelError) => error.code === "upgrade-owner-unknown");
121
+ assert.deepEqual(w.installs(), []);
122
+ } finally { w.cleanup(); }
123
+ });
124
+
125
+ test("self-uninstall resolves the owning package manager with its own refusals", async () => {
126
+ for (const [owner, expected] of [["pnpm", { name: "pnpm", command: "/usr/bin/pnpm", prefix: [] }], ["npm", { name: "npm", command: "/usr/bin/npm", prefix: [] }]] as const) {
127
+ const w = world({ owner });
128
+ try {
129
+ assert.deepEqual(await w.manager("uninstall"), expected);
130
+ } finally { w.cleanup(); }
131
+ }
132
+ const linked = world({ owner: "linked" });
133
+ try {
134
+ await assert.rejects(linked.manager("uninstall"), (error: MainChannelError) =>
135
+ error.code === "uninstall-owner-unknown" && /remove it the way you installed it/.test(error.message));
136
+ } finally { linked.cleanup(); }
137
+ const missing = world({ tools: ["npm"] });
138
+ try {
139
+ await assert.rejects(missing.manager("uninstall"), (error: MainChannelError) => error.code === "uninstall-manager-missing");
140
+ } finally { missing.cleanup(); }
141
+ });
142
+
143
+ test("upgrade keeps its owner refusals unchanged", async () => {
144
+ const linked = world({ owner: "linked" });
145
+ try {
146
+ await assert.rejects(linked.manager(), (error: MainChannelError) =>
147
+ error.code === "upgrade-owner-unknown" && /update it the way you installed it/.test(error.message));
148
+ } finally { linked.cleanup(); }
149
+ const missing = world({ tools: ["npm"] });
150
+ try {
151
+ await assert.rejects(missing.manager(), (error: MainChannelError) =>
152
+ error.code === "upgrade-manager-missing" && /which owns this Gentle Shell installation, is not on PATH/.test(error.message));
153
+ await assert.rejects(missing.upgrade([]), (error: MainChannelError) => error.code === "upgrade-manager-missing");
154
+ assert.deepEqual(missing.installs(), []);
155
+ } finally { missing.cleanup(); }
156
+ });
157
+
158
+ test("an unreachable registry fails without installing anything", async () => {
159
+ const w = world({ latest: "" });
160
+ try {
161
+ await assert.rejects(w.upgrade([]), (error: MainChannelError) => error.code === "latest-release-unavailable");
162
+ assert.deepEqual(w.calls, []);
163
+ } finally { w.cleanup(); }
164
+ });
165
+
166
+ test("a main install already at both latest main commits changes nothing", async () => {
167
+ const w = world();
168
+ try {
169
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
170
+ assert.equal(await w.upgrade([], "4.0.0-main.6e7e3a18f794"), 0);
171
+ assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 6e7e3a18f794, Gentle AI 1f9d5e6423e3."]);
172
+ assert.deepEqual(w.calls, []);
173
+ } finally { w.cleanup(); }
174
+ });
175
+
176
+ test("a main install rebuilds only what moved on main and records the new commits", posixOnly, async () => {
177
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL } });
178
+ try {
179
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
180
+ assert.equal(await w.upgrade([], "4.0.0-main.6e7e3a18f794"), 0);
181
+ assert.ok(w.calls.some((call) => call.argv.join(" ") === `install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI}`));
182
+ const tgz = join(w.home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz");
183
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${tgz} --allow-build=gentle-pi`]);
184
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI });
185
+ assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
186
+ } finally { w.cleanup(); }
187
+ });
188
+
189
+ test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => {
190
+ const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } });
191
+ try {
192
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
193
+ assert.equal(await w.upgrade([], "4.0.0-main.6e7e3a18f794"), 0);
194
+ assert.equal(w.calls.some((call) => call.argv[1]?.includes("cmd/gentle-ai@")), false);
195
+ assert.equal(w.installs().length, 1);
196
+ } finally { w.cleanup(); }
197
+ });
198
+
199
+ test("--channel main switches a release install to both latest main commits", posixOnly, async () => {
200
+ const w = world();
201
+ try {
202
+ assert.equal(await w.upgrade(["--channel", "main"]), 0);
203
+ assert.ok(w.calls.some((call) => call.argv[1] === `github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${AI_SHA}`));
204
+ assert.equal(w.installs().length, 1);
205
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA });
206
+ assert.deepEqual(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path,
207
+ join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA, "gentle-ai"));
208
+ } finally { w.cleanup(); }
209
+ });
210
+
211
+ test("--channel release switches a main install back and removes only the main override", async () => {
212
+ const w = world();
213
+ try {
214
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
215
+ const registration = join(w.home, ".pi", "gentle-ai", "dev-binary.json");
216
+ writeFileSync(registration, JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA, "gentle-ai") }));
217
+ assert.equal(await w.upgrade(["--channel=release"], "4.0.0-main.6e7e3a18f794"), 0);
218
+ assert.deepEqual(w.installs(), ["/usr/bin/pnpm add -g gentle-pi@4.1.0 --allow-build=gentle-pi"]);
219
+ assert.equal(existsSync(registration), false);
220
+ assert.deepEqual(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8")), { schema: CHANNEL_SCHEMA, channel: "release" });
221
+ } finally { w.cleanup(); }
222
+ });
223
+
224
+ test("switching to release keeps a dev binary the user registered themselves", async () => {
225
+ const w = world();
226
+ try {
227
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
228
+ const registration = join(w.home, ".pi", "gentle-ai", "dev-binary.json");
229
+ writeFileSync(registration, JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/Users/me/src/gentle-ai/gentle-ai" }));
230
+ assert.equal(await w.upgrade(["--channel", "release"], "4.0.0-main.6e7e3a18f794"), 0);
231
+ assert.equal(existsSync(registration), true);
232
+ } finally { w.cleanup(); }
233
+ });
234
+
235
+ // A missing Go is not a blocker: the build downloads the pinned Go (main-channel.test.ts).
236
+ test("main needs pnpm on PATH", async () => {
237
+ const w = world({ tools: ["go", "npm"] });
238
+ try {
239
+ await assert.rejects(w.upgrade(["--channel", "main"]), (error: MainChannelError) => error.code === "main-requires-tools");
240
+ assert.deepEqual(w.calls, []);
241
+ } finally { w.cleanup(); }
242
+ });
243
+
244
+ // S8: on Windows npm and pnpm are `.cmd` shims, which cannot run with shell:false.
245
+ // The host's invocation adapter says how each one runs (node.exe plus its JS entry).
246
+ const NODE_EXE = "C:\\nodejs\\node.exe";
247
+ const NPM_CLI = "C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js";
248
+ const PNPM_ENTRY = "C:\\tools\\pnpm\\package\\bin\\pnpm.mjs";
249
+ const windowsInvocations = async (name: string): Promise<Invocation> => (name === "npm" ? { command: NODE_EXE, prefix: [NPM_CLI] }
250
+ : name === "pnpm" ? { command: NODE_EXE, prefix: [PNPM_ENTRY] } : null);
251
+ // This host's sandbox paths, read as Windows paths (backslashes), still resolve.
252
+ const windowsFs = { ...fsPromises, realpath: (path: string) => fsPromises.realpath(path.replaceAll("\\", "/")) } as typeof fsPromises;
253
+ const windowsWorld = (options: Parameters<typeof world>[0] = {}) => world({ platform: "win32", bin: "C:/nodejs", suffix: ".cmd", invocation: windowsInvocations, fs: windowsFs, ...options });
254
+
255
+ test("Windows: a release update runs npm or pnpm through the host's invocation, never a .cmd", async () => {
256
+ for (const [owner, expected] of [["pnpm", `${NODE_EXE} ${PNPM_ENTRY} add -g gentle-pi@4.1.0 --allow-build=gentle-pi`], ["npm", `${NODE_EXE} ${NPM_CLI} install -g gentle-pi@4.1.0`]]) {
257
+ const w = windowsWorld({ owner });
258
+ try {
259
+ assert.equal(await w.upgrade([]), 0);
260
+ assert.deepEqual(w.installs(), [expected]);
261
+ assert.deepEqual(w.roots.map((call) => `${call.command} ${call.argv.join(" ")}`), [`${NODE_EXE} ${NPM_CLI} root -g`]);
262
+ assert.equal([...w.calls, ...w.roots].some((call) => /\.(cmd|bat)$/i.test(call.command)), false);
263
+ } finally { w.cleanup(); }
264
+ }
265
+ });
266
+
267
+ test("Windows: a main update packs and installs with the host's pnpm invocation", posixOnly, async () => {
268
+ const w = windowsWorld();
269
+ try {
270
+ assert.equal(await w.upgrade(["--channel", "main"]), 0);
271
+ const pack = w.calls.find((call) => call.argv.includes("pack"));
272
+ assert.deepEqual([pack?.command, pack?.argv.slice(0, 2)], [NODE_EXE, [PNPM_ENTRY, "pack"]]);
273
+ assert.deepEqual(w.installs().map((line) => line.split(" ").slice(0, 4).join(" ")), [`${NODE_EXE} ${PNPM_ENTRY} add -g`]);
274
+ assert.equal(w.calls.some((call) => /\.(cmd|bat)$/i.test(call.command)), false);
275
+ } finally { w.cleanup(); }
276
+ });
277
+
278
+ test("Windows: a package manager that resolves only to a .cmd, or not at all, is missing and nothing runs", async () => {
279
+ for (const invocation of [undefined, async () => null]) {
280
+ for (const owner of ["pnpm", "npm"]) {
281
+ const w = windowsWorld({ owner, invocation });
282
+ try {
283
+ await assert.rejects(w.upgrade([]), (error: MainChannelError) => error.code === (owner === "npm" ? "upgrade-owner-unknown" : "upgrade-manager-missing"));
284
+ assert.deepEqual([w.calls, w.roots], [[], []]);
285
+ } finally { w.cleanup(); }
286
+ }
287
+ const main = windowsWorld({ invocation });
288
+ try {
289
+ await assert.rejects(main.upgrade(["--channel", "main"]), (error: MainChannelError) => error.code === "main-requires-tools");
290
+ assert.deepEqual(main.calls, []);
291
+ } finally { main.cleanup(); }
292
+ }
293
+ });
294
+
295
+ test("Windows: self-uninstall resolves npm and pnpm through the host's invocation, never a .cmd", async () => {
296
+ for (const [owner, expected] of [["pnpm", { name: "pnpm", command: NODE_EXE, prefix: [PNPM_ENTRY] }], ["npm", { name: "npm", command: NODE_EXE, prefix: [NPM_CLI] }]] as const) {
297
+ const w = windowsWorld({ owner });
298
+ try {
299
+ assert.deepEqual(await w.manager("uninstall"), expected);
300
+ assert.deepEqual(w.roots.map((call) => `${call.command} ${call.argv.join(" ")}`), [`${NODE_EXE} ${NPM_CLI} root -g`]);
301
+ } finally { w.cleanup(); }
302
+ }
303
+ for (const invocation of [undefined, async () => null]) {
304
+ for (const owner of ["pnpm", "npm"]) {
305
+ const w = windowsWorld({ owner, invocation });
306
+ try {
307
+ await assert.rejects(w.manager("uninstall"), (error: MainChannelError) => error.code === (owner === "npm" ? "uninstall-owner-unknown" : "uninstall-manager-missing"));
308
+ assert.deepEqual([w.calls, w.roots], [[], []]);
309
+ } finally { w.cleanup(); }
310
+ }
311
+ }
312
+ });
313
+
314
+ test("POSIX: without an invocation adapter the package managers `which` finds run as they are, with no prefix", posixOnly, async () => {
315
+ for (const [owner, args] of [["npm", []], ["pnpm", []], ["pnpm", ["--channel", "main"]]] as const) {
316
+ const w = world({ owner, platform: "linux" });
317
+ try {
318
+ assert.equal(await w.upgrade([...args]), 0);
319
+ assert.deepEqual(w.roots.map((call) => `${call.command} ${call.argv.join(" ")}`), ["/usr/bin/npm root -g"]);
320
+ assert.ok(w.installs().every((line) => line.startsWith(`/usr/bin/${owner} ${owner === "npm" ? "install" : "add"} -g `)), w.installs().join("\n"));
321
+ if (args.length > 0) assert.equal(w.calls.find((call) => call.argv[0] === "pack")?.command, "/usr/bin/pnpm");
322
+ } finally { w.cleanup(); }
323
+ }
324
+ });
325
+
326
+ test("unknown upgrade arguments are a usage error", async () => {
327
+ for (const args of [["--channel"], ["--channel", "nightly"], ["--force"], ["main"]]) {
328
+ const w = world();
329
+ try {
330
+ await assert.rejects(w.upgrade(args), (error: MainChannelError) => error.code === "upgrade-usage");
331
+ assert.deepEqual(w.calls, []);
332
+ assert.deepEqual(w.fetches, []);
333
+ } finally { w.cleanup(); }
334
+ }
335
+ });
336
+
337
+ // T3 (S4): the release branch first offers the migration to the bundled install.
338
+ test("a release upgrade that migrates installs nothing globally, records the release channel and removes only the main override", async () => {
339
+ const seen: { latest: string; owner: unknown }[] = [];
340
+ const w = world({ migrate: async ({ latest, owner }) => { seen.push({ latest, owner: await owner() }); return { code: 0, installed: true }; } });
341
+ try {
342
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
343
+ const registration = join(w.home, ".pi", "gentle-ai", "dev-binary.json");
344
+ writeFileSync(registration, JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA, "gentle-ai") }));
345
+ assert.equal(await w.upgrade(["--channel", "release"], "4.0.0-main.6e7e3a18f794"), 0);
346
+ assert.deepEqual(seen, [{ latest: "4.1.0", owner: { name: "pnpm", command: "/usr/bin/pnpm", prefix: [] } }]);
347
+ assert.deepEqual(w.installs(), []);
348
+ assert.equal(existsSync(registration), false);
349
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "release" });
350
+ } finally { w.cleanup(); }
351
+ // A migration whose removal failed keeps the bundled install: its exit code, and the release channel recorded.
352
+ const kept = world({ migrate: async () => ({ code: 1, installed: true }) });
353
+ try {
354
+ assert.equal(await kept.upgrade([]), 1);
355
+ assert.deepEqual(kept.installs(), []);
356
+ assert.equal(existsSync(join(kept.home, ".pi", "gentle-ai", "channel.json")), true);
357
+ } finally { kept.cleanup(); }
358
+ // B3: a migration that failed (it printed why and returns null) leaves this run to the global update.
359
+ const failed = world({ migrate: async () => null });
360
+ try {
361
+ assert.equal(await failed.upgrade([]), 0);
362
+ assert.deepEqual(failed.installs(), ["/usr/bin/pnpm add -g gentle-pi@4.1.0 --allow-build=gentle-pi"]);
363
+ } finally { failed.cleanup(); }
364
+ });
365
+
366
+ test("a release upgrade the migration declines keeps the current path, and main never asks", async () => {
367
+ let asked = 0;
368
+ const w = world({ migrate: async () => { asked += 1; return null; } });
369
+ try {
370
+ assert.equal(await w.upgrade([]), 0);
371
+ assert.deepEqual(w.installs(), ["/usr/bin/pnpm add -g gentle-pi@4.1.0 --allow-build=gentle-pi"]);
372
+ assert.deepEqual(w.lines, ["Updated gentle-shell 4.0.0 to 4.1.0 (release)."]);
373
+ assert.equal(asked, 1);
374
+ } finally { w.cleanup(); }
375
+ // The migration's owner check keeps the current refusals for an npm link.
376
+ const linked = world({ owner: "linked", migrate: async ({ owner }) => owner().then(() => ({ code: 0, installed: true }), () => null) });
377
+ try {
378
+ await assert.rejects(linked.upgrade([]), (error: MainChannelError) => error.code === "upgrade-owner-unknown");
379
+ } finally { linked.cleanup(); }
380
+ const main = world({ migrate: async () => { asked += 1; return { code: 0, installed: true }; } });
381
+ try {
382
+ await writeChannel(main.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
383
+ assert.equal(await main.upgrade([], "4.0.0-main.6e7e3a18f794"), 0);
384
+ assert.equal(asked, 1);
385
+ } finally { main.cleanup(); }
386
+ });
387
+
388
+ test("removeGlobalShell removes gentle-pi with the manager that owns it, through its verified invocation", async () => {
389
+ const calls: string[] = [];
390
+ const run = async (command: string, argv: string[]) => { calls.push(`${command} ${argv.join(" ")}`); return { code: calls.length === 3 ? 1 : 0, stdout: "" }; };
391
+ assert.equal(await removeGlobalShell({ name: "pnpm", command: "/usr/bin/pnpm", prefix: [] }, run), true);
392
+ assert.equal(await removeGlobalShell({ name: "npm", command: "C:\\nodejs\\node.exe", prefix: ["C:\\nodejs\\npm-cli.js"] }, run), true);
393
+ assert.equal(await removeGlobalShell({ name: "npm", command: "/usr/bin/npm", prefix: [] }, run), false);
394
+ assert.deepEqual(calls, ["/usr/bin/pnpm remove -g gentle-pi", "C:\\nodejs\\node.exe C:\\nodejs\\npm-cli.js uninstall -g gentle-pi", "/usr/bin/npm uninstall -g gentle-pi"]);
395
+ });