gentle-pi 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -13,7 +13,7 @@ function harness(env: NodeJS.ProcessEnv = {}, mode: "tui" | "print" = "tui", shu
13
13
  let session = "first";
14
14
  let finish!: () => void;
15
15
  let signal!: AbortSignal;
16
- const sent: RuntimeMetricBucket[][] = [];
16
+ const sent: (readonly RuntimeMetricBucket[])[] = [];
17
17
  const launches: unknown[] = [];
18
18
  const ctx: any = { cwd: "/fixture", mode, model,
19
19
  sessionManager: { getSessionId: () => session, getEntries: () => assert.fail("no reconstruction") } };
@@ -2,8 +2,11 @@ import assert from "node:assert/strict";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { PassThrough } from "node:stream";
4
4
  import { test } from "node:test";
5
- import { readFileSync, realpathSync } from "node:fs";
5
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
6
8
  import { setGentleAiDevBinaryEnvironmentForTesting } from "../lib/gentle-ai-binary.ts";
9
+ import { setBundledInstallForTesting } from "../lib/gentle-shell-bundled.ts";
7
10
  import * as native from "../lib/runtime-metrics-native.ts";
8
11
  import { parseAgentClass, type RuntimeMetricBucket } from "../lib/runtime-metrics.ts";
9
12
  import { createHash } from "node:crypto";
@@ -273,3 +276,30 @@ test("spawn failure silently discards", async () => {
273
276
  spawn: () => { calls++; throw new Error("private error"); } }), "discarded");
274
277
  assert.equal(calls, 1);
275
278
  });
279
+
280
+ // S9: this package running from a bundled install (the testing overlay stands
281
+ // in for its location; the detection itself is tested with real installs in
282
+ // gentle-ai-binary-isolation.test.ts) and the launcher's GENTLE_SHELL_PREFIX.
283
+ function bundledIsolation(t: { after: (fn: () => void) => void }): { prefix: string; env: NodeJS.ProcessEnv } {
284
+ const prefix = realpathSync(mkdtempSync(join(tmpdir(), "gentle-ai-isolation-site-")));
285
+ setBundledInstallForTesting({ root: prefix });
286
+ t.after(() => {
287
+ setBundledInstallForTesting(undefined);
288
+ rmSync(prefix, { recursive: true, force: true });
289
+ });
290
+ return { prefix, env: { PATH: "/usr/bin", GENTLE_SHELL_PREFIX: prefix } };
291
+ }
292
+
293
+ test("the one-shot send runs gentle-ai with Gentle AI's isolation variables only in a bundled install", async (t) => {
294
+ const { prefix, env } = bundledIsolation(t);
295
+ for (const [given, home] of [[env, join(prefix, "gentle-ai")], [{ PATH: "/usr/bin" }, undefined]] as const) {
296
+ const f = fixture();
297
+ const pending = native.sendNativeRuntimeEvent([], "/fixture", { ...f.deps, env: given });
298
+ const options = f.calls[0][2] as { env: NodeJS.ProcessEnv };
299
+ assert.equal(options.env.GENTLE_AI_HOME, home);
300
+ if (home === undefined) assert.equal(options.env, given, "the environment is passed unchanged");
301
+ else assert.equal(options.env.GENTLE_AI_NO_SELF_UPDATE, "1");
302
+ f.close();
303
+ assert.equal(await pending, "stored");
304
+ }
305
+ });
@@ -2,11 +2,16 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
4
4
  import { GentlePromptEditor } from "../extensions/gentle-shell.ts";
5
- import { SelectionEngine } from "../lib/selection-engine.ts";
5
+ import { SelectionEngine, withReverseSpan } from "../lib/selection-engine.ts";
6
6
  import { decodePrintableKey } from "../lib/pi-tui-keys.ts";
