@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.
- package/ARCHITECTURE.md +9 -9
- package/README.md +26 -8
- package/dist/{aibroker-client-BP74gGwD.mjs → aibroker-client-Bn9SnGMa.mjs} +3 -3
- package/dist/{aibroker-client-BP74gGwD.mjs.map → aibroker-client-Bn9SnGMa.mjs.map} +1 -1
- package/dist/{aibroker-client-C6m71p6O.mjs → aibroker-client-D5NT0S1N.mjs} +1 -1
- package/dist/{auto-route-_5zzJz5q.mjs → auto-route-CcD0OWWB.mjs} +4 -4
- package/dist/{auto-route-_5zzJz5q.mjs.map → auto-route-CcD0OWWB.mjs.map} +1 -1
- package/dist/{chain-DTII7xeL.mjs → chain-BbcfP77K.mjs} +5 -4
- package/dist/{chain-DTII7xeL.mjs.map → chain-BbcfP77K.mjs.map} +1 -1
- package/dist/cli/index.mjs +23 -21
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +23 -21
- package/dist/{clusters-j0ZuNYp9.mjs → clusters-Cw_P6lO-.mjs} +2 -2
- package/dist/{clusters-j0ZuNYp9.mjs.map → clusters-Cw_P6lO-.mjs.map} +1 -1
- package/dist/{config-B64vFg14.mjs → config-DCu8THKi.mjs} +41 -9
- package/dist/{config-B64vFg14.mjs.map → config-DCu8THKi.mjs.map} +1 -1
- package/dist/config-DeqFqa1N.mjs +3 -0
- package/dist/config-DxMZjpvY.mjs +1289 -0
- package/dist/config-DxMZjpvY.mjs.map +1 -0
- package/dist/context-handover-cache-B9NKSjwR.mjs +3242 -0
- package/dist/context-handover-cache-B9NKSjwR.mjs.map +1 -0
- package/dist/daemon/index.mjs +23 -22
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BNjbVsQU.mjs +24 -0
- package/dist/{daemon-B8N1egl0.mjs → daemon-BppziPi6.mjs} +52 -38
- package/dist/daemon-BppziPi6.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +144 -117
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/db-BOBfewFH.mjs +3 -0
- package/dist/{db-a1ixZQjr.mjs → db-DJ8rk0VM.mjs} +15 -6
- package/dist/{db-a1ixZQjr.mjs.map → db-DJ8rk0VM.mjs.map} +1 -1
- package/dist/db-DbY2O8QO.mjs +3 -0
- package/dist/{db-Ca5qfsMC.mjs → db-Ddns80yf.mjs} +15 -6
- package/dist/{db-Ca5qfsMC.mjs.map → db-Ddns80yf.mjs.map} +1 -1
- package/dist/{detect-CdaA48EI.mjs → detect-Bf2z-oKB.mjs} +1 -1
- package/dist/{detect-CdaA48EI.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
- package/dist/detector-CkwkXuGh.mjs +5 -0
- package/dist/{detector-C73Nxm94.mjs → detector-DWXkeTlk.mjs} +2 -2
- package/dist/{detector-C73Nxm94.mjs.map → detector-DWXkeTlk.mjs.map} +1 -1
- package/dist/{embeddings-CEBGrzwu.mjs → embeddings-ClqKtLZ8.mjs} +1 -1
- package/dist/{embeddings-DOLZnT1X.mjs → embeddings-DvRKcQyt.mjs} +1 -1
- package/dist/{embeddings-DOLZnT1X.mjs.map → embeddings-DvRKcQyt.mjs.map} +1 -1
- package/dist/factory-47Qf_df7.mjs +3 -0
- package/dist/{factory-DEb9NmHJ.mjs → factory-DNYAqjH0.mjs} +8 -8
- package/dist/{factory-DEb9NmHJ.mjs.map → factory-DNYAqjH0.mjs.map} +1 -1
- package/dist/{fallback-oJl3BaIN.mjs → fallback-Cwt4hmh5.mjs} +182 -122
- package/dist/fallback-Cwt4hmh5.mjs.map +1 -0
- package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
- package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
- package/dist/hooks/capture-all-events.mjs +117 -34
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/capture-session-summary.mjs +110 -33
- package/dist/hooks/capture-session-summary.mjs.map +4 -4
- package/dist/hooks/capture-tool-output.mjs +105 -28
- package/dist/hooks/capture-tool-output.mjs.map +4 -4
- package/dist/hooks/cleanup-session-files.mjs +54 -13
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +7565 -122
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +55 -14
- package/dist/hooks/initialize-session.mjs.map +2 -2
- package/dist/hooks/inject-observations.mjs +79 -18
- package/dist/hooks/inject-observations.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +55 -14
- package/dist/hooks/load-core-context.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +185 -103
- package/dist/hooks/load-project-context.mjs.map +4 -4
- package/dist/hooks/mcp-deferred-gate.mjs +54 -13
- package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
- package/dist/hooks/mcp-deferred-reminder.mjs +9 -0
- package/dist/hooks/mcp-deferred-reminder.mjs.map +3 -3
- package/dist/hooks/observe.mjs +54 -13
- package/dist/hooks/observe.mjs.map +2 -2
- package/dist/hooks/post-compact-inject.mjs +7419 -16
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +7778 -171
- package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
- package/dist/hooks/route-edits-to-worker.mjs +90 -0
- package/dist/hooks/route-edits-to-worker.mjs.map +7 -0
- package/dist/hooks/security-validator.mjs +147 -6
- package/dist/hooks/security-validator.mjs.map +4 -4
- package/dist/hooks/session-commands.mjs +9 -0
- package/dist/hooks/session-commands.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +182 -105
- package/dist/hooks/stop-hook.mjs.map +4 -4
- package/dist/hooks/subagent-stop-hook.mjs +3 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
- package/dist/hooks/sync-todo-to-md.mjs +54 -13
- package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
- package/dist/hooks/update-tab-on-action.mjs +3 -0
- package/dist/hooks/update-tab-on-action.mjs.map +1 -1
- package/dist/hooks/update-tab-titles.mjs +3 -0
- package/dist/hooks/update-tab-titles.mjs.map +1 -1
- package/dist/hooks/whisper-reinject.mjs +3 -0
- package/dist/hooks/whisper-reinject.mjs.map +1 -1
- package/dist/hooks/whisper-rules.mjs +134 -29
- package/dist/hooks/whisper-rules.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +7737 -117
- package/dist/hooks/worker-proxy.mjs.map +4 -4
- package/dist/hooks/worker-status-line.mjs +7791 -144
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/hooks/worker-supervision.mjs +7721 -115
- package/dist/hooks/worker-supervision.mjs.map +4 -4
- package/dist/index.d.mts +4 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +10 -10
- package/dist/{indexer-backend-BPTzKFZU.mjs → indexer-backend-CdvCJCkD.mjs} +3 -3
- package/dist/{indexer-backend-BPTzKFZU.mjs.map → indexer-backend-CdvCJCkD.mjs.map} +1 -1
- package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C6NryMMb.mjs} +2 -2
- package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C6NryMMb.mjs.map} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-BSjM5xZh.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-BSjM5xZh.mjs.map} +1 -1
- package/dist/{latent-ideas-CtSca4qG.mjs → latent-ideas-BfL4nCQ3.mjs} +4 -4
- package/dist/{latent-ideas-CtSca4qG.mjs.map → latent-ideas-BfL4nCQ3.mjs.map} +1 -1
- package/dist/{link-boost-Bucnz3n8.mjs → link-boost-C5PaNdzm.mjs} +1 -1
- package/dist/{link-boost-Bucnz3n8.mjs.map → link-boost-C5PaNdzm.mjs.map} +1 -1
- package/dist/main-resolver-CnfnWa8C.mjs +7 -0
- package/dist/{main-resolver-DnkxPYkr.mjs → main-resolver-DvYu9jbl.mjs} +21 -11
- package/dist/main-resolver-DvYu9jbl.mjs.map +1 -0
- package/dist/{migrate-Cjzeefn9.mjs → migrate-CfERwXhY.mjs} +2 -2
- package/dist/{migrate-Cjzeefn9.mjs.map → migrate-CfERwXhY.mjs.map} +1 -1
- package/dist/{neighborhood-CM7Jgb51.mjs → neighborhood-CETR_P4y.mjs} +2 -2
- package/dist/{neighborhood-CM7Jgb51.mjs.map → neighborhood-CETR_P4y.mjs.map} +1 -1
- package/dist/{note-context-DCwE2A-M.mjs → note-context-DCcRWBoN.mjs} +1 -1
- package/dist/{note-context-DCwE2A-M.mjs.map → note-context-DCcRWBoN.mjs.map} +1 -1
- package/dist/pai-home-ChAOupZc.mjs +164 -0
- package/dist/pai-home-ChAOupZc.mjs.map +1 -0
- package/dist/{pai-marker-CHtbJMwJ.mjs → pai-marker-B20KqhA8.mjs} +1 -1
- package/dist/{pai-marker-CHtbJMwJ.mjs.map → pai-marker-B20KqhA8.mjs.map} +1 -1
- package/dist/{planner-D5Ui7DAm.mjs → planner-pXRc-8yt.mjs} +7 -6
- package/dist/{planner-D5Ui7DAm.mjs.map → planner-pXRc-8yt.mjs.map} +1 -1
- package/dist/{postgres-CtOzfoQU.mjs → postgres-CUSXWgTG.mjs} +2 -2
- package/dist/{postgres-CtOzfoQU.mjs.map → postgres-CUSXWgTG.mjs.map} +1 -1
- package/dist/{program-zpoYnJGz.mjs → program-LLNPpxlj.mjs} +8308 -6065
- package/dist/program-LLNPpxlj.mjs.map +1 -0
- package/dist/query-feedback-C7rSfiXU.mjs +3 -0
- package/dist/{query-feedback-Dqz-HC__.mjs → query-feedback-CYyOGi3y.mjs} +20 -6
- package/dist/query-feedback-CYyOGi3y.mjs.map +1 -0
- package/dist/{reranker-CwTCNsgA.mjs → reranker-B5qUTamW.mjs} +1 -1
- package/dist/{reranker-xPm04PXx.mjs → reranker-HHmhZk6v.mjs} +1 -1
- package/dist/{reranker-xPm04PXx.mjs.map → reranker-HHmhZk6v.mjs.map} +1 -1
- package/dist/router-CremHKTI.mjs +3 -0
- package/dist/{router-DD6D4j31.mjs → router-DBAZfE7X.mjs} +2 -2
- package/dist/{router-DD6D4j31.mjs.map → router-DBAZfE7X.mjs.map} +1 -1
- package/dist/{run-D7K5UALn.mjs → run-DDdT9teG.mjs} +530 -210
- package/dist/run-DDdT9teG.mjs.map +1 -0
- package/dist/{runtime-paths-rni52zHX.mjs → runtime-paths-B0P1TvUr.mjs} +1 -1
- package/dist/{runtime-paths-rni52zHX.mjs.map → runtime-paths-B0P1TvUr.mjs.map} +1 -1
- package/dist/{search-Rpk1cSBC.mjs → search-B69cQwDB.mjs} +3 -3
- package/dist/{search-Rpk1cSBC.mjs.map → search-B69cQwDB.mjs.map} +1 -1
- package/dist/{search-CfPpJAWQ.mjs → search-fb-5ovj4.mjs} +2 -2
- package/dist/{server-D4vAieF4.mjs → server-Ch99xt5K.mjs} +15 -14
- package/dist/server-Ch99xt5K.mjs.map +1 -0
- package/dist/skills/Advisor/SKILL.md +3 -3
- package/dist/skills/Providers/SKILL.md +30 -0
- package/dist/skills/Reconstruct/SKILL.md +1 -1
- package/dist/skills/VaultContext/SKILL.md +1 -1
- package/dist/skills/Whisper/SKILL.md +3 -3
- package/dist/skills/Worker/SKILL.md +1 -1
- package/dist/{sources-CS43CpuS.mjs → sources-KBGEA_E6.mjs} +2 -2
- package/dist/{sources-CS43CpuS.mjs.map → sources-KBGEA_E6.mjs.map} +1 -1
- package/dist/{sqlite-Cq3gJcq9.mjs → sqlite-By6r2YhZ.mjs} +3 -3
- package/dist/{sqlite-Cq3gJcq9.mjs.map → sqlite-By6r2YhZ.mjs.map} +1 -1
- package/dist/{state-CY6I-Hnc.mjs → state-DnYGRmS7.mjs} +1 -1
- package/dist/{state-BRdB-Bf1.mjs → state-PJrgfDLW.mjs} +1 -1
- package/dist/{state-BRdB-Bf1.mjs.map → state-PJrgfDLW.mjs.map} +1 -1
- package/dist/{stop-words-Hfu8u22w.mjs → stop-words-BaMEGVeY.mjs} +1 -1
- package/dist/{stop-words-Hfu8u22w.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
- package/dist/{sync-BWbe8JTg.mjs → sync-Btcb6xA4.mjs} +3 -3
- package/dist/{sync-BWbe8JTg.mjs.map → sync-Btcb6xA4.mjs.map} +1 -1
- package/dist/{themes-B7h9YDiy.mjs → themes-BVoAnLZj.mjs} +3 -3
- package/dist/{themes-B7h9YDiy.mjs.map → themes-BVoAnLZj.mjs.map} +1 -1
- package/dist/{tools-BW3NLMPk.mjs → tools-BradfQk7.mjs} +4 -3
- package/dist/{tools-LlzcV_eK.mjs → tools-UrSHIe29.mjs} +30 -233
- package/dist/tools-UrSHIe29.mjs.map +1 -0
- package/dist/{trace-u4hT-pLD.mjs → trace-Cy8quQTm.mjs} +1 -1
- package/dist/{trace-u4hT-pLD.mjs.map → trace-Cy8quQTm.mjs.map} +1 -1
- package/dist/{utils-DhMex3Ox.mjs → utils-BD8g8oSf.mjs} +1 -1
- package/dist/{utils-9Err2RBW.mjs → utils-CJymHkYm.mjs} +1 -1
- package/dist/{utils-9Err2RBW.mjs.map → utils-CJymHkYm.mjs.map} +1 -1
- package/dist/{vault-indexer-mata9S7b.mjs → vault-indexer-C5i-OAyr.mjs} +2 -2
- package/dist/{vault-indexer-mata9S7b.mjs.map → vault-indexer-C5i-OAyr.mjs.map} +1 -1
- package/dist/wakeup-TnGjJY3I.mjs +218 -0
- package/dist/wakeup-TnGjJY3I.mjs.map +1 -0
- package/dist/work-queue-worker-BA5IDBwQ.mjs +567 -0
- package/dist/work-queue-worker-BA5IDBwQ.mjs.map +1 -0
- package/dist/work-queue-worker-DLM2wlqM.mjs +14 -0
- package/dist/{zettelkasten-F0D_XfQ4.mjs → zettelkasten-BBjA7GcP.mjs} +4 -4
- package/dist/{zettelkasten-F0D_XfQ4.mjs.map → zettelkasten-BBjA7GcP.mjs.map} +1 -1
- package/docs/commands/README.md +30 -3
- package/docs/commands/audit.md +88 -0
- package/docs/commands/backup.md +2 -2
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/config.md +59 -0
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/launch.md +45 -0
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +3 -3
- 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 +46 -1
- package/docs/commands/projects.md +46 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +1 -1
- 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 +95 -6
- package/docs/commands/zettel.md +1 -1
- package/docs/provider-abstraction.md +1 -1
- package/docs/provider-independence-details.md +8 -5
- package/docs/task-bus.md +1 -1
- package/docs/worker.md +136 -83
- package/docs/workers-config.md +446 -0
- package/package.json +4 -2
- package/scripts/build-hooks.mjs +56 -3
- package/src/hooks/pre-compact.sh +5 -2
- package/src/hooks/session-stop.sh +29 -4
- package/src/hooks/ts/capture-all-events.ts +3 -3
- package/src/hooks/ts/lib/advisor-budget.test.ts +98 -0
- package/src/hooks/ts/lib/advisor-budget.ts +90 -0
- package/src/hooks/ts/lib/agent-gate.test.ts +101 -0
- package/src/hooks/ts/lib/agent-gate.ts +112 -0
- package/src/hooks/ts/lib/context-fill.ts +2 -2
- package/src/hooks/ts/lib/edit-gate.test.ts +52 -0
- package/src/hooks/ts/lib/edit-gate.ts +51 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +8 -0
- package/src/hooks/ts/lib/pai-paths.test.ts +93 -0
- package/src/hooks/ts/lib/pai-paths.ts +168 -24
- package/src/hooks/ts/lib/project-utils/index.ts +1 -0
- package/src/hooks/ts/lib/project-utils/paths.ts +5 -4
- package/src/hooks/ts/lib/project-utils.ts +1 -0
- package/src/hooks/ts/post-tool-use/capture-tool-output.ts +2 -2
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +46 -99
- package/src/hooks/ts/pre-tool-use/route-edits-to-worker.test.ts +47 -0
- package/src/hooks/ts/pre-tool-use/route-edits-to-worker.ts +96 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +2 -1
- package/src/hooks/ts/session-end/capture-session-summary.ts +4 -4
- package/src/hooks/ts/session-start/initialize-session.ts +3 -3
- package/src/hooks/ts/session-start/inject-observations.test.ts +56 -0
- package/src/hooks/ts/session-start/inject-observations.ts +28 -8
- package/src/hooks/ts/session-start/load-core-context.ts +4 -4
- package/src/hooks/ts/session-start/load-project-context.ts +2 -2
- package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -0
- package/src/hooks/ts/session-start/session-commands.ts +4 -0
- package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +36 -0
- package/src/hooks/ts/stop/stop-hook.ts +6 -11
- package/src/hooks/ts/user-prompt/whisper-rules.test.ts +49 -0
- package/src/hooks/ts/user-prompt/whisper-rules.ts +88 -47
- package/statusline-command.sh +277 -116
- package/templates/agent-prefs.example.md +8 -8
- package/templates/claude-md.template.md +1 -1
- package/templates/voices.example.json +3 -3
- package/dist/config-C_ErGddD.mjs +0 -3
- package/dist/config-_lQI_W08.mjs +0 -640
- package/dist/config-_lQI_W08.mjs.map +0 -1
- package/dist/context-handover-cache-pNFdBNrU.mjs +0 -1548
- package/dist/context-handover-cache-pNFdBNrU.mjs.map +0 -1
- package/dist/daemon-B8N1egl0.mjs.map +0 -1
- package/dist/daemon-DHfyoTlk.mjs +0 -23
- package/dist/db-O-cyAPfS.mjs +0 -3
- package/dist/db-XEwJbuGO.mjs +0 -3
- package/dist/detector-C-7KRi42.mjs +0 -5
- package/dist/factory-Dbo8yNPi.mjs +0 -3
- package/dist/fallback-oJl3BaIN.mjs.map +0 -1
- package/dist/main-resolver-BAfOMppW.mjs +0 -7
- package/dist/main-resolver-DnkxPYkr.mjs.map +0 -1
- package/dist/program-zpoYnJGz.mjs.map +0 -1
- package/dist/query-feedback-DGoVRaWD.mjs +0 -3
- package/dist/query-feedback-Dqz-HC__.mjs.map +0 -1
- package/dist/router-BTIGZR2_.mjs +0 -3
- package/dist/run-D7K5UALn.mjs.map +0 -1
- package/dist/server-D4vAieF4.mjs.map +0 -1
- package/dist/tools-LlzcV_eK.mjs.map +0 -1
- package/dist/work-queue-worker-AEmfVE0F.mjs +0 -14
- package/dist/work-queue-worker-DIT-VLSh.mjs +0 -2136
- package/dist/work-queue-worker-DIT-VLSh.mjs.map +0 -1
- /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
|
|
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
|
|
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
|
+
});
|