@tekmidian/pai 0.54.1 → 0.55.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.
- package/dist/{aibroker-client-CNfIBrl5.mjs → aibroker-client-B8Wtasez.mjs} +1 -1
- package/dist/{aibroker-client-DzkTpVjx.mjs → aibroker-client-BK1h--W_.mjs} +3 -3
- package/dist/{aibroker-client-DzkTpVjx.mjs.map → aibroker-client-BK1h--W_.mjs.map} +1 -1
- package/dist/{indexer-backend-CdvCJCkD.mjs → async-CLvuY7b-.mjs} +7 -7
- package/dist/async-CLvuY7b-.mjs.map +1 -0
- package/dist/auto-route-CF8WNaoy.mjs +100 -0
- package/dist/auto-route-CF8WNaoy.mjs.map +1 -0
- package/dist/{chain-CZKlI0hq.mjs → chain-DogidBji.mjs} +4 -4
- package/dist/{chain-CZKlI0hq.mjs.map → chain-DogidBji.mjs.map} +1 -1
- package/dist/cli/index.mjs +22 -23
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +22 -23
- package/dist/{clusters-Cw_P6lO-.mjs → clusters-Ci1mseh2.mjs} +4 -36
- package/dist/clusters-Ci1mseh2.mjs.map +1 -0
- package/dist/{config-B3EPzRjf.mjs → config-B93YS8tP.mjs} +3 -3
- package/dist/{config-B3EPzRjf.mjs.map → config-B93YS8tP.mjs.map} +1 -1
- package/dist/{config-Bn9jpblO.mjs → config-D1omG_UU.mjs} +3 -3
- package/dist/{config-Bn9jpblO.mjs.map → config-D1omG_UU.mjs.map} +1 -1
- package/dist/{config-RjeVGJ7K.mjs → config-DCYWFaEi.mjs} +1 -1
- package/dist/{context-handover-cache-CXyH6QsC.mjs → context-handover-cache-k2kWbE6G.mjs} +82 -99
- package/dist/context-handover-cache-k2kWbE6G.mjs.map +1 -0
- package/dist/daemon/index.mjs +18 -24
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-B7Y8GQnt.mjs +19 -0
- package/dist/{daemon-aJTxzg20.mjs → daemon-Cgrdvdo6.mjs} +97 -466
- package/dist/daemon-Cgrdvdo6.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +10 -10
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector-DWXkeTlk.mjs → detector-3g7XuU1r.mjs} +5 -12
- package/dist/detector-3g7XuU1r.mjs.map +1 -0
- package/dist/detector-DgDn56j-.mjs +3 -0
- package/dist/{embeddings-ClqKtLZ8.mjs → embeddings-BNv3TQC5.mjs} +1 -1
- package/dist/{embeddings-DvRKcQyt.mjs → embeddings-DfrYNIng.mjs} +1 -1
- package/dist/{embeddings-DvRKcQyt.mjs.map → embeddings-DfrYNIng.mjs.map} +1 -1
- package/dist/factory-C9wGUe30.mjs +3 -0
- package/dist/factory-DiuntvwG.mjs +250 -0
- package/dist/factory-DiuntvwG.mjs.map +1 -0
- package/dist/{fallback-CCFIpPha.mjs → fallback-DS6AEJsU.mjs} +190 -132
- package/dist/fallback-DS6AEJsU.mjs.map +1 -0
- package/dist/federation-db-CJDw_Pwq.mjs +3 -0
- package/dist/{db-DJ8rk0VM.mjs → federation-db-w8e-sKjp.mjs} +5 -5
- package/dist/federation-db-w8e-sKjp.mjs.map +1 -0
- package/dist/{helpers-crDEr6S2.mjs → helpers-D9WkIf9R.mjs} +1 -1
- package/dist/{helpers-crDEr6S2.mjs.map → helpers-D9WkIf9R.mjs.map} +1 -1
- package/dist/hooks/capture-all-events.mjs.map +1 -1
- package/dist/hooks/capture-session-summary.mjs.map +1 -1
- package/dist/hooks/capture-tool-output.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs.map +1 -1
- package/dist/hooks/load-core-context.mjs +5 -0
- package/dist/hooks/load-core-context.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs.map +1 -1
- package/dist/hooks/post-compact-inject.mjs.map +1 -1
- package/dist/hooks/route-agents-to-worker.mjs.map +1 -1
- package/dist/hooks/route-edits-to-worker.mjs +1 -1
- package/dist/hooks/route-edits-to-worker.mjs.map +2 -2
- package/dist/hooks/security-validator.mjs +8367 -19
- package/dist/hooks/security-validator.mjs.map +4 -4
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/dist/hooks/whisper-rules.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs.map +1 -1
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs.map +1 -1
- package/dist/index.d.mts +327 -100
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +7 -11
- package/dist/indexer-backend-Cc-_NK3q.mjs +1 -0
- package/dist/indexer-backend-DcDtin1O.mjs +5 -0
- package/dist/{ipc-client-C6NryMMb.mjs → ipc-client-Dasm515c.mjs} +2 -2
- package/dist/{ipc-client-C6NryMMb.mjs.map → ipc-client-Dasm515c.mjs.map} +1 -1
- package/dist/kg-entity-Cj8Q6YmU.mjs +29 -0
- package/dist/kg-entity-Cj8Q6YmU.mjs.map +1 -0
- package/dist/{latent-ideas-BfL4nCQ3.mjs → latent-ideas-BRy6HNwg.mjs} +4 -4
- package/dist/{latent-ideas-BfL4nCQ3.mjs.map → latent-ideas-BRy6HNwg.mjs.map} +1 -1
- package/dist/{link-boost-C5PaNdzm.mjs → link-boost-BptbAPuv.mjs} +1 -1
- package/dist/{link-boost-C5PaNdzm.mjs.map → link-boost-BptbAPuv.mjs.map} +1 -1
- package/dist/{main-resolver-Bt1f6jJc.mjs → main-resolver-Dx-pxic1.mjs} +56 -31
- package/dist/main-resolver-Dx-pxic1.mjs.map +1 -0
- package/dist/main-resolver-uDtK9p6e.mjs +7 -0
- package/dist/merge-BncjJTfb.mjs +3 -0
- package/dist/merge-xoyfZQ4W.mjs +6 -0
- package/dist/merge-xoyfZQ4W.mjs.map +1 -0
- package/dist/{migrate-CfERwXhY.mjs → migrate-BD7D8EEh.mjs} +21 -54
- package/dist/migrate-BD7D8EEh.mjs.map +1 -0
- package/dist/{neighborhood-CETR_P4y.mjs → neighborhood-D8uscnGy.mjs} +4 -25
- package/dist/neighborhood-D8uscnGy.mjs.map +1 -0
- package/dist/{note-context-DCcRWBoN.mjs → note-context-CgffaUMh.mjs} +3 -23
- package/dist/note-context-CgffaUMh.mjs.map +1 -0
- package/dist/{pai-home-ChAOupZc.mjs → pai-home-CeOIYltV.mjs} +2 -2
- package/dist/pai-home-CeOIYltV.mjs.map +1 -0
- package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-D1MMswkz.mjs} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-D1MMswkz.mjs.map} +1 -1
- package/dist/{planner-D3keWys5.mjs → planner-C2wWnZrZ.mjs} +7 -7
- package/dist/{planner-D3keWys5.mjs.map → planner-C2wWnZrZ.mjs.map} +1 -1
- package/dist/{postgres-CUSXWgTG.mjs → postgres-nvU5pShF.mjs} +863 -4
- package/dist/postgres-nvU5pShF.mjs.map +1 -0
- package/dist/{program-B3eRMhff.mjs → program-Cb-9J5Ie.mjs} +2260 -1399
- package/dist/program-Cb-9J5Ie.mjs.map +1 -0
- package/dist/query-feedback-COYCnvHQ.mjs +3 -0
- package/dist/{query-feedback-CTs_jx6i.mjs → query-feedback-xz5axTj3.mjs} +3 -3
- package/dist/query-feedback-xz5axTj3.mjs.map +1 -0
- package/dist/{db-Ddns80yf.mjs → registry-db-Bkpbp7Gf.mjs} +5 -5
- package/dist/registry-db-Bkpbp7Gf.mjs.map +1 -0
- package/dist/registry-db-DwXpf7yF.mjs +3 -0
- package/dist/registry-postgres-B07-hmn8.mjs +795 -0
- package/dist/registry-postgres-B07-hmn8.mjs.map +1 -0
- package/dist/registry-sqlite-CkGND_tm.mjs +593 -0
- package/dist/registry-sqlite-CkGND_tm.mjs.map +1 -0
- package/dist/{reranker-B5qUTamW.mjs → reranker-BS5gqGeF.mjs} +1 -1
- package/dist/{reranker-HHmhZk6v.mjs → reranker-CRVawoEh.mjs} +1 -1
- package/dist/{reranker-HHmhZk6v.mjs.map → reranker-CRVawoEh.mjs.map} +1 -1
- package/dist/router-BXOf_e9e.mjs +3 -0
- package/dist/{router-DBAZfE7X.mjs → router-BdxdsAvs.mjs} +2 -2
- package/dist/{router-DBAZfE7X.mjs.map → router-BdxdsAvs.mjs.map} +1 -1
- package/dist/{run-DLG25bzt.mjs → run-DkTrZejF.mjs} +234 -49
- package/dist/run-DkTrZejF.mjs.map +1 -0
- package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-BRwDeBEg.mjs} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-BRwDeBEg.mjs.map} +1 -1
- package/dist/search-BQHL5wDr.mjs +3 -0
- package/dist/search-BaTbyWjK.mjs +107 -0
- package/dist/search-BaTbyWjK.mjs.map +1 -0
- package/dist/{server-CGiHdaUp.mjs → server-CGuZFOeR.mjs} +4 -4
- package/dist/{server-CGiHdaUp.mjs.map → server-CGuZFOeR.mjs.map} +1 -1
- package/dist/{session-keepalive-DklS8VmU.mjs → session-keepalive-C5_69Av6.mjs} +7 -7
- package/dist/{session-keepalive-DklS8VmU.mjs.map → session-keepalive-C5_69Av6.mjs.map} +1 -1
- package/dist/skills/Plan/SKILL.md +1 -1
- package/dist/{sources-KBGEA_E6.mjs → sources-BBe3AZ-W.mjs} +8 -27
- package/dist/sources-BBe3AZ-W.mjs.map +1 -0
- package/dist/sqlite-D9p5a4qe.mjs +935 -0
- package/dist/sqlite-D9p5a4qe.mjs.map +1 -0
- package/dist/state-CwlGAKgF.mjs +3 -0
- package/dist/{state-DwrsOoMi.mjs → state-n13uPK5B.mjs} +6 -5
- package/dist/state-n13uPK5B.mjs.map +1 -0
- package/dist/{stop-words-BaMEGVeY.mjs → stop-words-ClCwO8mZ.mjs} +1 -1
- package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-ClCwO8mZ.mjs.map} +1 -1
- package/dist/{themes-BVoAnLZj.mjs → themes-B2v7RNnA.mjs} +3 -3
- package/dist/{themes-BVoAnLZj.mjs.map → themes-B2v7RNnA.mjs.map} +1 -1
- package/dist/{tools-BEqiJt_j.mjs → tools-Diro2s8J.mjs} +157 -398
- package/dist/tools-Diro2s8J.mjs.map +1 -0
- package/dist/{tools-BwsOTmjh.mjs → tools-KgL1rkPD.mjs} +4 -4
- package/dist/{trace-Cy8quQTm.mjs → trace-BRflVyBi.mjs} +1 -1
- package/dist/{trace-Cy8quQTm.mjs.map → trace-BRflVyBi.mjs.map} +1 -1
- package/dist/{utils-BD8g8oSf.mjs → utils-BrX9FLeK.mjs} +1 -1
- package/dist/{utils-CJymHkYm.mjs → utils-C9HsYDpO.mjs} +1 -1
- package/dist/{utils-CJymHkYm.mjs.map → utils-C9HsYDpO.mjs.map} +1 -1
- package/dist/{vault-indexer-C5i-OAyr.mjs → vault-indexer-DHYBpRZ5.mjs} +2 -2
- package/dist/{vault-indexer-C5i-OAyr.mjs.map → vault-indexer-DHYBpRZ5.mjs.map} +1 -1
- package/dist/{wakeup-Yk5lQ0jb.mjs → wakeup-D0uvqLsE.mjs} +95 -4
- package/dist/wakeup-D0uvqLsE.mjs.map +1 -0
- package/dist/work-queue-worker-DEbWsrDQ.mjs +11 -0
- package/dist/{work-queue-worker-utPp4hUv.mjs → work-queue-worker-DufMOtrL.mjs} +9 -9
- package/dist/work-queue-worker-DufMOtrL.mjs.map +1 -0
- package/dist/{zettelkasten-DezbXghl.mjs → zettelkasten-BPxSOalU.mjs} +4 -4
- package/dist/{zettelkasten-DezbXghl.mjs.map → zettelkasten-BPxSOalU.mjs.map} +1 -1
- package/docker/init.sql +5 -1
- package/docs/commands/README.md +5 -0
- package/docs/commands/audit.md +1 -1
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/config.md +4 -4
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +15 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/hooks-db.md +47 -0
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/launch.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +2 -2
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +1 -1
- package/docs/commands/zettel.md +1 -1
- package/docs/design/postgres-only.md +438 -0
- package/docs/design/registry-method-map.md +174 -0
- package/package.json +1 -1
- package/src/hooks/pre-compact.sh +2 -40
- package/src/hooks/session-hooks-db.test.ts +93 -0
- package/src/hooks/session-stop.sh +2 -26
- package/src/hooks/ts/lib/edit-gate.test.ts +9 -0
- package/src/hooks/ts/lib/edit-gate.ts +5 -2
- package/src/hooks/ts/lib/plutil-guard.test.ts +54 -0
- package/src/hooks/ts/lib/plutil-guard.ts +43 -0
- package/src/hooks/ts/lib/worker-git-guard.test.ts +134 -0
- package/src/hooks/ts/lib/worker-git-guard.ts +103 -0
- package/src/hooks/ts/pre-tool-use/security-validator.test.ts +68 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +69 -21
- package/src/hooks/ts/session-start/load-core-context.ts +13 -0
- package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +13 -0
- package/dist/auto-route-CcD0OWWB.mjs +0 -86
- package/dist/auto-route-CcD0OWWB.mjs.map +0 -1
- package/dist/clusters-Cw_P6lO-.mjs.map +0 -1
- package/dist/context-handover-cache-CXyH6QsC.mjs.map +0 -1
- package/dist/daemon-CknQ6YGZ.mjs +0 -25
- package/dist/daemon-aJTxzg20.mjs.map +0 -1
- package/dist/db-BOBfewFH.mjs +0 -3
- package/dist/db-DJ8rk0VM.mjs.map +0 -1
- package/dist/db-DbY2O8QO.mjs +0 -3
- package/dist/db-Ddns80yf.mjs.map +0 -1
- package/dist/detect-Bf2z-oKB.mjs +0 -86
- package/dist/detect-Bf2z-oKB.mjs.map +0 -1
- package/dist/detector-CkwkXuGh.mjs +0 -5
- package/dist/detector-DWXkeTlk.mjs.map +0 -1
- package/dist/factory-Ba8j9K7q.mjs +0 -3
- package/dist/factory-Bt7TWQje.mjs +0 -150
- package/dist/factory-Bt7TWQje.mjs.map +0 -1
- package/dist/fallback-CCFIpPha.mjs.map +0 -1
- package/dist/indexer-backend-CdvCJCkD.mjs.map +0 -1
- package/dist/kg-entity-BSW7ra8i.mjs +0 -176
- package/dist/kg-entity-BSW7ra8i.mjs.map +0 -1
- package/dist/main-resolver-Bt1f6jJc.mjs.map +0 -1
- package/dist/main-resolver-DYHhw5Ig.mjs +0 -7
- package/dist/migrate-CfERwXhY.mjs.map +0 -1
- package/dist/neighborhood-CETR_P4y.mjs.map +0 -1
- package/dist/note-context-DCcRWBoN.mjs.map +0 -1
- package/dist/pai-home-ChAOupZc.mjs.map +0 -1
- package/dist/postgres-CUSXWgTG.mjs.map +0 -1
- package/dist/program-B3eRMhff.mjs.map +0 -1
- package/dist/query-feedback-Bm6XvCmg.mjs +0 -3
- package/dist/query-feedback-CTs_jx6i.mjs.map +0 -1
- package/dist/router-CremHKTI.mjs +0 -3
- package/dist/run-DLG25bzt.mjs.map +0 -1
- package/dist/search-B69cQwDB.mjs +0 -303
- package/dist/search-B69cQwDB.mjs.map +0 -1
- package/dist/search-fb-5ovj4.mjs +0 -4
- package/dist/sources-KBGEA_E6.mjs.map +0 -1
- package/dist/sqlite-By6r2YhZ.mjs +0 -271
- package/dist/sqlite-By6r2YhZ.mjs.map +0 -1
- package/dist/state-Ca0ltLgI.mjs +0 -3
- package/dist/state-DwrsOoMi.mjs.map +0 -1
- package/dist/sync-Btcb6xA4.mjs +0 -310
- package/dist/sync-Btcb6xA4.mjs.map +0 -1
- package/dist/tools-BEqiJt_j.mjs.map +0 -1
- package/dist/wakeup-Yk5lQ0jb.mjs.map +0 -1
- package/dist/work-queue-worker-DsdJx-jn.mjs +0 -14
- package/dist/work-queue-worker-utPp4hUv.mjs.map +0 -1
package/src/hooks/pre-compact.sh
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
108
|
+
# Mark the latest open/compacted session completed
|
|
116
109
|
# ---------------------------------------------------------------------------
|
|
117
110
|
|
|
118
|
-
|
|
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
|
|
49
|
-
`
|
|
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
|
+
});
|