@tekmidian/pai 0.54.1 → 0.56.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 (251) hide show
  1. package/dist/{aibroker-client-CNfIBrl5.mjs → aibroker-client-B8Wtasez.mjs} +1 -1
  2. package/dist/{aibroker-client-DzkTpVjx.mjs → aibroker-client-BK1h--W_.mjs} +3 -3
  3. package/dist/{aibroker-client-DzkTpVjx.mjs.map → aibroker-client-BK1h--W_.mjs.map} +1 -1
  4. package/dist/{indexer-backend-CdvCJCkD.mjs → async-CLvuY7b-.mjs} +7 -7
  5. package/dist/async-CLvuY7b-.mjs.map +1 -0
  6. package/dist/auto-route-CF8WNaoy.mjs +100 -0
  7. package/dist/auto-route-CF8WNaoy.mjs.map +1 -0
  8. package/dist/{chain-CZKlI0hq.mjs → chain-26f7uGUs.mjs} +4 -4
  9. package/dist/{chain-CZKlI0hq.mjs.map → chain-26f7uGUs.mjs.map} +1 -1
  10. package/dist/cli/index.mjs +22 -23
  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 +22 -23
  14. package/dist/{clusters-Cw_P6lO-.mjs → clusters-Ci1mseh2.mjs} +4 -36
  15. package/dist/clusters-Ci1mseh2.mjs.map +1 -0
  16. package/dist/{config-B3EPzRjf.mjs → config-B93YS8tP.mjs} +3 -3
  17. package/dist/{config-B3EPzRjf.mjs.map → config-B93YS8tP.mjs.map} +1 -1
  18. package/dist/{config-Bn9jpblO.mjs → config-D1omG_UU.mjs} +3 -3
  19. package/dist/{config-Bn9jpblO.mjs.map → config-D1omG_UU.mjs.map} +1 -1
  20. package/dist/{config-RjeVGJ7K.mjs → config-DCYWFaEi.mjs} +1 -1
  21. package/dist/{context-handover-cache-CXyH6QsC.mjs → context-handover-cache-k2kWbE6G.mjs} +82 -99
  22. package/dist/context-handover-cache-k2kWbE6G.mjs.map +1 -0
  23. package/dist/daemon/index.mjs +18 -24
  24. package/dist/daemon/index.mjs.map +1 -1
  25. package/dist/daemon-BOWol-LF.mjs +19 -0
  26. package/dist/{daemon-aJTxzg20.mjs → daemon-BsuM0UdV.mjs} +97 -466
  27. package/dist/daemon-BsuM0UdV.mjs.map +1 -0
  28. package/dist/daemon-mcp/index.mjs +10 -10
  29. package/dist/daemon-mcp/index.mjs.map +1 -1
  30. package/dist/{detector-DWXkeTlk.mjs → detector-3g7XuU1r.mjs} +5 -12
  31. package/dist/detector-3g7XuU1r.mjs.map +1 -0
  32. package/dist/detector-DgDn56j-.mjs +3 -0
  33. package/dist/{embeddings-ClqKtLZ8.mjs → embeddings-BNv3TQC5.mjs} +1 -1
  34. package/dist/{embeddings-DvRKcQyt.mjs → embeddings-DfrYNIng.mjs} +1 -1
  35. package/dist/{embeddings-DvRKcQyt.mjs.map → embeddings-DfrYNIng.mjs.map} +1 -1
  36. package/dist/factory-C9wGUe30.mjs +3 -0
  37. package/dist/factory-DiuntvwG.mjs +250 -0
  38. package/dist/factory-DiuntvwG.mjs.map +1 -0
  39. package/dist/{fallback-CCFIpPha.mjs → fallback-CVe3sb94.mjs} +192 -134
  40. package/dist/fallback-CVe3sb94.mjs.map +1 -0
  41. package/dist/federation-db-CJDw_Pwq.mjs +3 -0
  42. package/dist/{db-DJ8rk0VM.mjs → federation-db-w8e-sKjp.mjs} +5 -5
  43. package/dist/federation-db-w8e-sKjp.mjs.map +1 -0
  44. package/dist/{helpers-crDEr6S2.mjs → helpers-D9WkIf9R.mjs} +1 -1
  45. package/dist/{helpers-crDEr6S2.mjs.map → helpers-D9WkIf9R.mjs.map} +1 -1
  46. package/dist/hooks/capture-all-events.mjs.map +1 -1
  47. package/dist/hooks/capture-session-summary.mjs.map +1 -1
  48. package/dist/hooks/capture-tool-output.mjs.map +1 -1
  49. package/dist/hooks/context-compression-hook.mjs.map +1 -1
  50. package/dist/hooks/load-core-context.mjs +5 -0
  51. package/dist/hooks/load-core-context.mjs.map +2 -2
  52. package/dist/hooks/load-project-context.mjs.map +1 -1
  53. package/dist/hooks/post-compact-inject.mjs.map +1 -1
  54. package/dist/hooks/route-agents-to-worker.mjs.map +1 -1
  55. package/dist/hooks/route-edits-to-worker.mjs +1 -1
  56. package/dist/hooks/route-edits-to-worker.mjs.map +2 -2
  57. package/dist/hooks/security-validator.mjs +8367 -19
  58. package/dist/hooks/security-validator.mjs.map +4 -4
  59. package/dist/hooks/stop-hook.mjs.map +2 -2
  60. package/dist/hooks/whisper-rules.mjs.map +2 -2
  61. package/dist/hooks/worker-proxy.mjs.map +1 -1
  62. package/dist/hooks/worker-status-line.mjs.map +2 -2
  63. package/dist/hooks/worker-supervision.mjs.map +1 -1
  64. package/dist/index.d.mts +327 -100
  65. package/dist/index.d.mts.map +1 -1
  66. package/dist/index.mjs +7 -11
  67. package/dist/indexer-backend-Cc-_NK3q.mjs +1 -0
  68. package/dist/indexer-backend-DcDtin1O.mjs +5 -0
  69. package/dist/{ipc-client-C6NryMMb.mjs → ipc-client-Dasm515c.mjs} +2 -2
  70. package/dist/{ipc-client-C6NryMMb.mjs.map → ipc-client-Dasm515c.mjs.map} +1 -1
  71. package/dist/kg-entity-Cj8Q6YmU.mjs +29 -0
  72. package/dist/kg-entity-Cj8Q6YmU.mjs.map +1 -0
  73. package/dist/{latent-ideas-BfL4nCQ3.mjs → latent-ideas-BRy6HNwg.mjs} +4 -4
  74. package/dist/{latent-ideas-BfL4nCQ3.mjs.map → latent-ideas-BRy6HNwg.mjs.map} +1 -1
  75. package/dist/{link-boost-C5PaNdzm.mjs → link-boost-BptbAPuv.mjs} +1 -1
  76. package/dist/{link-boost-C5PaNdzm.mjs.map → link-boost-BptbAPuv.mjs.map} +1 -1
  77. package/dist/{main-resolver-Bt1f6jJc.mjs → main-resolver-Dx-pxic1.mjs} +56 -31
  78. package/dist/main-resolver-Dx-pxic1.mjs.map +1 -0
  79. package/dist/main-resolver-uDtK9p6e.mjs +7 -0
  80. package/dist/merge-BncjJTfb.mjs +3 -0
  81. package/dist/merge-xoyfZQ4W.mjs +6 -0
  82. package/dist/merge-xoyfZQ4W.mjs.map +1 -0
  83. package/dist/{migrate-CfERwXhY.mjs → migrate-BD7D8EEh.mjs} +21 -54
  84. package/dist/migrate-BD7D8EEh.mjs.map +1 -0
  85. package/dist/{neighborhood-CETR_P4y.mjs → neighborhood-D8uscnGy.mjs} +4 -25
  86. package/dist/neighborhood-D8uscnGy.mjs.map +1 -0
  87. package/dist/{note-context-DCcRWBoN.mjs → note-context-CgffaUMh.mjs} +3 -23
  88. package/dist/note-context-CgffaUMh.mjs.map +1 -0
  89. package/dist/{pai-home-ChAOupZc.mjs → pai-home-CeOIYltV.mjs} +2 -2
  90. package/dist/pai-home-CeOIYltV.mjs.map +1 -0
  91. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-D1MMswkz.mjs} +1 -1
  92. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-D1MMswkz.mjs.map} +1 -1
  93. package/dist/{planner-D3keWys5.mjs → planner-ye7wrrfB.mjs} +7 -7
  94. package/dist/{planner-D3keWys5.mjs.map → planner-ye7wrrfB.mjs.map} +1 -1
  95. package/dist/{postgres-CUSXWgTG.mjs → postgres-nvU5pShF.mjs} +863 -4
  96. package/dist/postgres-nvU5pShF.mjs.map +1 -0
  97. package/dist/{program-B3eRMhff.mjs → program-DuC1RcaO.mjs} +2268 -1402
  98. package/dist/program-DuC1RcaO.mjs.map +1 -0
  99. package/dist/query-feedback-COYCnvHQ.mjs +3 -0
  100. package/dist/{query-feedback-CTs_jx6i.mjs → query-feedback-xz5axTj3.mjs} +3 -3
  101. package/dist/query-feedback-xz5axTj3.mjs.map +1 -0
  102. package/dist/{db-Ddns80yf.mjs → registry-db-Bkpbp7Gf.mjs} +5 -5
  103. package/dist/registry-db-Bkpbp7Gf.mjs.map +1 -0
  104. package/dist/registry-db-DwXpf7yF.mjs +3 -0
  105. package/dist/registry-postgres-B07-hmn8.mjs +795 -0
  106. package/dist/registry-postgres-B07-hmn8.mjs.map +1 -0
  107. package/dist/registry-sqlite-CkGND_tm.mjs +593 -0
  108. package/dist/registry-sqlite-CkGND_tm.mjs.map +1 -0
  109. package/dist/{reranker-B5qUTamW.mjs → reranker-BS5gqGeF.mjs} +1 -1
  110. package/dist/{reranker-HHmhZk6v.mjs → reranker-CRVawoEh.mjs} +1 -1
  111. package/dist/{reranker-HHmhZk6v.mjs.map → reranker-CRVawoEh.mjs.map} +1 -1
  112. package/dist/router-BXOf_e9e.mjs +3 -0
  113. package/dist/{router-DBAZfE7X.mjs → router-BdxdsAvs.mjs} +2 -2
  114. package/dist/{router-DBAZfE7X.mjs.map → router-BdxdsAvs.mjs.map} +1 -1
  115. package/dist/{run-DLG25bzt.mjs → run-CqRvXGjd.mjs} +309 -49
  116. package/dist/run-CqRvXGjd.mjs.map +1 -0
  117. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-BRwDeBEg.mjs} +1 -1
  118. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-BRwDeBEg.mjs.map} +1 -1
  119. package/dist/search-BQHL5wDr.mjs +3 -0
  120. package/dist/search-BaTbyWjK.mjs +107 -0
  121. package/dist/search-BaTbyWjK.mjs.map +1 -0
  122. package/dist/{server-CGiHdaUp.mjs → server-CGuZFOeR.mjs} +4 -4
  123. package/dist/{server-CGiHdaUp.mjs.map → server-CGuZFOeR.mjs.map} +1 -1
  124. package/dist/{session-keepalive-DklS8VmU.mjs → session-keepalive-BQ6AQ9ez.mjs} +7 -7
  125. package/dist/{session-keepalive-DklS8VmU.mjs.map → session-keepalive-BQ6AQ9ez.mjs.map} +1 -1
  126. package/dist/skills/Plan/SKILL.md +1 -1
  127. package/dist/{sources-KBGEA_E6.mjs → sources-BBe3AZ-W.mjs} +8 -27
  128. package/dist/sources-BBe3AZ-W.mjs.map +1 -0
  129. package/dist/sqlite-D9p5a4qe.mjs +935 -0
  130. package/dist/sqlite-D9p5a4qe.mjs.map +1 -0
  131. package/dist/state-CwlGAKgF.mjs +3 -0
  132. package/dist/{state-DwrsOoMi.mjs → state-n13uPK5B.mjs} +6 -5
  133. package/dist/state-n13uPK5B.mjs.map +1 -0
  134. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-ClCwO8mZ.mjs} +1 -1
  135. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-ClCwO8mZ.mjs.map} +1 -1
  136. package/dist/{themes-BVoAnLZj.mjs → themes-B2v7RNnA.mjs} +3 -3
  137. package/dist/{themes-BVoAnLZj.mjs.map → themes-B2v7RNnA.mjs.map} +1 -1
  138. package/dist/{tools-BEqiJt_j.mjs → tools-Diro2s8J.mjs} +157 -398
  139. package/dist/tools-Diro2s8J.mjs.map +1 -0
  140. package/dist/{tools-BwsOTmjh.mjs → tools-KgL1rkPD.mjs} +4 -4
  141. package/dist/{trace-Cy8quQTm.mjs → trace-BRflVyBi.mjs} +1 -1
  142. package/dist/{trace-Cy8quQTm.mjs.map → trace-BRflVyBi.mjs.map} +1 -1
  143. package/dist/{utils-BD8g8oSf.mjs → utils-BrX9FLeK.mjs} +1 -1
  144. package/dist/{utils-CJymHkYm.mjs → utils-C9HsYDpO.mjs} +1 -1
  145. package/dist/{utils-CJymHkYm.mjs.map → utils-C9HsYDpO.mjs.map} +1 -1
  146. package/dist/{vault-indexer-C5i-OAyr.mjs → vault-indexer-DHYBpRZ5.mjs} +2 -2
  147. package/dist/{vault-indexer-C5i-OAyr.mjs.map → vault-indexer-DHYBpRZ5.mjs.map} +1 -1
  148. package/dist/{wakeup-Yk5lQ0jb.mjs → wakeup-D0uvqLsE.mjs} +95 -4
  149. package/dist/wakeup-D0uvqLsE.mjs.map +1 -0
  150. package/dist/work-queue-worker-DEbWsrDQ.mjs +11 -0
  151. package/dist/{work-queue-worker-utPp4hUv.mjs → work-queue-worker-DufMOtrL.mjs} +9 -9
  152. package/dist/work-queue-worker-DufMOtrL.mjs.map +1 -0
  153. package/dist/{zettelkasten-DezbXghl.mjs → zettelkasten-BPxSOalU.mjs} +4 -4
  154. package/dist/{zettelkasten-DezbXghl.mjs.map → zettelkasten-BPxSOalU.mjs.map} +1 -1
  155. package/docker/init.sql +5 -1
  156. package/docs/commands/README.md +5 -0
  157. package/docs/commands/audit.md +1 -1
  158. package/docs/commands/backup.md +1 -1
  159. package/docs/commands/clear-names.md +1 -1
  160. package/docs/commands/config.md +4 -4
  161. package/docs/commands/daemon.md +1 -1
  162. package/docs/commands/db.md +15 -1
  163. package/docs/commands/end.md +1 -1
  164. package/docs/commands/help.md +1 -1
  165. package/docs/commands/hooks-db.md +47 -0
  166. package/docs/commands/identity.md +1 -1
  167. package/docs/commands/kg.md +1 -1
  168. package/docs/commands/launch.md +1 -1
  169. package/docs/commands/mcp.md +1 -1
  170. package/docs/commands/memory.md +1 -1
  171. package/docs/commands/notify.md +1 -1
  172. package/docs/commands/observation.md +1 -1
  173. package/docs/commands/obsidian.md +1 -1
  174. package/docs/commands/pause.md +1 -1
  175. package/docs/commands/project.md +1 -1
  176. package/docs/commands/projects.md +1 -1
  177. package/docs/commands/registry.md +1 -1
  178. package/docs/commands/restore.md +1 -1
  179. package/docs/commands/session.md +2 -2
  180. package/docs/commands/sessions.md +1 -1
  181. package/docs/commands/setup.md +1 -1
  182. package/docs/commands/shell-init.md +1 -1
  183. package/docs/commands/skill.md +1 -1
  184. package/docs/commands/task.md +1 -1
  185. package/docs/commands/topic.md +1 -1
  186. package/docs/commands/update.md +1 -1
  187. package/docs/commands/worker.md +1 -1
  188. package/docs/commands/zettel.md +1 -1
  189. package/docs/design/postgres-only.md +438 -0
  190. package/docs/design/registry-method-map.md +174 -0
  191. package/package.json +1 -1
  192. package/src/hooks/pre-compact.sh +2 -40
  193. package/src/hooks/session-hooks-db.test.ts +93 -0
  194. package/src/hooks/session-stop.sh +2 -26
  195. package/src/hooks/ts/lib/edit-gate.test.ts +9 -0
  196. package/src/hooks/ts/lib/edit-gate.ts +5 -2
  197. package/src/hooks/ts/lib/plutil-guard.test.ts +54 -0
  198. package/src/hooks/ts/lib/plutil-guard.ts +43 -0
  199. package/src/hooks/ts/lib/worker-git-guard.test.ts +134 -0
  200. package/src/hooks/ts/lib/worker-git-guard.ts +103 -0
  201. package/src/hooks/ts/pre-tool-use/security-validator.test.ts +68 -0
  202. package/src/hooks/ts/pre-tool-use/security-validator.ts +69 -21
  203. package/src/hooks/ts/session-start/load-core-context.ts +13 -0
  204. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +13 -0
  205. package/dist/auto-route-CcD0OWWB.mjs +0 -86
  206. package/dist/auto-route-CcD0OWWB.mjs.map +0 -1
  207. package/dist/clusters-Cw_P6lO-.mjs.map +0 -1
  208. package/dist/context-handover-cache-CXyH6QsC.mjs.map +0 -1
  209. package/dist/daemon-CknQ6YGZ.mjs +0 -25
  210. package/dist/daemon-aJTxzg20.mjs.map +0 -1
  211. package/dist/db-BOBfewFH.mjs +0 -3
  212. package/dist/db-DJ8rk0VM.mjs.map +0 -1
  213. package/dist/db-DbY2O8QO.mjs +0 -3
  214. package/dist/db-Ddns80yf.mjs.map +0 -1
  215. package/dist/detect-Bf2z-oKB.mjs +0 -86
  216. package/dist/detect-Bf2z-oKB.mjs.map +0 -1
  217. package/dist/detector-CkwkXuGh.mjs +0 -5
  218. package/dist/detector-DWXkeTlk.mjs.map +0 -1
  219. package/dist/factory-Ba8j9K7q.mjs +0 -3
  220. package/dist/factory-Bt7TWQje.mjs +0 -150
  221. package/dist/factory-Bt7TWQje.mjs.map +0 -1
  222. package/dist/fallback-CCFIpPha.mjs.map +0 -1
  223. package/dist/indexer-backend-CdvCJCkD.mjs.map +0 -1
  224. package/dist/kg-entity-BSW7ra8i.mjs +0 -176
  225. package/dist/kg-entity-BSW7ra8i.mjs.map +0 -1
  226. package/dist/main-resolver-Bt1f6jJc.mjs.map +0 -1
  227. package/dist/main-resolver-DYHhw5Ig.mjs +0 -7
  228. package/dist/migrate-CfERwXhY.mjs.map +0 -1
  229. package/dist/neighborhood-CETR_P4y.mjs.map +0 -1
  230. package/dist/note-context-DCcRWBoN.mjs.map +0 -1
  231. package/dist/pai-home-ChAOupZc.mjs.map +0 -1
  232. package/dist/postgres-CUSXWgTG.mjs.map +0 -1
  233. package/dist/program-B3eRMhff.mjs.map +0 -1
  234. package/dist/query-feedback-Bm6XvCmg.mjs +0 -3
  235. package/dist/query-feedback-CTs_jx6i.mjs.map +0 -1
  236. package/dist/router-CremHKTI.mjs +0 -3
  237. package/dist/run-DLG25bzt.mjs.map +0 -1
  238. package/dist/search-B69cQwDB.mjs +0 -303
  239. package/dist/search-B69cQwDB.mjs.map +0 -1
  240. package/dist/search-fb-5ovj4.mjs +0 -4
  241. package/dist/sources-KBGEA_E6.mjs.map +0 -1
  242. package/dist/sqlite-By6r2YhZ.mjs +0 -271
  243. package/dist/sqlite-By6r2YhZ.mjs.map +0 -1
  244. package/dist/state-Ca0ltLgI.mjs +0 -3
  245. package/dist/state-DwrsOoMi.mjs.map +0 -1
  246. package/dist/sync-Btcb6xA4.mjs +0 -310
  247. package/dist/sync-Btcb6xA4.mjs.map +0 -1
  248. package/dist/tools-BEqiJt_j.mjs.map +0 -1
  249. package/dist/wakeup-Yk5lQ0jb.mjs.map +0 -1
  250. package/dist/work-queue-worker-DsdJx-jn.mjs +0 -14
  251. package/dist/work-queue-worker-utPp4hUv.mjs.map +0 -1
