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
@@ -27,7 +27,6 @@ export interface ChangesModel {
27
27
  files: ChangedFile[];
28
28
  added: number;
29
29
  deleted: number;
30
- notice?: string;
31
30
  }
32
31
 
33
32
  export interface ChangesSnapshot {
@@ -46,6 +45,11 @@ interface NumstatEntry {
46
45
  }
47
46
 
48
47
  export const CHANGES_COMMAND = "/gentle:changes";
48
+ // One selected file's diff is the only bounded thing in Changes: past either
49
+ // limit the panel shows DIFF_TOO_LARGE instead of buffering and coloring it.
50
+ export const DIFF_MAX_BYTES = 1024 * 1024;
51
+ export const DIFF_MAX_LINES = 20_000;
52
+ export const DIFF_TOO_LARGE = "Diff too large — press o to open in editor.";
49
53
  const WIDGET_GLYPH = "✎";
50
54
  const STATUS_BY_CODE: Record<string, ChangeStatus> = {
51
55
  A: CHANGE_STATUS.ADDED,
@@ -106,6 +110,8 @@ export function snapshotChanges(snapshot: ChangesSnapshot): ChangedFile[] {
106
110
  const counts = new Map(parseNumstat(snapshot.numstat).map((entry) => [entry.path, entry]));
107
111
  const files: ChangedFile[] = [];
108
112
  for (const [path, status] of parsePorcelain(snapshot.porcelain)) {
113
+ // Dependency backups are local artifacts; tracked files remain visible.
114
+ if (status === CHANGE_STATUS.UNTRACKED && /(?:^|\/)node_modules\.backup-[^/]+\//.test(path)) continue;
109
115
  const entry = counts.get(path);
110
116
  files.push({ path, added: entry?.added ?? 0, deleted: entry?.deleted ?? 0, status });
111
117
  }
@@ -123,7 +129,7 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
123
129
 
124
130
  export function changesSummary(model: ChangesModel): string {
125
131
  const noun = model.files.length === 1 ? "file" : "files";
126
- return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? " · capture limit reached" : ""}`;
132
+ return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
127
133
  }
128
134
 
129
135
  // One line: summary, the files joined by dots, and the command pushed to
@@ -132,7 +138,7 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
132
138
  if (model.files.length === 0) return [];
133
139
  const noun = model.files.length === 1 ? "file" : "files";
134
140
  const dot = theme.fg("muted", "·");
135
- const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? ` ${dot} ${theme.fg("warning", "capture limit reached")}` : ""}`;
141
+ const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
136
142
  const hint = theme.fg("dim", CHANGES_COMMAND);
137
143
  const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
138
144
  const left = `${head} ${dot} ${list}`;
@@ -146,10 +152,14 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
146
152
  export interface GitResult {
147
153
  stdout: string;
148
154
  code: number;
155
+ /** The runner stopped reading at its output bound; stdout is partial. */
156
+ truncated?: boolean;
149
157
  }
150
158
 
151
159
  export type GitRunner = (args: string[]) => Promise<GitResult>;
152
160
  export type LineCounter = (path: string) => Promise<number>;
161
+ /** A cheap content identity (e.g. size and mtime) so same-count edits refresh their diff. */
162
+ export type FileRevision = (path: string) => Promise<string | undefined>;
153
163
 
154
164
  export interface WorktreeChanges {
155
165
  root: string;
@@ -189,76 +199,41 @@ export async function foreignRootBranch(git: GitRunner): Promise<string | undefi
189
199
  return verified.code === 0 ? branch : "no commits yet";
190
200
  }
191
201
 
192
- // The session-evidence tracker behind /gentle:changes records roots, never
193
- // branches, so the overlay would label every tree "detached". This resolves
194
- // each root's own HEAD state once (branch, "no commits yet", undefined for a
195
- // real detached HEAD, or UNKNOWN_BRANCH when the root could not even be
196
- // asked), decorates the trees from the cache, and tells the caller when a
197
- // fresh answer arrived so the view can repaint. Git is touched only while the
198
- // overlay is open, never at startup or on evidence refresh.
199
- export class RootBranchLabels {
200
- private readonly labels = new Map<string, string | undefined>();
201
- private readonly pending = new Map<string, Promise<void>>();
202
- private readonly gitForRoot: (root: string) => GitRunner;
203
- private readonly onChange: () => void;
204
- constructor(gitForRoot: (root: string) => GitRunner, onChange: () => void = () => {}) {
205
- this.gitForRoot = gitForRoot;
206
- this.onChange = onChange;
207
- }
208
-
209
- decorate(trees: readonly WorktreeChanges[]): WorktreeChanges[] {
210
- return trees.map((tree) => {
211
- if (tree.branch !== undefined) return tree;
212
- if (!this.labels.has(tree.root)) this.resolve(tree.root);
213
- const branch = this.labels.get(tree.root);
214
- return branch === undefined ? tree : { ...tree, branch };
215
- });
216
- }
217
-
218
- /** Every in-flight resolution has finished. */
219
- async settled(): Promise<void> {
220
- while (this.pending.size) await Promise.all(this.pending.values());
221
- }
222
-
223
- private resolve(root: string): void {
224
- if (this.pending.has(root)) return;
225
- // A rejection here means the root could not even be asked -- distinct
226
- // from foreignRootBranch resolving to undefined, which means git
227
- // answered and there really is no branch (a real detached HEAD).
228
- const task = foreignRootBranch(this.gitForRoot(root))
229
- .then(
230
- (branch) => branch,
231
- () => UNKNOWN_BRANCH,
232
- )
233
- .then((branch) => {
234
- this.labels.set(root, branch);
235
- this.pending.delete(root);
236
- this.onChange();
237
- });
238
- this.pending.set(root, task);
239
- }
240
- }
241
-
202
+ // /gentle:changes: one group per registered root holding its working tree
203
+ // against HEAD. A root whose Git fails (timed out, rejected) has no group but
204
+ // is listed in failedRoots: a failed scan is never reported as a clean root.
242
205
  export class WorktreeChangesTracker {
243
206
  worktrees: WorktreeChanges[] = [];
207
+ failedRoots: string[] = [];
208
+ scannedRootCount = 0;
244
209
  private inFlight: Promise<ChangesModel> | undefined;
245
210
  private readonly discover: GitRunner;
246
211
  private readonly gitForRoot: (root: string) => GitRunner;
247
212
  private readonly linesForRoot: (root: string) => LineCounter;
248
213
  private readonly registeredRoots: () => readonly string[];
214
+ private readonly revisionForRoot: (root: string) => FileRevision | undefined;
249
215
 
250
- constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => []) {
216
+ constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => [], revisionForRoot: (root: string) => FileRevision | undefined = () => undefined) {
251
217
  this.discover = discover;
252
218
  this.gitForRoot = gitForRoot;
253
219
  this.linesForRoot = linesForRoot;
254
220
  this.registeredRoots = registeredRoots;
221
+ this.revisionForRoot = revisionForRoot;
255
222
  }
256
223
 
224
+ private modelOf: WorktreeChanges[] | undefined;
225
+ private combined: ChangesModel = emptyChanges();
226
+
227
+ // Footers read this every frame; rebuild only when a scan replaced the trees.
257
228
  get model(): ChangesModel {
258
- return changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
259
- ...file,
260
- path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
261
- }))));
229
+ if (this.modelOf !== this.worktrees) {
230
+ this.modelOf = this.worktrees;
231
+ this.combined = changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
232
+ ...file,
233
+ path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
234
+ }))));
235
+ }
236
+ return this.combined;
262
237
  }