7
- import { CURSOR_MARKER, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
7
+ import { CURSOR_MARKER, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
8
8
  import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
9
- import { stripAnsi } from "../lib/terminal-theme.ts";
9
+ import { withPromptHint } from "../lib/shell-prompt.ts";
10
+
11
+ function resolveFakeCursors(rows: string[], hardware = false): string[] {
12
+ const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
13
+ return host.resolveFakeCursors.call({ showHardwareCursor: hardware }, rows);
14
+ }
10
15
 
11
16
  // Native selection engine tests: drive a real CustomEditor through the
12
17
  // SelectionEngine the same way GentlePromptEditor wires it — engine.handleInput
@@ -45,6 +50,30 @@ const click = (x: number, y: number, width: number): TuiMouseEvent => ({
45
50
  type: "click", button: "left", x, y, width, height: 30, screenX: x, screenY: y, shift: false, alt: false, ctrl: false,
46
51
  });
47
52
 
53
+ test("legacy and APC cursors preserve native selection and empty hints", () => {
54
+ const legacy = `${CURSOR_MARKER}\x1b[7ma\x1b[0mbc`;
55
+ assert.equal(withReverseSpan(legacy, 0, 3), `\x1b[7m${CURSOR_MARKER}\x1b[7ma\x1b[0m\x1b[7mbc\x1b[27m`);
56
+ const apc = `${CURSOR_MARKER}\x1b_pi:fc\x07a\x1b_pi:/fc\x07bc`;
57
+ const selected = withReverseSpan(apc, 0, 3);
58
+ assert.ok(selected.includes(`${CURSOR_MARKER}\x1b_pi:fc\x07`));
59
+ for (const hardware of [false, true]) {
60
+ const resolved = resolveFakeCursors([selected], hardware)[0];
61
+ assert.ok(resolved.includes("\x1b[7mbc\x1b[27m"));
62
+ assert.equal(stripAnsi(resolved), "abc");
63
+ }
64
+ // Outside selection the host still suppresses only the focused fake cursor.
65
+ assert.equal(resolveFakeCursors([apc], true)[0], `${CURSOR_MARKER}abc`);
66
+ assert.ok(resolveFakeCursors([apc.replace(CURSOR_MARKER, "")], true)[0].includes("\x1b[7ma\x1b[27m"));
67
+ for (const cursor of ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"]) {
68
+ const row = `${CURSOR_MARKER}${cursor}${" ".repeat(30)}`;
69
+ const hinted = withPromptHint(row, "type here", (_role, text) => text);
70
+ assert.ok(hinted.startsWith(`${CURSOR_MARKER}${cursor}`));
71
+ assert.ok(hinted.includes("type here"));
72
+ assert.equal(visibleWidth(hinted), visibleWidth(row));
73
+ assert.equal(withPromptHint(row + "draft", "type here", (_role, text) => text), row + "draft");
74
+ }
75
+ });
76
+
48
77
  test("T2 float prompt native cursor, selection, replacement and mouse share geometry", () => {
49
78
  const previous = cardStyle();
50
79
  setCardStyle(CARD_STYLE.FLOAT);
@@ -56,11 +85,12 @@ test("T2 float prompt native cursor, selection, replacement and mouse share geom
56
85
  let rows = editor.render(80);
57
86
  assert.match(rows[0], /\x1b\[35m▎\x1b\[39m/);
58
87
  assert.equal(visibleWidth(rows[1].split(CURSOR_MARKER)[0]), 6);
59
- assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7md\x1b[0m\x1b[44m`));
88
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b_pi:fc\x07d\x1b_pi:/fc\x07`));
89
+ assert.ok(resolveFakeCursors(rows)[1].includes(`${CURSOR_MARKER}\x1b[7md\x1b[27m`));
60
90
  editor.handleInput("\x1b[1;2H");
61
91
  rows = editor.render(80);
62
92
  assert.equal(stripAnsi(rows[1].replaceAll(CURSOR_MARKER, "")).trim(), "▎ abcd");
63
- assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b[7ma\x1b[0m\x1b[44m\x1b[7mbc\x1b[27md`), "native selection inversion survives the software cursor reset");
93
+ assert.ok(rows[1].includes(`${CURSOR_MARKER}\x1b_pi:fc\x07a\x1b_pi:/fc\x07\x1b[7mbc\x1b[27md`), "native selection inversion survives the deferred software cursor reset");
64
94
  assert.match(rows[2], /3 chars selected/);
65
95
  assert.doesNotMatch(rows[2], /╯/);
66
96
  assert.ok(rows[2].includes("\x1b[44m"));
@@ -168,6 +198,36 @@ test("alt+a selects all; backspace replaces the whole text", () => {
168
198
  assert.equal(cursorOf(editor).line, 0);
169
199
  });
170
200
 
201
+ test("alt+e selects all; backspace replaces the whole text", () => {
202
+ const editor = makeEditor();
203
+ const engine = new SelectionEngine(editor);
204
+ const native = (d: string) => editor.handleInput(d);
205
+ editor.setText("abc\ndef");
206
+ engine.handleInput("\x1be", native);
207
+ engine.handleInput(BACKSPACE, native);
208
+ assert.equal(editor.getText(), "");
209
+ });
210
+
211
+ // gentle-shell#1565: a registered extension shortcut owns its chord before
212
+ // any selection key, matching CustomEditor's own precedence.
213
+ test("selection chords yield to a claiming shortcut and keep selecting otherwise", () => {
214
+ const editor = makeEditor();
215
+ const engine = new SelectionEngine(editor);
216
+ const forwarded: string[] = [];
217
+ const native = (d: string) => { forwarded.push(d); editor.handleInput(d); };
218
+ const probed: string[] = [];
219
+ const shortcut = (d: string) => { probed.push(d); return d === ALT_A; };
220
+ editor.setText("abc");
221
+ engine.handleInput(ALT_A, native, shortcut);
222
+ assert.deepEqual(probed, [ALT_A]);
223
+ assert.deepEqual(forwarded, []);
224
+ engine.handleInput(BACKSPACE, native, shortcut);
225
+ assert.equal(editor.getText(), "ab", "a claimed chord must not select");
226
+ engine.handleInput("\x1be", native, shortcut);
227
+ engine.handleInput(BACKSPACE, native, shortcut);
228
+ assert.equal(editor.getText(), "", "an unclaimed chord still selects");
229
+ });
230
+
171
231
  test("movement collapses the selection; later delete behaves natively", () => {
172
232
  const editor = makeEditor();
173
233
  const engine = new SelectionEngine(editor);
@@ -4,7 +4,7 @@ import { mkdtemp, writeFile, rm, realpath } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { installSessionChangeCapture, publishForeignSessionChange } from "../lib/session-change-capture.ts";
7
- import { SessionChanges, SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
7
+ import { SessionChanges, SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
8
8
 
9
9
  async function fixture(run: (f: any) => Promise<void>, child = false) {
10
10
  const root = await realpath(await mkdtemp(join(tmpdir(), "change-capture-")));
@@ -50,8 +50,9 @@ test("successful writes capture only the exact tool target and persist for reloa
50
50
  assert.equal(entries[0].customType,SESSION_CHANGE_ENTRY);
51
51
  await writeFile(join(root,"file"),"later human output\n");
52
52
  const changes = new SessionChanges("session",entries);
53
- assert.match(changes.loadDiff(root,changes.model.files[0]),/\+agent output/);
54
- assert.doesNotMatch(changes.loadDiff(root,changes.model.files[0]),/later human/);
53
+ assert.match(entries[0].data.evidence.after.text,/agent output/);
54
+ assert.doesNotMatch(entries[0].data.evidence.after.text,/later human/);
55
+ assert.equal(changes.model.files.length,1);
55
56
  }));
56
57
 
57
58
  test("failed and stale-session tool outcomes never add session changes", async () => fixture(async ({root,fire,entries,switchSession}) => {
@@ -78,9 +79,9 @@ test("child carries bounded evidence in the existing tool-result details transpo
78
79
  assert.deepEqual(entries,[]);
79
80
  },true));
80
81
 
81
- test("the capture-limit warning is emitted once, not on every later write", async () => fixture(async ({root,fire,pi}) => {
82
- const events: Array<{notice?: string}> = [];
83
- pi.events.on(SESSION_CHANGE_EVENT, (data: {notice?: string}) => events.push(data));
82
+ test("capture caps bound session entries without a Changes notice", async () => fixture(async ({root,entries,fire,pi}) => {
83
+ const events: unknown[] = [];
84
+ pi.events.on("gentle-pi:session-change", (data: unknown) => events.push(data));
84
85
  for (let i = 0; i < 80; i++) {
85
86
  const path = `f${i}`;
86
87
  const content = "x".repeat(60 * 1024) + "\n";
@@ -90,8 +91,7 @@ test("the capture-limit warning is emitted once, not on every later write", asyn
90
91
  await fire("tool_result",{...event,isError:false});
91
92
  await fire("tool_execution_end",{toolCallId:`w${i}`,toolName:"write",isError:false});
92
93
  }
93
- const withNotice = events.filter(event => event.notice !== undefined);
94
- assert.equal(withNotice.length,1);
95
- assert.match(withNotice[0].notice!,/limit reached/);
96
- assert.equal(events.length,80);
94
+ const persisted = entries.filter(entry => entry.customType === SESSION_CHANGE_ENTRY).length;
95
+ assert.ok(persisted > 0 && persisted < 80, `the session byte cap stops persisting evidence (${persisted})`);
96
+ assert.deepEqual(events, [], "Changes reads Git; capture emits no refresh or limit notice");
97
97
  }));
@@ -1,9 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import shell from "../extensions/gentle-shell.ts";
4
- import { SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import shell, { shellGitRunner } from "../extensions/gentle-shell.ts";
8
+ import { SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
9
+ import { SESSION_WORKTREE_ENTRY } from "../lib/session-worktree-registry.ts";
5
10
 
6
- function fixture(entries: any[] = []) {
11
+ // /gentle:changes shows the current Git state (working tree vs HEAD, untracked
12
+ // included) of the session's repository and of every worktree its subagents
13
+ // used. Captured write/edit evidence only names roots; Git names the files.
14
+
15
+ type Runner = (root: string) => (args: string[]) => Promise<{ stdout: string; code: number }>;
16
+ function fixture(entries: any[] = [], options: { root?: string; sessionId?: string; gitRunner?: Runner; pollMs?: number } = {}) {
7
17
  const handlers = new Map<string, Function[]>(), listeners = new Map<string, Function[]>();
8
18
  const commands = new Map<string, any>(), widgets = new Map<string, any>();
9
19
  let gitCalls = 0;
@@ -11,55 +21,239 @@ function fixture(entries: any[] = []) {
11
21
  events: { on: (key, fn) => { listeners.set(key,[...(listeners.get(key) ?? []), fn]); return () => {}; }, emit: (key,data) => listeners.get(key)?.forEach(fn=>fn(data)) },
12
22
  appendEntry: (customType,data) => entries.push({type:"custom",customType,data}), registerTool() {}, registerShortcut() {}, registerMessageRenderer() {},
13
23
  registerCommand: (key, registration) => commands.set(key,registration) };
14
- const notices: string[] = [];
15
- const ctx: any = { hasUI:true, cwd:"/repo", sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
16
- ui: { setFooter() {}, getEditorComponent:()=>({}), setWorkingVisible() {}, setWidget:(key,value)=>widgets.set(key,value), notify:(text)=>notices.push(text) } };
17
- shell(pi,{}, {resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
18
- gitRunner:()=>async()=>{gitCalls++; return await new Promise<any>(()=>{});} });
24
+ const notices: string[] = [], levels: string[] = [];
25
+ const root = options.root ?? "/repo";
26
+ const sessionId = options.sessionId ?? "session";
27
+ const overlay: { view?: any; close?: () => void } = {};
28
+ const ctx: any = { hasUI:true, cwd:root, sessionManager:{getSessionId:()=>sessionId,getEntries:()=>entries},
29
+ ui: { setFooter() {}, getEditorComponent:()=>({}), setWorkingVisible() {}, setWidget:(key,value)=>value===undefined?widgets.delete(key):widgets.set(key,value), notify:(text,level)=>{ notices.push(text); levels.push(level); },
30
+ custom:(factory:any)=>new Promise(resolve=>{ overlay.close=()=>resolve(null); overlay.view=factory({terminal:{rows:40,columns:200},requestRender(){}},{fg:(_c:string,t:string)=>t,bold:(t:string)=>t},{},resolve); }) } };
31
+ shell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:String(options.pollMs ?? 60_000)}, {resolveWorktree:()=>({root,commonDir:root+"/.git"}),devBinary:()=>undefined,
32
+ gitRunner:(cwd)=>async(args)=>{gitCalls++; return options.gitRunner ? options.gitRunner(cwd)(args) : await new Promise<any>(()=>{});} });
19
33
  const fire=async(key,event={})=>{for(const fn of handlers.get(key)??[]) await fn(event,ctx);};
20
- return {pi,ctx,entries,notices,commands,widgets,fire,gitCalls:()=>gitCalls};
34
+ // Opens the panel and closes it again: its groups (root, branch, files)
35
+ // and the rendered group rows.
36
+ const panel=async()=>{
37
+ overlay.view=undefined;
38
+ const opened=commands.get("gentle:changes").handler("",ctx);
39
+ for(let i=0;i<200&&!overlay.view;i++){ if(await Promise.race([opened.then(()=>true),new Promise(r=>setTimeout(()=>r(false),5))])) break; }
40
+ if(!overlay.view) return undefined;
41
+ const text=overlay.view.render(200).join("\n");
42
+ const trees=(overlay.view.trees as any[]).map(tree=>({root:tree.root as string,branch:tree.branch as string|undefined,files:tree.model.files.map((file:any)=>file.path) as string[],added:tree.model.added as number,deleted:tree.model.deleted as number}));
43
+ overlay.close!(); await opened;
44
+ return {text,trees,files:trees.flatMap(tree=>tree.files)};
45
+ };
46
+ return {pi,ctx,entries,notices,levels,commands,widgets,fire,panel,overlay,gitCalls:()=>gitCalls};
47
+ }
48
+
49
+ function tempRepo(t: { after(fn: () => void): void }, name = "gentle-changes-") {
50
+ const root = realpathSync(mkdtempSync(join(tmpdir(), name)));
51
+ t.after(() => rmSync(root, { recursive: true, force: true }));
52
+ const git = (...args: string[]) => execFileSync("git", ["-C", root, "-c", "user.name=test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", ...args], { encoding: "utf8" });
53
+ git("init", "-q", "--initial-branch=main");
54
+ writeFileSync(join(root, "file.ts"), "base\n");
55
+ git("add", "file.ts"); git("commit", "-q", "-m", "base");
56
+ return { root, git };
21
57
  }
22
- test("Gentle Shell startup never waits for a repository scan",async()=>{
23
- const f=fixture();
24
- await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked by Git inventory")),100))]);
25
- assert.equal(f.gitCalls(),0);
26
- await f.commands.get("gentle:changes").handler("",f.ctx);
27
- assert.match(f.notices.join("\n"),/captured.*agent|agent.*changes/i);
58
+ const sessionRoot = (root: string, evidence = "session:cwd", sessionId = "session") => ({ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId, root, evidence } });
59
+ const captured = (root: string, id: string, path = "file.ts", sessionId = "session") => ({ type: "custom", customType: SESSION_CHANGE_ENTRY,
60
+ data: { sessionId, evidence: { id, root, path, before: { kind: "text", text: "base\n" }, after: { kind: "text", text: "agent\n" } } } });
61
+ const settle = async () => { for (let i = 0; i < 40; i++) await new Promise(resolve => setTimeout(resolve, 5)); };
62
+ const real: Runner = (cwd) => shellGitRunner(cwd);
63
+
64
+ test("Gentle Shell startup never waits for a repository scan", async () => {
65
+ const f = fixture([sessionRoot("/repo"), captured("/repo", "a")]);
66
+ await Promise.race([f.fire("session_start"), new Promise((_, reject) => setTimeout(() => reject(new Error("startup blocked by Git inventory")), 100))]);
67
+ assert.equal(f.gitCalls(), 0, "session_start never runs Git");
68
+ assert.equal(f.widgets.has("gentle-shell-changes"), false, "the list fills only after a turn end or panel open");
28
69
  await f.fire("session_shutdown");
29
70
  });
30
- test("reload and new evidence refresh Changes without Git or live file reads",async()=>{
31
- const f=fixture([{type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id:"child:1",root:"/repo",path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}}]);
32
- await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked")),100))]);
33
- f.pi.events.emit(SESSION_CHANGE_EVENT,{sessionId:"session"});
34
- await new Promise(resolve=>setImmediate(resolve));
35
- assert.equal(f.gitCalls(),0);
36
- assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
71
+
72
+ test("the orchestrator repository's dirty file is listed, and leaves once committed", async (t) => {
73
+ const repo = tempRepo(t);
74
+ writeFileSync(join(repo.root, "file.ts"), "human or agent, Git decides\n");
75
+ writeFileSync(join(repo.root, "new.ts"), "one\ntwo\n");
76
+ const f = fixture([], { root: repo.root, gitRunner: real });
77
+ await f.fire("session_start");
78
+ assert.equal(f.gitCalls(), 0);
79
+ await f.fire("agent_end"); await settle();
80
+ assert.equal(f.widgets.has("gentle-shell-changes"), true, "uncaptured working-tree changes are listed");
81
+ const panel = await f.panel();
82
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.files, tree.added, tree.deleted]), [[repo.root, ["file.ts", "new.ts"], 3, 1]], "untracked files count their lines");
83
+ repo.git("add", "-A"); repo.git("commit", "-q", "-m", "work");
84
+ await f.fire("agent_end"); await settle();
85
+ assert.equal(f.widgets.has("gentle-shell-changes"), false, "committed work leaves the widget and the counter");
86
+ assert.equal(await f.panel(), undefined);
87
+ assert.match(f.notices.join("\n"), /No uncommitted changes/);
37
88
  await f.fire("session_shutdown");
38
89
  });
39
- test("the changes overlay labels each session tree with its root's branch instead of detached",async()=>{
40
- const evidence=(root:string,id:string)=>({type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id,root,path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}});
41
- const entries=[evidence("/repo","child:1"),evidence("/other","child:2")];
42
- const handlers=new Map<string,Function[]>();
43
- const commands=new Map<string,any>();
44
- const gitRoots:string[]=[];
45
- const pi:any={on:(key,fn)=>handlers.set(key,[...(handlers.get(key)??[]),fn]),events:{on:()=>()=>{},emit(){}},appendEntry(){},registerTool(){},registerShortcut(){},registerMessageRenderer(){},registerCommand:(key,registration)=>commands.set(key,registration)};
46
- let view:any; let renders=0;
47
- const ctx:any={hasUI:true,cwd:"/repo",sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
48
- ui:{setFooter(){},getEditorComponent:()=>({}),setWorkingVisible(){},setWidget(){},notify(){},
49
- custom:async(factory:any)=>{view=factory({terminal:{rows:40,columns:120},requestRender:()=>renders++},{fg:(_c:string,t:string)=>t,bold:(t:string)=>t},{},()=>{});return new Promise(()=>{});}}};
50
- shell(pi,{},{resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
51
- gitRunner:(root:string)=>async(args:string[])=>{gitRoots.push(root);await new Promise(r=>setImmediate(r));
52
- if(args[0]==="symbolic-ref")return root==="/repo"?{stdout:"feature\n",code:0}:{stdout:"",code:1};
53
- return {stdout:"deadbeef\n",code:0};}});
54
- for(const fn of handlers.get("session_start")??[])await fn({},ctx);
55
- void commands.get("gentle:changes").handler("",ctx);
56
- await new Promise(r=>setImmediate(r));
57
- assert.ok(view,"the overlay opened");
58
- for(let i=0;i<6;i++)await new Promise(r=>setImmediate(r));
59
- const rendered=view.render(120).join("\n");
60
- assert.match(rendered,/feature · repo/,"the repo tree carries its branch");
61
- assert.match(rendered,/detached · other/,"a genuinely detached root still reads detached");
62
- assert.deepEqual([...new Set(gitRoots)].sort(),["/other","/repo"],"git is asked once per root, only while the overlay is open");
63
- assert.ok(renders>0,"a resolved label asks the overlay to repaint");
64
- for(const fn of handlers.get("session_shutdown")??[])await fn({},ctx);
90
+
91
+ test("a subagent worktree is its own group and disappears once the worktree is removed", async (t) => {
92
+ const repo = tempRepo(t);
93
+ const linked = join(realpathSync(mkdtempSync(join(tmpdir(), "gentle-changes-wt-"))), "linked");
94
+ t.after(() => rmSync(join(linked, ".."), { recursive: true, force: true }));
95
+ repo.git("worktree", "add", "-q", "-b", "agent-branch", linked);
96
+ writeFileSync(join(repo.root, "file.ts"), "orchestrator\n");
97
+ writeFileSync(join(linked, "worker.ts"), "worker\n");
98
+ const f = fixture([sessionRoot(repo.root), sessionRoot(linked, "subagent:spawn")], { root: repo.root, gitRunner: real });
99
+ await f.fire("session_start");
100
+ const panel = await f.panel();
101
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.branch, tree.files]).sort(), [[linked, "agent-branch", ["worker.ts"]], [repo.root, "main", ["file.ts"]]].sort(), "one group per root, each with its own branch");
102
+ assert.match(panel!.text, /agent-branch · linked/);
103
+ repo.git("worktree", "remove", "--force", linked);
104
+ await f.fire("agent_end"); await settle();
105
+ const after = await f.panel();
106
+ assert.deepEqual(after!.trees.map(tree => tree.root), [repo.root], "a removed worktree leaves without error");
107
+ assert.doesNotMatch(f.notices.join("\n"), /Git status failed|Could not read/, "a removed worktree is not a failed scan");
108
+ await f.fire("session_shutdown");
109
+ });
110
+
111
+ test("captured evidence only names a subagent's independent repository; Git names its files", async (t) => {
112
+ const repo = tempRepo(t);
113
+ const foreign = tempRepo(t, "gentle-changes-foreign-");
114
+ writeFileSync(join(foreign.root, "real.ts"), "dirty\n");
115
+ // The captured path is clean in Git, so it is not listed.
116
+ const f = fixture([sessionRoot(repo.root), captured(foreign.root, "child:1", "file.ts")], { root: repo.root, gitRunner: real });
117
+ await f.fire("session_start");
118
+ const panel = await f.panel();
119
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.files]), [[foreign.root, ["real.ts"]]]);
120
+ await f.fire("session_shutdown");
121
+ });
122
+
123
+ test("reload and new sessions read the same state from Git, without stale entries", async (t) => {
124
+ const repo = tempRepo(t);
125
+ const linked = join(realpathSync(mkdtempSync(join(tmpdir(), "gentle-changes-wt-"))), "linked");
126
+ t.after(() => rmSync(join(linked, ".."), { recursive: true, force: true }));
127
+ repo.git("worktree", "add", "-q", linked);
128
+ writeFileSync(join(repo.root, "file.ts"), "agent\n");
129
+ writeFileSync(join(linked, "file.ts"), "worker\n");
130
+ const entries = [sessionRoot(repo.root), sessionRoot(linked, "subagent:spawn"), captured(repo.root, "a"), captured(repo.root, "b", "gone.ts")];
131
+ repo.git("commit", "-q", "-am", "orchestrator work");
132
+ const reloaded = fixture(entries, { root: repo.root, gitRunner: real });
133
+ await reloaded.fire("session_start");
134
+ assert.equal(reloaded.gitCalls(), 0);
135
+ const panel = await reloaded.panel();
136
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.files]), [[linked, ["file.ts"]]], "committed and captured-only files are not listed after reload");
137
+ await reloaded.fire("session_shutdown");
138
+ const next = fixture([...entries], { root: repo.root, sessionId: "new-session", gitRunner: real });
139
+ await next.fire("session_start");
140
+ assert.equal(await next.panel(), undefined, "another session's worktrees are not inherited");
141
+ assert.match(next.notices.join("\n"), /No uncommitted changes/);
142
+ await next.fire("session_shutdown");
143
+ });
144
+
145
+ test("a failing Git root is omitted while the other roots stay listed", async (t) => {
146
+ const repo = tempRepo(t);
147
+ const broken = tempRepo(t, "gentle-changes-broken-");
148
+ writeFileSync(join(repo.root, "file.ts"), "agent\n");
149
+ writeFileSync(join(broken.root, "file.ts"), "agent\n");
150
+ const failing: Runner = (cwd) => cwd === broken.root ? async () => { throw new Error("git timed out"); } : shellGitRunner(cwd);
151
+ const f = fixture([sessionRoot(repo.root), sessionRoot(broken.root, "explicit")], { root: repo.root, gitRunner: failing });
152
+ await f.fire("session_start");
153
+ await f.fire("agent_end"); await settle();
154
+ const panel = await f.panel();
155
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.files]), [[repo.root, ["file.ts"]]]);
156
+ assert.match(f.notices.join("\n"), /Git status failed for 1 root\b/, "the panel reports the failed root");
157
+ await f.fire("session_shutdown");
158
+ });
159
+
160
+ // A root whose `git status` exits nonzero is a failed scan, never a clean
161
+ // root: when it is the only root the panel must not claim a clean tree.
162
+ const statusFailsAt = (broken: string): Runner => (cwd) => cwd === broken ? async (args) => args[0] === "status" ? { stdout: "", code: 128 } : shellGitRunner(cwd)(args) : shellGitRunner(cwd);
163
+
164
+ test("when every scanned root fails, Changes reports an error instead of a clean tree", async (t) => {
165
+ const repo = tempRepo(t);
166
+ writeFileSync(join(repo.root, "file.ts"), "agent\n");
167
+ const f = fixture([sessionRoot(repo.root)], { root: repo.root, gitRunner: statusFailsAt(repo.root) });
168
+ await f.fire("session_start");
169
+ assert.equal(await f.panel(), undefined);
170
+ assert.doesNotMatch(f.notices.join("\n"), /No uncommitted changes/);
171
+ assert.match(f.notices.join("\n"), /Could not read Git status for 1 root\b/);
172
+ assert.equal(f.levels.at(-1), "error");
173
+ await f.fire("session_shutdown");
174
+ });
175
+
176
+ test("a clean root next to a failed one reports the failure count, not a plain clean tree", async (t) => {
177
+ const repo = tempRepo(t);
178
+ const broken = tempRepo(t, "gentle-changes-broken-");
179
+ const f = fixture([sessionRoot(repo.root), sessionRoot(broken.root, "explicit")], { root: repo.root, gitRunner: statusFailsAt(broken.root) });
180
+ await f.fire("session_start");
181
+ assert.equal(await f.panel(), undefined);
182
+ assert.match(f.notices.join("\n"), /Git status failed for 1 of 2 roots/);
183
+ assert.equal(f.levels.at(-1), "warning");
184
+ await f.fire("session_shutdown");
185
+ });
186
+
187
+ test("a dirty root next to a failed one opens the panel and reports the failure count", async (t) => {
188
+ const repo = tempRepo(t);
189
+ const broken = tempRepo(t, "gentle-changes-broken-");
190
+ writeFileSync(join(repo.root, "file.ts"), "agent\n");
191
+ writeFileSync(join(broken.root, "file.ts"), "agent\n");
192
+ const f = fixture([sessionRoot(repo.root), sessionRoot(broken.root, "explicit")], { root: repo.root, gitRunner: statusFailsAt(broken.root) });
193
+ await f.fire("session_start");
194
+ const panel = await f.panel();
195
+ assert.deepEqual(panel!.trees.map(tree => [tree.root, tree.files]), [[repo.root, ["file.ts"]]]);
196
+ assert.match(f.notices.join("\n"), /Git status failed for 1 root\b/);
197
+ await f.fire("session_shutdown");
198
+ });
199
+
200
+ test("every changed file is listed without a capture limit", async (t) => {
201
+ const repo = tempRepo(t);
202
+ mkdirSync(join(repo.root, "many"));
203
+ for (let i = 0; i < 300; i++) writeFileSync(join(repo.root, "many", `${i}.ts`), "x\n");
204
+ const f = fixture([], { root: repo.root, gitRunner: real });
205
+ await f.fire("session_start");
206
+ const panel = await f.panel();
207
+ assert.equal(panel!.files.length, 300);
208
+ assert.equal(panel!.trees[0].added, 300);
209
+ assert.equal(f.widgets.has("gentle-shell-changes"), true);
210
+ assert.doesNotMatch(panel!.text, /limit/);
211
+ assert.doesNotMatch(f.notices.join("\n"), /limit/);
212
+ await f.fire("session_shutdown");
213
+ });
214
+
215
+ test("the open panel diffs the selected file against HEAD and reloads a same-count edit", async (t) => {
216
+ const repo = tempRepo(t);
217
+ writeFileSync(join(repo.root, "file.ts"), "first\n");
218
+ writeFileSync(join(repo.root, "new.ts"), "hello\n");
219
+ const f = fixture([], { root: repo.root, gitRunner: real, pollMs: 20 });
220
+ await f.fire("session_start");
221
+ const opened = f.commands.get("gentle:changes").handler("", f.ctx);
222
+ for (let i = 0; i < 200 && !f.overlay.view; i++) await new Promise(resolve => setTimeout(resolve, 5));
223
+ const view = f.overlay.view;
224
+ const shows = async (pattern: RegExp) => {
225
+ for (let i = 0; i < 100 && !pattern.test(view.render(200).join("\n")); i++) await new Promise(resolve => setTimeout(resolve, 10));
226
+ return view.render(200).join("\n");
227
+ };
228
+ try {
229
+ view.handleInput("\r");
230
+ view.handleInput("j");
231
+ assert.match(await shows(/\+first/), /-base/, "tracked files diff against HEAD");
232
+ view.handleInput("j");
233
+ assert.match(await shows(/\+hello/), /\+hello/, "untracked files show their full content");
234
+ view.handleInput("k");
235
+ await new Promise(resolve => setTimeout(resolve, 20));
236
+ writeFileSync(join(repo.root, "file.ts"), "secnd\n");
237
+ assert.match(await shows(/\+secnd/), /\+secnd/, "the poll reloads a same-count edit");
238
+ } finally { f.overlay.close!(); await opened; await f.fire("session_shutdown"); }
239
+ });
240
+
241
+ // A repository rooted at HOME (or a broad user folder) makes `git status` scan
242
+ // the user's whole tree: Changes never scans such a root, at turn end or in
243
+ // the panel, matching the startup warning that tells the user to move.
244
+ test("a repository rooted at HOME is never scanned by Changes", async (t) => {
245
+ const repo = tempRepo(t, "gentle-changes-home-");
246
+ writeFileSync(join(repo.root, "file.ts"), "dirty at home\n");
247
+ const previousHome = process.env.HOME;
248
+ process.env.HOME = repo.root;
249
+ t.after(() => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; });
250
+ const f = fixture([sessionRoot(repo.root)], { root: repo.root, gitRunner: real });
251
+ await f.fire("session_start");
252
+ await f.fire("agent_end"); await settle();
253
+ assert.equal(f.gitCalls(), 0, "no Git runs against a home-rooted repository");
254
+ assert.equal(f.widgets.has("gentle-shell-changes"), false);
255
+ assert.equal(await f.panel(), undefined);
256
+ assert.equal(f.gitCalls(), 0);
257
+ assert.doesNotMatch(f.notices.join("\n"), /Git status failed|Could not read/, "a filtered home root is not a failed scan");
258
+ await f.fire("session_shutdown");
65
259
  });
@@ -3,15 +3,16 @@ import test from "node:test";
3
3
  import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { SessionChanges, readChangeSnapshot, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
6
+ import { SessionChanges, readChangeSnapshot, sessionChangeRoots, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
7
+ import { SESSION_WORKTREE_ENTRY } from "../lib/session-worktree-registry.ts";
7
8
 
8
9
  const text = (text: string) => ({ kind: "text" as const, text });
9
10
  const absent = { kind: "absent" as const };
10
11
  const evidence = (id = "a", root = "/repo", path = "file.ts", before = text("old\n"), after = text("new\n")) => ({ id, root, path, before, after });
11
12
 
12
- test("session changes starts empty without reading the worktree or its untracked contents", async () => {
13
+ test("session changes starts empty without reading the worktree or its untracked contents", () => {
13
14
  const changes = new SessionChanges("session");
14
- assert.deepEqual(await changes.refresh(), { files: [], added: 0, deleted: 0 });
15
+ assert.deepEqual(changes.model, { files: [], added: 0, deleted: 0 });
15
16
  assert.deepEqual(changes.worktrees, []);
16
17
  });
17
18
 
@@ -20,9 +21,7 @@ test("only attributed changes appear, against the pre-tool content rather than H
20
21
  changes.record(evidence("a", "/repo", "file.ts", text("preexisting human edit\nold\n"), text("preexisting human edit\nnew\n")));
21
22
  assert.equal(changes.model.files.length, 1);
22
23
  assert.equal(changes.model.added, 1);
23
- assert.equal(changes.model.deleted, 1);
24
- assert.match(changes.loadDiff("/repo", changes.model.files[0]), /\+new/);
25
- assert.doesNotMatch(changes.loadDiff("/repo", changes.model.files[0]), /\+preexisting/);
24
+ assert.equal(changes.model.deleted, 1, "the preexisting line is not counted");
26
25
  });
27
26
 
28
27
  test("diff counts include content lines that resemble patch headers", () => {
@@ -37,7 +36,7 @@ test("contiguous own edits combine and own reverts remove the file and worktree"
37
36
  changes.record(evidence());
38
37
  changes.record(evidence("b", "/repo", "file.ts", text("new\n"), text("final\n")));
39
38
  assert.equal(changes.model.added, 1);
40
- assert.match(changes.loadDiff("/repo", changes.model.files[0]), /\+final/);
39
+ assert.equal(changes.model.deleted, 1);
41
40
  changes.record(evidence("c", "/repo", "file.ts", text("final\n"), text("old\n")));
42
41
  assert.deepEqual(changes.worktrees, []);
43
42
  });
@@ -48,7 +47,7 @@ test("external edits between agent operations are not folded into an attributed
48
47
  changes.record(evidence("b", "/repo", "file.ts", text("external\n"), text("agent again\n")));
49
48
  const file = changes.model.files[0];
50
49
  assert.match(file.countsUnavailable!, /external|continuity/i);
51
- assert.doesNotMatch(changes.loadDiff("/repo", file), /\+external|\-external/);
50
+ assert.equal(file.added, 0);
52
51
  });
53
52
 
54
53
  test("new files and child worktrees are admitted only by positive mutation evidence", () => {
@@ -85,30 +84,24 @@ test("bounded captures mark partial counts, including the narrow widget", async
85
84
  assert.match(renderChangesWidget(changes.model,{fg:(_color,text)=>text},75).join("\n"),/partial counts/);
86
85
  });
87
86
 
88
- test("same-count edits change preview revisions and malformed evidence is rejected", () => {
87
+ test("malformed evidence is rejected", () => {
89
88
  const changes = new SessionChanges("session");
90
- changes.record(evidence());
91
- const first = changes.model.files[0].diffRevision;
92
- changes.record(evidence("b","/repo","file.ts",text("new\n"),text("other\n")));
93
- assert.notEqual(changes.model.files[0].diffRevision,first);
94
89
  assert.equal(changes.record({...evidence("bad"),path:"../escape"}),false);
95
90
  assert.equal(changes.record({...evidence("bad"),path:".git/config"}),false);
96
91
  });
97
92
 
98
- test("capture capacity is explicit instead of growing without limit", () => {
93
+ test("capture capacity bounds recorded evidence", () => {
99
94
  const changes = new SessionChanges("session");
100
95
  for(let i=0;i<257;i++) changes.record(evidence(String(i),"/repo",String(i)+".ts"));
101
96
  assert.equal(changes.model.files.length,256);
102
- assert.match(changes.notice!,/limit reached/);
97
+ assert.equal(changes.record(evidence("late","/repo","late.ts")),false);
103
98
  });
104
99
 
105
- test("capture-limit notice is delivered once but stays on the model", () => {
106
- const changes = new SessionChanges("session");
107
- for(let i=0;i<257;i++) changes.record(evidence(String(i),"/repo",String(i)+".ts"));
108
- assert.match(changes.takeNotice()!,/limit reached/);
109
- assert.equal(changes.takeNotice(),undefined);
110
- assert.match(changes.notice!,/limit reached/);
111
- assert.match(changes.model.notice!,/limit reached/);
112
- assert.equal(new SessionChanges("s").takeNotice(),undefined);
113
- assert.equal(new SessionChanges("s").model.notice,undefined);
100
+ test("session roots come from this session's worktree registrations and captured evidence, never files", () => {
101
+ const registered = (sessionId: string, root: string) => ({ type: "custom", customType: SESSION_WORKTREE_ENTRY, data: { sessionId, root, evidence: "subagent:spawn" } });
102
+ const captured = (sessionId: string, root: string) => ({ type: "custom", customType: SESSION_CHANGE_ENTRY, data: { sessionId, evidence: evidence(root, root) } });
103
+ const entries = [registered("session", "/repo"), registered("session", "/linked"), captured("session", "/foreign"), captured("session", "/repo"),
104
+ registered("other", "/elsewhere"), captured("other", "/theirs"), registered("session", "relative"), { type: "custom", customType: SESSION_CHANGE_ENTRY, data: { sessionId: "session", evidence: { root: "/bad" } } }];
105
+ assert.deepEqual(sessionChangeRoots("session", entries), ["/repo", "/linked", "/foreign"]);
106
+ assert.deepEqual(sessionChangeRoots("missing", entries), []);
114
107
  });