@@ -15,13 +15,6 @@ TAB_COLOR="${ADAPTER_DIR:-${PAI_DIR:-$HOME/.claude}}/tab-color-command.sh"
15
15
 
16
16
  # Bail gracefully if pai is not installed
17
17
  command -v "$PAI_OS" &>/dev/null || exit 0
18
- command -v sqlite3 &>/dev/null || exit 0
19
-
20
- # PAI_HOME (~/.claude/pai by default — see src/config/pai-home.ts), falling
21
- # back to the pre-2026-09-19 ~/.pai/registry.db.
22
- REGISTRY_DB="${PAI_HOME:-$HOME/.claude/pai}/registry.db"
23
- [ -f "$REGISTRY_DB" ] || REGISTRY_DB="$HOME/.pai/registry.db"
24
- [ -f "$REGISTRY_DB" ] || exit 0
25
18
 
26
19
  # ---------------------------------------------------------------------------
27
20
  # Detect current project
@@ -41,41 +34,10 @@ fi
41
34
  [ -z "$PROJECT_SLUG" ] && exit 0
42
35
 
43
36
  # ---------------------------------------------------------------------------
44
- # Look up project and latest open session
45
- # ---------------------------------------------------------------------------
46
-
47
- PROJECT_ID=$(sqlite3 "$REGISTRY_DB" \
48
- "SELECT id FROM projects WHERE slug = '$PROJECT_SLUG' LIMIT 1" 2>/dev/null) || exit 0
49
- [ -z "$PROJECT_ID" ] && exit 0
50
-
51
- SESSION_ID=$(sqlite3 "$REGISTRY_DB" \
52
- "SELECT id FROM sessions WHERE project_id = $PROJECT_ID AND status = 'open' ORDER BY created_at DESC LIMIT 1" \
53
- 2>/dev/null) || true
54
-
55
- # ---------------------------------------------------------------------------
56
- # Update session status to compacted
57
- # ---------------------------------------------------------------------------
58
-
59
- if [ -n "$SESSION_ID" ]; then
60
- sqlite3 "$REGISTRY_DB" \
61
- "UPDATE sessions SET status = 'compacted' WHERE id = $SESSION_ID" 2>/dev/null || true
62
- fi
63
-
64
- # ---------------------------------------------------------------------------
65
- # Log to compaction_log
37
+ # Mark the latest open session compacted and log the event
66
38
  # ---------------------------------------------------------------------------