263
238
 
264
239
  async start(): Promise<void> {
@@ -278,17 +253,29 @@ export class WorktreeChangesTracker {
278
253
  }
279
254
 
280
255
  private async capture(): Promise<ChangesModel> {
281
- const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
282
- const trees: WorktreeChanges[] = [];
283
- const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
284
256
  // Discovery labels registered roots; it never grants visibility to siblings.
285
257
  const roots = new Set(this.registeredRoots());
286
- for (const root of roots) {
258
+ // No root to scan means no Git at all, not even branch discovery.
259
+ if (roots.size === 0) {
260
+ this.worktrees = [];
261
+ this.failedRoots = [];
262
+ this.scannedRootCount = 0;
263
+ return this.model;
264
+ }
265
+ const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
266
+ const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
267
+ // Roots scan in parallel so one slow root does not delay the others.
268
+ const failed: string[] = [];
269
+ const scanned = await Promise.all([...roots].map(async (root): Promise<WorktreeChanges | undefined> => {
287
270
  const known = metadata.get(root);
288
271
  try {
289
- const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root));
272
+ const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root), this.revisionForRoot(root));
290
273
  await tracker.start();
291
- if (!tracker.model.files.length) continue;
274
+ if (!tracker.statusRead) {
275
+ failed.push(root);
276
+ return undefined;
277
+ }
278
+ if (!tracker.model.files.length) return undefined;
292
279
  // A root missing from this discovery scan belongs to a different Git
293
280
  // clone entirely (e.g. an inner repository nested inside an outer
294
281
  // one) -- worktree list can never describe a foreign clone, so ask
@@ -299,22 +286,40 @@ export class WorktreeChangesTracker {
299
286
  // change scan above failing -- that must label the root
300
287
  // UNKNOWN_BRANCH, not drop real, already-captured changes.
301
288
  const branch = known ? known.branch : await foreignRootBranch(this.gitForRoot(root)).catch(() => UNKNOWN_BRANCH);
302
- trees.push({ root, ...(branch ? { branch } : {}), model: tracker.model });
289
+ return { root, ...(branch ? { branch } : {}), model: tracker.model };
303
290
  } catch {
304
- // Linked roots can disappear between discovery and status.
291
+ // A root the caller stops registering mid-scan (e.g. a removed
292
+ // worktree) triggers the rescan below without it, not a failure.
293
+ failed.push(root);
294
+ return undefined;
305
295
  }
306
- }
296
+ }));
297
+ const trees = scanned.filter((tree): tree is WorktreeChanges => tree !== undefined);
307
298
  const latest = new Set(this.registeredRoots());
