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
@@ -74,6 +74,35 @@ test("alias identity preserves status age, validates data, and rejects stale bra
74
74
  assert.deepEqual(cache.get(manager)?.aliases, valid.aliases);
75
75
  });
76
76
 
77
+ test("explicit work topic refreshes the current alias when no subject is declared (#2553)", () => {
78
+ const cache = new OrchestratorStateCache();
79
+ const entries: { type: string; customType: string; data: unknown }[] = [];
80
+ const manager = { getSessionId: () => "owner", getCwd: () => "/repo", getBranch: () => entries };
81
+ const append = (customType: string, data: unknown) => entries.push({ type: "custom", customType, data });
82
+ cache.load(manager);
83
+ const work = { area: "gentle-shell", topic: "pr-2509-followup" };
84
+ cache.publish(manager, { work }, append, 10);
85
+ assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "pr-2509-followup" });
86
+ // Fresh topic on a later publication updates Current; Initial is retained.
87
+ cache.publish(manager, { work: { area: "gentle-shell", topic: "ci-watch" } }, append, 20);
88
+ assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "ci-watch" });
89
+ // A state-only refresh never re-derives the alias from stored work.
90
+ cache.publish(manager, undefined, append, 30);
91
+ assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "ci-watch" });
92
+ // An explicit subject in the same publication wins over the topic.
93
+ cache.publish(manager, { work }, append, 40, "declared-task");
94
+ assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "declared-task" });
95
+ // Legacy records without aliases: topic refreshes Current, Initial stays unknown.
96
+ const legacy = [{ type: "custom", customType: ORCHESTRATOR_STATE_ENTRY, data: {
97
+ schema: 1, sessionId: "owner", recordedAt: 1, cwd: "/repo", source: "owner-curated",
98
+ ownerReply: false, authority: "none", state: null } }];
99
+ const legacyManager = { getSessionId: () => "owner", getCwd: () => "/repo", getBranch: () => legacy };
100
+ const legacyCache = new OrchestratorStateCache();
101
+ legacyCache.load(legacyManager);
102
+ legacyCache.publish(legacyManager, { work }, append, 50);
103
+ assert.deepEqual(legacyCache.get(legacyManager)?.aliases, { initialAlias: null, currentAlias: "pr-2509-followup" });
104
+ });
105
+
77
106
  test("recorded cwd preserves only unambiguous native absolute paths", () => {
78
107
  const separators = [0x00a0, ...Array.from({ length: 11 }, (_, i) => 0x2000 + i), 0x202f, 0x205f, 0x3000];
79
108
  const invalid = ["relative/repo", "/repo\nother", "/repo\ud800", "/" + "x".repeat(1024),
@@ -8,16 +8,20 @@ const source = readFileSync(join(root, "extensions/gentle-agents.ts"), "utf8");
8
8
  const sessionTool = source.match(/name: "orchestrator_session_id",\s*label: "[^"]+",\s*description: ("(?:[^"\\]|\\.)*")/);
9
9
  assert.ok(sessionTool, "Registered session tool exposes its publication contract");
10
10
  const description: string = JSON.parse(sessionTool[1]);
11
- const guidance = readFileSync(join(root, "assets/orchestrator-delegation.md"), "utf8");
11
+ // S11 T2: state publication is reference material; the delegation card only points at it.
12
+ const guidance = readFileSync(join(root, "assets/orchestrator-delegation-reference.md"), "utf8");
12
13
  const docs = readFileSync(join(root, "docs/gentle-agents-activity.md"), "utf8");
13
14
 
14
- test("shipped guidance publishes status before work and refreshes task transitions", () => {
15
- assert.match(description, /For work beyond small direct tasks, publish subject and current\/next status here; refresh at task changes\/completion/);
15
+ // Harness roadmap T6: the runtime publishes todo progress, so guidance stops
16
+ // asking the model to refresh status at every task transition.
17
+ test("shipped guidance declares a subject and leaves progress to the todo list", () => {
18
+ assert.match(description, /Progress is published automatically from the todo list/);
19
+ assert.doesNotMatch(description, /refresh at task changes/);
16
20
  assert.doesNotMatch(description, /Before meaningful work, publish subject/);
17
- assert.match(guidance, /Before work beyond small direct tasks, publish own `state` with `subject`/);
21
+ assert.match(guidance, /Progress is published automatically from the todo list: never call `orchestrator_session_id` just to report status/);
22
+ assert.match(guidance, /Before work beyond small direct tasks, declare a `subject`/);
18
23
  assert.doesNotMatch(guidance, /Before meaningful work or delegation, publish own `state`/);
19
- assert.match(guidance, /current\/next scope and status/);
20
- assert.match(guidance, /Refresh at task changes\/completion/);
24
+ assert.doesNotMatch(guidance, /Refresh at task changes\/completion/);
21
25
  assert.doesNotMatch(guidance, /when helpful, publish short explicit own `state`/);
22
26
  assert.match(guidance, /Do not require a subject declaration for small direct tasks/);
23
27
  assert.match(guidance, /no extra model turn, repeated reads or per-token\/tool updates/);
@@ -25,10 +29,10 @@ test("shipped guidance publishes status before work and refreshes task transitio
25
29
  });
26
30
 
27
31
  test("hub documentation separates busy-owner snapshots from replies and negative ownership claims", () => {
28
- assert.match(docs, /before work beyond small direct tasks/);
32
+ assert.match(docs, /before work beyond\s+small direct tasks/);
29
33
  assert.match(docs, /while the owner is busy/);
30
34
  assert.match(docs, /current and next scope/);
31
- assert.match(docs, /update at task changes and completion/);
35
+ assert.match(docs, /Progress is published automatically: after each todo write/);
32
36
  assert.match(docs, /A search with no matches does not establish that nobody else is working on the feature/);
33
37
  assert.match(docs, /state `recordedAt` and scope `resolvedAt` keep their historical meaning/);
34
38
  });
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fileURLToPath } from "node:url";
4
+ import { missingRequiredPaths, requiredPaths } from "../scripts/verify-package-files.mjs";
5
+
6
+ // The voice worker is spawned by path and never imported, so only the package
7
+ // check keeps it in the published tarball next to the modules that launch it.
8
+
9
+ const root = fileURLToPath(new URL("..", import.meta.url));
10
+
11
+ test("the package check requires the voice command, its modules and the worker script", () => {
12
+ const voice = ["extensions/gentle-voice.ts", "lib/voice-command.ts", "lib/voice-session.ts", "lib/voice-ui.ts", "scripts/voice-worker.cjs", "docs/voice.md"];
13
+ for (const path of voice) assert.ok(requiredPaths.includes(path), `${path} is required`);
14
+ assert.deepEqual(missingRequiredPaths(root, voice), []);
15
+ });
16
+
17
+ // The canvas server reads its page and scripts from assets/ by path, never by import.
18
+ const designCanvas = [
19
+ "lib/design-canvas-server.ts",
20
+ "lib/design-open-browser.ts",
21
+ "lib/design-comments.ts",
22
+ "lib/design-handoffs.ts",
23
+ "assets/design-canvas/index.html",
24
+ "assets/design-canvas/canvas.js",
25
+ "assets/design-canvas/canvas.css",
26
+ "assets/design-canvas/inspector.js",
27
+ ];
28
+
29
+ // The design command names its skill file by path when Pi has not loaded it.
30
+ test("the package check requires the design command, its helpers and its skill", () => {
31
+ const design = ["extensions/gentle-design.ts", "lib/gentle-design.ts", "lib/design-sources.ts", "skills/design/SKILL.md", ...designCanvas];
32
+ for (const path of design) assert.ok(requiredPaths.includes(path), `${path} is required`);
33
+ assert.deepEqual(missingRequiredPaths(root, design), []);
34
+ });
@@ -232,7 +232,7 @@ test("package verification excludes the retired init extension while retaining O
232
232
  assert.equal(existsSync(join(PACKAGE_ROOT, "extensions", "sdd-init.ts")), false);
233
233
  assert.doesNotMatch(verifier, /^\s*"extensions\/sdd-init\.ts",?$/m);
234
234
  assert.match(verifier, /existsSync\(join\(root, "extensions\/sdd-init\.ts"\)\)/);
235
- for (const resource of ["extensions/gentle-ai.ts", "extensions/skill-registry.ts", "assets/orchestrator.md", "assets/orchestrator-delegation.md", "assets/agents/gentle-ai-worker.md", "assets/agents/review-risk.md", "assets/chains/4r-review.chain.md"]) {
235
+ for (const resource of ["extensions/gentle-ai.ts", "extensions/skill-registry.ts", "assets/orchestrator.md", "assets/orchestrator-delegation.md", "assets/orchestrator-delegation-reference.md", "assets/agents/gentle-ai-worker.md", "assets/agents/review-risk.md", "assets/chains/4r-review.chain.md"]) {
236
236
  assert.ok(verifier.includes(`"${resource}"`), `${resource} must remain required`);
237
237
  }
238
238
  });
@@ -431,20 +431,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
431
431
  );
432
432
  });
433
433
 
434
- test("package verification binds the published Gentle AI v4.1.1 runtime pin", () => {
434
+ test("package verification binds the published Gentle AI v4.3.1 runtime pin", () => {
435
435
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
436
436
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
437
437
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
438
438
 
439
- assert.match(installer, /INSTALLER_VERSION = "4\.1\.1"/);
439
+ assert.match(installer, /INSTALLER_VERSION = "4\.3\.1"/);
440
440
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
441
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:\+WmZ2IvIOiJ8qtHa7wsjnYKNasem\+PByeOZ6HIjoAL8="/);
441
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:00p8dnjJrhYFq1Qikk2oY6K\/UXIty9A\/IBUUOUiX9XA="/);
442
442
  assert.match(installer, /GOTOOLCHAIN: "local"/);
443
443
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
444
444
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
445
445
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
446
446
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
447
- assert.match(verifier, /v4\.1\.1/);
447
+ assert.match(verifier, /v4\.3\.1/);
448
448
  });
449
449
 
450
450
 
@@ -1722,27 +1722,29 @@ test("normal and forced installation copy generic agents with complete role cont
1722
1722
  });
1723
1723
 
1724
1724
  test("bounded implementation routing resolves the explicit canonical fallback reference", () => {
1725
- const routing = readFileSync(
1726
- join(PACKAGE_ROOT, "assets", "orchestrator-delegation.md"),
1727
- "utf8",
1728
- );
1729
- const reference = "For bounded writes, follow the canonical Writer rule under Mandatory Delegation Triggers.";
1725
+ // S11 T2: the Delegation Triggers live in the delegation card and the Work Routing Ladder (with its
1726
+ // Simple Delegation pointer back to them) in the reference, so the pointer resolves across both files.
1727
+ const routing = ["orchestrator-delegation.md", "orchestrator-delegation-reference.md"]
1728
+ .map((file) => readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8"))
1729
+ .join("\n\n");
1730
+ // S9 canon change (gentle-ai 87a3b2eb): the canonical heading is "Delegation Triggers" (no "Mandatory").
1731
+ const reference = "For bounded writes, follow the canonical Writer rule under Delegation Triggers.";
1730
1732
  const resolveSimpleDelegation = (source: string): string => {
1731
1733
  const simpleDelegation = readMarkdownSection(source, "2. Simple Delegation");
1732
1734
  assert.ok(simpleDelegation.split("\n").includes(reference), "Simple Delegation must name the exact canonical Writer rule");
1733
- const canonical = readMarkdownSection(source, "Mandatory Delegation Triggers");
1735
+ const canonical = readMarkdownSection(source, "Delegation Triggers");
1734
1736
  assertWorkerFallbackRouting(canonical, "resolved Simple Delegation");
1735
1737
  return canonical;
1736
1738
  };
1737
- const mandatoryDelegation = readMarkdownSection(routing, "Mandatory Delegation Triggers");
1739
+ const delegationTriggers = readMarkdownSection(routing, "Delegation Triggers");
1738
1740
 
1739
1741
  assertWorkerFallbackRouting(resolveSimpleDelegation(routing), "Simple Delegation");
1740
- assertWorkerFallbackRouting(mandatoryDelegation, "Mandatory Delegation Triggers");
1742
+ assertWorkerFallbackRouting(delegationTriggers, "Delegation Triggers");
1741
1743
  assert.throws(() => resolveSimpleDelegation(routing.replace(reference, "")), /must name the exact canonical Writer rule/);
1742
- assert.throws(() => resolveSimpleDelegation(routing.replace(reference, reference.replace("Mandatory Delegation Triggers", "Other Rule"))),
1744
+ assert.throws(() => resolveSimpleDelegation(routing.replace(reference, reference.replace("Delegation Triggers", "Other Rule"))),
1743
1745
  /must name the exact canonical Writer rule/);
1744
- assert.throws(() => resolveSimpleDelegation(routing.replace("#### Mandatory Delegation Triggers", "#### Missing Canonical Rule")),
1745
- /exactly one Mandatory Delegation Triggers section/);
1746
+ assert.throws(() => resolveSimpleDelegation(routing.replace("#### Delegation Triggers", "#### Missing Canonical Rule")),
1747
+ /exactly one Delegation Triggers section/);
1746
1748
  assert.throws(() => resolveSimpleDelegation(routing.replace("user-configured `worker`", "unspecified worker")),
1747
1749
  /must prefer the package-owned worker before a user-configured worker/);
1748
1750
  assert.doesNotMatch(
@@ -1758,8 +1760,10 @@ test("bounded implementation routing resolves the explicit canonical fallback re
1758
1760
  });
1759
1761
 
1760
1762
  test("orchestrator routes generic roles without static RDD lens routing", () => {
1761
- for (const file of ["orchestrator.md", "orchestrator-delegation.md"]) {
1762
- const routing = readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8");
1763
+ // S11 T2: the explorer fallback binding moved from the delegation card to its reference; the card and
1764
+ // the reference together are the lazy delegation route.
1765
+ for (const files of [["orchestrator.md"], ["orchestrator-delegation.md", "orchestrator-delegation-reference.md"]]) {
1766
+ const routing = files.map((file) => readFileSync(join(PACKAGE_ROOT, "assets", file), "utf8")).join("\n\n");
1763
1767
  assert.match(routing, /`gentle-ai-explore`/);
1764
1768
  assert.match(routing, /`gentle-ai-worker`/);
1765
1769
  assert.match(routing, /`gentle-ai-verify`/);
@@ -1789,9 +1793,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1789
1793
  assert.match(wrapper, /quietToolsEnabled/);
1790
1794
  });
1791
1795
 
1792
- test("Gentle Shell v4.2.0 package manifest declares the release version", () => {
1796
+ test("Gentle Shell v4.4.0 package manifest declares the release version", () => {
1793
1797
  const packageJson = readPackageJson();
1794
- assert.equal(packageJson.version, "4.2.0", "the release manifest must be explicitly pinned to v4.2.0");
1798
+ assert.equal(packageJson.version, "4.4.0", "the release manifest must be explicitly pinned to v4.4.0");
1795
1799
  assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
1796
1800
  assert.ok(packageJson.files?.includes("assets/"));
1797
1801
  assert.ok(packageJson.files?.includes("contracts/"));
@@ -7,7 +7,7 @@ import { syncBuiltinESMExports } from "node:module";
7
7
  import { join } from "node:path";
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { registerYoloSessionPolicy, discoverYoloUiAdapter } from "../lib/yolo-session-policy.ts";
10
- import { parsePermissionModePolicyFile, resolvePermissionModePolicy, writePermissionModePolicy, permissionModePolicyFile, PERMISSION_MODE_SCHEMA } from "../lib/permission-mode-policy.ts";
10
+ import { isPermissionMode, parsePermissionModePolicyFile, resolvePermissionModePolicy, writePermissionModePolicy, permissionModePolicyFile, PERMISSION_MODE_SCHEMA } from "../lib/permission-mode-policy.ts";
11
11
 
12
12
  function fixture(t: test.TestContext) {
13
13
  const home = mkdtempSync(join(tmpdir(), "gentle-mode-"));
@@ -192,3 +192,12 @@ test("stale pending activation, scope loss and malformed preference remain close
192
192
  assert.equal(await h.controller.saveDefault("yolo", h.context), false);
193
193
  assert.equal(readFileSync(permissionModePolicyFile(options), "utf8"), "{");
194
194
  });
195
+
196
+ test("super-yolo is a valid saved and parsed permission mode", t => {
197
+ const options = fixture(t);
198
+ assert.equal(isPermissionMode("super-yolo"), true);
199
+ assert.equal(isPermissionMode("superyolo"), false);
200
+ const path = writePermissionModePolicy("super-yolo", options);
201
+ assert.equal(parsePermissionModePolicyFile(readFileSync(path, "utf8")), "super-yolo");
202
+ assert.equal(resolvePermissionModePolicy(options).mode, "super-yolo");
203
+ });
@@ -5,7 +5,7 @@ import { test } from 'node:test';
5
5
  const PI = '@earendil-works/pi-coding-agent';
6
6
  // A virtual symlink filesystem: listed global links point into a pnpm store.
7
7
  // The store's adjacent peer is a sibling, not Shell/node_modules/Pi.
8
- function launch(args: string[], scenario = 'owned') {
8
+ function launch(args: string[], scenario = 'owned', piVersion = '1.2.0') {
9
9
  const adjacent = scenario.startsWith('adjacent');
10
10
  const npm = scenario.startsWith('npm-');
11
11
  const nodeModules = '/global/v11/selected/node_modules';
@@ -31,7 +31,7 @@ function launch(args: string[], scenario = 'owned') {
31
31
  source = source.replace('const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));', ${JSON.stringify(`const packageRoot = '${shellRoot}';`)});
32
32
  source = source.replace('const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying });', 'const bundled = undefined;');
33
33
  source = source.replace('runtime = resolvePiRuntime({', ${JSON.stringify(`runtime = ({ kind: '${adjacent ? 'bundled' : 'env'}', command: '${adjacent ? 'node' : piRoot + '/dist/bundle/cli.js'}', args: ${JSON.stringify(adjacent ? [piRoot + '/dist/bundle/cli.js'] : [])} }); /*`)}).replace('nodeExecPath: process.execPath,\\n\\t\\t});', 'nodeExecPath: process.execPath, */');
34
- source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "1.2.0" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
34
+ source = source.replace('const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {', 'const versionProbe = { stdout: "${piVersion}" }; /*').replace('env: bundledEnv ?? process.env,\\n\\t});', 'env: bundledEnv ?? process.env, */');
35
35
  source = source.replace('async function packageManagerAdapters() {', 'async function packageManagerAdapters() { return globalThis.fakeAdapters; } async function unusedAdapters() {');
36
36
  source = source.replace('let homeHadContentBeforeBootstrap = false;', 'if (passthrough.length === 1) { console.error("error: pi cannot self-update this installation. This installation is not managed by a global pnpm install."); process.exitCode = 1; return; } console.log("FORWARD " + JSON.stringify(passthrough)); return; let homeHadContentBeforeBootstrap = false;');
37
37
  return {format:'module', source, shortCircuit:true};
@@ -79,6 +79,25 @@ for (const scenario of ['ambiguous', 'malformed', 'npm-link', 'npm-file', 'share
79
79
  assert.doesNotMatch(result.stdout + result.stderr, /MUTATION/);
80
80
  });
81
81
 
82
+ // `update` is how an old Pi gets replaced, so the minimum-version gate must not block it.
83
+ test('public update replaces a Pi older than the required minimum', () => {
84
+ const result = launch(['update'], 'owned', '1.1.0');
85
+ assert.equal(result.status, 0, result.stderr);
86
+ assert.match(result.stdout, /MUTATION \["add","-g","@earendil-works\/pi-coding-agent@latest"\]/);
87
+ assert.doesNotMatch(result.stdout + result.stderr, /older than the required minimum/);
88
+ assert.equal(result.stderr, '');
89
+ });
90
+
91
+ test('an old Pi still fails the minimum-version gate outside a self update', () => {
92
+ for (const args of [['list'], ['update', '--extensions-only']]) {
93
+ const result = launch(args, 'owned', '1.1.0');
94
+ assert.equal(result.status, 1, result.stdout);
95
+ assert.equal(result.stdout, '');
96
+ assert.match(result.stderr, /pi version 1\.1\.0 is older than the required minimum 1\.2\.0\./);
97
+ assert.doesNotMatch(result.stdout, /MUTATION/);
98
+ }
99
+ });
100
+
82
101
  test('public extensions-only update preserves the forwarded command', () => {
83
102
  const result = launch(['update', '--extensions-only']);
84
103
  assert.equal(result.status, 0, result.stderr);
@@ -11,8 +11,12 @@ const CLI = 'dist/bundle/cli.js';
11
11
  // peer is a sibling of the Shell package; a separate global Pi links to the
12
12
  // same store entry. npm nests the peer under the Shell package.
13
13
  function fixture({windows = false, npm = false, runtime = 'adjacent', groups = 'both', peerAfter = '1.2.0', shellVersion = '4.1.0',
14
- badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = ''} = {}) {
14
+ badManifest = false, failed = false, mismatch = false, linked = false, readback = true, unrelatedPeer = false, shim = '',
15
+ artifact = true, configHome = '', installFailure = undefined as undefined | {stdout?: string, stderrTail?: string}} = {}) {
15
16
  const base = windows ? 'C:/pnpm' : '/pnpm';
17
+ const home = windows ? 'C:/Users/u' : '/home/u';
18
+ // The tarball `gentle-shell upgrade --channel main` packs, named after its version.
19
+ const tarball = `${configHome || `${home}/.pi/gentle-ai`}/main/packages/gentle-pi-${shellVersion}.tgz`;
16
20
  const storePi = (version: string) => `${base}/store/links/@earendil-works/pi-coding-agent/${version}/h/node_modules/${PI}`;
17
21
  const shellModules = npm ? `${base}/npm/lib/node_modules` : `${base}/store/links/@/gentle-pi/4.1.0/h1/node_modules`;
18
22
  const shell = `${shellModules}/gentle-pi`;
@@ -31,6 +35,7 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
31
35
  const mutations: string[][] = [];
32
36
  const out: string[] = [];
33
37
  const cwds: unknown[] = [];
38
+ const runOptions: unknown[] = [];
34
39
  const entry = (pkg: string, modules: string, extra = {}) => ({[pkg]: {path: `${modules}/${pkg}`, ...extra}});
35
40
  const shellEntry = entry('gentle-pi', g1, linked ? {link: true} : {});
36
41
  const piEntry = entry(PI, g2, linked ? {resolved: 'file:/src/pi'} : {});
@@ -41,7 +46,7 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
41
46
  return npm ? group : [group];
42
47
  };
43
48
  const exists = (n: string) => [g1, g2, g3, shellModules, shell, storePi(state.peer), storePi(state.group), storePi('1.1.0'), nestedPeer]
44
- .some(root => n === root || n.startsWith(root + '/'));
49
+ .some(root => n === root || n.startsWith(root + '/')) || (artifact && n === tarball);
45
50
  const realpath = async (value: string) => {
46
51
  const n = value.replaceAll('\\', '/');
47
52
  if (n === shimFile) return n;
@@ -69,11 +74,14 @@ function fixture({windows = false, npm = false, runtime = 'adjacent', groups = '
69
74
  cwds.push(options?.cwd);
70
75
  if (args.includes('list') || args.includes('ls')) return {code: failed ? 1 : 0, stdout: readback || mutations.length === 0 ? JSON.stringify(listing()) : '{'};
71
76
  mutations.push(args);
72
- if (args.some(arg => arg.startsWith('gentle-pi@'))) state.peer = peerAfter;
77
+ runOptions.push(options);
78
+ if (installFailure) return {code: 1, stdout: installFailure.stdout ?? '', ...('stderrTail' in installFailure ? {stderrTail: installFailure.stderrTail} : {})};
79
+ if (args.some(arg => arg.startsWith('gentle-pi@') || arg.endsWith('.tgz'))) state.peer = peerAfter;
73
80
  if (args.some(arg => arg.startsWith(PI + '@'))) state.group = peerAfter;
74
81
  return {code: 0};
75
82
  };
76
- return {mutations, out, cwds, state, shell, selectedCli, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
83
+ return {mutations, out, cwds, runOptions, state, shell, selectedCli, tarball, input: {platform: windows ? 'win32' : 'linux', packageRoot: `${g1}/gentle-pi`, currentVersion: '1.1.0', cwd: '/neutral',
84
+ home, env: configHome ? {GENTLE_PI_CONFIG_HOME: configHome} : {},
77
85
  runtime: runtime === 'adjacent' ? {kind: 'bundled', command: 'node', args: [selectedCli]} : {kind: runtime, command: shim ? shimFile : selectedCli, args: []},
78
86
  out: (line: string) => out.push(line),
79
87
  adapters: {which: async (name: string) => name === (npm ? 'npm' : 'pnpm') ? `/fake/${name}` : null, fs: {realpath, readFile}, run}}};
@@ -117,6 +125,60 @@ test('npm nests the peer under Shell and reinstalls Shell for it', async () => {
117
125
  assert.deepEqual(f.out, ['npm installed gentle-pi@4.1.0; the selected Pi is now 1.2.0 (was 1.1.0).']);
118
126
  });
119
127
 
128
+ // #2324: main-channel Shell versions are never published; only the tarball
129
+ // `gentle-shell upgrade --channel main` packed can reinstall that exact build.
130
+ const MAIN = '4.2.0-main.fb5e731119a7';
131
+ const native = (path: string, windows: boolean) => windows ? path.replaceAll('/', '\\') : path;
132
+
133
+ for (const windows of [false, true]) test(`a main-channel Shell is reinstalled from its recorded tarball, never from the registry (windows: ${windows})`, async () => {
134
+ const f = fixture({windows, shellVersion: MAIN});
135
+ assert.equal(await updateSelectedPi(f.input), 0);
136
+ assert.deepEqual(f.mutations, [['add', '-g', native(f.tarball, windows), '--allow-build=gentle-pi']]);
137
+ assert.deepEqual(f.out, [`pnpm installed ${native(f.tarball, windows)}; the selected Pi is now 1.2.0 (was 1.1.0).`]);
138
+ assert.equal(f.state.group, '1.1.0');
139
+ });
140
+
141
+ test('npm reinstalls a main-channel Shell from its recorded tarball', async () => {
142
+ const f = fixture({npm: true, shellVersion: MAIN});
143
+ assert.equal(await updateSelectedPi(f.input), 0);
144
+ assert.deepEqual(f.mutations, [['install', '-g', f.tarball]]);
145
+ });
146
+
147
+ test('a main-channel tarball is found under GENTLE_PI_CONFIG_HOME', async () => {
148
+ const f = fixture({shellVersion: MAIN, configHome: '/custom/config'});
149
+ assert.equal(await updateSelectedPi(f.input), 0);
150
+ assert.deepEqual(f.mutations, [['add', '-g', '/custom/config/main/packages/gentle-pi-4.2.0-main.fb5e731119a7.tgz', '--allow-build=gentle-pi']]);
151
+ });
152
+
153
+ test('a missing main-channel tarball names the rebuild command and installs nothing', async () => {
154
+ const f = fixture({shellVersion: MAIN, artifact: false});
155
+ await assert.rejects(updateSelectedPi(f.input), {message: `gentle-shell update: the main-channel build ${f.tarball} is missing, and this unpublished build cannot be reinstalled from the registry; run 'gentle-shell upgrade --channel release' and then 'gentle-shell upgrade --channel main' to rebuild it. No installation changed.`});
156
+ assert.deepEqual(f.mutations, []);
157
+ assert.deepEqual(f.out, []);
158
+ });
159
+
160
+ // A relative config home names different folders for `upgrade` and `update`
161
+ // run from different directories, and npm/pnpm resolve it from the neutral cwd.
162
+ test('a relative GENTLE_PI_CONFIG_HOME is refused for a main-channel Shell without mutation', async () => {
163
+ const f = fixture({shellVersion: MAIN, configHome: 'relative/config'});
164
+ await assert.rejects(updateSelectedPi(f.input), /ownership cannot locate the main-channel build without an absolute config home/);
165
+ assert.deepEqual(f.mutations, []);
166
+ assert.deepEqual(f.out, []);
167
+ });
168
+
169
+ test('a main-channel Shell without a home directory is refused without mutation', async () => {
170
+ const f = fixture({shellVersion: MAIN});
171
+ delete (f.input as {home?: string}).home;
172
+ await assert.rejects(updateSelectedPi(f.input), /ownership cannot locate the main-channel build without an absolute config home/);
173
+ assert.deepEqual(f.mutations, []);
174
+ });
175
+
176
+ for (const runtime of ['path', 'env']) test(`a main-channel Shell never redirects the update of a selected ${runtime} Pi`, async () => {
177
+ const f = fixture({runtime, shellVersion: MAIN, artifact: false});
178
+ assert.equal(await updateSelectedPi(f.input), 0);
179
+ assert.deepEqual(f.mutations, [['add', '-g', `${PI}@latest`]]);
180
+ });
181
+
120
182
  test('an npm PATH runtime updates the global Pi without its scripts', async () => {
121
183
  const f = fixture({npm: true, runtime: 'path'});
122
184
  assert.equal(await updateSelectedPi(f.input), 0);
@@ -184,3 +246,36 @@ test('an unrelated selected override cannot authorize the adjacent Shell group',
184
246
  await assert.rejects(updateSelectedPi(f.input), /ownership/);
185
247
  assert.deepEqual(f.mutations, []);
186
248
  });
249
+
250
+ // #2371: a failed install shows the package manager it ran and one sanitized
251
+ // line of its own output; pnpm 11 prints ERR_PNPM_* on stdout, npm on stderr.
252
+ test('a failed pnpm install names the pnpm it ran and its ERR_PNPM line from stdout', async () => {
253
+ const f = fixture({shellVersion: MAIN, installFailure: {stderrTail: '',
254
+ stdout: `Progress: resolved 1\n\u001b[31m[ERR_PNPM_INVALID_VERSION_UNION]\u001b[39m Invalid versions union. Found: "gentle-pi@file:/home/u/x.tgz". Use exact versions only.\nDone\n`}});
255
+ await assert.rejects(updateSelectedPi(f.input), {message: `gentle-shell update: pnpm (/fake/pnpm) could not install ${f.tarball}\n[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union. Found: "gentle-pi@file:~/x.tgz". Use exact versions only.`});
256
+ assert.deepEqual(f.runOptions, [{deadlineMs: 20 * 60_000, cwd: '/neutral', stderrTail: 4096}]);
257
+ assert.deepEqual(f.out, []);
258
+ });
259
+
260
+ test('a failed npm install shows its stderr error line before stdout', async () => {
261
+ const f = fixture({npm: true, installFailure: {stdout: 'unrelated', stderrTail: 'npm error code EACCES\nnpm error Error: EACCES: permission denied\nnpm error A complete log is in /home/u/.npm/x.log\n'}});
262
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: npm (/fake/npm) could not install gentle-pi@4.1.0\nnpm error Error: EACCES: permission denied'});
263
+ assert.deepEqual(f.out, []);
264
+ });
265
+
266
+ test('a failed install without output keeps the single line, naming the command it ran', async () => {
267
+ const f = fixture({installFailure: {}});
268
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0'});
269
+ assert.deepEqual(f.mutations, [['add', '-g', 'gentle-pi@4.1.0', '--allow-build=gentle-pi']]);
270
+ });
271
+
272
+ test('a stderr warning never hides the ERR_PNPM line pnpm printed on stdout', async () => {
273
+ const f = fixture({installFailure: {stderrTail: '(node:1) [DEP0040] DeprecationWarning: The `punycode` module is deprecated.\n',
274
+ stdout: '[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union.\n'}});
275
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0\n[ERR_PNPM_INVALID_VERSION_UNION] Invalid versions union.'});
276
+ });
277
+
278
+ test('without any error line the last stderr line is shown', async () => {
279
+ const f = fixture({installFailure: {stderrTail: 'killed\n', stdout: 'Progress: resolved 1\n'}});
280
+ await assert.rejects(updateSelectedPi(f.input), {message: 'gentle-shell update: pnpm (/fake/pnpm) could not install gentle-pi@4.1.0\nkilled'});
281
+ });
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test, { after, before } from "node:test";
7
+ import { __testing } from "../extensions/gentle-ai.ts";
8
+
9
+ // gentle-ai R3 (odd/tasks/pi-routing-from-bundle.md): the harness ODD step
10
+ // lines 1-7, the phase-reporting line, and the resume line come from the
11
+ // mirrored provider contract bundle's routing/pi.md instead of a hand-copied
12
+ // literal, and the rendered primary-session prompt stays byte-identical.
13
+ //
14
+ // The golden was captured from the base commit (main 4372ff2d, after R1) before the steps moved into the
15
+ // bundle, with the inputs below and the package assets root normalized. When a
16
+ // prompt change is intended, regenerate it with
17
+ // UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN=1 and review the golden diff.
18
+
19
+ const REPO_ROOT = join(import.meta.dirname, "..");
20
+ const GOLDEN_PATH = join(REPO_ROOT, "tests", "fixtures", "primary-session-prompt", "gentleman.golden.md");
21
+ const ACTIVE_TOOLS = ["read", "bash", "edit", "write", "subagent_run"] as const;
22
+ const MIRROR_ROOT = join(REPO_ROOT, "contracts", "review-provider-contract-mirror");
23
+
24
+ let scratch: string;
25
+ const previousEnvironment = new Map<string, string | undefined>();
26
+ before(() => {
27
+ scratch = mkdtempSync(join(tmpdir(), "gentle-pi-primary-prompt-"));
28
+ for (const key of ["GENTLE_PI_CONFIG_HOME", "GENTLE_PI_BACKGROUND_SUBAGENTS"]) previousEnvironment.set(key, process.env[key]);
29
+ process.env.GENTLE_PI_CONFIG_HOME = join(scratch, "config");
30
+ delete process.env.GENTLE_PI_BACKGROUND_SUBAGENTS;
31
+ });
32
+ after(() => {
33
+ for (const [key, value] of previousEnvironment) {
34
+ if (value === undefined) delete process.env[key];
35
+ else process.env[key] = value;
36
+ }
37
+ rmSync(scratch, { recursive: true, force: true });
38
+ });
39
+
40
+ function renderPrimaryPrompt(): string {
41
+ const prompt = __testing.buildGentlePrompt("gentleman", join(scratch, "project"), ACTIVE_TOOLS);
42
+ return prompt.replaceAll(join(REPO_ROOT, "assets"), "{{GENTLE_PI_ASSETS_ROOT}}");
43
+ }
44
+
45
+ function mirroredRoutingText(): string {
46
+ const lock = JSON.parse(readFileSync(join(MIRROR_ROOT, "provider-contract.lock.json"), "utf8")) as { contract_semver: string };
47
+ return readFileSync(join(MIRROR_ROOT, `v${lock.contract_semver}`, "bundle", "routing", "pi.md"), "utf8");
48
+ }
49
+
50
+ // Writes a one-file mirror whose lock records `entrySha256` for routing/pi.md.
51
+ function tempRoutingMirror(text: string, entrySha256 = createHash("sha256").update(text, "utf8").digest("hex")): string {
52
+ const root = mkdtempSync(join(scratch, "mirror-"));
53
+ const routingDir = join(root, "v9.9.9", "bundle", "routing");
54
+ mkdirSync(routingDir, { recursive: true });
55
+ writeFileSync(join(routingDir, "pi.md"), text, "utf8");
56
+ writeFileSync(join(root, "provider-contract.lock.json"), JSON.stringify({ contract_semver: "9.9.9", entries: { "routing/pi.md": entrySha256 } }), "utf8");
57
+ return root;
58
+ }
59
+
60
+ test("the rendered primary-session prompt is byte-identical to the golden captured from the base", () => {
61
+ const rendered = renderPrimaryPrompt();
62
+ if (process.env.UPDATE_PRIMARY_SESSION_PROMPT_GOLDEN === "1") writeFileSync(GOLDEN_PATH, rendered, "utf8");
63
+ assert.equal(rendered, readFileSync(GOLDEN_PATH, "utf8"));
64
+ });
65
+
66
+ test("the ODD steps, phase reporting, and resume lines come from the mirrored routing/pi.md without its blank lines", () => {
67
+ const routing = mirroredRoutingText();
68
+ const section = routing.slice(routing.indexOf("### ODD protocol"), routing.indexOf("\n### Task Size"));
69
+ const expected = section.split("\n").slice(1).filter((line) => line !== "");
70
+ assert.equal(expected.length, 9);
71
+ assert.equal(__testing.readMirroredOddProtocolSteps(), expected.join("\n"));
72
+ assert.ok(renderPrimaryPrompt().includes(`Run these steps in this order on every request:\n${expected.join("\n")}\n\nHarness principles:`));
73
+ assert.ok(routing.includes(`${expected[6]}\n\n${expected[7]}\n\n${expected[8]}`), "the bundle separates step 7, phase reporting, and resume with blank lines");
74
+ });
75
+
76
+ test("extensions/gentle-ai.ts no longer carries the ODD step lines as string literals", () => {
77
+ const source = readFileSync(join(REPO_ROOT, "extensions", "gentle-ai.ts"), "utf8");
78
+ for (const literal of ["1. **Authorize.**", "5. **Track before the first write.**", "7. **Close.**", "Phase reporting: the Gentle Shell prompt label", "Resume an interrupted feature with"]) {
79
+ assert.ok(!source.includes(literal), `extensions/gentle-ai.ts still hard-codes ${JSON.stringify(literal)}`);
80
+ }
81
+ });
82
+
83
+ test("a changed mirrored routing block changes the rendered steps", () => {
84
+ const routing = mirroredRoutingText().replace("1. **Authorize.** ", "1. **Authorize.** Changed upstream. ");
85
+ const mirror = tempRoutingMirror(routing);
86
+ assert.match(__testing.readMirroredOddProtocolSteps(mirror), /^1\. \*\*Authorize\.\*\* Changed upstream\. /);
87
+ });
88
+
89
+ test("a tampered or missing mirrored routing/pi.md fails closed with an explicit error", () => {
90
+ const unavailable = /Gentle Shell cannot render its ODD steps: the mirrored provider bundle's routing\/pi\.md is missing, unreadable, or fails digest verification/;
91
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(mirroredRoutingText(), "0".repeat(64))), unavailable);
92
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(join(scratch, "absent-mirror")), unavailable);
93
+ });
94
+
95
+ test("a routing block whose ODD protocol section changed shape fails closed", () => {
96
+ const unexpectedShape = /routing\/pi\.md ODD protocol section has an unexpected shape/;
97
+ const withoutResume = mirroredRoutingText().replace(/\nResume an interrupted feature[^\n]*\n/, "\n");
98
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutResume)), unexpectedShape);
99
+ const withoutSection = mirroredRoutingText().replace("### ODD protocol (MANDATORY, in this order, on every request)", "### ODD protocol");
100
+ assert.throws(() => __testing.readMirroredOddProtocolSteps(tempRoutingMirror(withoutSection)), unexpectedShape);
101
+ });