67
39
 
68
- TS=$(date +%s)000
69
-
70
- if [ -n "$SESSION_ID" ]; then
71
- sqlite3 "$REGISTRY_DB" \
72
- "INSERT INTO compaction_log (project_id, session_id, trigger, files_written, created_at) VALUES ($PROJECT_ID, $SESSION_ID, 'precompact', '', $TS)" \
73
- 2>/dev/null || true
74
- else
75
- sqlite3 "$REGISTRY_DB" \
76
- "INSERT INTO compaction_log (project_id, session_id, trigger, files_written, created_at) VALUES ($PROJECT_ID, NULL, 'precompact', '', $TS)" \
77
- 2>/dev/null || true
78
- fi
40
+ "$PAI_OS" hooks-db pre-compact "$PROJECT_SLUG" 2>/dev/null || true
79
41
 
80
42
  # ---------------------------------------------------------------------------
81
43
  # Sync Obsidian vault
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync, readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ /**
8
+ * Proof for docs/design/postgres-only.md unit 8: session-stop.sh and
9
+ * pre-compact.sh no longer shell out to sqlite3 — they shell out to
10
+ * `pai hooks-db <verb> <projectSlug>`, exactly once per run. A fake `pai`
11
+ * on PATH logs every invocation it receives; both scripts run detached
12
+ * (backgrounded + disowned), so the test polls the log file for the
13
+ * expected line instead of asserting on synchronous stdout.
14
+ */
15
+ describe("session-stop.sh / pre-compact.sh call pai hooks-db", () => {
16
+ let fakeBinDir: string;
17
+ let homeDir: string;
18
+ let logFile: string;
19
+
20
+ beforeEach(() => {
21
+ fakeBinDir = mkdtempSync(join(tmpdir(), "hooks-db-test-bin-"));
22
+ homeDir = mkdtempSync(join(tmpdir(), "hooks-db-test-home-"));
23
+ logFile = join(fakeBinDir, "pai-calls.log");
24
+ writeFileSync(logFile, "");
25
+
26
+ const fakePai = join(fakeBinDir, "pai");
27
+ writeFileSync(
28
+ fakePai,
29
+ [
30
+ "#!/bin/bash",
31
+ `echo "$*" >> ${JSON.stringify(logFile)}`,
32
+ 'if [ "$1" = "project" ] && [ "$2" = "detect" ]; then',
33
+ ' echo \'{"slug":"testproj"}\'',
34
+ "fi",
35
+ "exit 0",
36
+ ].join("\n")
37
+ );
38
+ chmodSync(fakePai, 0o755);
39
+ });
40
+
41
+ afterEach(() => {
42
+ rmSync(fakeBinDir, { recursive: true, force: true });
43
+ rmSync(homeDir, { recursive: true, force: true });
44
+ });
45
+
46
+ function runHookDetached(script: string): void {
47
+ execFileSync("bash", [script], {
48
+ encoding: "utf8",
49
+ timeout: 15_000,
50
+ input: "",
51
+ env: {
52
+ ...process.env,
53
+ PATH: `${fakeBinDir}:${process.env.PATH}`,
54
+ HOME: homeDir,
55
+ PAI_HOME: join(homeDir, ".claude", "pai"),
56
+ PAI_DIR: join(homeDir, ".claude"), // no tab-color-command.sh here: TAB_COLOR call is a no-op
57
+ PAI_WORKER: "",
58
+ },
59
+ });
60
+ }
61
+
62
+ function waitForLogLine(match: string, timeoutMs = 5000): string {
63
+ const deadline = Date.now() + timeoutMs;
64
+ while (Date.now() < deadline) {
65
+ if (existsSync(logFile)) {
66
+ const content = readFileSync(logFile, "utf8");
67
+ if (content.includes(match)) return content;
68
+ }
69
+ }
70
+ return existsSync(logFile) ? readFileSync(logFile, "utf8") : "";
71
+ }
72
+
73
+ it("session-stop.sh invokes exactly `pai hooks-db session-stop <slug>`", () => {
74
+ runHookDetached("src/hooks/session-stop.sh");
75
+ const content = waitForLogLine("hooks-db session-stop");
76
+ const calls = content.split("\n").filter((l) => l.startsWith("hooks-db"));
77
+ expect(calls).toEqual(["hooks-db session-stop testproj"]);
78
+ });
79
+
80
+ it("pre-compact.sh invokes exactly `pai hooks-db pre-compact <slug>`", () => {
81
+ runHookDetached("src/hooks/pre-compact.sh");
82
+ const content = waitForLogLine("hooks-db pre-compact");
83
+ const calls = content.split("\n").filter((l) => l.startsWith("hooks-db"));
84
+ expect(calls).toEqual(["hooks-db pre-compact testproj"]);
85
+ });
86
+
87
+ it("neither script contains a raw sqlite3 call", () => {
88
+ const stopScript = readFileSync("src/hooks/session-stop.sh", "utf8");
89
+ const compactScript = readFileSync("src/hooks/pre-compact.sh", "utf8");
90
+ expect(stopScript).not.toContain("sqlite3");
91
+ expect(compactScript).not.toContain("sqlite3");
92
+ });
93
+ });
@@ -11,7 +11,6 @@ PAI_OS="pai"
11
11
 