308
299
  // A root admitted during status must not wait for a later poll (which may
309
300
  // be disabled). Ordinary overlapping polls still share exactly one scan.
310
301
  if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
311
302
  this.worktrees = trees;
303
+ this.failedRoots = failed;
304
+ this.scannedRootCount = roots.size;
312
305
  return this.model;
313
306
  }
314
307
  }
315
308
 
316
309
  const noLines: LineCounter = async () => 0;
317
310
 
311
+ // Per-file line counts and revision lookups are file IO: a large change set
312
+ // runs this many files at a time instead of one by one or all at once.
313
+ export const FILE_WORK_CONCURRENCY = 8;
314
+
315
+ async function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
316
+ let next = 0;
317
+ const worker = async () => {
318
+ while (next < items.length) await work(items[next++]);
319
+ };
320
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
321
+ }
322
+
318
323
  const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
319
324
  const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
320
325
 
@@ -324,20 +329,27 @@ const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z
324
329
  export class ChangesTracker {
325
330
  private readonly git: GitRunner;
326
331
  private readonly countLines: LineCounter;
332
+ private readonly revision: FileRevision | undefined;
327
333
  private current: ChangesModel = emptyChanges();
328
334
  private available = false;
329
335
  private inFlight: Promise<ChangesModel> | undefined;
330
336
  private queued = false;
331
337
 
332
- constructor(git: GitRunner, countLines: LineCounter = noLines) {
338
+ constructor(git: GitRunner, countLines: LineCounter = noLines, revision?: FileRevision) {
333
339
  this.git = git;
334
340
  this.countLines = countLines;
341
+ this.revision = revision;
335
342
  }
336
343
 
337
344
  get model(): ChangesModel {
338
345
  return this.current;
339
346
  }
340
347
 
348
+ /** False when the last start could not read `git status`. */
349
+ get statusRead(): boolean {
350
+ return this.available;
351
+ }
352
+
341
353
  async start(): Promise<void> {
342
354
  const files = await this.capture();
343
355
  this.available = files !== undefined;
@@ -372,9 +384,14 @@ export class ChangesTracker {
372
384
  if (porcelain.code !== 0) return undefined;
373
385
  const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
374
386
  // Untracked files never appear in numstat; count their lines directly.
375
- for (const file of files) {
376
- if (file.status === CHANGE_STATUS.UNTRACKED) file.added = await this.countLines(file.path).catch(() => 0);
377
- }
387
+ await eachLimited(files, FILE_WORK_CONCURRENCY, async (file) => {
388
+ const [added, revision] = await Promise.all([
389
+ file.status === CHANGE_STATUS.UNTRACKED ? this.countLines(file.path).catch(() => 0) : undefined,
390
+ this.revision?.(file.path).catch(() => undefined),
391
+ ]);
392
+ if (added !== undefined) file.added = added;
393
+ if (revision) file.diffRevision = revision;
394
+ });
378
395
  return files;
379
396
  }
380
397
  }
@@ -74,7 +74,9 @@ export const DOUBLE_ESC_CANCEL_HINT = "esc again to cancel";
74
74
  export const IDLE_ESC_CLEAR_HINT = "esc again to clear";
75
75
  const LABEL_ROLE = "muted";
76
76
  const HINT_ROLE = "dim";
77
- const FAKE_CURSOR = "\x1b[7m \x1b[0m";
77
+ // Audited legacy SGR and Pi 1.2 APC cursors. Do not resolve the latter
78
+ // here: the host TUI owns hardware-cursor suppression after composition.
79
+ const EMPTY_FAKE_CURSORS = ["\x1b[7m \x1b[0m", "\x1b_pi:fc\x07 \x1b_pi:/fc\x07"];
78
80
  const SCROLL_INDICATOR = /[↑↓] \d+ more/;
79
81
  const STATE_LABEL: Record<PromptState, string | undefined> = {
80
82
  [PROMPT_STATE.IDLE]: undefined,
@@ -228,9 +230,9 @@ export function framePromptLines(lines: string[], width: number, options: Prompt
228
230
  }
229
231
 
230
232
  export function withPromptHint(line: string, hint: string, fg: PromptFrameOptions["fg"]): string {
231
- const cursorAt = line.indexOf(FAKE_CURSOR);
232
- if (cursorAt === -1) return line;
233
- const afterCursor = cursorAt + FAKE_CURSOR.length;
233
+ const cursor = EMPTY_FAKE_CURSORS.find((marker) => line.includes(marker));
234
+ if (!cursor) return line;
235
+ const afterCursor = line.indexOf(cursor) + cursor.length;
234
236
  const trailing = line.slice(afterCursor);
235
237
  if (trailing.trim() !== "" || trailing.length < hint.length + 1) return line;
236
238
  return `${line.slice(0, afterCursor)} ${fg(HINT_ROLE, hint)}${" ".repeat(trailing.length - hint.length - 1)}`;
@@ -179,6 +179,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
179
179
  };
180
180
  scroll.handleMouse = (event) => {
181
181
  if (event.type === "wheel") {
182
+ // A bounded section (Todo) owns its inner viewport. Only wheel
183
+ // events it declines should move the surrounding rail.
184
+ const sectionResult = dispatchPartMouse(event);
185
+ if (sectionResult?.handled) return sectionResult;
182
186
  // Consume even at the boundary or over blank rail space: Pi 0.85.1
183
187
  // can send unconsumed delta to the primary transcript despite containment.
184
188
  scroll.scrollBy(event.wheelDelta ?? 0);
@@ -375,7 +379,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
375
379
  stopped = true;
376
380
  state.active = false;
377
381
  clearInterval(timer);
378
- scroll.hideTransientScrollbar();
382
+ scroll.setScrollbar("hidden");
379
383
  for (const cleanup of cleanups.reverse()) cleanup();
380
384
  tui.requestRender();
381
385
  };
package/lib/shell-todo.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
2
  import { CARD_TONE, panelHeaderRow, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts";
3
3
  import { sanitizeTerminalText } from "./terminal-theme.ts";
4
4
  import { paintHoverable } from "./shell-hover.ts";
@@ -11,7 +11,11 @@ import { paintHoverable } from "./shell-hover.ts";
11
11
  export const TODO_STATUS = {
12
12
  PENDING: "pending",
13
13
  IN_PROGRESS: "in_progress",
14
+ /** Open, but waiting on something outside the list; always carries a note naming it. */
15
+ BLOCKED: "blocked",
14
16
  DONE: "done",
17
+ /** Will not be done, on purpose: terminal, and never counts as open. */
18
+ DROPPED: "dropped",
15
19
  } as const;
16
20
 
17
21
  export type TodoStatus = (typeof TODO_STATUS)[keyof typeof TODO_STATUS];
@@ -86,10 +90,20 @@ export interface TodoRenderOptions {
86
90
  export const TODO_DETAILS_KEY = "gentleTodo";
87
91
  export const TODO_TOOL_NAME = "todo";
88
92
  export const TODO_GLYPH = "❀";
89
- const STATUS_ALIASES: Record<string, TodoStatus> = { completed: TODO_STATUS.DONE, complete: TODO_STATUS.DONE, doing: TODO_STATUS.IN_PROGRESS, todo: TODO_STATUS.PENDING };
90
- const STATUS_GLYPH: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.DONE]: "✓" };
91
- const STATUS_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "dim" };
92
- const GLYPH_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.DONE]: "success" };
93
+ const STATUS_ALIASES: Record<string, TodoStatus> = {
94
+ completed: TODO_STATUS.DONE,
95
+ complete: TODO_STATUS.DONE,
96
+ doing: TODO_STATUS.IN_PROGRESS,
97
+ todo: TODO_STATUS.PENDING,
98
+ cancelled: TODO_STATUS.DROPPED,
99
+ canceled: TODO_STATUS.DROPPED,
100
+ abandoned: TODO_STATUS.DROPPED,
101
+ };
102
+ const STATUS_LIST = "pending, in_progress, blocked, done, or dropped";
103
+ const BLOCKED_NEEDS_NOTE = "blocked needs a note naming what it waits for";
104
+ const STATUS_GLYPH: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "○", [TODO_STATUS.IN_PROGRESS]: "◐", [TODO_STATUS.BLOCKED]: "⊘", [TODO_STATUS.DONE]: "✓", [TODO_STATUS.DROPPED]: "✕" };
105
+ const STATUS_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "text", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "text", [TODO_STATUS.DONE]: "dim", [TODO_STATUS.DROPPED]: "dim" };
106
+ const GLYPH_ROLE: Record<TodoStatus, string> = { [TODO_STATUS.PENDING]: "muted", [TODO_STATUS.IN_PROGRESS]: "accent", [TODO_STATUS.BLOCKED]: "warning", [TODO_STATUS.DONE]: "success", [TODO_STATUS.DROPPED]: "muted" };
93
107
  const NOTE_ROLE = "muted";
