@tekmidian/pai 0.42.5 → 0.42.6

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 (292) hide show
  1. package/ARCHITECTURE.md +9 -9
  2. package/README.md +26 -8
  3. package/dist/{aibroker-client-BP74gGwD.mjs → aibroker-client-Bn9SnGMa.mjs} +3 -3
  4. package/dist/{aibroker-client-BP74gGwD.mjs.map → aibroker-client-Bn9SnGMa.mjs.map} +1 -1
  5. package/dist/{aibroker-client-C6m71p6O.mjs → aibroker-client-D5NT0S1N.mjs} +1 -1
  6. package/dist/{auto-route-_5zzJz5q.mjs → auto-route-CcD0OWWB.mjs} +4 -4
  7. package/dist/{auto-route-_5zzJz5q.mjs.map → auto-route-CcD0OWWB.mjs.map} +1 -1
  8. package/dist/{chain-DTII7xeL.mjs → chain-BbcfP77K.mjs} +5 -4
  9. package/dist/{chain-DTII7xeL.mjs.map → chain-BbcfP77K.mjs.map} +1 -1
  10. package/dist/cli/index.mjs +23 -21
  11. package/dist/cli/index.mjs.map +1 -1
  12. package/dist/cli/program.d.mts.map +1 -1
  13. package/dist/cli/program.mjs +23 -21
  14. package/dist/{clusters-j0ZuNYp9.mjs → clusters-Cw_P6lO-.mjs} +2 -2
  15. package/dist/{clusters-j0ZuNYp9.mjs.map → clusters-Cw_P6lO-.mjs.map} +1 -1
  16. package/dist/{config-B64vFg14.mjs → config-DCu8THKi.mjs} +41 -9
  17. package/dist/{config-B64vFg14.mjs.map → config-DCu8THKi.mjs.map} +1 -1
  18. package/dist/config-DeqFqa1N.mjs +3 -0
  19. package/dist/config-DxMZjpvY.mjs +1289 -0
  20. package/dist/config-DxMZjpvY.mjs.map +1 -0
  21. package/dist/context-handover-cache-B9NKSjwR.mjs +3242 -0
  22. package/dist/context-handover-cache-B9NKSjwR.mjs.map +1 -0
  23. package/dist/daemon/index.mjs +23 -22
  24. package/dist/daemon/index.mjs.map +1 -1
  25. package/dist/daemon-BNjbVsQU.mjs +24 -0
  26. package/dist/{daemon-B8N1egl0.mjs → daemon-BppziPi6.mjs} +52 -38
  27. package/dist/daemon-BppziPi6.mjs.map +1 -0
  28. package/dist/daemon-mcp/index.mjs +144 -117
  29. package/dist/daemon-mcp/index.mjs.map +1 -1
  30. package/dist/db-BOBfewFH.mjs +3 -0
  31. package/dist/{db-a1ixZQjr.mjs → db-DJ8rk0VM.mjs} +15 -6
  32. package/dist/{db-a1ixZQjr.mjs.map → db-DJ8rk0VM.mjs.map} +1 -1
  33. package/dist/db-DbY2O8QO.mjs +3 -0
  34. package/dist/{db-Ca5qfsMC.mjs → db-Ddns80yf.mjs} +15 -6
  35. package/dist/{db-Ca5qfsMC.mjs.map → db-Ddns80yf.mjs.map} +1 -1
  36. package/dist/{detect-CdaA48EI.mjs → detect-Bf2z-oKB.mjs} +1 -1
  37. package/dist/{detect-CdaA48EI.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
  38. package/dist/detector-CkwkXuGh.mjs +5 -0
  39. package/dist/{detector-C73Nxm94.mjs → detector-DWXkeTlk.mjs} +2 -2
  40. package/dist/{detector-C73Nxm94.mjs.map → detector-DWXkeTlk.mjs.map} +1 -1
  41. package/dist/{embeddings-CEBGrzwu.mjs → embeddings-ClqKtLZ8.mjs} +1 -1
  42. package/dist/{embeddings-DOLZnT1X.mjs → embeddings-DvRKcQyt.mjs} +1 -1
  43. package/dist/{embeddings-DOLZnT1X.mjs.map → embeddings-DvRKcQyt.mjs.map} +1 -1
  44. package/dist/factory-47Qf_df7.mjs +3 -0
  45. package/dist/{factory-DEb9NmHJ.mjs → factory-DNYAqjH0.mjs} +8 -8
  46. package/dist/{factory-DEb9NmHJ.mjs.map → factory-DNYAqjH0.mjs.map} +1 -1
  47. package/dist/{fallback-oJl3BaIN.mjs → fallback-Cwt4hmh5.mjs} +182 -122
  48. package/dist/fallback-Cwt4hmh5.mjs.map +1 -0
  49. package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
  50. package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
  51. package/dist/hooks/capture-all-events.mjs +117 -34
  52. package/dist/hooks/capture-all-events.mjs.map +4 -4
  53. package/dist/hooks/capture-session-summary.mjs +110 -33
  54. package/dist/hooks/capture-session-summary.mjs.map +4 -4
  55. package/dist/hooks/capture-tool-output.mjs +105 -28
  56. package/dist/hooks/capture-tool-output.mjs.map +4 -4
  57. package/dist/hooks/cleanup-session-files.mjs +54 -13
  58. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  59. package/dist/hooks/context-compression-hook.mjs +7565 -122
  60. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  61. package/dist/hooks/initialize-session.mjs +55 -14
  62. package/dist/hooks/initialize-session.mjs.map +2 -2
  63. package/dist/hooks/inject-observations.mjs +79 -18
  64. package/dist/hooks/inject-observations.mjs.map +3 -3
  65. package/dist/hooks/load-core-context.mjs +55 -14
  66. package/dist/hooks/load-core-context.mjs.map +2 -2
  67. package/dist/hooks/load-project-context.mjs +185 -103
  68. package/dist/hooks/load-project-context.mjs.map +4 -4
  69. package/dist/hooks/mcp-deferred-gate.mjs +54 -13
  70. package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
  71. package/dist/hooks/mcp-deferred-reminder.mjs +9 -0
  72. package/dist/hooks/mcp-deferred-reminder.mjs.map +3 -3
  73. package/dist/hooks/observe.mjs +54 -13
  74. package/dist/hooks/observe.mjs.map +2 -2
  75. package/dist/hooks/post-compact-inject.mjs +7419 -16
  76. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  77. package/dist/hooks/route-agents-to-worker.mjs +7778 -171
  78. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  79. package/dist/hooks/route-edits-to-worker.mjs +90 -0
  80. package/dist/hooks/route-edits-to-worker.mjs.map +7 -0
  81. package/dist/hooks/security-validator.mjs +147 -6
  82. package/dist/hooks/security-validator.mjs.map +4 -4
  83. package/dist/hooks/session-commands.mjs +9 -0
  84. package/dist/hooks/session-commands.mjs.map +3 -3
  85. package/dist/hooks/stop-hook.mjs +182 -105
  86. package/dist/hooks/stop-hook.mjs.map +4 -4
  87. package/dist/hooks/subagent-stop-hook.mjs +3 -0
  88. package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
  89. package/dist/hooks/sync-todo-to-md.mjs +54 -13
  90. package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
  91. package/dist/hooks/update-tab-on-action.mjs +3 -0
  92. package/dist/hooks/update-tab-on-action.mjs.map +1 -1
  93. package/dist/hooks/update-tab-titles.mjs +3 -0
  94. package/dist/hooks/update-tab-titles.mjs.map +1 -1
  95. package/dist/hooks/whisper-reinject.mjs +3 -0
  96. package/dist/hooks/whisper-reinject.mjs.map +1 -1
  97. package/dist/hooks/whisper-rules.mjs +134 -29
  98. package/dist/hooks/whisper-rules.mjs.map +4 -4
  99. package/dist/hooks/worker-proxy.mjs +7737 -117
  100. package/dist/hooks/worker-proxy.mjs.map +4 -4
  101. package/dist/hooks/worker-status-line.mjs +7791 -144
  102. package/dist/hooks/worker-status-line.mjs.map +4 -4
  103. package/dist/hooks/worker-supervision.mjs +7721 -115
  104. package/dist/hooks/worker-supervision.mjs.map +4 -4
  105. package/dist/index.d.mts +4 -2
  106. package/dist/index.d.mts.map +1 -1
  107. package/dist/index.mjs +10 -10
  108. package/dist/{indexer-backend-BPTzKFZU.mjs → indexer-backend-CdvCJCkD.mjs} +3 -3
  109. package/dist/{indexer-backend-BPTzKFZU.mjs.map → indexer-backend-CdvCJCkD.mjs.map} +1 -1
  110. package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C6NryMMb.mjs} +2 -2
  111. package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C6NryMMb.mjs.map} +1 -1
  112. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-BSjM5xZh.mjs} +1 -1
  113. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-BSjM5xZh.mjs.map} +1 -1
  114. package/dist/{latent-ideas-CtSca4qG.mjs → latent-ideas-BfL4nCQ3.mjs} +4 -4
  115. package/dist/{latent-ideas-CtSca4qG.mjs.map → latent-ideas-BfL4nCQ3.mjs.map} +1 -1
  116. package/dist/{link-boost-Bucnz3n8.mjs → link-boost-C5PaNdzm.mjs} +1 -1
  117. package/dist/{link-boost-Bucnz3n8.mjs.map → link-boost-C5PaNdzm.mjs.map} +1 -1
  118. package/dist/main-resolver-CnfnWa8C.mjs +7 -0
  119. package/dist/{main-resolver-DnkxPYkr.mjs → main-resolver-DvYu9jbl.mjs} +21 -11
  120. package/dist/main-resolver-DvYu9jbl.mjs.map +1 -0
  121. package/dist/{migrate-Cjzeefn9.mjs → migrate-CfERwXhY.mjs} +2 -2
  122. package/dist/{migrate-Cjzeefn9.mjs.map → migrate-CfERwXhY.mjs.map} +1 -1
  123. package/dist/{neighborhood-CM7Jgb51.mjs → neighborhood-CETR_P4y.mjs} +2 -2
  124. package/dist/{neighborhood-CM7Jgb51.mjs.map → neighborhood-CETR_P4y.mjs.map} +1 -1
  125. package/dist/{note-context-DCwE2A-M.mjs → note-context-DCcRWBoN.mjs} +1 -1
  126. package/dist/{note-context-DCwE2A-M.mjs.map → note-context-DCcRWBoN.mjs.map} +1 -1
  127. package/dist/pai-home-ChAOupZc.mjs +164 -0
  128. package/dist/pai-home-ChAOupZc.mjs.map +1 -0
  129. package/dist/{pai-marker-CHtbJMwJ.mjs → pai-marker-B20KqhA8.mjs} +1 -1
  130. package/dist/{pai-marker-CHtbJMwJ.mjs.map → pai-marker-B20KqhA8.mjs.map} +1 -1
  131. package/dist/{planner-D5Ui7DAm.mjs → planner-pXRc-8yt.mjs} +7 -6
  132. package/dist/{planner-D5Ui7DAm.mjs.map → planner-pXRc-8yt.mjs.map} +1 -1
  133. package/dist/{postgres-CtOzfoQU.mjs → postgres-CUSXWgTG.mjs} +2 -2
  134. package/dist/{postgres-CtOzfoQU.mjs.map → postgres-CUSXWgTG.mjs.map} +1 -1
  135. package/dist/{program-zpoYnJGz.mjs → program-LLNPpxlj.mjs} +8308 -6065
  136. package/dist/program-LLNPpxlj.mjs.map +1 -0
  137. package/dist/query-feedback-C7rSfiXU.mjs +3 -0
  138. package/dist/{query-feedback-Dqz-HC__.mjs → query-feedback-CYyOGi3y.mjs} +20 -6
  139. package/dist/query-feedback-CYyOGi3y.mjs.map +1 -0
  140. package/dist/{reranker-CwTCNsgA.mjs → reranker-B5qUTamW.mjs} +1 -1
  141. package/dist/{reranker-xPm04PXx.mjs → reranker-HHmhZk6v.mjs} +1 -1
  142. package/dist/{reranker-xPm04PXx.mjs.map → reranker-HHmhZk6v.mjs.map} +1 -1
  143. package/dist/router-CremHKTI.mjs +3 -0
  144. package/dist/{router-DD6D4j31.mjs → router-DBAZfE7X.mjs} +2 -2
  145. package/dist/{router-DD6D4j31.mjs.map → router-DBAZfE7X.mjs.map} +1 -1
  146. package/dist/{run-D7K5UALn.mjs → run-DDdT9teG.mjs} +530 -210
  147. package/dist/run-DDdT9teG.mjs.map +1 -0
  148. package/dist/{runtime-paths-rni52zHX.mjs → runtime-paths-B0P1TvUr.mjs} +1 -1
  149. package/dist/{runtime-paths-rni52zHX.mjs.map → runtime-paths-B0P1TvUr.mjs.map} +1 -1
  150. package/dist/{search-Rpk1cSBC.mjs → search-B69cQwDB.mjs} +3 -3
  151. package/dist/{search-Rpk1cSBC.mjs.map → search-B69cQwDB.mjs.map} +1 -1
  152. package/dist/{search-CfPpJAWQ.mjs → search-fb-5ovj4.mjs} +2 -2
  153. package/dist/{server-D4vAieF4.mjs → server-Ch99xt5K.mjs} +15 -14
  154. package/dist/server-Ch99xt5K.mjs.map +1 -0
  155. package/dist/skills/Advisor/SKILL.md +3 -3
  156. package/dist/skills/Providers/SKILL.md +30 -0
  157. package/dist/skills/Reconstruct/SKILL.md +1 -1
  158. package/dist/skills/VaultContext/SKILL.md +1 -1
  159. package/dist/skills/Whisper/SKILL.md +3 -3
  160. package/dist/skills/Worker/SKILL.md +1 -1
  161. package/dist/{sources-CS43CpuS.mjs → sources-KBGEA_E6.mjs} +2 -2
  162. package/dist/{sources-CS43CpuS.mjs.map → sources-KBGEA_E6.mjs.map} +1 -1
  163. package/dist/{sqlite-Cq3gJcq9.mjs → sqlite-By6r2YhZ.mjs} +3 -3
  164. package/dist/{sqlite-Cq3gJcq9.mjs.map → sqlite-By6r2YhZ.mjs.map} +1 -1
  165. package/dist/{state-CY6I-Hnc.mjs → state-DnYGRmS7.mjs} +1 -1
  166. package/dist/{state-BRdB-Bf1.mjs → state-PJrgfDLW.mjs} +1 -1
  167. package/dist/{state-BRdB-Bf1.mjs.map → state-PJrgfDLW.mjs.map} +1 -1
  168. package/dist/{stop-words-Hfu8u22w.mjs → stop-words-BaMEGVeY.mjs} +1 -1
  169. package/dist/{stop-words-Hfu8u22w.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
  170. package/dist/{sync-BWbe8JTg.mjs → sync-Btcb6xA4.mjs} +3 -3
  171. package/dist/{sync-BWbe8JTg.mjs.map → sync-Btcb6xA4.mjs.map} +1 -1
  172. package/dist/{themes-B7h9YDiy.mjs → themes-BVoAnLZj.mjs} +3 -3
  173. package/dist/{themes-B7h9YDiy.mjs.map → themes-BVoAnLZj.mjs.map} +1 -1
  174. package/dist/{tools-BW3NLMPk.mjs → tools-BradfQk7.mjs} +4 -3
  175. package/dist/{tools-LlzcV_eK.mjs → tools-UrSHIe29.mjs} +30 -233
  176. package/dist/tools-UrSHIe29.mjs.map +1 -0
  177. package/dist/{trace-u4hT-pLD.mjs → trace-Cy8quQTm.mjs} +1 -1
  178. package/dist/{trace-u4hT-pLD.mjs.map → trace-Cy8quQTm.mjs.map} +1 -1
  179. package/dist/{utils-DhMex3Ox.mjs → utils-BD8g8oSf.mjs} +1 -1
  180. package/dist/{utils-9Err2RBW.mjs → utils-CJymHkYm.mjs} +1 -1
  181. package/dist/{utils-9Err2RBW.mjs.map → utils-CJymHkYm.mjs.map} +1 -1
  182. package/dist/{vault-indexer-mata9S7b.mjs → vault-indexer-C5i-OAyr.mjs} +2 -2
  183. package/dist/{vault-indexer-mata9S7b.mjs.map → vault-indexer-C5i-OAyr.mjs.map} +1 -1
  184. package/dist/wakeup-TnGjJY3I.mjs +218 -0
  185. package/dist/wakeup-TnGjJY3I.mjs.map +1 -0
  186. package/dist/work-queue-worker-BA5IDBwQ.mjs +567 -0
  187. package/dist/work-queue-worker-BA5IDBwQ.mjs.map +1 -0
  188. package/dist/work-queue-worker-DLM2wlqM.mjs +14 -0
  189. package/dist/{zettelkasten-F0D_XfQ4.mjs → zettelkasten-BBjA7GcP.mjs} +4 -4
  190. package/dist/{zettelkasten-F0D_XfQ4.mjs.map → zettelkasten-BBjA7GcP.mjs.map} +1 -1
  191. package/docs/commands/README.md +30 -3
  192. package/docs/commands/audit.md +88 -0
  193. package/docs/commands/backup.md +2 -2
  194. package/docs/commands/clear-names.md +1 -1
  195. package/docs/commands/config.md +59 -0
  196. package/docs/commands/daemon.md +1 -1
  197. package/docs/commands/db.md +1 -1
  198. package/docs/commands/end.md +1 -1
  199. package/docs/commands/help.md +1 -1
  200. package/docs/commands/identity.md +1 -1
  201. package/docs/commands/kg.md +1 -1
  202. package/docs/commands/launch.md +45 -0
  203. package/docs/commands/mcp.md +1 -1
  204. package/docs/commands/memory.md +3 -3
  205. package/docs/commands/notify.md +1 -1
  206. package/docs/commands/observation.md +1 -1
  207. package/docs/commands/obsidian.md +1 -1
  208. package/docs/commands/pause.md +1 -1
  209. package/docs/commands/project.md +46 -1
  210. package/docs/commands/projects.md +46 -1
  211. package/docs/commands/registry.md +1 -1
  212. package/docs/commands/restore.md +1 -1
  213. package/docs/commands/session.md +1 -1
  214. package/docs/commands/sessions.md +1 -1
  215. package/docs/commands/setup.md +1 -1
  216. package/docs/commands/shell-init.md +1 -1
  217. package/docs/commands/skill.md +1 -1
  218. package/docs/commands/task.md +1 -1
  219. package/docs/commands/topic.md +1 -1
  220. package/docs/commands/update.md +1 -1
  221. package/docs/commands/worker.md +95 -6
  222. package/docs/commands/zettel.md +1 -1
  223. package/docs/provider-abstraction.md +1 -1
  224. package/docs/provider-independence-details.md +8 -5
  225. package/docs/task-bus.md +1 -1
  226. package/docs/worker.md +136 -83
  227. package/docs/workers-config.md +446 -0
  228. package/package.json +4 -2
  229. package/scripts/build-hooks.mjs +56 -3
  230. package/src/hooks/pre-compact.sh +5 -2
  231. package/src/hooks/session-stop.sh +29 -4
  232. package/src/hooks/ts/capture-all-events.ts +3 -3
  233. package/src/hooks/ts/lib/advisor-budget.test.ts +98 -0
  234. package/src/hooks/ts/lib/advisor-budget.ts +90 -0
  235. package/src/hooks/ts/lib/agent-gate.test.ts +101 -0
  236. package/src/hooks/ts/lib/agent-gate.ts +112 -0
  237. package/src/hooks/ts/lib/context-fill.ts +2 -2
  238. package/src/hooks/ts/lib/edit-gate.test.ts +52 -0
  239. package/src/hooks/ts/lib/edit-gate.ts +51 -0
  240. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +8 -0
  241. package/src/hooks/ts/lib/pai-paths.test.ts +93 -0
  242. package/src/hooks/ts/lib/pai-paths.ts +168 -24
  243. package/src/hooks/ts/lib/project-utils/index.ts +1 -0
  244. package/src/hooks/ts/lib/project-utils/paths.ts +5 -4
  245. package/src/hooks/ts/lib/project-utils.ts +1 -0
  246. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +2 -2
  247. package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
  248. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +46 -99
  249. package/src/hooks/ts/pre-tool-use/route-edits-to-worker.test.ts +47 -0
  250. package/src/hooks/ts/pre-tool-use/route-edits-to-worker.ts +96 -0
  251. package/src/hooks/ts/pre-tool-use/security-validator.ts +2 -1
  252. package/src/hooks/ts/session-end/capture-session-summary.ts +4 -4
  253. package/src/hooks/ts/session-start/initialize-session.ts +3 -3
  254. package/src/hooks/ts/session-start/inject-observations.test.ts +56 -0
  255. package/src/hooks/ts/session-start/inject-observations.ts +28 -8
  256. package/src/hooks/ts/session-start/load-core-context.ts +4 -4
  257. package/src/hooks/ts/session-start/load-project-context.ts +2 -2
  258. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -0
  259. package/src/hooks/ts/session-start/session-commands.ts +4 -0
  260. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +36 -0
  261. package/src/hooks/ts/stop/stop-hook.ts +6 -11
  262. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +49 -0
  263. package/src/hooks/ts/user-prompt/whisper-rules.ts +88 -47
  264. package/statusline-command.sh +277 -116
  265. package/templates/agent-prefs.example.md +8 -8
  266. package/templates/claude-md.template.md +1 -1
  267. package/templates/voices.example.json +3 -3
  268. package/dist/config-C_ErGddD.mjs +0 -3
  269. package/dist/config-_lQI_W08.mjs +0 -640
  270. package/dist/config-_lQI_W08.mjs.map +0 -1
  271. package/dist/context-handover-cache-pNFdBNrU.mjs +0 -1548
  272. package/dist/context-handover-cache-pNFdBNrU.mjs.map +0 -1
  273. package/dist/daemon-B8N1egl0.mjs.map +0 -1
  274. package/dist/daemon-DHfyoTlk.mjs +0 -23
  275. package/dist/db-O-cyAPfS.mjs +0 -3
  276. package/dist/db-XEwJbuGO.mjs +0 -3
  277. package/dist/detector-C-7KRi42.mjs +0 -5
  278. package/dist/factory-Dbo8yNPi.mjs +0 -3
  279. package/dist/fallback-oJl3BaIN.mjs.map +0 -1
  280. package/dist/main-resolver-BAfOMppW.mjs +0 -7
  281. package/dist/main-resolver-DnkxPYkr.mjs.map +0 -1
  282. package/dist/program-zpoYnJGz.mjs.map +0 -1
  283. package/dist/query-feedback-DGoVRaWD.mjs +0 -3
  284. package/dist/query-feedback-Dqz-HC__.mjs.map +0 -1
  285. package/dist/router-BTIGZR2_.mjs +0 -3
  286. package/dist/run-D7K5UALn.mjs.map +0 -1
  287. package/dist/server-D4vAieF4.mjs.map +0 -1
  288. package/dist/tools-LlzcV_eK.mjs.map +0 -1
  289. package/dist/work-queue-worker-AEmfVE0F.mjs +0 -14
  290. package/dist/work-queue-worker-DIT-VLSh.mjs +0 -2136
  291. package/dist/work-queue-worker-DIT-VLSh.mjs.map +0 -1
  292. /package/dist/{indexer-D7MvSQPY.mjs → indexer-C5Le7XE7.mjs} +0 -0
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ ADVISOR_MAX_AGE_SECONDS,
4
+ freshBudgetPercent,
5
+ normaliseMode,
6
+ resolveAdvisorMode,
7
+ } from "./advisor-budget.js";
8
+
9
+ const NOW = 1_790_000_000;
10
+
11
+ describe("freshBudgetPercent", () => {
12
+ it("returns the percentage while the reading is current", () => {
13
+ expect(freshBudgetPercent({ weeklyBudgetPercent: 2, asOf: NOW - 30 }, NOW)).toBe(2);
14
+ });
15
+
16
+ it("keeps a zero percentage — 0% used is a reading, not a missing one", () => {
17
+ expect(freshBudgetPercent({ weeklyBudgetPercent: 0, asOf: NOW }, NOW)).toBe(0);
18
+ });
19
+
20
+ it("drops a reading older than the max age", () => {
21
+ const stale = { weeklyBudgetPercent: 97, asOf: NOW - ADVISOR_MAX_AGE_SECONDS - 1 };
22
+ expect(freshBudgetPercent(stale, NOW)).toBeUndefined();
23
+ });
24
+
25
+ it("drops a reading with no asOf at all", () => {
26
+ // This is the shape the old statusline wrote. It is indistinguishable from
27
+ // one that stopped being updated, so it cannot be trusted.
28
+ expect(freshBudgetPercent({ weeklyBudgetPercent: 97, mode: "auto" }, NOW)).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe("normaliseMode", () => {
33
+ it("accepts the five real modes", () => {
34
+ for (const m of ["normal", "conservative", "strict", "critical", "auto"]) {
35
+ expect(normaliseMode(m)).toBe(m);
36
+ }
37
+ });
38
+
39
+ it("treats the empty string as auto", () => {
40
+ // {"mode": ""} is what the statusline used to write with no manual
41
+ // override; `?? "auto"` did not catch it and the advisor went silent.
42
+ expect(normaliseMode("")).toBe("auto");
43
+ });
44
+
45
+ it("treats anything unrecognised as auto", () => {
46
+ expect(normaliseMode(undefined)).toBe("auto");
47
+ expect(normaliseMode(7)).toBe("auto");
48
+ expect(normaliseMode("panic")).toBe("auto");
49
+ });
50
+ });
51
+
52
+ describe("resolveAdvisorMode", () => {
53
+ it.each([
54
+ [0, "normal"],
55
+ [2, "normal"],
56
+ [59, "normal"],
57
+ [60, "conservative"],
58
+ [79, "conservative"],
59
+ [80, "strict"],
60
+ [91, "strict"],
61
+ [92, "critical"],
62
+ [100, "critical"],
63
+ ])("maps %i%% used to %s", (percent, expected) => {
64
+ const got = resolveAdvisorMode({ weeklyBudgetPercent: percent, asOf: NOW }, NOW);
65
+ expect(got.mode).toBe(expected);
66
+ expect(got.percent).toBe(percent);
67
+ });
68
+
69
+ it("reads the percentage as USED, not as remaining", () => {
70
+ // The whole fault this module exists for: 2% used is a nearly untouched
71
+ // budget and must read "normal". If anything ever flips the sense, 2 lands
72
+ // on 98 and this test fails rather than a session being told to stop
73
+ // working. Both ends are pinned so an inversion cannot pass.
74
+ expect(resolveAdvisorMode({ weeklyBudgetPercent: 2, asOf: NOW }, NOW).mode).toBe("normal");
75
+ expect(resolveAdvisorMode({ weeklyBudgetPercent: 98, asOf: NOW }, NOW).mode).toBe("critical");
76
+ });
77
+
78
+ it("says nothing at all when the reading is stale", () => {
79
+ const stale = { weeklyBudgetPercent: 97, asOf: NOW - ADVISOR_MAX_AGE_SECONDS - 1 };
80
+ expect(resolveAdvisorMode(stale, NOW)).toEqual({ mode: undefined, percent: undefined });
81
+ });
82
+
83
+ it("says nothing at all when there is no reading", () => {
84
+ expect(resolveAdvisorMode({}, NOW)).toEqual({ mode: undefined, percent: undefined });
85
+ });
86
+
87
+ it("honours a manual mode even with no usable percentage", () => {
88
+ const got = resolveAdvisorMode({ mode: "strict" }, NOW);
89
+ expect(got.mode).toBe("strict");
90
+ expect(got.percent).toBeUndefined();
91
+ });
92
+
93
+ it("lets a manual mode override a current reading", () => {
94
+ const got = resolveAdvisorMode({ mode: "critical", weeklyBudgetPercent: 2, asOf: NOW }, NOW);
95
+ expect(got.mode).toBe("critical");
96
+ expect(got.percent).toBe(2);
97
+ });
98
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * advisor-budget.ts
3
+ *
4
+ * Deciding how much of the weekly Anthropic budget is gone, and whether that
5
+ * answer is current enough to act on.
6
+ *
7
+ * The statusline writes `~/.claude/pai/advisor-mode.json` on every render of an
8
+ * Anthropic session; the whisper hook reads it and injects an instruction about
9
+ * how much work to do. That makes this the one number in PAI that changes the
10
+ * assistant's behaviour rather than merely describing it, which is why it is
11
+ * worth its own module with its own tests.
12
+ */
13
+
14
+ export interface AdvisorConfig {
15
+ weeklyBudgetPercent?: number; // 0-100, used (not remaining)
16
+ asOf?: number; // epoch seconds the percentage was read
17
+ mode?: "normal" | "conservative" | "strict" | "critical" | "auto";
18
+ forceModel?: string; // override: always use this model for subagents
19
+ }
20
+
21
+ export type AdvisorMode = "normal" | "conservative" | "strict" | "critical" | "auto";
22
+
23
+ const VALID_MODES: readonly string[] = ["normal", "conservative", "strict", "critical", "auto"];
24
+
25
+ /**
26
+ * How old a budget reading may be before it stops being obeyed.
27
+ *
28
+ * A live machine rewrites the file many times a minute, so it never comes near
29
+ * this. Exceeding it means nothing has reported a budget for hours — which is
30
+ * exactly what a broken usage source looks like.
31
+ *
32
+ * That failure has happened: the OAuth token the statusline read went away, its
33
+ * usage cache froze, and "weekly budget at 97%" was injected into every session
34
+ * for a day and a half after the real figure had reset to 2% — telling every
35
+ * assistant to downgrade models and skip verification on a budget that was
36
+ * almost untouched. A budget guard has to fail towards doing the work, not
37
+ * towards refusing it.
38
+ */
39
+ export const ADVISOR_MAX_AGE_SECONDS = 6 * 60 * 60;
40
+
41
+ /**
42
+ * The budget percentage, but only while it can be shown to be current.
43
+ *
44
+ * A reading with no `asOf` comes from a writer that predates the timestamp, and
45
+ * there is no way to tell a fresh one from one that stopped being updated — so
46
+ * it counts as unknown rather than as true.
47
+ */
48
+ export function freshBudgetPercent(
49
+ config: AdvisorConfig,
50
+ nowSeconds: number,
51
+ ): number | undefined {
52
+ const pct = config.weeklyBudgetPercent;
53
+ if (typeof pct !== "number" || !Number.isFinite(pct)) return undefined;
54
+ if (typeof config.asOf !== "number" || !Number.isFinite(config.asOf)) return undefined;
55
+ if (nowSeconds - config.asOf > ADVISOR_MAX_AGE_SECONDS) return undefined;
56
+ return pct;
57
+ }
58
+
59
+ /**
60
+ * Normalise whatever is in the file to a mode.
61
+ *
62
+ * `??` only substitutes on null/undefined, so a config written as {"mode": ""}
63
+ * — which is what an older statusline produced when there was no manual
64
+ * override — left mode as the empty string, matching no branch downstream and
65
+ * silently disabling the advisor whatever the budget said. Anything that is not
66
+ * a recognised mode is "auto".
67
+ */
68
+ export function normaliseMode(mode: unknown): AdvisorMode {
69
+ return typeof mode === "string" && VALID_MODES.includes(mode)
70
+ ? (mode as AdvisorMode)
71
+ : "auto";
72
+ }
73
+
74
+ /**
75
+ * The mode to act on: the manual one if set, else derived from a current
76
+ * budget reading, else undefined — meaning say nothing at all.
77
+ */
78
+ export function resolveAdvisorMode(
79
+ config: AdvisorConfig,
80
+ nowSeconds: number,
81
+ ): { mode: Exclude<AdvisorMode, "auto"> | undefined; percent: number | undefined } {
82
+ const percent = freshBudgetPercent(config, nowSeconds);
83
+ const configured = normaliseMode(config.mode);
84
+ if (configured !== "auto") return { mode: configured, percent };
85
+ if (percent === undefined) return { mode: undefined, percent: undefined };
86
+ if (percent < 60) return { mode: "normal", percent };
87
+ if (percent < 80) return { mode: "conservative", percent };
88
+ if (percent < 92) return { mode: "strict", percent };
89
+ return { mode: "critical", percent };
90
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ decideAgentGate,
4
+ renderDecision,
5
+ shortLabel,
6
+ SUBAGENT_TOOL_NAMES,
7
+ BYPASS_ENV,
8
+ type AgentGateInput,
9
+ } from "./agent-gate.js";
10
+
11
+ /**
12
+ * A real PreToolUse payload shape: taken from an actual in-process subagent
13
+ * launch recorded in a session transcript (tool_use name "Agent", input keys
14
+ * description/model/prompt), wrapped in the PreToolUse envelope the harness
15
+ * feeds hooks on stdin.
16
+ */
17
+ function payload(toolName: string, description = "Survey App Store state"): AgentGateInput {
18
+ return {
19
+ session_id: "00000000-0000-0000-0000-000000000000",
20
+ cwd: "/tmp/some-project",
21
+ tool_name: toolName,
22
+ tool_input: { description, model: "sonnet", prompt: "Do the thing." } as AgentGateInput["tool_input"],
23
+ };
24
+ }
25
+
26
+ /** What the harness actually reads: parse the hook's stdout. */
27
+ function decisionOf(input: AgentGateInput, env: Record<string, string | undefined>) {
28
+ return JSON.parse(renderDecision(decideAgentGate(input, env))).hookSpecificOutput;
29
+ }
30
+
31
+ describe("agent gate", () => {
32
+ it("covers both the current and the legacy subagent tool name", () => {
33
+ expect([...SUBAGENT_TOOL_NAMES]).toEqual(["Agent", "Task"]);
34
+ });
35
+
36
+ for (const name of SUBAGENT_TOOL_NAMES) {
37
+ it(`denies a ${name} call`, () => {
38
+ const out = decisionOf(payload(name), {});
39
+ expect(out.permissionDecision).toBe("deny");
40
+ expect(out.hookEventName).toBe("PreToolUse");
41
+ expect(out.permissionDecisionReason).toContain("pai worker run --provider anthropic");
42
+ });
43
+
44
+ it(`allows a ${name} call when ${BYPASS_ENV}=1`, () => {
45
+ const out = decisionOf(payload(name), { [BYPASS_ENV]: "1" });
46
+ expect(out.permissionDecision).toBe("allow");
47
+ expect(out.permissionDecisionReason).toBeUndefined();
48
+ });
49
+ }
50
+
51
+ it("denies regardless of the workers config — no config is consulted", () => {
52
+ // The gate used to allow when routing was off or the active provider was
53
+ // Anthropic. It takes no config argument at all now, so there is nothing
54
+ // left that can switch it off but the bypass env var.
55
+ expect(decideAgentGate.length).toBe(2);
56
+ expect(decisionOf(payload("Agent"), { PAI_WORKERS_ACTIVE: "anthropic" }).permissionDecision).toBe(
57
+ "deny"
58
+ );
59
+ });
60
+
61
+ it("only the exact value 1 bypasses", () => {
62
+ for (const v of ["0", "true", "yes", "", undefined]) {
63
+ expect(decisionOf(payload("Agent"), { [BYPASS_ENV]: v }).permissionDecision).toBe("deny");
64
+ }
65
+ });
66
+
67
+ it("lets a non-subagent tool through untouched", () => {
68
+ const out = decisionOf(payload("Bash"), {});
69
+ expect(out.permissionDecision).toBe("allow");
70
+ });
71
+
72
+ it("denies an event with no tool_name (malformed subagent launch)", () => {
73
+ expect(decisionOf({}, {}).permissionDecision).toBe("deny");
74
+ });
75
+
76
+ it("ledgers both outcomes so `pai worker log` shows them", () => {
77
+ expect(decideAgentGate(payload("Agent"), {}).ledger).toBe("DENIED-ANTHROPIC-AGENT");
78
+ expect(decideAgentGate(payload("Agent"), { [BYPASS_ENV]: "1" }).ledger).toBe(
79
+ "ALLOWED-ANTHROPIC-AGENT"
80
+ );
81
+ });
82
+
83
+ it("puts the subagent description in the suggested --label", () => {
84
+ const out = decisionOf(payload("Agent", " Report local release config "), {});
85
+ expect(out.permissionDecisionReason).toContain('--label "Report local release config"');
86
+ });
87
+
88
+ it("falls back to a generic label when there is no description", () => {
89
+ const out = decisionOf({ tool_name: "Agent" }, {});
90
+ expect(out.permissionDecisionReason).toContain('--label "task"');
91
+ });
92
+
93
+ it("truncates a very long description to a usable label", () => {
94
+ expect(shortLabel(payload("Agent", "x".repeat(200)))).toHaveLength(60);
95
+ });
96
+
97
+ it("names the bypass in the deny reason so the escape hatch stays discoverable", () => {
98
+ const out = decisionOf(payload("Agent"), {});
99
+ expect(out.permissionDecisionReason).toContain(`${BYPASS_ENV}=1`);
100
+ });
101
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * agent-gate.ts — the decision behind the PreToolUse subagent gate.
3
+ *
4
+ * Pure: no stdin, no filesystem, no config, no process.env read of its own.
5
+ * The hook entry (pre-tool-use/route-agents-to-worker.ts) does the I/O; this
6
+ * module decides, so the decision can be tested directly instead of inferred
7
+ * from a hook that happens to exit 0.
8
+ *
9
+ * The gate is deliberately NOT conditional on the workers config. It used to
10
+ * allow whenever routing was off, no provider was configured, or the active
11
+ * provider was the native Anthropic one — which made the guard silently
12
+ * self-disabling. Observed 2026-09-19 with `workers.active = "anthropic"`:
13
+ * two in-process subagents ran to completion, invisible to `pai worker ps`.
14
+ * Visibility is the point of the gate, and it is worth just as much when the
15
+ * work is meant to run on an Anthropic model — `pai worker run --provider
16
+ * anthropic` delivers exactly that, and shows up in `pai worker ps`.
17
+ */
18
+
19
+ export interface AgentGateInput {
20
+ session_id?: string;
21
+ cwd?: string;
22
+ tool_name?: string;
23
+ tool_input?: { description?: string; prompt?: string; subagent_type?: string } | string;
24
+ }
25
+
26
+ /**
27
+ * Tool names the harness has used for in-process subagents. "Agent" is what
28
+ * the current harness emits (verified against real tool_use records in the
29
+ * session transcripts); "Task" is the older name, matched as well so that a
30
+ * harness rename in either direction cannot silently reopen the hole.
31
+ */
32
+ export const SUBAGENT_TOOL_NAMES = ["Agent", "Task"] as const;
33
+
34
+ /** Environment variable that disables the gate for one session. */
35
+ export const BYPASS_ENV = "ALLOW_ANTHROPIC_AGENTS";
36
+
37
+ export type GateDecision =
38
+ | { decision: "allow"; ledger: string | null }
39
+ | { decision: "deny"; reason: string; ledger: string };
40
+
41
+ export function isSubagentTool(name: string): boolean {
42
+ return (SUBAGENT_TOOL_NAMES as readonly string[]).includes(name);
43
+ }
44
+
45
+ /** The label the deny message suggests for the replacement worker run. */
46
+ export function shortLabel(input: AgentGateInput): string {
47
+ const desc =
48
+ typeof input.tool_input === "object" && input.tool_input !== null
49
+ ? input.tool_input.description ?? ""
50
+ : "";
51
+ return desc.replace(/\s+/g, " ").trim().slice(0, 60);
52
+ }
53
+
54
+ export function denyReason(label: string): string {
55
+ const name = label || "task";
56
+ return (
57
+ "The in-process subagent tool is disabled: every subagent must run through " +
58
+ "`pai worker run`, so that it shows up in `pai worker ps` and the status line and " +
59
+ "can be followed, replayed and killed. Delegate with Bash instead, in the background:\n\n" +
60
+ `pai worker run --provider anthropic --label "${name}" -p '<full, self-contained task spec>' ` +
61
+ "--allowedTools 'Read,Edit,Write,Bash,Grep,Glob' --output-format json\n\n" +
62
+ "- `--provider anthropic` is the supported way to get an Anthropic-model subagent, " +
63
+ "so there is never a reason to reach for the built-in tool.\n" +
64
+ "- Drop `--provider` to use the configured worker provider; pick the job with " +
65
+ "`--class draft|plan|implement|review|research|spotcheck`.\n" +
66
+ "- Run it in the background and always with a timeout.\n" +
67
+ "- Web research: add WebSearch,WebFetch to --allowedTools.\n" +
68
+ "- The answer is in the `result` field of the JSON it prints. Review the diff yourself.\n" +
69
+ "- `pai worker ps` lists running workers; `pai worker follow <id>` shows one live.\n" +
70
+ "Keep only orchestration, review and synthesis in this session. " +
71
+ `To re-enable in-process subagents for one session: start it with ${BYPASS_ENV}=1.`
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Decide. `env` is passed in so the bypass can be exercised without mutating
77
+ * the process environment.
78
+ */
79
+ export function decideAgentGate(
80
+ input: AgentGateInput,
81
+ env: Record<string, string | undefined>
82
+ ): GateDecision {
83
+ const name = input.tool_name;
84
+ // Matcher-scoped hook, but a stray event for another tool must pass through.
85
+ // A *missing* tool_name is not treated as another tool: the gate denies,
86
+ // because the only thing routed here is a subagent launch.
87
+ if (name && !isSubagentTool(name)) return { decision: "allow", ledger: null };
88
+
89
+ if (env[BYPASS_ENV] === "1") return { decision: "allow", ledger: "ALLOWED-ANTHROPIC-AGENT" };
90
+
91
+ return {
92
+ decision: "deny",
93
+ reason: denyReason(shortLabel(input)),
94
+ ledger: "DENIED-ANTHROPIC-AGENT",
95
+ };
96
+ }
97
+
98
+ /** Render a decision as the JSON the harness reads on the hook's stdout. */
99
+ export function renderDecision(d: GateDecision): string {
100
+ if (d.decision === "allow") {
101
+ return JSON.stringify({
102
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
103
+ });
104
+ }
105
+ return JSON.stringify({
106
+ hookSpecificOutput: {
107
+ hookEventName: "PreToolUse",
108
+ permissionDecision: "deny",
109
+ permissionDecisionReason: d.reason,
110
+ },
111
+ });
112
+ }
@@ -324,12 +324,12 @@ export const THRESHOLD_MARGIN_TOKENS = {
324
324
  //
325
325
  // Deliberately re-implements the tiny bit of path encoding it needs (below)
326
326
  // rather than importing project-utils/paths.ts's encodePath: that module
327
- // chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does
327
+ // chain ends in pai-paths.ts, which calls process.exit(1) if ADAPTER_DIR does
328
328
  // not resolve to an existing directory. context-fill.ts is specifically the
329
329
  // module a hook falls back on when its environment is unreliable — pulling
330
330
  // in a dependency that can kill the process on import would defeat that.
331
331
  // Claude Code's transcript directory is a fixed, home-relative convention
332
- // (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so
332
+ // (~/.claude/projects/<encoded-cwd>/), not something ADAPTER_DIR governs, so
333
333
  // resolving it directly here is also just the more correct dependency, not
334
334
  // only the safer one.
335
335
 
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isProseEdit, decideEditGate, type EditGateContext } from "./edit-gate.js";
3
+
4
+ describe("isProseEdit", () => {
5
+ it.each([
6
+ ["Notes/TODO.md", true],
7
+ ["notes/x.md", true],
8
+ ["README.md", true],
9
+ ["docs/commands/worker.md", true],
10
+ ["src/workers/run.ts", false],
11
+ ["package.json", false],
12
+ ["scripts/build-hooks.mjs", false],
13
+ ["src/foo.md", true],
14
+ ])("%s -> %s", (filePath, expected) => {
15
+ expect(isProseEdit(filePath)).toBe(expected);
16
+ });
17
+ });
18
+
19
+ describe("decideEditGate", () => {
20
+ function ctx(overrides: Partial<EditGateContext>): EditGateContext {
21
+ return {
22
+ filePath: "/repo/src/workers/run.ts",
23
+ isPaiWorker: false,
24
+ isExemptPrefix: false,
25
+ isGitWorkTree: true,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ it("allows a prose path even inside a git work tree", () => {
31
+ expect(decideEditGate(ctx({ filePath: "/repo/Notes/TODO.md" }))).toEqual({ decision: "allow" });
32
+ });
33
+
34
+ it("blocks a code path inside a git work tree with the standing message", () => {
35
+ const out = decideEditGate(ctx({}));
36
+ expect(out.decision).toBe("block");
37
+ expect((out as { reason: string }).reason).toContain("main session does not edit code");
38
+ expect((out as { reason: string }).reason).toContain("pai worker run");
39
+ });
40
+
41
+ it("allows worker sessions regardless of path", () => {
42
+ expect(decideEditGate(ctx({ isPaiWorker: true }))).toEqual({ decision: "allow" });
43
+ });
44
+
45
+ it("allows exempt prefixes regardless of path", () => {
46
+ expect(decideEditGate(ctx({ isExemptPrefix: true }))).toEqual({ decision: "allow" });
47
+ });
48
+
49
+ it("allows paths outside any git work tree", () => {
50
+ expect(decideEditGate(ctx({ isGitWorkTree: false }))).toEqual({ decision: "allow" });
51
+ });
52
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * edit-gate.ts — the decision behind the PreToolUse edit-routing gate.
3
+ *
4
+ * Pure: no stdin, no filesystem, no process.env read of its own. The hook
5
+ * entry (pre-tool-use/route-edits-to-worker.ts) resolves paths and checks the
6
+ * filesystem/env, then hands this module the already-computed facts to
7
+ * decide on — same split as lib/agent-gate.ts.
8
+ */
9
+
10
+ import { basename, extname } from "node:path";
11
+
12
+ export interface EditGateContext {
13
+ /** Absolute, already-resolved target path. */
14
+ filePath: string;
15
+ isPaiWorker: boolean;
16
+ isExemptPrefix: boolean;
17
+ isGitWorkTree: boolean;
18
+ }
19
+
20
+ export type EditGateDecision = { decision: "allow" } | { decision: "block"; reason: string };
21
+
22
+ /** Extensions that are prose regardless of which directory they live in. */
23
+ const PROSE_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
24
+
25
+ /**
26
+ * True for notes, session logs, handovers and other prose the outer session
27
+ * writes directly instead of routing through a worker — a `Notes`/`notes`
28
+ * directory segment anywhere in the path, or a prose file extension.
29
+ * Extension check runs first: a source file that happens to live under a
30
+ * `notes/` directory is still code, but `src/foo.md` is still prose.
31
+ */
32
+ export function isProseEdit(filePath: string): boolean {
33
+ const ext = extname(filePath).toLowerCase();
34
+ if (PROSE_EXTENSIONS.has(ext)) return true;
35
+ return filePath.split(/[\\/]/).some((segment) => segment === "Notes" || segment === "notes");
36
+ }
37
+
38
+ export function decideEditGate(ctx: EditGateContext): EditGateDecision {
39
+ if (ctx.isPaiWorker) return { decision: "allow" };
40
+ if (ctx.isExemptPrefix) return { decision: "allow" };
41
+ if (isProseEdit(ctx.filePath)) return { decision: "allow" };
42
+ if (!ctx.isGitWorkTree) return { decision: "allow" };
43
+
44
+ const label = basename(ctx.filePath).slice(0, 60) || "edit";
45
+ return {
46
+ decision: "block",
47
+ reason:
48
+ `main session does not edit code: run a worker instead — ` +
49
+ `pai worker run --label "${label}" --class implement --output-format json -p <spec> (target: ${ctx.filePath})`,
50
+ };
51
+ }
@@ -61,10 +61,18 @@ describe("session-start/mcp-deferred-reminder entrypoint", () => {
61
61
  const ENTRYPOINT = "src/hooks/ts/session-start/mcp-deferred-reminder.ts";
62
62
 
63
63
  function runHook(stdin: string): { status: number; stdout: string } {
64
+ // Explicitly unset PAI_WORKER: this suite may itself be running inside a
65
+ // worker (PAI_WORKER=1 inherited from process.env), which would make the
66
+ // session-start worker guard swallow output regardless of `source` and
67
+ // give a false negative here — these cases are about the source-based
68
+ // decision, not the worker guard (see session-start-worker-guard.test.ts
69
+ // for that).
70
+ const { PAI_WORKER: _unused, ...env } = process.env;
64
71
  const stdout = execFileSync("bun", [ENTRYPOINT], {
65
72
  input: stdin,
66
73
  encoding: "utf8",
67
74
  timeout: 15_000,
75
+ env,
68
76
  });
69
77
  return { status: 0, stdout };
70
78
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The PAI_DIR→ADAPTER_DIR deprecation notice used to fire on every process
3
+ * that had PAI_DIR set at all — including the operator's own settings.json,
4
+ * which sets PAI_DIR to exactly the default adapter root (~/.claude). That
5
+ * must stay silent; only a PAI_DIR that actually differs from the default is
6
+ * worth a heads-up, and even then only once per process.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ let home: string;
15
+
16
+ // resolveAdapterDir() resolves the default (homedir()/.claude) at module
17
+ // load, so the stub has to be in place before the dynamic import below.
18
+ vi.mock("os", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("os")>();
20
+ return { ...actual, homedir: () => home, default: { ...actual, homedir: () => home } };
21
+ });
22
+
23
+ const ORIGINAL_ENV = { ...process.env };
24
+ const ORIGINAL_ARGV = [...process.argv];
25
+
26
+ function resetNoticeFlag(): void {
27
+ delete (globalThis as { __paiDirNoticePrinted?: boolean }).__paiDirNoticePrinted;
28
+ }
29
+
30
+ function deprecationLines(writeSpy: ReturnType<typeof vi.spyOn>): string[] {
31
+ return writeSpy.mock.calls.map((c) => String(c[0])).filter((line) => line.includes("PAI_DIR is deprecated"));
32
+ }
33
+
34
+ beforeEach(() => {
35
+ home = mkdtempSync(join(tmpdir(), "pai-paths-test-"));
36
+ mkdirSync(join(home, ".claude", "Hooks"), { recursive: true });
37
+ delete process.env.ADAPTER_DIR;
38
+ delete process.env.PAI_DIR;
39
+ delete process.env.PAI_QUIET_NOTICES;
40
+ process.argv = [...ORIGINAL_ARGV];
41
+ resetNoticeFlag();
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(home, { recursive: true, force: true });
46
+ process.env = { ...ORIGINAL_ENV };
47
+ process.argv = [...ORIGINAL_ARGV];
48
+ resetNoticeFlag();
49
+ vi.resetModules();
50
+ });
51
+
52
+ describe("resolveAdapterDir PAI_DIR deprecation notice", () => {
53
+ it("stays silent when PAI_DIR equals the default adapter root", async () => {
54
+ process.env.PAI_DIR = join(home, ".claude");
55
+ const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
56
+
57
+ const { ADAPTER_DIR } = await import("./pai-paths.js");
58
+
59
+ expect(ADAPTER_DIR).toBe(join(home, ".claude"));
60
+ expect(deprecationLines(writeSpy)).toHaveLength(0);
61
+ writeSpy.mockRestore();
62
+ });
63
+
64
+ it("prints exactly one line when PAI_DIR differs from the default", async () => {
65
+ const otherAdapter = mkdtempSync(join(tmpdir(), "pai-paths-other-"));
66
+ mkdirSync(join(otherAdapter, "Hooks"), { recursive: true });
67
+ process.env.PAI_DIR = otherAdapter;
68
+ const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
69
+
70
+ const { ADAPTER_DIR } = await import("./pai-paths.js");
71
+
72
+ expect(ADAPTER_DIR).toBe(otherAdapter);
73
+ expect(deprecationLines(writeSpy)).toHaveLength(1);
74
+
75
+ writeSpy.mockRestore();
76
+ rmSync(otherAdapter, { recursive: true, force: true });
77
+ });
78
+
79
+ it("stays silent when PAI_QUIET_NOTICES=1 even if PAI_DIR differs", async () => {
80
+ const otherAdapter = mkdtempSync(join(tmpdir(), "pai-paths-quiet-"));
81
+ mkdirSync(join(otherAdapter, "Hooks"), { recursive: true });
82
+ process.env.PAI_DIR = otherAdapter;
83
+ process.env.PAI_QUIET_NOTICES = "1";
84
+ const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
85
+
86
+ await import("./pai-paths.js");
87
+
88
+ expect(deprecationLines(writeSpy)).toHaveLength(0);
89
+
90
+ writeSpy.mockRestore();
91
+ rmSync(otherAdapter, { recursive: true, force: true });
92
+ });
93
+ });