12
12
  # Bail gracefully if pai is not installed
13
13
  command -v "$PAI_OS" &>/dev/null || exit 0
14
- command -v sqlite3 &>/dev/null || exit 0
15
14
 
16
15
  # ---------------------------------------------------------------------------
17
16
  # Read the Claude session UUID from the hook payload
@@ -39,12 +38,6 @@ if [ ! -t 0 ]; then
39
38
  fi
40
39
  fi
41
40
 
42
- # PAI_HOME (~/.claude/pai by default — see src/config/pai-home.ts), falling
43
- # back to the pre-2026-09-19 ~/.pai/registry.db.
44
- REGISTRY_DB="${PAI_HOME:-$HOME/.claude/pai}/registry.db"
45
- [ -f "$REGISTRY_DB" ] || REGISTRY_DB="$HOME/.pai/registry.db"
46
- [ -f "$REGISTRY_DB" ] || exit 0
47
-
48
41
  # ---------------------------------------------------------------------------
49
42
  # Everything below runs DETACHED. Nothing here is on the critical path.
50
43
  # ---------------------------------------------------------------------------
@@ -112,27 +105,10 @@ fi
112
105
  [ -z "$PROJECT_SLUG" ] && exit 0
113
106
 
114
107
  # ---------------------------------------------------------------------------