94
108
  const STALE_AFTER_TURNS = 2;
95
109
  /** Above this many rows the finished tasks fold into one line and the rest is capped. */
@@ -111,20 +125,30 @@ function parseStatus(value: unknown): TodoStatus | undefined {
111
125
  return STATUS_ALIASES[normalized];
112
126
  }
113
127
 
128
+ function countStatus(state: TodoState, status: TodoStatus): number {
129
+ return state.tasks.filter((task) => task.status === status).length;
130
+ }
131
+
132
+ /** Dropped tasks are neither done nor open. */
114
133
  export function todoSummary(state: TodoState): TodoSummary {
115
- const done = state.tasks.filter((task) => task.status === TODO_STATUS.DONE).length;
116
- return { done, total: state.tasks.length, open: state.tasks.length - done };
134
+ const done = countStatus(state, TODO_STATUS.DONE);
135
+ return { done, total: state.tasks.length, open: state.tasks.length - done - countStatus(state, TODO_STATUS.DROPPED) };
117
136
  }
118
137
 
138
+ // Only work the model can pick up goes stale: a list whose open tasks all
139
+ // wait on something outside it has nothing to bring up to date.
119
140
  export function staleTurns(state: TodoState, currentTurn: number): number {
120
- if (state.updatedTurn === null || todoSummary(state).open === 0) return 0;
141
+ const actionable = state.tasks.some((task) => task.status === TODO_STATUS.PENDING || task.status === TODO_STATUS.IN_PROGRESS);
142
+ if (state.updatedTurn === null || !actionable) return 0;
121
143
  return Math.max(0, currentTurn - state.updatedTurn);
122
144
  }
