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
@@ -653,13 +653,14 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
653
653
  const scripts = join(f.root, "scripts"); mkdirSync(scripts);
654
654
  // Observe the copied production entry's first PowerShell handle so the
655
655
  // existing ownership-checked guard can reap it if the fixture times out.
656
- writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8")));
656
+ writeFileSync(join(scripts, "bootstrap.cmd"), observeStage(readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"), true));
657
657
  const local = join(f.root, "home"); mkdirSync(local);
658
658
  const env = { ...nativeEnv(f.root), LOCALAPPDATA: local };
659
659
  // The same outer bound as every other production fragment (nativeCmd): a cold
660
660
  // cmd.exe plus Windows PowerShell 5.1 start can exceed 5 s while the CI step runs
661
661
  // the installer suites in parallel. The bundle stage itself has no deadline.
662
662
  console.error("WINDOWS_ENTRY_PROBE_START timeoutMs=14000");
663
+ const startedAt = Date.now();
663
664
  const started = performance.now();
664
665
  const result = await nativeCmdFile(f.root, "scripts\\bootstrap.cmd", env, 14000);
665
666
  console.error("WINDOWS_ENTRY_PROBE_RESULT", JSON.stringify({ elapsedMs: performance.now() - started, status: result.status, guardKilled: result.guardKilled, expectedDiagnostic: /No acquisition attempted/.test(result.stderr) }));
@@ -668,6 +669,8 @@ test("native Windows entry: spaces, Unicode, CMD metacharacters and early missin
668
669
  assertNative(result, 1);
669
670
  assert.match(result.stderr, /No acquisition attempted/);
670
671
  assert.match(readFileSync(join(f.root, "process-records"), "utf8").trim(), /^[1-9][0-9]*\|[1-9][0-9]*$/, "the copied entry must expose its first PowerShell handle to fixture cleanup");
672
+ const readyMs = Number(readFileSync(join(f.root, "powershell-ready-ms"), "utf8"));
673
+ assert.ok(Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= Date.now(), "the fixture must timestamp PowerShell executing its body");
671
674
  assert.deepEqual(readdirSync(local), []);
672
675
  } finally {
673
676
  try { cleanNativeProcesses(f.root); } finally { f.cleanup(); }
@@ -1051,6 +1054,55 @@ test("the one-launch walk applies the single-path walk, per path, with its own f
1051
1054
  assert.doesNotMatch(many, /Invoke-Expression|\biex\b|ScriptBlock\]::Create|EncodedCommand/);
1052
1055
  });
1053
1056
 
1057
+ test("storage metadata public adapters preserve verdicts and unexpected failures", () => {
1058
+ const path = "D:\\metadata fixture\\tool.exe";
1059
+ const env = { SystemRoot: "C:\\Windows" };
1060
+ let calls = 0;
1061
+ const single = (output: string) => verifyWindowsStorage(path, env, (_command: string, _args: string[], data: Record<string, string>) => {
1062
+ calls++; assert.equal(data.GENTLE_WINDOWS_CHECK, path); return output;
1063
+ });
1064
+ if (process.platform === "win32") {
1065
+ assert.equal(single("safe"), undefined);
1066
+ for (const check of ["target-reparse", "parent-owner", "ancestor-acl-mask"]) {
1067
+ assert.throws(() => single(`unsafe:${check}|${encoded(`${path}|||`)}`), (error: Error & { check?: string; detail?: { at: string } }) => error.check === check && error.detail?.at === path);
1068
+ }
1069
+ assert.throws(() => single("malformed"), /Windows ACL evidence rejected/);
1070
+ assert.equal(calls, 5);
1071
+ const failure = Object.assign(new Error("metadata read failed"), { code: "EACCES" });
1072
+ assert.throws(() => verifyWindowsStorage(path, env, () => { throw failure; }), (error: unknown) => error === failure);
1073
+ } else {
1074
+ assert.throws(() => single("safe"), /Native Windows storage verification unavailable/);
1075
+ assert.equal(calls, 0, "unsupported platforms reject before invoking the runner");
1076
+ }
1077
+ const paths = [path, "D:\\metadata fixture\\next.cmd"];
1078
+ assert.deepEqual(windowsModule.verifyWindowsStorageMany(paths, env, { platform: "win32", processAdapter: (_command: string, _args: string[], data: Record<string, string>) => {
1079
+ assert.equal(data.GENTLE_WINDOWS_CHECKS, paths.join("|")); return "unknown\nsafe";
1080
+ } }), [{ check: "unchecked", at: path }, null]);
1081
+ });
1082
+
1083
+ // Inspect scripts through the public process seam; never execute PowerShell.
1084
+ for (const mode of ["single", "batch"] as const) {
1085
+ test(`storage metadata ${mode} uses throwing static attributes before ACL reads`, { skip: mode === "single" && process.platform !== "win32" ? "single-path script requires the real Windows platform" : false }, () => {
1086
+ let script = "";
1087
+ const processAdapter = (_command: string, args: string[], env: Record<string, string>) => {
1088
+ script = args[4];
1089
+ assert.equal(env[mode === "single" ? "GENTLE_WINDOWS_CHECK" : "GENTLE_WINDOWS_CHECKS"], "C:\\metadata\\node.exe");
1090
+ return "safe";
1091
+ };
1092
+ if (mode === "single") verifyWindowsStorage("C:\\metadata\\node.exe", { SystemRoot: "C:\\Windows" }, processAdapter);
1093
+ else windowsModule.verifyWindowsStorageMany(["C:\\metadata\\node.exe"], { SystemRoot: "C:\\Windows" }, { platform: "win32", processAdapter });
1094
+ assert.ok(script.includes("$attributes = [IO.File]::GetAttributes($path);"));
1095
+ const reparse = "if ($attributes -band [IO.FileAttributes]::ReparsePoint) { $detail = $path + '|||'; throw ($role + '-reparse') };";
1096
+ const branch = "if ($attributes -band [IO.FileAttributes]::Directory) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };";
1097
+ assert.ok(script.includes(reparse));
1098
+ assert.ok(script.includes(branch));
1099
+ assert.ok(script.indexOf("GetAttributes($path)") < script.indexOf(reparse));
1100
+ assert.ok(script.indexOf(reparse) < script.indexOf(branch));
1101
+ assert.doesNotMatch(script, /Get-Item|\[IO\.(?:File|Directory)\]::Exists|FileInfo|DirectoryInfo|Refresh/);
1102
+ assert.ok(script.includes(mode === "single" ? "} else { throw } }" : "} else { 'unknown' } }"));
1103
+ });
1104
+ }
1105
+
1054
1106
  // S6 decision fixtures: an in-memory folder tree and an injected walk verdict per
1055
1107
  // path, the same way the helper tests inject storage. `weak` maps a walked path
1056
1108
  // to the check its walk fails; every other walk passes.
@@ -1194,16 +1246,20 @@ test("production and fixture PowerShell never depend on autoloading Microsoft.Po
1194
1246
  assert.match(source, /\[IO\.Directory\]::SetAccessControl\(/);
1195
1247
  }
1196
1248
  // Walks that may reach a file read it as a file, never through a directory API.
1197
- for (const source of [productionAclSources()[1], productionAclSources()[2]]) {
1198
- assert.match(source, /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1249
+ assert.match(productionAclSources()[1], /if \(\$item\.PSIsContainer\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1250
+ for (const source of [productionAclSources()[2], manyCheck()]) {
1251
+ assert.match(source, /if \(\$attributes -band \[IO\.FileAttributes\]::Directory\) \{ \$acl = \[IO\.Directory\]::GetAccessControl\(\$path\) \} else \{ \$acl = \[IO\.File\]::GetAccessControl\(\$path\) \}/);
1199
1252
  }
1200
1253
  });
1201
1254
  const psRecordLine = ' "$record = [Diagnostics.Process]::GetCurrentProcess(); [IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$record.Id + [char]124 + [string]$record.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^';
1202
- function observeStage(stage: string) {
1255
+ // Entry-only telemetry: this timestamps execution of the PowerShell body, not
1256
+ // merely process creation. Keep it separate from the guard's PID/ticks records.
1257
+ const psReadyLine = ' "[IO.File]::WriteAllText([IO.Path]::Combine($env:GENTLE_FIXTURE_ROOT,\'powershell-ready-ms\'),[string][DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds());" ^';
1258
+ function observeStage(stage: string, entryReadiness = false) {
1203
1259
  const lines = stage.split("\n");
1204
1260
  const index = lines.findIndex((line) => line.includes("LanguageMode"));
1205
1261
  assert.ok(index >= 0);
1206
- lines.splice(index + 1, 0, psRecordLine);
1262
+ lines.splice(index + 1, 0, psRecordLine, ...(entryReadiness ? [psReadyLine] : []));
1207
1263
  return lines.join("\n");
1208
1264
  }
1209
1265
  function cmdComposition(stages: string[]) {
@@ -1234,6 +1290,16 @@ test("native entry observation preserves the complete production batch", () => {
1234
1290
  assert.equal(observed.filter((line) => line !== psRecordLine).join("\n"), source);
1235
1291
  });
1236
1292
 
1293
+ test("native entry readiness observation is opt-in and preserves production and ownership records", () => {
1294
+ const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8");
1295
+ const observed = observeStage(source, true).split("\n");
1296
+ assert.equal(observed.filter((line) => line.includes("powershell-ready-ms")).length, 1);
1297
+ assert.equal(observed.filter((line) => line === psRecordLine).length, 1);
1298
+ assert.equal(observed.filter((line) => line !== psRecordLine && !line.includes("powershell-ready-ms")).join("\n"), source);
1299
+ assert.doesNotMatch(observeStage(source), /powershell-ready-ms/);
1300
+ assert.ok(observed.findIndex((line) => line.includes("powershell-ready-ms")) > observed.indexOf(psRecordLine));
1301
+ });
1302
+
1237
1303
  test("native fixture compositions retain exact production fragments and no transport command", () => {
1238
1304
  for (const marker of Object.values(stageMarkers)) {
1239
1305
  const original = cmdStage(marker);
@@ -1301,9 +1367,17 @@ if (Test-Path -LiteralPath $env:GENTLE_FIXTURE_RECORDS) {
1301
1367
  if ($reaped) { [Console]::WriteLine('fixture guard reaped residual owned process') };
1302
1368
  exit 0;
1303
1369
  `;
1370
+ const GUARD_TIMEOUT_MS = 5000;
1304
1371
  function cleanNativeProcesses(root: string) {
1305
1372
  const ps = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe");
1306
- const result = spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: 5000, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
1373
+ const spawnGuard = (timeoutMs: number) => spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: timeoutMs, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 });
1374
+ // A hosted Windows runner can cold-start PowerShell slower than any fixed
1375
+ // bound under load (run 40000806368: ETIMEDOUT at 5s, same commit green on
1376
+ // rerun). The guard's job is cleanup, not a load measurement, and the guard
1377
+ // is idempotent — it kills only verified owned processes and exits 0 — so a
1378
+ // timeout is retried once with a wider bound before failing the fixture.
1379
+ let result = spawnGuard(GUARD_TIMEOUT_MS);
1380
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") result = spawnGuard(GUARD_TIMEOUT_MS * 4);
1307
1381
  assert.equal(result.error, undefined, "fixture guard must be bounded and available");
1308
1382
  assert.equal(result.status, 0, `fixture guard must only clean verified owned processes: ${(result.stderr ?? "").slice(0, 4000)}`);
1309
1383
  assert.ok(["", "fixture guard reaped residual owned process"].includes(result.stdout.trim()), "unexpected fixture guard output");
@@ -1314,10 +1388,22 @@ async function nativeCmd(root: string, stages: string[], env: NodeJS.ProcessEnv
1314
1388
  return nativeCmdFile(root, "fixture.cmd", env, limit);
1315
1389
  }
1316
1390
  async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv, limit = 5000): Promise<NativeResult> {
1391
+ const startedAt = Date.now();
1317
1392
  const started = performance.now();
1318
1393
  const trace = (phase: string, detail: Record<string, unknown> = {}) => {
1319
1394
  if (file === "scripts\\bootstrap.cmd") console.error("WINDOWS_ENTRY_PHASE", JSON.stringify({ phase, elapsedMs: performance.now() - started, limit, ...detail }));
1320
1395
  };
1396
+ // Wall-clock timestamps share an epoch with PowerShell. These measurements
1397
+ // are diagnostic only; the monotonic outer deadline is never reset. Emit
1398
+ // the split before synchronous guard cleanup can inflate execution time.
1399
+ const traceEntryTiming = () => {
1400
+ if (file !== "scripts\\bootstrap.cmd") return;
1401
+ const readyPath = join(root, "powershell-ready-ms");
1402
+ const finishedAt = Date.now();
1403
+ const readyMs = existsSync(readyPath) ? Number(readFileSync(readyPath, "utf8")) : NaN;
1404
+ const valid = Number.isSafeInteger(readyMs) && readyMs >= startedAt && readyMs <= finishedAt;
1405
+ trace("entry-timing", { readyObserved: valid, startupMs: valid ? readyMs - startedAt : null, executionMs: valid ? finishedAt - readyMs : null });
1406
+ };
1321
1407
  trace("spawn-request");
1322
1408
  const child = spawn(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", file], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
1323
1409
  let stdout = ""; let stderr = ""; let guardKilled = false;
@@ -1327,6 +1413,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
1327
1413
  const result = await new Promise<NativeResult>((resolveChild, reject) => {
1328
1414
  const timer = setTimeout(() => {
1329
1415
  guardKilled = true;
1416
+ traceEntryTiming();
1330
1417
  trace("guard-fired", { recordExists: existsSync(join(root, "process-records")), diagnosticSeen: /No acquisition attempted/.test(stderr) });
1331
1418
  try { cleanNativeProcesses(root); } catch (error) { guardError = error; }
1332
1419
  child.kill("SIGKILL");
@@ -1341,7 +1428,7 @@ async function nativeCmdFile(root: string, file: string, env: NodeJS.ProcessEnv,
1341
1428
  if (!/No acquisition attempted/.test(before) && /No acquisition attempted/.test(stderr)) trace("missing-bundle-diagnostic");
1342
1429
  if (stderr.length > 1048576) { guardError = new Error("Fixture output limit"); child.kill("SIGKILL"); } });
1343
1430
  child.once("error", (error) => { clearTimeout(timer); reject(error); });
1344
- child.once("close", (status) => { trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
1431
+ child.once("close", (status) => { if (!guardKilled) traceEntryTiming(); trace("cmd-close", { status, guardKilled }); clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); });
1345
1432
  });
1346
1433
  const residualReaped = cleanNativeProcesses(root);
1347
1434
  assert.equal(residualReaped, false, "production must reap its own recorded children; fixture cleanup cannot mask a failure");
@@ -0,0 +1,111 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { basename, dirname, join, parse, resolve } from 'node:path';
5
+ import { getSystemErrorName } from 'node:util';
6
+ import { windowsProcessCheck, windowsBootstrapReason } from '../scripts/installer-windows.mjs';
7
+
8
+ // Only owned inert Node children. Never invoke PowerShell, ACL/storage or setup.
9
+ const base = resolve('odd/checks/issue-1921-windows-prerequisite-diagnostics');
10
+ mkdirSync(base, { recursive: true });
11
+ const root = mkdtempSync(join(base, 'inert-'));
12
+ const fixture = join(root, 'child.mjs');
13
+ writeFileSync(fixture, `import { writeSync } from 'node:fs';
14
+ const mode = process.argv[2];
15
+ if (mode === 'cap') { writeSync(2, 'overflow fixture\\n'); writeSync(1, 'x'.repeat(2*1024*1024)); }
16
+ else if (mode === 'success') { writeSync(1, ' ' + JSON.stringify({cwd:process.cwd()}) + ' \\n'); }
17
+ else { writeSync(1, process.argv[3]); writeSync(2, process.argv[4] ?? ''); process.exit(7); }
18
+ `);
19
+ const env = { SystemRoot: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows',
20
+ PATH: dirname(process.execPath), DIAGNOSTIC_SECRET: 'must-not-retain-env' };
21
+ function failure(command: string, args: string[]): any {
22
+ let caught: any;
23
+ try { windowsProcessCheck(command, args, env); } catch (error) { caught = error; }
24
+ assert.equal(caught?.message, 'Windows prerequisite process failed');
25
+ assert.equal(windowsBootstrapReason(caught, 'tools-storage'), 'process-failed (tools-storage)');
26
+ return caught;
27
+ }
28
+ function causeOf(error: any): any {
29
+ assert.ok(error.cause instanceof Error, 'standard cause Error must survive existing cause.stack observer');
30
+ const cause = error.cause;
31
+ assert.deepEqual(Object.keys(cause).sort(), ['error', 'signal', 'status', 'stderr', 'stdout']);
32
+ assert.ok(cause.stack.length <= 8192);
33
+ assert.ok(cause.stdout.length <= 1024 && cause.stderr.length <= 1024);
34
+ assert.ok(!cause.stack.includes(root) && !cause.stack.includes('installer-windows.mjs'));
35
+ assert.ok(!cause.error?.message?.includes(root));
36
+ assert.ok(!JSON.stringify(cause).includes(env.DIAGNOSTIC_SECRET));
37
+ return cause;
38
+ }
39
+
40
+ test('metadata: nonzero exit keeps bounded streams and usable cause.stack', () => {
41
+ const args = [fixture, 'text', 'controlled stdout\n', 'controlled stderr\n', 'private-argv-sentinel'];
42
+ const before = structuredClone({ args, env });
43
+ const error = failure(process.execPath, args), cause = causeOf(error);
44
+ assert.equal(cause.status, 7); assert.equal(cause.signal, null); assert.equal(cause.error, null);
45
+ assert.equal(cause.stdout, 'controlled stdout\n'); assert.equal(cause.stderr, 'controlled stderr\n');
46
+ assert.match(error.cause?.stack, /"status":7/);
47
+ assert.match(error.cause?.stack, /controlled stderr/);
48
+ assert.ok(!JSON.stringify(cause).includes('private-argv-sentinel'));
49
+ assert.deepEqual({ args, env }, before);
50
+ });
51
+
52
+ test('metadata: missing synthetic executable preserves ENOENT without raw spawn error', () => {
53
+ const cause = causeOf(failure(join(root, 'missing.exe'), ['private-argv-sentinel']));
54
+ assert.equal(cause.status, null); assert.equal(cause.signal, null);
55
+ assert.deepEqual(Object.keys(cause.error).sort(), ['code', 'errno', 'message', 'name']);
56
+ assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
57
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
58
+ assert.ok(cause.error.message.length <= 256);
59
+ assert.equal(cause.stdout, ''); assert.equal(cause.stderr, '');
60
+ assert.match(cause.stack, /ENOENT/);
61
+ });
62
+
63
+ test('metadata: output overflow preserves ENOBUFS and actual kill signal without waiting15s', () => {
64
+ const cause = causeOf(failure(process.execPath, [fixture, 'cap']));
65
+ assert.equal(cause.status, null); assert.equal(cause.signal, 'SIGKILL');
66
+ assert.equal(cause.error.code, 'ENOBUFS'); assert.equal(cause.error.name, 'Error');
67
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOBUFS');
68
+ assert.equal(cause.stderr, 'overflow fixture\n');
69
+ assert.match(cause.stack, /ENOBUFS/);
70
+ });
71
+
72
+ test('privacy: private paths, SID/encoded ACL detail and sensitive output are suppressed; caps hold', () => {
73
+ const privateLines = ['visible', 'C:\\Users\\Synthetic Person\\private.txt', '/home/synthetic/private.txt',
74
+ 'S-1-5-21-111-222-333-1001', 'DOMAIN\\synthetic', 'unsafe:target-owner|YWJjZA=='];
75
+ const cause = causeOf(failure(process.execPath, [fixture, 'text', privateLines.join('\n'),
76
+ 'Authorization: Bearer synthetic-secret\ncontinuation-must-not-survive']));
77
+ assert.match(cause.stdout, /visible/);
78
+ for (const value of privateLines.slice(1)) assert.ok(!cause.stdout.includes(value));
79
+ assert.ok(!cause.stack.includes('synthetic-secret') && !cause.stack.includes('continuation-must-not-survive'));
80
+ const bounded = causeOf(failure(process.execPath, [fixture, 'text', 'safe line\n'.repeat(300), 'safe error\n'.repeat(300)]));
81
+ assert.equal(bounded.stdout.length, 1024); assert.equal(bounded.stderr.length, 1024);
82
+ });
83
+
84
+ test('PR privacy: credential markers recognize underscore separators without changing API_KEY handling', () => {
85
+ for (const marker of ['GITHUB_TOKEN', 'PREFIX_API_KEY_SUFFIX', 'AWS_ACCESS_KEY_ID', 'API_KEY', 'access_key']) {
86
+ const payload = `${marker}=synthetic-value-1921\ncontinuation-value-1921`;
87
+ assert.equal(payload.includes('\\'), false, 'actual argv bytes must not trigger the path filter');
88
+ const cause = causeOf(failure(process.execPath,
89
+ ['-e', 'process.stderr.write(process.argv[1]);process.exit(7)', payload]));
90
+ assert.equal(cause.status, 7);
91
+ assert.equal(cause.stderr, '[redacted]');
92
+ assert.ok(!cause.stack.includes('synthetic-value-1921') && !cause.stack.includes('continuation-value-1921'));
93
+ }
94
+ });
95
+
96
+ test('PR privacy: missing bare executable identity never enters cause message or stack', () => {
97
+ const command = `fixture-private-executable-1921-${basename(root)}`;
98
+ assert.equal(parse(command).root, '', 'a path-based probe would miss the bare-name leak');
99
+ const cause = causeOf(failure(command, ['private-argv-sentinel']));
100
+ assert.equal(cause.status, null); assert.equal(cause.signal, null);
101
+ assert.equal(cause.error.code, 'ENOENT'); assert.equal(cause.error.name, 'Error');
102
+ assert.equal(getSystemErrorName(cause.error.errno), 'ENOENT');
103
+ assert.ok(!JSON.stringify(cause).includes(command) && !cause.stack.includes(command));
104
+ assert.match(cause.error.message, /ENOENT/);
105
+ });
106
+
107
+ test('legacy: neutral cwd, trimmed success and pre-spawn CMD/BAT rejection stay exact', () => {
108
+ assert.equal(windowsProcessCheck(process.execPath, [fixture, 'success'], env), JSON.stringify({ cwd: parse(process.execPath).root }));
109
+ for (const suffix of ['.CMD', '.bat']) assert.throws(() => windowsProcessCheck(join(root, `absent${suffix}`), [], env),
110
+ error => error instanceof Error && error.message === 'Unproven command interpreter target' && error.cause === undefined);
111
+ });
@@ -4,7 +4,7 @@ import * as fsPromises from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
- import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
7
+ import { CHANNEL_SCHEMA, GENTLE_AI_MODULE, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
8
8
  import { requirements } from "../scripts/installer-preflight.mjs";
9
9
 
10
10
  const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
@@ -19,7 +19,8 @@ type Invocation = { command: string; prefix: string[] } | null;
19
19
  * `which` finds `<bin>/<name><suffix>`; `invocation`, when given, is the host's adapter. */
20
20
  function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin",
21
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 } = {}) {
22
+ migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined,
23
+ failGoInstall = false, failShellInstall = false } = {}) {
23
24
  // owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root.
24
25
  const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-")));
25
26
  const home = join(root, "home");
@@ -50,13 +51,22 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
50
51
  }
51
52
  calls.push({ command, argv });
52
53
  if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) {
54
+ if (failGoInstall) return { code: 1, stdout: "" };
53
55
  mkdirSync(options.env!.GOBIN, { recursive: true });
54
56
  for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
55
57
  return { code: 0, stdout: "" };
56
58
  }
57
59
  if (argv[0] === "version" && command === `${bin}/go`) {
60
+ if (argv[1] === "-m") {
61
+ const commit = String(argv[2] ?? "").split("/").at(-2) ?? "";
62
+ return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\tv4.0.1-0.20261008202137-${commit.slice(0, 12)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
63
+ }
58
64
  return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
59
65
  }
66
+ if (argv[0] === "list" && command === `${bin}/go`) {
67
+ const commit = String(argv[3] ?? "").slice(`${GENTLE_AI_MODULE}@`.length);
68
+ return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`, Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: commit } }) };
69
+ }
60
70
  if (argv[0] === "version") {
61
71
  const sha = command.split("/").at(-2) ?? "";
62
72
  return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
@@ -65,6 +75,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
65
75
  writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } }));
66
76
  return { code: 0, stdout: "" };
67
77
  }
78
+ if (failShellInstall && argv.includes("-g") && argv.some((arg) => arg.endsWith(".tgz"))) return { code: 1, stdout: "" };
68
79
  if (argv.includes("pack")) {
69
80
  const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version;
70
81
  writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz");
@@ -186,6 +197,225 @@ test("a main install rebuilds only what moved on main and records the new commit
186
197
  } finally { w.cleanup(); }
187
198
  });