115
- # Look up project and latest open/compacted session
108
+ # Mark the latest open/compacted session completed
116
109
  # ---------------------------------------------------------------------------
117
110
 
118
- PROJECT_ID=$(sqlite3 "$REGISTRY_DB" \
119
- "SELECT id FROM projects WHERE slug = '$PROJECT_SLUG' LIMIT 1" 2>/dev/null) || exit 0
120
- [ -z "$PROJECT_ID" ] && exit 0
121
-
122
- SESSION_ID=$(sqlite3 "$REGISTRY_DB" \
123
- "SELECT id FROM sessions WHERE project_id = $PROJECT_ID AND status IN ('open','compacted') ORDER BY created_at DESC LIMIT 1" \
124
- 2>/dev/null) || true
125
-
126
- # ---------------------------------------------------------------------------
127
- # Mark session completed and set closed_at
128
- # ---------------------------------------------------------------------------
129
-
130
- if [ -n "$SESSION_ID" ]; then
131
- TS=$(date +%s)000
132
- sqlite3 "$REGISTRY_DB" \
133
- "UPDATE sessions SET status = 'completed', closed_at = $TS WHERE id = $SESSION_ID" \
134
- 2>/dev/null || true
135
- fi
111
+ "$PAI_OS" hooks-db session-stop "$PROJECT_SLUG" 2>/dev/null || true
136
112
 
137
113
  # ---------------------------------------------------------------------------
138
114
  # Auto-generate slug from transcript and rename session note
@@ -38,6 +38,15 @@ describe("decideEditGate", () => {
38
38
  expect((out as { reason: string }).reason).toContain("pai worker run");
39
39
  });
40
40
 
41
+ it("tells the session this is a standing policy, not a one-off error, and names the next step", () => {
42
+ const out = decideEditGate(ctx({}));
43
+ const reason = (out as { reason: string }).reason;
44
+ expect(reason).toContain("standing policy for the whole session");
45
+ expect(reason).toContain("retrying Edit/Write");
46
+ expect(reason).toContain("--provider anthropic");
47
+ expect(reason).toContain(ctx({}).filePath);
48
+ });
49
+
41
50
  it("allows worker sessions regardless of path", () => {
42
51
  expect(decideEditGate(ctx({ isPaiWorker: true }))).toEqual({ decision: "allow" });
43
52
  });