123
145
 
124
146
  function summaryText(state: TodoState): string {
125
147
  const { done, total } = todoSummary(state);
126
- const inProgress = state.tasks.filter((task) => task.status === TODO_STATUS.IN_PROGRESS).length;
127
- return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress`;
148
+ const inProgress = countStatus(state, TODO_STATUS.IN_PROGRESS);
149
+ const blocked = countStatus(state, TODO_STATUS.BLOCKED);
150
+ const dropped = countStatus(state, TODO_STATUS.DROPPED);
151
+ return `${total} ${total === 1 ? "task" : "tasks"} · ${done} done · ${inProgress} in progress${blocked ? ` · ${blocked} blocked` : ""}${dropped ? ` · ${dropped} dropped` : ""}`;
128
152
  }
129
153
 
130
154
  function taskLine(task: TodoTask): string {
@@ -135,8 +159,9 @@ function buildTask(input: TodoTaskInput, id: number): TodoTask | string {
135
159
  const title = cleanText(input.title);
136
160
  if (title.length === 0) return "title is required";
137
161
  const status = parseStatus(input.status);
138
- if (status === undefined) return `unknown status "${input.status}" (use pending, in_progress, or done)`;
162
+ if (status === undefined) return `unknown status "${input.status}" (use ${STATUS_LIST})`;
139
163
  const note = cleanText(input.note);
164
+ if (status === TODO_STATUS.BLOCKED && note.length === 0) return BLOCKED_NEEDS_NOTE;
140
165
  return { id, title, status, ...(note ? { note } : {}) };
141
166
  }
142
167
 
@@ -233,15 +258,28 @@ export function replayTodo(entries: readonly unknown[]): TodoState {
233
258
  return state;
234
259
  }
235
260
 
236
- export function todoPromptBlock(state: TodoState, stale: number): string | undefined {
261
+ // gentle-shell#1367, #1662: the system prompt is a cached prefix for every
262
+ // provider, so the block it carries is byte-stable for the whole session:
263
+ // rules only, no task content and no counters. The current list travels as a
264
+ // request-local message at the end of the conversation (todoStateMessage),
265
+ // where a change costs only the tail of the request, never the prefix.
266
+ const TODO_PROMPT_BLOCK = [
267
+ "## Todo list",
268
+ "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
269
+ "A blocked task waits on something outside the list: do not work on it until its condition holds. When the plan changes, mark the tasks it made obsolete dropped instead of leaving them pending or marking them done.",
270
+ "While tasks stay open, the current list arrives as the last message of each request; it is not repeated here.",
271
+ ].join("\n");
272
+
273
+ /** The byte-stable rules block appended to every system prompt. */
274
+ export function todoPromptBlock(): string {
275
+ return TODO_PROMPT_BLOCK;
276
+ }
277
+
278
+ /** The open-task snapshot delivered as a request-local message; undefined when nothing is open. */
279
+ export function todoStateMessage(state: TodoState): string | undefined {
237
280
  if (todoSummary(state).open === 0) return undefined;
238
281
  const lines = state.tasks.map((task, index) => `${index + 1}. [${task.status}] ${task.title}${task.note ? ` — ${task.note}` : ""}`);
239
- const staleLine = stale >= STALE_AFTER_TURNS ? `\n(stale: ${stale} turns without an update — bring the list up to date now)` : "";
240
- return [
241
- "## Todo list",
242
- "Keep it current with the `todo` tool: mark a task in_progress before starting it, done right after finishing it, and rewrite the whole list with `write` whenever the plan changes. Update it before you end the turn.",
243
- ...lines,
244
- ].join("\n") + staleLine;
282
+ return ["Current todo list:", ...lines].join("\n");
245
283
  }
246
284
 
247
285
  function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
@@ -253,18 +291,22 @@ function taskRow(task: TodoTask, theme: TodoTheme, inner: number): string {
253
291
  .join("\n");
254
292
  }
255
293
  const title = task.title;
256
- const note = task.status === TODO_STATUS.IN_PROGRESS && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : "";
294
+ const showsNote = task.status === TODO_STATUS.IN_PROGRESS || task.status === TODO_STATUS.BLOCKED;
295
+ const note = showsNote && task.note ? ` ${theme.fg(NOTE_ROLE, "·")} ${theme.fg(NOTE_ROLE, task.note)}` : "";
257
296
  return `${theme.fg(GLYPH_ROLE[task.status], STATUS_GLYPH[task.status])} ${theme.fg(STATUS_ROLE[task.status], title)}${note}`;
258
297
  }
259
298
 
260
- // Long lists keep the card short: done tasks fold into "✓ N done" and the
261
- // open ones fill the remaining rows, with a trailing count for the rest.
299
+ // Long lists keep the card short: done and dropped tasks fold into "✓ N done"
300
+ // and "✕ N dropped", and the open ones fill the remaining rows, with a
301
+ // trailing count for the rest.
262
302
  function bodyRows(state: TodoState, theme: TodoTheme, inner: number): string[] {
263
303
  if (state.tasks.length <= ROW_CAP) return state.tasks.map((task) => taskRow(task, theme, inner));
264
- const { done } = todoSummary(state);
265
- const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE);
304
+ const open = state.tasks.filter((task) => task.status !== TODO_STATUS.DONE && task.status !== TODO_STATUS.DROPPED);
266
305
  const rows: string[] = [];
267
- if (done > 0) rows.push(`${theme.fg(GLYPH_ROLE[TODO_STATUS.DONE], STATUS_GLYPH[TODO_STATUS.DONE])} ${theme.fg(NOTE_ROLE, `${done} done`)}`);
306
+ for (const [status, label] of [[TODO_STATUS.DONE, "done"], [TODO_STATUS.DROPPED, "dropped"]] as const) {
307
+ const count = countStatus(state, status);
308
+ if (count > 0) rows.push(`${theme.fg(GLYPH_ROLE[status], STATUS_GLYPH[status])} ${theme.fg(NOTE_ROLE, `${count} ${label}`)}`);
309
+ }
268
310
  const room = ROW_CAP - rows.length - (open.length > ROW_CAP - rows.length ? 1 : 0);
269
311
  for (const task of open.slice(0, room)) rows.push(taskRow(task, theme, inner));
270
312
  if (open.length > room) rows.push(theme.fg(NOTE_ROLE, `… ${open.length - room} more`));
@@ -276,6 +318,8 @@ function collapsedRow(state: TodoState, theme: TodoTheme, inner: number): string
276
318
  if (active) return taskRow(active, theme, inner);
277
319
  const pending = state.tasks.find((task) => task.status === TODO_STATUS.PENDING);
278
320
  if (pending) return taskRow(pending, theme, inner);
321
+ const blocked = state.tasks.find((task) => task.status === TODO_STATUS.BLOCKED);
322
+ if (blocked) return taskRow(blocked, theme, inner);
279
323
  const { open } = todoSummary(state);
280
324
  return `${theme.fg(GLYPH_ROLE[TODO_STATUS.PENDING], STATUS_GLYPH[TODO_STATUS.PENDING])} ${theme.fg(NOTE_ROLE, `${open} open`)}`;
281
325
  }
@@ -288,6 +332,8 @@ export function todoCardTone(staleTurns: number): CardTone {
288
332
  export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number, options: TodoRenderOptions): string[] {
289
333
  if (state.tasks.length === 0) return [];
290
334
  const { done, total } = todoSummary(state);
335
+ // Dropped work is not part of the plan anymore, so it leaves the denominator.
336
+ const planned = total - countStatus(state, TODO_STATUS.DROPPED);
291
337
  const stale = options.staleTurns >= STALE_AFTER_TURNS;
292
338
  const action = options.collapsed ? "expand" : "collapse";
293
339
  const actionLabel = action[0]!.toUpperCase() + action.slice(1);
@@ -300,12 +346,21 @@ export function renderTodoCard(state: TodoState, theme: TodoTheme, width: number
300
346
  // Content columns of the card body in the active style (float is narrower).
301
347
  const inner = panelInnerWidth(theme, width, tone);
302
348
  const rows = options.collapsed ? [collapsedRow(state, theme, inner)] : options.scrollable ? state.tasks.map((task) => taskRow(task, theme, inner)) : bodyRows(state, theme, inner);
303
- const body = stale ? [theme.fg(NOTE_ROLE, `stale · ${options.staleTurns} turns`), ...rows] : rows;
349
+ const staleLabel = theme.fg(NOTE_ROLE, `stale · ${options.staleTurns} turns`);
350
+ let body = options.collapsed
351
+ ? [truncateToWidth(`${stale ? `${staleLabel} · ` : ""}${rows[0]}`, inner, "…")]
352
+ : stale ? [staleLabel, ...rows] : rows;
353
+ if (!options.collapsed && !options.scrollable) {
354
+ // Budget physical lines after wrapping: one long task can exceed the
355
+ // old task-count cap by itself. Scrollable hosts retain every line.
356
+ body = body.flatMap((row) => wrapTextWithAnsi(row, Math.max(1, inner)));
357
+ if (body.length > ROW_CAP) body = [...body.slice(0, ROW_CAP - 1), theme.fg(NOTE_ROLE, "… more")];
358
+ }
304
359
  // The clickable control paints the shared hover role while hovered --
305
360
  // same treatment every other clickable surface uses -- instead of its
306
361
  // ordinary accent role.
307
362
  return renderCard(
308
- { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${total}`, body, tone, glyph: TODO_GLYPH },
363
+ { title: `Todos ${paintHoverable(theme, control, options.hovered, "accent")}`, subtitle: `${done} of ${planned}`, body, tone, glyph: TODO_GLYPH },
309
364
  theme,
