gentle-pi 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,280 @@
1
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { sanitizeTerminalText } from "./terminal-theme.ts";
3
+ import {
4
+ CARD_TONE, cardAwaitingResult, cardBodyRows, cardBottom, cardInnerWidth, cardLine, cardRunningLine, cardTop,
5
+ floatRows, markCardResult, type CardRowContext, type CardTheme, type CardTone,
6
+ } from "./shell-card.ts";
7
+
8
+ type Operation = "session" | "consult" | "list";
9
+ type Result = { content: Array<{ type: string; text?: string }>; details?: unknown };
10
+ interface Context extends CardRowContext {
11
+ args?: Record<string, unknown>;
12
+ expanded?: boolean;
13
+ isError?: boolean;
14
+ state?: { orchestratorFailed?: boolean };
15
+ }
16
+ const clean = (value: unknown): string => typeof value === "string" ? sanitizeTerminalText(value) : "";
17
+ const line = (value: unknown): string => clean(value).replace(/\s+/gu, " ").trim();
18
+ const text = (result: Result): string => clean(result.content.filter(part => part.type === "text").map(part => part.text ?? "").join("\n"));
19
+ const record = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
20
+ const hiddenFields = new Set(["schema", "digest", "publication"]);
21
+ const fieldLabels: Record<string, string> = {
22
+ sessionId: "Session ID", senderSessionId: "Sender session", recipientSessionId: "Recipient session",
23
+ targetSessionId: "Target session", id: "ID", cwd: "Workspace",
24
+ };
25
+ function label(key: string): string {
26
+ const named = Object.hasOwn(fieldLabels, key) ? fieldLabels[key]
27
+ : line(key).replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").trim().toLowerCase().replace(/^./u, char => char.toUpperCase());
28
+ return named.replace(/\bid\b/gi, "ID");
29
+ }
30
+ const section = (title: string, rows: string[]): string[] => rows.length ? ["", `${title}:`, ...rows] : [];
31
+
32
+ /** Format plain tool data as labelled rows, never as JSON or executable terminal text. */
33
+ export function readableDataRows(value: unknown, indent = ""): string[] {
34
+ const fields = record(value);
35
+ const unknownRepository = Object.hasOwn(fields, "root") && Object.hasOwn(fields, "cloneHash")
36
+ && (fields.root === null || fields.cloneHash === null);
37
+ return [...(unknownRepository ? [`${indent}Repository identity: unknown`] : []), ...Object.entries(fields).flatMap(([key, entry]) => {
38
+ if (hiddenFields.has(key) || entry === undefined || entry === null) return [];
39
+ const heading = `${indent}${label(key)}:`;
40
+ if (key === "cursor") return [`${indent}Continuation token: provided`];
41
+ if (Array.isArray(entry)) {
42
+ const rows = entry.flatMap((item, index) => {
43
+ if (item !== null && typeof item === "object") {
44
+ const { label: itemLabel, ...fields } = record(item);
45
+ return [`${indent} ${index + 1}.${line(itemLabel) ? ` ${line(itemLabel)}` : ""}`, ...readableDataRows(fields, `${indent} `)];
46
+ }
47
+ return item === null ? [] : [`${indent} • ${line(String(item))}`];
48
+ });
49
+ return rows.length ? [heading, ...rows] : [];
50
+ }
51
+ if (typeof entry === "object") {
52
+ const rows = readableDataRows(entry, `${indent} `);
53
+ return rows.length ? [heading, ...rows] : [];
54
+ }
55
+ const shown = typeof entry === "boolean" ? entry ? "yes" : "no" : clean(String(entry));
56
+ return shown ? [`${heading} ${shown}`] : [];
57
+ })];
58
+ }
59
+
60
+ function parsedOutput(result: Result): unknown {
61
+ const body = text(result);
62
+ try { return JSON.parse(body); } catch { return body; }
63
+ }
64
+
65
+ function requestRows(args: unknown): string[] {
66
+ const input = record(args);
67
+ return readableDataRows(input.state === null ? { ...input, state: "withdrawn" } : input);
68
+ }
69
+
70
+ /** Prefer structured data over its duplicated model-facing serialization. */
71
+ export function expandedToolRows(args: unknown, result: Result): string[] {
72
+ const data = record(record(result.details).gentleAgents);
73
+ const output = Object.keys(data).length ? data : parsedOutput(result);
74
+ return [...section("Request", requestRows(args)), ...section("Details", typeof output === "string" ? output.split("\n") : readableDataRows(output)),
75
+ ...result.content.filter(part => part.type !== "text").map(part => `Attachment: ${line(part.type)}`)];
76
+ }
77
+
78
+ function discoveredSessionRows(candidates: unknown[]): string[] {
79
+ const missing = candidates.filter(candidate => record(candidate).freshness !== "recent").length;
80
+ return [...(missing ? [`Metadata unavailable for ${missing} session${missing === 1 ? "" : "s"}; no current context inferred.`, ""] : []),
81
+ ...candidates.flatMap((candidate, index) => {
82
+ const peer = record(candidate);
83
+ const { sessionId, label: peerLabel, reachability, freshness, ...context } = peer;
84
+ const recent = freshness === "recent";
85
+ return [`${index + 1}. ${recent && line(peerLabel) ? line(peerLabel) : "Session"}`,
86
+ ` Session ID: ${line(sessionId)}`, ...(recent ? [
87
+ ...(!Object.keys(record(context.scope)).length ? [" Repository scope: unknown"] : []),
88
+ ...readableDataRows(context, " "),
89
+ ] : []), ""];
90
+ })];
91
+ }
92
+
93
+ const OVERVIEW_LIMIT = 6;
94
+ interface OverviewRow { name: string; id: string; context: string; tasks: string }
95
+ interface OverviewTheme extends CardTheme { bold?(value: string): string }
96
+ const shortId = (value: unknown): string => {
97
+ const id = line(value);
98
+ return visibleWidth(id) <= 13 ? id : `${Array.from(id).slice(0, 8).join("")}…${Array.from(id).slice(-4).join("")}`;
99
+ };
100
+ const workspaceName = (value: unknown): string => line(value).split(/[\\/]/).filter(Boolean).at(-1) || line(value) || "—";
101
+ const positiveCount = (value: unknown): number => typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : 0;
102
+
103
+ /** One physical row per item, including on narrow terminals; expansion is not an unbounded dump. */
104
+ function overviewTable(rows: OverviewRow[], width: number, work: boolean, theme: OverviewTheme): string[] {
105
+ const cell = (value: string, columns: number, role: string, heading: boolean) => {
106
+ const clipped = truncateToWidth(value, columns, "…");
107
+ const styled = theme.fg(heading ? "toolTitle" : role, clipped);
108
+ return (heading && theme.bold ? theme.bold(styled) : styled) + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
109
+ };
110
+ const format = (row: OverviewRow, heading = false) => {
111
+ if (width < 40) {
112
+ const styled = truncateToWidth(`${theme.fg(heading ? "toolTitle" : "dim", row.id)} ${theme.fg(heading ? "toolTitle" : "accent", row.name)}`, width, "…");
113
+ return heading && theme.bold ? theme.bold(styled) : styled;
114
+ }
115
+ const name = (columns: number) => cell(row.name, columns, "accent", heading);
116
+ const id = cell(row.id, 13, "dim", heading);
117
+ if (width < 64) return `${name(width - 15)} ${id}`;
118
+ const nameWidth = Math.min(36, width - (work ? 37 : 44));
119
+ const tasksRole = row.tasks === "?" ? "warning" : row.tasks === "0" ? "muted" : "syntaxNumber";
120
+ return `${name(nameWidth)} ${id} ${cell(row.context, 20, "muted", heading)}${work ? "" : ` ${cell(row.tasks, 5, tasksRole, heading)}`}`;
121
+ };
122
+ return rows.length ? [format({ name: "Alias", id: "ID (short)", context: work ? "Work" : "Workspace", tasks: "Tasks" }, true), ...rows.slice(0, OVERVIEW_LIMIT).map(row => format(row))] : [];
123
+ }
124
+
125
+ function discoveryOverviewRows(candidates: unknown[], width: number, theme: OverviewTheme): string[] {
126
+ const recent = candidates.map(record).filter(peer => peer.freshness === "recent");
127
+ const rows = recent.map(peer => {
128
+ const catalog = record(peer.catalog);
129
+ const tasks = Array.isArray(peer.tasks) ? peer.tasks : Array.isArray(catalog.tasks) ? catalog.tasks : undefined;
130
+ const omitted = Array.isArray(peer.tasks) ? peer.omitted : catalog.omittedTasks;
131
+ return { name: line(peer.label) || "Unnamed", id: shortId(peer.sessionId), context: workspaceName(peer.workspace), tasks: tasks ? `${tasks.length}${positiveCount(omitted) ? "+" : ""}` : "?" };
132
+ });
133
+ return [...overviewTable(rows, width, false, theme),
134
+ ...(recent.length > OVERVIEW_LIMIT ? [theme.fg("muted", `+${recent.length - OVERVIEW_LIMIT} more recorded sessions`)] : []),
135
+ ...(candidates.length > recent.length ? [theme.fg("muted", `${candidates.length - recent.length} without recent metadata`)] : []),
136
+ theme.fg("dim", "Detail: consult(full ID)")];
137
+ }
138
+
139
+ function workOverviewRows(search: Record<string, unknown>, width: number, theme: OverviewTheme): string[] {
140
+ const matches = (Array.isArray(search.matches) ? search.matches : []).map(record);
141
+ const rows = matches.map(match => {
142
+ const work = record(match.work);
143
+ return { name: line(match.label) || line(match.taskId) || "Work item", id: shortId(match.sessionId), context: [line(work.area), line(work.topic)].filter(Boolean).join("/") || "—", tasks: "" };
144
+ });
145
+ const coverage = record(search.coverage);
146
+ const limits = [positiveCount(coverage.omittedMatches) && `${coverage.omittedMatches} matches omitted`, positiveCount(coverage.unexaminedPeers) && `${coverage.unexaminedPeers} peers not scanned`, positiveCount(coverage.unknownContext) && `${coverage.unknownContext} unknown contexts`].filter(Boolean).join(" · ");
147
+ const source = record(search.source);
148
+ return [...overviewTable(rows, width, true, theme), ...(matches.length > OVERVIEW_LIMIT ? [theme.fg("muted", `+${matches.length - OVERVIEW_LIMIT} more work matches`)] : []),
149
+ ...(limits ? [theme.fg("warning", limits)] : []), ...(source.status === "unavailable" ? [theme.fg("warning", `Source unavailable: ${line(source.reason) || "unknown"}`)] : []),
150
+ theme.fg("dim", "No authority · detail: consult(full ID)")];
151
+ }
152
+
153
+ function overviewSummary(row: string, theme: CardTheme): string {
154
+ const count = /^(\d+)(.*)$/.exec(row);
155
+ return count ? theme.fg(count[1] === "0" ? "muted" : "syntaxNumber", count[1]) + theme.fg("muted", count[2]) : theme.fg("muted", row);
156
+ }
157
+
158
+ const taskIdentity = (data: Record<string, unknown>): boolean => Object.hasOwn(data, "sessionName") || Object.hasOwn(data, "currentAlias") || Object.hasOwn(data, "initialAlias");
159
+
160
+ function identityBodyRows(rows: string[], tone: CardTone, theme: CardTheme, width: number, expanded: boolean): string[] {
161
+ // Wrap plain text before coloring: ANSI can otherwise add empty rows at narrow widths.
162
+ const wrapped = rows.flatMap(row => row.split("\n").flatMap(source => {
163
+ const field = /^(Current alias|Initial alias|Session name|Routing ID|Requested subject): (.*)$/.exec(source);
164
+ let offset = 0;
165
+ return (source === "" ? [""] : wrapTextWithAnsi(source, cardInnerWidth(width))).map(fragment => {
166
+ if (!field) return source === "Existing alias preserved; subject does not rename it." || source === "Session name preserved; subject updates task aliases." ? theme.fg("muted", fragment) : fragment;
167
+ const start = source.indexOf(fragment, offset);
168
+ offset = start + fragment.length;
169
+ const labelLength = Math.max(0, Math.min(fragment.length, field[1].length + 2 - start));
170
+ const role = field[1] === "Routing ID" ? "dim" : field[2] === "unknown" ? "muted" : "accent";
171
+ return theme.fg("muted", fragment.slice(0, labelLength)) + theme.fg(role, fragment.slice(labelLength));
172
+ });
173
+ }));
174
+ return (expanded ? wrapped : wrapped.slice(0, 3)).map(row => cardLine(row, tone, theme, width));
175
+ }
176
+
177
+ function summary(operation: Operation, args: Record<string, unknown>, result: Result, failed: boolean): string[] {
178
+ const body = text(result);
179
+ if (failed) return [line(body) || "Operation failed"];
180
+ const data = record(record(result.details).gentleAgents);
181
+ if (operation === "session" && typeof data.alias === "string") {
182
+ if (taskIdentity(data)) {
183
+ const name = line(data.sessionName) || line(data.alias) || "unnamed";
184
+ const rows = [`Current alias: ${line(data.currentAlias) || "unknown"}`, `Session name: ${name}`];
185
+ if (line(args.subject) && name !== "unnamed" && name !== line(args.subject)) rows.push("Session name preserved; subject updates task aliases.");
186
+ return rows;
187
+ }
188
+ const rows = [`Current alias: ${line(data.alias) || "unnamed"}`];
189
+ if (line(data.alias) && line(args.subject) && line(args.subject) !== line(data.alias)) rows.push("Existing alias preserved; subject does not rename it.");
190
+ return rows;
191
+ }
192
+ if (operation === "consult") {
193
+ const receipt = record(data.receipt ?? parsedOutput(result));
194
+ if (typeof receipt.status === "string") {
195
+ const code = line(receipt.code) || (Array.isArray(receipt.unknowns) ? line(receipt.unknowns[0]) : "");
196
+ return [`${line(receipt.status)}${code ? ` (${code})` : ""} · not an owner reply; authority none`];
197
+ }
198
+ }
199
+ if (operation === "list") {
200
+ const work = record(data.workSearch);
201
+ if (Array.isArray(work.matches)) return [`${work.matches.length} work matches · non-exhaustive; reachability unknown`];
202
+ if (Array.isArray(data.candidates)) return [`${data.candidates.length} advertised session${data.candidates.length === 1 ? "" : "s"} · reachability unknown`];
203
+ }
204
+ const parsed = parsedOutput(result);
205
+ return [typeof parsed === "string" ? line(body.split("\n").find(row => row.trim())) || "No output" : readableDataRows(parsed)[0] || "No output"];
206
+ }
207
+
208
+ export function orchestratorToolRenderers(operation: Operation, hint: (expanded: boolean) => string) {
209
+ const title = operation === "session" ? "Session identity" : operation === "consult" ? "Consult orchestrator" : "Discover orchestrators";
210
+ return {
211
+ renderShell: "self" as const,
212
+ renderCall(args: Record<string, unknown>, theme: CardTheme, context: Context) {
213
+ return {
214
+ render(width: number) {
215
+ if (width <= 0) return [];
216
+ const pending = cardAwaitingResult(context);
217
+ const tone = context.isError || context.state?.orchestratorFailed ? CARD_TONE.ERROR : CARD_TONE.INFO;
218
+ const subtitle = operation === "session" ? (line(args.subject) ? `Subject: ${line(args.subject)}` : "This session")
219
+ : operation === "consult" ? line(args.kind) || "metadata"
220
+ : Object.hasOwn(args, "filter") ? "Classified work" : args.recipient_session_id ? "Selected session" : "Advertised sessions";
221
+ return floatRows(tone, theme, width, inner => ({
222
+ head: [cardTop({ title, subtitle, body: [], tone, glyph: "🤖" }, theme, inner, hint(!!context.expanded))],
223
+ ...(pending ? {
224
+ body: [...(context.expanded ? cardBodyRows(requestRows(args), tone, theme, inner, { expanded: true }) : []), cardRunningLine(tone, theme, inner)],
225
+ bottom: cardBottom(tone, theme, inner),
226
+ } : {}),
227
+ }));
228
+ },
229
+ invalidate() {},
230
+ };
231
+ },
232
+ renderResult(result: Result, options: { expanded: boolean; isPartial?: boolean }, theme: CardTheme, context: Context) {
233
+ const failed = !!record(result.details).error || !!context.isError;
234
+ if (!options.isPartial) markCardResult(context.state);
235
+ if (context.state) context.state.orchestratorFailed = failed;
236
+ const tone = failed ? CARD_TONE.ERROR : CARD_TONE.INFO;
237
+ const args = context.args ?? {};
238
+ const data = record(record(result.details).gentleAgents);
239
+ const discoveryOverview = operation === "list" && !failed && !args.recipient_session_id && Array.isArray(data.candidates);
240
+ const workOverview = operation === "list" && !failed && Array.isArray(record(data.workSearch).matches);
241
+ const overview = options.expanded && (discoveryOverview || workOverview);
242
+ const body = options.isPartial && !failed ? ["Receiving partial result…"] : summary(operation, args, result, failed);
243
+ if (options.expanded && !overview) {
244
+ if (operation === "session" && typeof data.alias === "string" && !failed) {
245
+ if (taskIdentity(data)) body.push(`Initial alias: ${line(data.initialAlias) || "unknown"}`);
246
+ if (line(data.senderSessionId)) body.push(`Routing ID: ${line(data.senderSessionId)}`);
247
+ if (line(args.subject) && (!taskIdentity(data) || line(args.subject) !== line(data.currentAlias))) body.push(`Requested subject: ${line(args.subject)}`);
248
+ body.push(...(args.state === null ? ["Published state: withdrawn"] : section("Published state", readableDataRows(args.state))));
249
+ } else if (operation === "list" && Array.isArray(data.candidates) && !failed) {
250
+ body.push(...section("Request", requestRows(args)), ...section("Sessions", discoveredSessionRows(data.candidates)));
251
+ } else if (operation === "consult" && data.receipt !== undefined && !failed) {
252
+ body.push(...section("Request", requestRows(args)), ...section("Published context / advice", readableDataRows(data.receipt)));
253
+ } else if (!Object.keys(data).length && typeof parsedOutput(result) === "string") {
254
+ body.splice(0, body.length, ...section("Request", requestRows(args)), ...text(result).split("\n"));
255
+ } else {
256
+ body.push(...expandedToolRows(args, result));
257
+ }
258
+ }
259
+ return {
260
+ render(width: number) {
261
+ if (width <= 0) return [];
262
+ return floatRows(tone, theme, width, inner => {
263
+ const columns = cardInnerWidth(inner);
264
+ const summaries = discoveryOverview || workOverview ? body.map(row => overviewSummary(row, theme)) : body;
265
+ const rows = overview ? [...summaries, ...(discoveryOverview ? discoveryOverviewRows(data.candidates as unknown[], columns, theme) : workOverviewRows(record(data.workSearch), columns, theme))]
266
+ .map(row => truncateToWidth(row, columns, "…")) : summaries;
267
+ return {
268
+ afterHeading: true,
269
+ body: operation === "session" && !failed && typeof data.alias === "string"
270
+ ? identityBodyRows(body, tone, theme, inner, options.expanded)
271
+ : cardBodyRows(rows, tone, theme, inner, { expanded: options.expanded, previewRows: 3 }),
272
+ bottom: cardBottom(tone, theme, inner),
273
+ };
274
+ });
275
+ },
276
+ invalidate() {},
277
+ };
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,218 @@
1
+ import { isAbsolute } from "node:path";
2
+ import type { PresenceRecord } from "./agents-session-transport.ts";
3
+ import { discoverOrchestrators, type OrchestratorCandidate } from "./orchestrator-discovery.ts";
4
+ import { decodeWorkDescriptor, type WorkDescriptor, type WorkRef } from "./orchestrator-work.ts";
5
+ import type { RepositoryFact } from "./orchestrator-scope.ts";
6
+
7
+ export interface WorkFilter {
8
+ area?: string;
9
+ topic?: string;
10
+ tag?: string;
11
+ text?: string;
12
+ ref?: WorkRef;
13
+ repository_root?: string;
14
+ related_to?: { session_id: string; task_id?: string };
15
+ }
16
+ const safeText = (v: unknown, max: number): v is string => typeof v === "string" && !!v.trim()
17
+ && Buffer.byteLength(v) <= max && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(v);
18
+ const fold = (s: string) => s.normalize("NFC").trim().toLowerCase();
19
+
20
+ /** Validate before discovery; preserve public spelling and exact reference identity. */
21
+ export function validateWorkFilter(value: unknown): WorkFilter {
22
+ const invalid = () => { throw new Error("invalid-work-filter"); };
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
24
+ const input = value as Record<string, unknown>;
25
+ if (Object.keys(input).some(k => !["area", "topic", "tag", "text", "ref", "repository_root", "related_to"].includes(k))) return invalid();
26
+ const result: WorkFilter = {};
27
+ for (const key of ["area", "topic", "tag", "text", "repository_root"] as const) {
28
+ if (!Object.hasOwn(input, key)) continue;
29
+ const v = input[key];
30
+ if (!safeText(v, key === "text" ? 1024 : key === "repository_root" ? 256 : 64)) return invalid();
31
+ if (key === "repository_root" && (!isAbsolute(v) || /[\u00a0\u2000-\u200a\u202f\u205f\u3000]/u.test(v))) return invalid();
32
+ result[key] = v;
33
+ }
34
+ if (result.topic && !result.area) return invalid();
35
+ if (Object.hasOwn(input, "ref")) {
36
+ try { result.ref = decodeWorkDescriptor({ refs: [input.ref] }).refs![0]; }
37
+ catch { return invalid(); }
38
+ }
39
+ if (Object.hasOwn(input, "related_to")) {
40
+ const source = input.related_to;
41
+ if (!source || typeof source !== "object" || Array.isArray(source)) return invalid();
42
+ const keys = source as Record<string, unknown>;
43
+ if (Object.keys(keys).some(k => !["session_id", "task_id"].includes(k))
44
+ || !Object.hasOwn(keys, "session_id") || !safeText(keys.session_id, 256)
45
+ || (Object.hasOwn(keys, "task_id") && !safeText(keys.task_id, 256))) return invalid();
46
+ result.related_to = { session_id: keys.session_id,
47
+ ...(Object.hasOwn(keys, "task_id") ? { task_id: keys.task_id as string } : {}) };
48
+ }
49
+ return result;
50
+ }
51
+
52
+ interface WorkNode {
53
+ sessionId: string;
54
+ taskId?: string;
55
+ label?: string;
56
+ aliases?: OrchestratorCandidate["aliases"];
57
+ workspace?: string | null;
58
+ status?: string;
59
+ repository?: RepositoryFact;
60
+ work: WorkDescriptor;
61
+ recordedAt: number;
62
+ }
63
+ export interface WorkSearchResult {
64
+ schema: 1;
65
+ ownerReply: false;
66
+ authority: "none";
67
+ reachability: "unknown";
68
+ observedAt: number;
69
+ coverage: {
70
+ exhaustive: false;
71
+ examinedPeers: number;
72
+ unexaminedPeers: number;
73
+ unknownContext: number;
74
+ unclassified: number;
75
+ unmatchedTaskAnnotations: number;
76
+ catalogUnknown: number;
77
+ catalogOmittedTasks: number;
78
+ pendingCatalogPages: number;
79
+ omittedMatches: number;
80
+ };
81
+ source?: {
82
+ status: "available" | "unavailable";
83
+ provenance: "published-work";
84
+ selector: NonNullable<WorkFilter["related_to"]>;
85
+ reason?: string;
86
+ node?: WorkNode;
87
+ };
88
+ matches: (WorkNode & { reasons: string[] })[];
89
+ }
90
+
91
+ /** Only current catalog IDs are task identities; annotations never imply liveness. */
92
+ function collectNodes(candidate: OrchestratorCandidate, coverage: WorkSearchResult["coverage"]): WorkNode[] {
93
+ const record = candidate.workRecord;
94
+ if (!record) coverage.unknownContext++;
95
+ if (!candidate.catalog) coverage.catalogUnknown++;
96
+ else {
97
+ coverage.catalogOmittedTasks += candidate.catalog.omittedTasks;
98
+ if (candidate.catalog.cursor) coverage.pendingCatalogPages++;
99
+ }
100
+ const nodes: WorkNode[] = [];
101
+ const { tasks: annotations = {}, ...root } = record?.work ?? {};
102
+ const classified = (work: WorkDescriptor) => !!(work.area || work.tags?.length || work.refs?.length);
103
+ const base = { sessionId: candidate.sessionId, recordedAt: record?.recordedAt ?? 0 };
104
+ if (record && classified(root)) nodes.push({ ...base, label: candidate.aliases?.currentAlias ?? candidate.label, aliases: candidate.aliases, workspace: candidate.workspace,
105
+ repository: candidate.scope?.host, work: root });
106
+ else if (record) coverage.unclassified++;
107
+ const live = candidate.catalog?.tasks ?? [];
108
+ coverage.unmatchedTaskAnnotations += Object.keys(annotations).filter(id => !live.some(t => t.id === id)).length;
109
+ for (const task of live) {
110
+ const work = Object.hasOwn(annotations, task.id) ? annotations[task.id] : undefined;
111
+ if (!work) {
112
+ if (record) coverage.unclassified++;
113
+ continue;
114
+ }
115
+ nodes.push({ ...base, taskId: task.id, label: task.label, workspace: task.cwd, status: task.status,
116
+ repository: candidate.scope?.tasks.find(t => t.id === task.id)?.repository, work });
117
+ }
118
+ return nodes;
119
+ }
120
+
121
+ function matchReasons(node: WorkNode, filter: WorkFilter): string[] | undefined {
122
+ const reasons: string[] = [];
123
+ const check = (present: boolean, matches: boolean, reason: string) => {
124
+ if (!present) return true;
125
+ if (!matches) return false;
126
+ reasons.push(reason);
127
+ return true;
128
+ };
129
+ const work = node.work;
130
+ if (!check(filter.area !== undefined, !!work.area && fold(work.area) === fold(filter.area ?? ""), "area")) return;
131
+ if (!check(filter.topic !== undefined, !!work.topic && fold(work.topic) === fold(filter.topic ?? ""), "topic")) return;
132
+ if (!check(filter.tag !== undefined, !!work.tags?.some(t => fold(t) === fold(filter.tag ?? "")), "tag")) return;
133
+ const text = [node.label, work.area, work.topic, ...(work.tags ?? []),
134
+ ...(work.refs ?? []).flatMap(r => [r.kind, r.repository, r.id])].filter((s): s is string => s !== undefined);
135
+ if (!check(filter.text !== undefined, text.some(s => fold(s).includes(fold(filter.text ?? ""))), "text")) return;
136
+ const ref = filter.ref;
137
+ if (!check(!!ref, !!work.refs?.some(r => r.kind === ref?.kind && r.repository === ref.repository && r.id === ref.id), "ref")) return;
138
+ if (!check(filter.repository_root !== undefined, node.repository?.root === filter.repository_root, "repository_root")) return;
139
+ return reasons.length ? reasons : ["classified"];
140
+ }
141
+
142
+ function relationReasons(node: WorkNode, source: WorkNode): string[] {
143
+ if (node.sessionId === source.sessionId && node.taskId === source.taskId) return [];
144
+ const work = node.work;
145
+ const other = source.work;
146
+ const reasons: string[] = [];
147
+ if (work.refs?.some(r => other.refs?.some(s => r.kind === s.kind && r.repository === s.repository && r.id === s.id)))
148
+ reasons.push("shared-declared-reference");
149
+ const area = !!work.area && !!other.area && fold(work.area) === fold(other.area);
150
+ if (area) reasons.push("possible-area-overlap");
151
+ if (area && work.topic && other.topic && fold(work.topic) === fold(other.topic)) reasons.push("possible-topic-overlap");
152
+ if (work.tags?.some(t => other.tags?.some(s => fold(t) === fold(s)))) reasons.push("possible-tag-overlap");
153
+ return reasons;
154
+ }
155
+
156
+ /** Detached bounded index, not an owner reply, live probe, or exhaustive search. */
157
+ export function searchPublishedWork(profile: string, peers: readonly PresenceRecord[], filter: unknown = {},
158
+ selection?: { recipientSessionId?: string; cursor?: string }, now = Date.now()): WorkSearchResult {
159
+ const validated = validateWorkFilter(filter);
160
+ if (selection !== undefined) {
161
+ if (!selection || typeof selection !== "object" || Array.isArray(selection)
162
+ || Object.keys(selection).some(k => !["recipientSessionId", "cursor"].includes(k))
163
+ || (selection.recipientSessionId !== undefined && !safeText(selection.recipientSessionId, 256))
164
+ || (selection.cursor !== undefined && (typeof selection.cursor !== "string" || selection.cursor.length > 1024)))
165
+ throw new Error("invalid-work-selection");
166
+ }
167
+ if (selection?.cursor !== undefined && selection.recipientSessionId === undefined) throw new Error("work-cursor-requires-recipient");
168
+ const ids = [...new Set(peers.map(p => p.sessionId))].sort();
169
+ const eligible = selection?.recipientSessionId !== undefined ? ids.filter(id => id === selection.recipientSessionId) : ids;
170
+ const selected = new Set(eligible.slice(0, 64));
171
+ const sourceId = validated.related_to?.session_id;
172
+ if (sourceId !== undefined && ids.includes(sourceId) && !selected.has(sourceId)) {
173
+ if (selected.size === 64) selected.delete(eligible[63]);
174
+ selected.add(sourceId);
175
+ }
176
+ const result: WorkSearchResult = { schema: 1, ownerReply: false, authority: "none", reachability: "unknown", observedAt: now,
177
+ coverage: { exhaustive: false, examinedPeers: selected.size, unexaminedPeers: ids.length - selected.size,
178
+ unknownContext: 0, unclassified: 0, unmatchedTaskAnnotations: 0, catalogUnknown: 0,
179
+ catalogOmittedTasks: 0, pendingCatalogPages: 0, omittedMatches: 0 }, matches: [] };
180
+ // Retain duplicate activations so discovery can refuse ambiguous joins.
181
+ const candidatePeers = peers.filter(p => selected.has(p.sessionId));
182
+ const candidates = discoverOrchestrators(profile, candidatePeers, now, { ...selection, includeWork: true });
183
+ if (sourceId !== undefined && selected.has(sourceId) && selection?.recipientSessionId !== undefined
184
+ && selection.recipientSessionId !== sourceId) {
185
+ candidates.push(...discoverOrchestrators(profile, candidatePeers.filter(p => p.sessionId === sourceId), now, { includeWork: true }));
186
+ }
187
+ candidates.sort((a, b) => a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0);
188
+ const nodes = candidates.flatMap(c => collectNodes(c, result.coverage));
189
+ let source: WorkNode | undefined;
190
+ if (validated.related_to) {
191
+ const selector = validated.related_to;
192
+ const candidate = candidates.find(c => c.sessionId === sourceId);
193
+ source = nodes.find(n => n.sessionId === sourceId && n.taskId === selector.task_id);
194
+ const reason = !candidate ? "source-not-advertised" : !candidate.workRecord ? "source-context-unavailable"
195
+ : selector.task_id !== undefined && !candidate.catalog ? "source-catalog-unavailable"
196
+ : selector.task_id !== undefined && !candidate.catalog?.tasks.some(t => t.id === selector.task_id)
197
+ ? "source-task-not-on-current-page" : "source-unclassified";
198
+ result.source = { status: source ? "available" : "unavailable", provenance: "published-work", selector,
199
+ ...(source ? { node: source } : { reason }) };
200
+ }
201
+ const matches = nodes.filter(n => selection?.recipientSessionId === undefined || n.sessionId === selection.recipientSessionId)
202
+ .map(node => {
203
+ const ordinary = matchReasons(node, validated);
204
+ if (!validated.related_to) return { node, reasons: ordinary };
205
+ const related = source ? relationReasons(node, source) : [];
206
+ return { node, reasons: ordinary && related.length ? [...ordinary.filter(r => r !== "classified"), ...related] : undefined };
207
+ }).filter(m => m.reasons !== undefined);
208
+ result.coverage.omittedMatches = matches.length;
209
+ for (const { node, reasons } of matches) {
210
+ result.matches.push({ ...node, reasons: reasons! });
211
+ result.coverage.omittedMatches--;
212
+ if (Buffer.byteLength(JSON.stringify(result)) > 16 * 1024) {
213
+ result.matches.pop();
214
+ result.coverage.omittedMatches++;
215
+ }
216
+ }
217
+ return structuredClone(result);
218
+ }
@@ -0,0 +1,79 @@
1
+ export interface WorkRef {
2
+ kind: "issue" | "pr" | "task";
3
+ repository: string;
4
+ id: string;
5
+ }
6
+ export interface WorkDescriptor {
7
+ area?: string;
8
+ topic?: string;
9
+ tags?: string[];
10
+ refs?: WorkRef[];
11
+ }
12
+ export interface PublishedWork extends WorkDescriptor {
13
+ tasks?: Record<string, WorkDescriptor>;
14
+ }
15
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
16
+ const text = (v: unknown, max: number): v is string => typeof v === "string" && !!v.trim()
17
+ && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(v) && Buffer.byteLength(v) <= max;
18
+ const keys = (v: Record<string, unknown>, allowed: string[]) => Object.keys(v).every(k => allowed.includes(k));
19
+ // Explicit public host/owner/repo spelling only: no URLs, credentials, ports or inferred identity.
20
+ const repository = (v: unknown): v is string => text(v, 256)
21
+ && /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}\/[a-z0-9_-][a-z0-9_.-]*\/[a-z0-9_-][a-z0-9_.-]*$/i.test(v)
22
+ && v.split("/")[0].split(".").every(label => label.length <= 63);
23
+
24
+ /** Pure, detached validation. References are descriptive identifiers, never routing targets. */
25
+ function descriptor(value: unknown, allowEmpty = false): WorkDescriptor {
26
+ const invalid = () => { throw new Error("invalid-published-state"); };
27
+ if (!object(value) || !keys(value, ["area", "topic", "tags", "refs"])) return invalid();
28
+ const result: WorkDescriptor = {};
29
+ for (const key of ["area", "topic"] as const) {
30
+ if (Object.hasOwn(value, key)) {
31
+ if (!text(value[key], 64)) return invalid();
32
+ result[key] = value[key];
33
+ }
34
+ }
35
+ if (result.topic && !result.area) return invalid();
36
+ if (Object.hasOwn(value, "tags")) {
37
+ if (!Array.isArray(value.tags) || value.tags.length > 8 || !Array.from(value.tags).every(tag => text(tag, 64))
38
+ || new Set(value.tags).size !== value.tags.length) return invalid();
39
+ result.tags = [...value.tags];
40
+ }
41
+ if (Object.hasOwn(value, "refs")) {
42
+ if (!Array.isArray(value.refs) || value.refs.length > 8) return invalid();
43
+ result.refs = [];
44
+ const seen = new Set<string>();
45
+ for (const ref of value.refs) {
46
+ if (!object(ref) || Object.keys(ref).length !== 3 || !keys(ref, ["kind", "repository", "id"])
47
+ || !["issue", "pr", "task"].includes(ref.kind as string) || !repository(ref.repository)
48
+ || !text(ref.id, 256) || (ref.kind !== "task" && !/^[1-9][0-9]*$/.test(ref.id))) return invalid();
49
+ const key = JSON.stringify([ref.kind, ref.repository, ref.id]);
50
+ if (seen.has(key)) return invalid();
51
+ seen.add(key);
52
+ result.refs.push({ kind: ref.kind as WorkRef["kind"], repository: ref.repository, id: ref.id });
53
+ }
54
+ }
55
+ if (!allowEmpty && !result.area && !result.tags?.length && !result.refs?.length) return invalid();
56
+ return result;
57
+ }
58
+
59
+ export function decodeWorkDescriptor(value: unknown): WorkDescriptor {
60
+ return descriptor(value);
61
+ }
62
+
63
+ /** Only the curated root accepts exact owner-declared task IDs. */
64
+ export function decodeWork(value: unknown): PublishedWork {
65
+ if (!object(value) || !keys(value, ["area", "topic", "tags", "refs", "tasks"])) throw new Error("invalid-published-state");
66
+ const { tasks, ...fields } = value;
67
+ const result: PublishedWork = descriptor(fields, true);
68
+ if (Object.hasOwn(value, "tasks")) {
69
+ if (!object(tasks) || Object.keys(tasks).length > 8) throw new Error("invalid-published-state");
70
+ result.tasks = {};
71
+ for (const id of Object.keys(tasks)) {
72
+ if (!text(id, 256) || ["__proto__", "prototype", "constructor"].includes(id)) throw new Error("invalid-published-state");
73
+ result.tasks[id] = decodeWorkDescriptor(tasks[id]);
74
+ }
75
+ }
76
+ if (!result.area && !result.tags?.length && !result.refs?.length && !Object.keys(result.tasks ?? {}).length)
77
+ throw new Error("invalid-published-state");
78
+ return result;
79
+ }
@@ -0,0 +1,59 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { gentlePiConfigHome } from "./agent-home.ts";
5
+
6
+ export type PermissionMode = "normal" | "auto" | "yolo";
7
+ export const PERMISSION_MODE_SCHEMA = "gentle-pi.permission-mode/v1";
8
+ export interface PermissionModeOptions { gentlePiConfigHome?: string }
9
+ export interface PermissionModeResolution {
10
+ mode: PermissionMode;
11
+ malformed: boolean;
12
+ source: "global_file" | "default";
13
+ globalFile: string;
14
+ }
15
+ export function isPermissionMode(value: unknown): value is PermissionMode {
16
+ return value === "normal" || value === "auto" || value === "yolo";
17
+ }
18
+ /** Never consult merged Pi settings or repository configuration. */
19
+ export function permissionModePolicyFile(options: PermissionModeOptions = {}): string {
20
+ return join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "permission-mode.json");
21
+ }
22
+ export const permissionModePolicyPath = permissionModePolicyFile;
23
+ export function parsePermissionModePolicyFile(raw: string): PermissionMode | undefined {
24
+ try {
25
+ const value: unknown = JSON.parse(raw);
26
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== 2) return undefined;
27
+ if (!("schema" in value) || value.schema !== PERMISSION_MODE_SCHEMA || !("mode" in value)) return undefined;
28
+ return isPermissionMode(value.mode) ? value.mode : undefined;
29
+ } catch { return undefined; }
30
+ }
31
+ export function resolvePermissionModePolicy(options: PermissionModeOptions = {}): PermissionModeResolution {
32
+ const globalFile = permissionModePolicyFile(options);
33
+ try {
34
+ const mode = parsePermissionModePolicyFile(readFileSync(globalFile, "utf8"));
35
+ return { mode: mode ?? "normal", malformed: mode === undefined, source: "global_file", globalFile };
36
+ } catch (error) {
37
+ const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
38
+ return { mode: "normal", malformed: !missing, source: missing ? "default" : "global_file", globalFile };
39
+ }
40
+ }
41
+ /** Private same-directory atomic replacement; malformed/unreadable preferences require human repair. */
42
+ export function writePermissionModePolicy(mode: PermissionMode, options: PermissionModeOptions = {}): string {
43
+ if (!isPermissionMode(mode)) throw new Error("Invalid permission mode");
44
+ const resolution = resolvePermissionModePolicy(options);
45
+ if (resolution.malformed) throw new Error("Permission mode preference is malformed or unreadable; repair it before saving.");
46
+ const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
47
+ const path = resolution.globalFile;
48
+ const temporary = `${path}.${randomUUID()}.tmp`;
49
+ mkdirSync(home, { recursive: true });
50
+ try {
51
+ writeFileSync(temporary, `${JSON.stringify({ schema: PERMISSION_MODE_SCHEMA, mode })}\n`, { flag: "wx", mode: 0o600 });
52
+ // Recheck immediately before replacing: never silently repair an invalid file.
53
+ if (resolvePermissionModePolicy(options).malformed) throw new Error("Permission mode preference changed or is unreadable.");
54
+ renameSync(temporary, path);
55
+ } finally {
56
+ try { unlinkSync(temporary); } catch { /* Rename already consumed it. */ }
57
+ }
58
+ return path;
59
+ }