@@ -45,7 +45,10 @@ export function decideEditGate(ctx: EditGateContext): EditGateDecision {
45
45
  return {
46
46
  decision: "block",
47
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})`,
48
+ `main session does not edit code — this is a standing policy for the whole session, not a problem ` +
49
+ `with this edit: retrying Edit/Write on ${ctx.filePath} or any other code file will be denied the ` +
50
+ `same way. Next step: write a spec file, then run ` +
51
+ `pai worker run --label "${label}" --class implement --provider anthropic --output-format json -p "$(cat <spec>)" ` +
52
+ `(target: ${ctx.filePath})`,
50
53
  };
51
54
  }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectUnsafePlutil, plutilGuardMessage } from "./plutil-guard.js";
3
+
4
+ const DENIED_COMMANDS = [
5
+ "plutil -extract Label json /tmp/x.plist",
6
+ "plutil -replace Label -string foo /tmp/x.plist",
7
+ "plutil -insert Label -string foo /tmp/x.plist",
8
+ "plutil -remove Label /tmp/x.plist",
9
+ "plutil -convert xml1 /tmp/x.plist",
10
+ "plutil -create xml1 /tmp/x.plist",
11
+ "cd /tmp && plutil -extract Label json /tmp/x.plist",
12
+ "plutil -extract Label json /tmp/x.plist | cat",
13
+ "plutil -extract Label json /tmp/x.plist; echo done",
14
+ "echo $(plutil -extract Label json /tmp/x.plist)",
15
+ "echo `plutil -extract Label json /tmp/x.plist`",
16
+ "for f in *.plist; do plutil -extract Label json $f; done",
17
+ ];
18
+
19
+ const ALLOWED_COMMANDS = [
20
+ "plutil -p /tmp/x.plist",
21
+ "plutil -lint /tmp/x.plist",
22
+ "plutil -help",
23
+ "plutil -extract Label json -o - /tmp/x.plist",
24
+ "plutil -extract ProgramArguments json -o /tmp/out.json /tmp/x.plist",
25
+ "plutil -convert xml1 -o /tmp/out.plist /tmp/x.plist",
26
+ "for f in *.plist; do plutil -extract Label json -o - $f; done",
27
+ "ls -la",
28
+ "git status",
29
+ ];
30
+
31
+ describe("detectUnsafePlutil", () => {
32
+ for (const cmd of DENIED_COMMANDS) {
33
+ it(`denies: ${cmd}`, () => {
34
+ const result = detectUnsafePlutil(cmd);
35
+ expect(result.blocked).toBe(true);
36
+ expect(result.verb).toBeTruthy();
37
+ });
38
+ }
39
+
40
+ for (const cmd of ALLOWED_COMMANDS) {
41
+ it(`allows: ${cmd}`, () => {
42
+ expect(detectUnsafePlutil(cmd).blocked).toBe(false);
43
+ });
44
+ }
45
+ });
46
+
47
+ describe("plutilGuardMessage", () => {
48
+ it("names the verb and the safe alternatives", () => {
49
+ const msg = plutilGuardMessage("-extract");
50
+ expect(msg).toContain("-extract");
51
+ expect(msg).toContain("plutil -p <file>");
52
+ expect(msg).toContain("-o - <file>");
53
+ });
54
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * plutil-guard.ts — blocks plutil edit verbs (-extract, -replace, -insert,
3
+ * -remove, -convert, -create) run without an explicit -o <path>.
4
+ *
5
+ * Without -o, plutil writes the result back into the source file instead of
6
+ * printing it — `plutil -extract ProgramArguments json <plist>` run as a
7
+ * read destroyed three LaunchAgent plists on 2026-09-23. -p, -lint, -help
8
+ * and any edit verb that does carry -o are unaffected.
9
+ *
10
+ * Pure: scans the raw command string for each `plutil ...` invocation up to
11
+ * the next hard shell separator (&& || ; | backtick paren newline), so it
12
+ * catches compound lines, subshells, and loop bodies without a real parser.
13
+ */
14
+
15
+ const EDIT_VERB = /-(extract|replace|insert|remove|convert|create)\b/;
16
+ const HAS_DASH_O = /(^|\s)-o(\s|$)/;
17
+ const INVOCATION = /\bplutil\b([^&|;`()\n]*)/g;
18
+
19
+ export interface PlutilGuardResult {
20
+ blocked: boolean;
21
+ invocation?: string;
22
+ verb?: string;
23
+ }
24
+
25
+ export function detectUnsafePlutil(command: string): PlutilGuardResult {
26
+ INVOCATION.lastIndex = 0;
27
+ let m: RegExpExecArray | null;
28
+ while ((m = INVOCATION.exec(command))) {
29
+ const args = m[1];
30
+ const verbMatch = args.match(EDIT_VERB);
31
+ if (!verbMatch) continue;
32
+ if (HAS_DASH_O.test(args)) continue;
33
+ return { blocked: true, invocation: `plutil${args}`.trim(), verb: `-${verbMatch[1]}` };
34
+ }
35
+ return { blocked: false };
36
+ }
37
+
38
+ export function plutilGuardMessage(verb: string): string {
39
+ return (
40
+ `plutil ${verb} without -o rewrites the file in place; ` +
41
+ "read with `plutil -p <file>` or `plutil -extract <key> raw -o - <file>`"
42
+ );
43
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { decideWorkerGitGuard, workerGitGuardMessage } from "./worker-git-guard.js";
3
+
4
+ const CWD = "/repo";
5
+ const WORKTREES_ROOT = "/repo/.workers/worktrees";
6
+ const WORKTREE_CWD = "/repo/.workers/worktrees/abc123";
7
+
8
+ const DENIED_COMMANDS = [
9
+ "git stash",
10
+ "git stash pop",
11
+ "git stash push -m wip",
12
+ "git stash apply",
13
+ "git stash drop",
14
+ "git stash clear",
15
+ "git stash save wip",
16
+ "git stash branch new-branch",
17
+ "git stash create",
18
+ "git stash store stash@{0}",
19
+ "git reset --hard",
20
+ "git reset --hard HEAD~1",
21
+ "git reset HEAD",
22
+ "git clean -fd",
23
+ "git clean -fdx",
24
+ "git checkout -- src/file.ts",
25
+ "git checkout HEAD -- src/file.ts",
26
+ "git checkout .",
27
+ "git restore src/file.ts",
28
+ "git restore --staged src/file.ts",
29
+ "git switch main",
30
+ "git switch -c feature",
31
+ "git rebase main",
32
+ "git rebase -i HEAD~3",
33
+ "git merge feature-branch",
34
+ "git merge --no-ff worker/abc",
35
+ ];
36
+
37
+ const ALLOWED_COMMANDS = [
38
+ "git status",
39
+ "git diff",
40
+ "git diff --name-only HEAD",
41
+ "git log --oneline -5",
42
+ "git show HEAD",
43
+ "git rev-parse HEAD",
44
+ "git ls-files --others --exclude-standard",
45
+ "git blame src/file.ts",
46
+ "git add -A",
47
+ "git add src/file.ts",
48
+ "git commit -m 'wip'",
49
+ "git checkout main",
50
+ "git checkout -b feature",
51
+ "git stash list",
52
+ "git stash list --oneline",
53
+ "git stash show",
54
+ "git stash show -p",
55
+ "git stash show stash@{0}",
56
+ ];
57
+
58
+ describe("decideWorkerGitGuard", () => {
59
+ for (const cmd of DENIED_COMMANDS) {
60
+ it(`denies "${cmd}" for an in-place worker`, () => {
61
+ const r = decideWorkerGitGuard(cmd, true, CWD, WORKTREES_ROOT);
62
+ expect(r.blocked).toBe(true);
63
+ });
64
+ }
65
+
66
+ for (const cmd of ALLOWED_COMMANDS) {
67
+ it(`allows "${cmd}" for an in-place worker`, () => {
68
+ expect(decideWorkerGitGuard(cmd, true, CWD, WORKTREES_ROOT).blocked).toBe(false);
69
+ });
70
+ }
71
+
72
+ it("detects a denied command inside a compound line (&&)", () => {
73
+ expect(decideWorkerGitGuard("npm test && git stash", true, CWD, WORKTREES_ROOT).blocked).toBe(
74
+ true
75
+ );
76
+ });
77
+
78
+ it("detects a denied command inside a compound line (;)", () => {
79
+ expect(decideWorkerGitGuard("cd /tmp; git reset --hard", true, CWD, WORKTREES_ROOT).blocked).toBe(
80
+ true
81
+ );
82
+ });
83
+
84
+ it("detects a denied command inside a compound line (|)", () => {
85
+ expect(
86
+ decideWorkerGitGuard("echo apply | git stash apply", true, CWD, WORKTREES_ROOT).blocked
87
+ ).toBe(true);
88
+ });
89
+
90
+ it("detects a denied command behind a `git -C <dir>` prefix", () => {
91
+ expect(
92
+ decideWorkerGitGuard("git -C /repo stash", true, CWD, WORKTREES_ROOT).blocked
93
+ ).toBe(true);
94
+ });
95
+
96
+ it("allows every denied command for a worker whose cwd is inside the worktrees root", () => {
97
+ for (const cmd of DENIED_COMMANDS) {
98
+ expect(decideWorkerGitGuard(cmd, true, WORKTREE_CWD, WORKTREES_ROOT).blocked).toBe(false);
99
+ }
100
+ });
101
+
102
+ it("allows every denied command for a non-worker session", () => {
103
+ for (const cmd of DENIED_COMMANDS) {
104
+ expect(decideWorkerGitGuard(cmd, false, CWD, WORKTREES_ROOT).blocked).toBe(false);
105
+ }
106
+ });
107
+
108
+ it("allows read-only git regardless of worker status or cwd", () => {
109
+ for (const cmd of ALLOWED_COMMANDS) {
110
+ expect(decideWorkerGitGuard(cmd, true, WORKTREE_CWD, WORKTREES_ROOT).blocked).toBe(false);
111
+ expect(decideWorkerGitGuard(cmd, false, CWD, WORKTREES_ROOT).blocked).toBe(false);
112
+ }
113
+ });
114
+
115
+ it("blocks when the worktrees root could not be resolved — cannot prove cwd is a worktree", () => {
116
+ expect(decideWorkerGitGuard("git stash", true, CWD, null).blocked).toBe(true);
117
+ });
118
+
119
+ it("names the matched command in the result", () => {
120
+ expect(decideWorkerGitGuard("git stash", true, CWD, WORKTREES_ROOT).cmd).toBe("git stash");
121
+ expect(decideWorkerGitGuard("git checkout .", true, CWD, WORKTREES_ROOT).cmd).toBe(
122
+ "git checkout"
123
+ );
124
+ });
125
+ });
126
+
127
+ describe("workerGitGuardMessage", () => {
128
+ it("names the command and the --worktree escape hatch", () => {
129
+ const msg = workerGitGuardMessage("git stash");
130
+ expect(msg).toContain("git stash");
131
+ expect(msg).toContain("rewrites the shared checkout and is blocked");
132
+ expect(msg).toContain("--worktree");
133
+ });
134
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * worker-git-guard.ts — blocks tree-rewriting git commands from an in-place
3
+ * worker: one whose cwd is the operator's checkout itself, not a worktree
4
+ * under `<workers logDir>/worktrees/`.
5
+ *
6
+ * `git stash` (or reset/clean/checkout --/restore/switch/rebase/merge) in a
7
+ * shared checkout can drop or conflict with another worker's in-flight
8
+ * edits — observed twice on 2026-09-22, each time with a spec that already
9
+ * said "do not run git stash" in prose. Prose did not stop it; this gate
10
+ * does.
11
+ *
12
+ * Pure: no fs, no config read, no process.env read of its own. The hook
13
+ * (pre-tool-use/security-validator.ts) resolves whether the session is a
14
+ * worker and where its worktrees root is, and passes both in.
15
+ */
16
+
17
+ const TREE_REWRITE: { name: string; pattern: RegExp }[] = [
18
+ { name: "git reset", pattern: /\bgit(?:\s+-C\s+\S+)?\s+reset\b/ },
19
+ { name: "git clean", pattern: /\bgit(?:\s+-C\s+\S+)?\s+clean\b/ },
20
+ { name: "git restore", pattern: /\bgit(?:\s+-C\s+\S+)?\s+restore\b/ },
21
+ { name: "git switch", pattern: /\bgit(?:\s+-C\s+\S+)?\s+switch\b/ },
22
+ { name: "git rebase", pattern: /\bgit(?:\s+-C\s+\S+)?\s+rebase\b/ },
23
+ { name: "git merge", pattern: /\bgit(?:\s+-C\s+\S+)?\s+merge\b/ },
24
+ ];
25
+
26
+ /**
27
+ * `git checkout` only rewrites the tree when it names paths (`-- <paths>`,
28
+ * with or without a leading ref) or restores everything (`git checkout .`).
29
+ * A bare `git checkout <branch>` is a branch switch, not covered here —
30
+ * `git switch` above is the tree-rewriting form of that.
31
+ */
32
+ const CHECKOUT_INVOCATION = /\bgit(?:\s+-C\s+\S+)?\s+checkout\s+([^&;|`\n)]*)/g;
33
+
34
+ function checkoutRewritesTree(command: string): boolean {
35
+ CHECKOUT_INVOCATION.lastIndex = 0;
36
+ let m: RegExpExecArray | null;
37
+ while ((m = CHECKOUT_INVOCATION.exec(command))) {
38
+ const args = m[1].trim();
39
+ if (args === ".") return true;
40
+ if (/(^|\s)--(\s|$)/.test(args)) return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * `git stash list` and `git stash show` only read the stash; every other
47
+ * subcommand (push/pop/apply/drop/clear/save/create/store/branch) and the
48
+ * bare `git stash` (a push) can drop or conflict with in-flight edits.
49
+ */
50
+ const STASH_INVOCATION = /\bgit(?:\s+-C\s+\S+)?\s+stash\b(?:\s+(\S+))?/g;
51
+ const STASH_READONLY_SUBCOMMANDS = new Set(["list", "show"]);
52
+
53
+ function stashRewritesTree(command: string): boolean {
54
+ STASH_INVOCATION.lastIndex = 0;
55
+ let m: RegExpExecArray | null;
56
+ while ((m = STASH_INVOCATION.exec(command))) {
57
+ const sub = m[1];
58
+ if (!sub || !STASH_READONLY_SUBCOMMANDS.has(sub)) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ /** Absolute-path prefix check; `root` with or without a trailing separator. */
64
+ function isInsideWorktree(cwd: string, root: string): boolean {
65
+ const normalizedRoot = root.endsWith("/") ? root.slice(0, -1) : root;
66
+ return cwd === normalizedRoot || cwd.startsWith(`${normalizedRoot}/`);
67
+ }
68
+
69
+ export interface WorkerGitGuardResult {
70
+ blocked: boolean;
71
+ /** The tree-rewriting git invocation matched, e.g. "git stash". */
72
+ cmd?: string;
73
+ }
74
+
75
+ /**
76
+ * `worktreesRoot` is the absolute worktrees directory (see
77
+ * `worktreesDir` in src/workers/worktree.ts), or null when the hook could
78
+ * not resolve it. Null cannot prove `cwd` is a worktree, so a worker blocks
79
+ * in that case too — the guard fails closed on tree-rewriting git, not open.
80
+ */
81
+ export function decideWorkerGitGuard(
82
+ command: string,
83
+ isWorker: boolean,
84
+ cwd: string,
85
+ worktreesRoot: string | null
86
+ ): WorkerGitGuardResult {
87
+ if (!isWorker) return { blocked: false };
88
+ if (worktreesRoot && isInsideWorktree(cwd, worktreesRoot)) return { blocked: false };
89
+
90
+ for (const { name, pattern } of TREE_REWRITE) {
91
+ if (pattern.test(command)) return { blocked: true, cmd: name };
92
+ }
93
+ if (checkoutRewritesTree(command)) return { blocked: true, cmd: "git checkout" };
94
+ if (stashRewritesTree(command)) return { blocked: true, cmd: "git stash" };
95
+ return { blocked: false };
96
+ }
97
+
98
+ export function workerGitGuardMessage(cmd: string): string {
99
+ return (
100
+ `in-place worker: ${cmd} rewrites the shared checkout and is blocked; ` +
101
+ "take baselines from the numbers before your edit, or run with --worktree"
102
+ );
103
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+
4
+ // The entry does its own stdin read and calls main() unconditionally at
5
+ // module scope, and it must exercise the real exit-code/stderr contract —
6
+ // run it as a real subprocess, exactly like route-edits-to-worker.test.ts.
7
+ describe("security-validator", () => {
8
+ const ENTRYPOINT = "src/hooks/ts/pre-tool-use/security-validator.ts";
9
+
10
+ function runHook(
11
+ command: string,
12
+ env: NodeJS.ProcessEnv = {}
13
+ ): { status: number | null; stdout: string; stderr: string } {
14
+ const { PAI_WORKER: _drop, ...baseEnv } = process.env;
15
+ try {
16
+ const stdout = execFileSync("bun", [ENTRYPOINT], {
17
+ input: JSON.stringify({
18
+ session_id: "test",
19
+ tool_name: "Bash",
20
+ tool_input: { command },
21
+ cwd: process.cwd(),
22
+ }),
23
+ encoding: "utf8",
24
+ timeout: 15_000,
25
+ env: { ...baseEnv, ...env },
26
+ });
27
+ return { status: 0, stdout, stderr: "" };
28
+ } catch (err) {
29
+ const e = err as { status: number | null; stdout: string; stderr: string };
30
+ return { status: e.status, stdout: e.stdout, stderr: e.stderr };
31
+ }
32
+ }
33
+
34
+ it("writes the deny reason to stderr with exit code 2", () => {
35
+ const { status, stderr } = runHook("git stash", { PAI_WORKER: "1" });
36
+ expect(status).toBe(2);
37
+ expect(stderr).toContain("Blocked by PAI security validator:");
38
+ expect(stderr.length).toBeGreaterThan(0);
39
+ });
40
+
41
+ it("allows git stash list for an in-place worker", () => {
42
+ const { status, stderr } = runHook("git stash list", { PAI_WORKER: "1" });
43
+ expect(status).toBe(0);
44
+ expect(stderr).toBe("");
45
+ });
46
+
47
+ it("allows git stash show for an in-place worker", () => {
48
+ const { status } = runHook("git stash show", { PAI_WORKER: "1" });
49
+ expect(status).toBe(0);
50
+ });
51
+
52
+ it("denies git stash push for an in-place worker", () => {
53
+ const { status, stderr } = runHook("git stash push -m wip", { PAI_WORKER: "1" });
54
+ expect(status).toBe(2);
55
+ expect(stderr).toContain("Blocked by PAI security validator:");
56
+ });
57
+
58
+ it("denies git stash pop for an in-place worker", () => {
59
+ const { status } = runHook("git stash pop", { PAI_WORKER: "1" });
60
+ expect(status).toBe(2);
61
+ });
62
+
63
+ it("allows an ordinary command", () => {
64
+ const { status, stderr } = runHook("ls -la");
65
+ expect(status).toBe(0);
66
+ expect(stderr).toBe("");
67
+ });
68
+ });