310
365
  width,
311
366
  { expanded: true, hint, panel: true },
@@ -269,7 +269,7 @@ export function formatReset(resetAt: number | null, now: number): string {
269
269
  }
270
270
 
271
271
  function parseWindow(raw: RawWindow | null | undefined, now: number): UsageWindow | undefined {
272
- if (!raw || typeof raw.used_percent !== "number" || typeof raw.limit_window_seconds !== "number") return undefined;
272
+ if (!raw || !isFiniteNumber(raw.used_percent) || !isFiniteNumber(raw.limit_window_seconds) || raw.limit_window_seconds <= 0) return undefined;
273
273
  const resetAt =
274
274
  typeof raw.reset_at === "number" ? raw.reset_at * 1000 : typeof raw.reset_after_seconds === "number" ? now + raw.reset_after_seconds * 1000 : null;
275
275
  return { label: windowLabel(raw.limit_window_seconds), usedPercent: raw.used_percent, windowSeconds: raw.limit_window_seconds, resetAt };
@@ -297,9 +297,10 @@ export function parseCodexUsage(payload: unknown, now: number): ProviderUsage {
297
297
  function headerWindow(headers: Record<string, string>, kind: "primary" | "secondary"): UsageWindow | undefined {
298
298
  const used = Number.parseFloat(headers[`${HEADER_PREFIX}${kind}-used-percent`] ?? "");
299
299
  if (!Number.isFinite(used)) return undefined;
300
- const minutes = Number.parseInt(headers[`${HEADER_PREFIX}${kind}-window-minutes`] ?? "", 10);
300
+ // Missing or invalid duration is not a zero-minute quota or an assumed 5h window.
301
+ const seconds = Number(headers[`${HEADER_PREFIX}${kind}-window-minutes`]) * MINUTE;
302
+ if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
301
303
  const resetAt = Number.parseInt(headers[`${HEADER_PREFIX}${kind}-reset-at`] ?? "", 10);
302
- const seconds = Number.isFinite(minutes) ? minutes * MINUTE : 0;
303
304
  return { label: windowLabel(seconds), usedPercent: used, windowSeconds: seconds, resetAt: Number.isFinite(resetAt) ? resetAt * 1000 : null };
304
305
  }
305
306