188
199
 
200
+ // #2356: a Gentle AI step that fails still installs a main Shell that moved, so the
201
+ // retry runs the new Shell's code; the channel is recorded only when both succeed.
202
+ const GO_INSTALL_FAILED = `main-gentle-ai-build-failed: go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI} failed`;
203
+ const PRIOR_REGISTRATION = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/prior/main/gentle-ai" });
204
+ const NEW_SHELL_TGZ = (home: string) => join(home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz");
205
+ async function onPriorMain(w: ReturnType<typeof world>) {
206
+ await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
207
+ writeFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), PRIOR_REGISTRATION);
208
+ return () => ({ channel: readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8"), registration: readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8") });
209
+ }
210
+
211
+ test("a failed Gentle AI build still installs the main Shell that moved, records no channel and names the retry", posixOnly, async () => {
212
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true });
213
+ try {
214
+ const files = await onPriorMain(w);
215
+ const before = files();
216
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
217
+ assert.equal(error.code, "main-gentle-ai-build-failed");
218
+ assert.equal(error.message, `${GO_INSTALL_FAILED}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
219
+ return true;
220
+ });
221
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
222
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
223
+ assert.deepEqual(files(), before);
224
+ assert.deepEqual(JSON.parse(before.registration), JSON.parse(PRIOR_REGISTRATION));
225
+ } finally { w.cleanup(); }
226
+ });
227
+
228
+ test("a Go that cannot be obtained for the Gentle AI build still installs the main Shell that moved", posixOnly, async () => {
229
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, tools: ["pnpm", "npm"] });
230
+ try {
231
+ const files = await onPriorMain(w);
232
+ const before = files();
233
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
234
+ assert.equal(error.code, "main-requires-tools");
235
+ assert.match(error.message, /^main-requires-tools: the main channel builds Gentle AI with Go .*Unexpected Go acquisition in sandboxed upgrade fixture.*; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish$/);
236
+ return true;
237
+ });
238
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
239
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
240
+ assert.deepEqual(files(), before);
241
+ } finally { w.cleanup(); }
242
+ });
243
+
244
+ test("after a fail-forward, the retry from the new Shell builds Gentle AI and records both commits", posixOnly, async () => {
245
+ // The first copy of the built binary fails (as a rejected build would); the retry's succeeds.
246
+ let copies = 0;
247
+ const fs = { ...fsPromises, copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => {
248
+ if (copies++ === 0) throw new MainChannelError("main-gentle-ai-unverified", "first attempt rejected");
249
+ return fsPromises.copyFile(...args);
250
+ } } as typeof fsPromises;
251
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs });
252
+ try {
253
+ const files = await onPriorMain(w);
254
+ const before = files();
255
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
256
+ assert.deepEqual(files(), before);
257
+ // The retry is simulated as the new Shell's run: the same runUpgrade with the new
258
+ // version (the installed package itself is not executed). No channel was recorded,
259
+ // so it rebuilds Gentle AI and repacks the Shell before recording both commits.
260
+ w.lines.length = 0;
261
+ assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
262
+ assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
263
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`, `/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
264
+ assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI });
265
+ assert.equal(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path,
266
+ join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", NEW_AI, "gentle-ai"));
267
+ // A third run is a no-op: both commits are current.
268
+ w.lines.length = 0;
269
+ assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
270
+ assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
271
+ } finally { w.cleanup(); }
272
+ });
273
+
274
+ test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => {
275
+ const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true });
276
+ try {
277
+ const files = await onPriorMain(w);
278
+ const before = files();
279
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
280
+ assert.equal(error.code, "main-gentle-ai-build-failed");
281
+ assert.equal(error.message, GO_INSTALL_FAILED);
282
+ return true;
283
+ });
284
+ assert.deepEqual(w.installs(), []);
285
+ assert.deepEqual(w.lines, []);
286
+ assert.deepEqual(files(), before);
287
+ } finally { w.cleanup(); }
288
+ });
289
+
290
+ test("when the Gentle AI build and the Shell install both fail, the Shell failure is reported with the Gentle AI one", posixOnly, async () => {
291
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true, failShellInstall: true });
292
+ try {
293
+ const files = await onPriorMain(w);
294
+ const before = files();
295
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
296
+ assert.equal(error.code, "upgrade-install-failed");
297
+ assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (${GO_INSTALL_FAILED})`);
298
+ return true;
299
+ });
300
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
301
+ assert.deepEqual(w.lines, []);
302
+ assert.deepEqual(files(), before);
303
+ } finally { w.cleanup(); }
304
+ });
305
+
306
+ // An ordinary failure that is not a MainChannelError (EACCES) keeps the retry advice and both errors.
307
+ const eacces = (what: string) => Object.assign(new Error(`EACCES: permission denied, ${what}`), { code: "EACCES" });
308
+ const COPY_DENIED = eacces("copyfile");
309
+ const MANIFEST_DENIED = eacces("open package.json");
310
+ const deniedFs = ({ copy = false, manifest = false }) => ({ ...fsPromises,
311
+ copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => { if (copy) throw COPY_DENIED; return fsPromises.copyFile(...args); },
312
+ readFile: (async (path: string, options?: unknown) => {
313
+ if (manifest && String(path).endsWith(join(".source", "src", "package.json"))) throw MANIFEST_DENIED;
314
+ return fsPromises.readFile(path, options as never);
315
+ }) as typeof fsPromises.readFile }) as typeof fsPromises;
316
+
317
+ test("an ordinary error in the Gentle AI step still installs the main Shell that moved and names the retry", posixOnly, async () => {
318
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true }) });
319
+ try {
320
+ const files = await onPriorMain(w);
321
+ const before = files();
322
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
323
+ assert.ok(error instanceof MainChannelError);
324
+ assert.equal(error.code, "main-upgrade-incomplete");
325
+ assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, copyfile; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
326
+ assert.equal(error.cause, COPY_DENIED);
327
+ return true;
328
+ });
329
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
330
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
331
+ assert.deepEqual(files(), before);
332
+ } finally { w.cleanup(); }
333
+ });
334
+
335
+ test("ordinary errors in both steps report the Shell error with the Gentle AI one", posixOnly, async () => {
336
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true, manifest: true }) });
337
+ try {
338
+ const files = await onPriorMain(w);
339
+ const before = files();
340
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
341
+ assert.ok(error instanceof MainChannelError);
342
+ assert.equal(error.code, "main-upgrade-incomplete");
343
+ assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, open package.json; Gentle AI was not updated either (EACCES: permission denied, copyfile)");
344
+ assert.equal(error.cause, MANIFEST_DENIED);
345
+ return true;
346
+ });
347
+ assert.deepEqual(w.installs(), []);
348
+ assert.deepEqual(w.lines, []);
349
+ assert.deepEqual(files(), before);
350
+ } finally { w.cleanup(); }
351
+ });
352
+
353
+ test("an ordinary error in the Gentle AI step with the main Shell already current is rethrown unchanged", posixOnly, async () => {
354
+ const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, fs: deniedFs({ copy: true }) });
355
+ try {
356
+ const files = await onPriorMain(w);
357
+ const before = files();
358
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: Error) => error === COPY_DENIED);
359
+ assert.deepEqual(w.installs(), []);
360
+ assert.deepEqual(w.lines, []);
361
+ assert.deepEqual(files(), before);
362
+ } finally { w.cleanup(); }
363
+ });
364
+
365
+ // A rejection that is not an Error, even a falsy one, is still a failed Gentle AI step.
366
+ const rejectingCopy = (value: unknown) => ({ ...fsPromises, copyFile: async () => { throw value; } }) as typeof fsPromises;
367
+
368
+ test("a falsy rejection in the Gentle AI step still records no channel and names the retry", posixOnly, async () => {
369
+ for (const value of [undefined, null, 0, false]) {
370
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(value) });
371
+ try {
372
+ const files = await onPriorMain(w);
373
+ const before = files();
374
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
375
+ assert.ok(error instanceof MainChannelError);
376
+ assert.equal(error.code, "main-upgrade-incomplete");
377
+ assert.equal(error.message, `main-upgrade-incomplete: ${String(value)}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
378
+ return true;
379
+ });
380
+ assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
381
+ assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
382
+ assert.deepEqual(files(), before);
383
+ } finally { w.cleanup(); }
384
+ }
385
+ });
386
+
387
+ test("a rejection that is not an Error but has a message keeps that message", posixOnly, async () => {
388
+ for (const failShellInstall of [false, true]) {
389
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy({ message: "EACCES: copy denied", code: "EACCES" }), failShellInstall });
390
+ try {
391
+ const files = await onPriorMain(w);
392
+ const before = files();
393
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
394
+ assert.equal(error.message, failShellInstall
395
+ ? `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (EACCES: copy denied)`
396
+ : "main-upgrade-incomplete: EACCES: copy denied; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
397
+ return true;
398
+ });
399
+ assert.deepEqual(files(), before);
400
+ } finally { w.cleanup(); }
401
+ }
402
+ });
403
+
404
+ test("a null rejection in the Gentle AI step is kept when the Shell install fails too", posixOnly, async () => {
405
+ const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(null), failShellInstall: true });
406
+ try {
407
+ const files = await onPriorMain(w);
408
+ const before = files();
409
+ await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
410
+ assert.equal(error.code, "upgrade-install-failed");
411
+ assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (null)`);
412
+ return true;
413
+ });
414
+ assert.deepEqual(w.lines, []);
415
+ assert.deepEqual(files(), before);
416
+ } finally { w.cleanup(); }
417
+ });
418
+
189
419
  test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => {
190
420
  const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } });
191
421
  try {