@tekmidian/pai 0.61.0 → 0.63.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 (69) hide show
  1. package/dist/{async-CLvuY7b-.mjs → async-DNVWtQl2.mjs} +2 -2
  2. package/dist/{async-CLvuY7b-.mjs.map → async-DNVWtQl2.mjs.map} +1 -1
  3. package/dist/{chain-C5oeyUg4.mjs → chain-CUfE6WYK.mjs} +2 -2
  4. package/dist/{chain-C5oeyUg4.mjs.map → chain-CUfE6WYK.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +11 -11
  6. package/dist/cli/program.mjs +11 -11
  7. package/dist/{context-handover-cache-BdtJZpgv.mjs → context-handover-cache-DFNnlWv2.mjs} +2 -2
  8. package/dist/{context-handover-cache-BdtJZpgv.mjs.map → context-handover-cache-DFNnlWv2.mjs.map} +1 -1
  9. package/dist/daemon/index.mjs +8 -8
  10. package/dist/daemon-C8-uzOuV.mjs +19 -0
  11. package/dist/{daemon-DSsIEqzW.mjs → daemon-Cex3dT9f.mjs} +32 -18
  12. package/dist/{daemon-DSsIEqzW.mjs.map → daemon-Cex3dT9f.mjs.map} +1 -1
  13. package/dist/daemon-mcp/index.mjs +4 -4
  14. package/dist/{factory-EUUhSSoq.mjs → factory-BozZa81q.mjs} +3 -3
  15. package/dist/{factory-EUUhSSoq.mjs.map → factory-BozZa81q.mjs.map} +1 -1
  16. package/dist/{factory-DW7BCJJT.mjs → factory-UO9_DFMN.mjs} +1 -1
  17. package/dist/{fallback-DDbJVaR8.mjs → fallback-ktbR0aR7.mjs} +2 -2
  18. package/dist/{fallback-DDbJVaR8.mjs.map → fallback-ktbR0aR7.mjs.map} +1 -1
  19. package/dist/{helpers-D9WkIf9R.mjs → helpers-Dj-B314s.mjs} +3 -2
  20. package/dist/{helpers-D9WkIf9R.mjs.map → helpers-Dj-B314s.mjs.map} +1 -1
  21. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  22. package/dist/hooks/load-project-context.mjs +8519 -133
  23. package/dist/hooks/load-project-context.mjs.map +4 -4
  24. package/dist/hooks/security-validator.mjs.map +1 -1
  25. package/dist/hooks/worker-guard.mjs +7859 -0
  26. package/dist/hooks/worker-guard.mjs.map +7 -0
  27. package/dist/hooks/worker-status-line.mjs +101 -8
  28. package/dist/hooks/worker-status-line.mjs.map +4 -4
  29. package/dist/index.mjs +1 -1
  30. package/dist/{indexer-backend-DcDtin1O.mjs → indexer-backend-BN9rpp9O.mjs} +2 -2
  31. package/dist/{main-resolver-DIQ6m_RA.mjs → main-resolver-BIft_FVF.mjs} +3 -3
  32. package/dist/{main-resolver-DIQ6m_RA.mjs.map → main-resolver-BIft_FVF.mjs.map} +1 -1
  33. package/dist/{main-resolver-CxOsxtOr.mjs → main-resolver-C9-poMdi.mjs} +1 -1
  34. package/dist/{planner-D8jAr_sx.mjs → planner-DAQMTMjP.mjs} +4 -4
  35. package/dist/{planner-D8jAr_sx.mjs.map → planner-DAQMTMjP.mjs.map} +1 -1
  36. package/dist/{postgres-nvU5pShF.mjs → postgres-CxD6_Dca.mjs} +3 -3
  37. package/dist/{postgres-nvU5pShF.mjs.map → postgres-CxD6_Dca.mjs.map} +1 -1
  38. package/dist/{program-BTdspOOa.mjs → program-De1GVVM4.mjs} +178 -48
  39. package/dist/program-De1GVVM4.mjs.map +1 -0
  40. package/dist/{run-CxxZKEZw.mjs → run-CZVCCvXE.mjs} +353 -133
  41. package/dist/run-CZVCCvXE.mjs.map +1 -0
  42. package/dist/{session-keepalive-urk--pfa.mjs → session-keepalive-CWGzLBVU.mjs} +2 -2
  43. package/dist/{session-keepalive-urk--pfa.mjs.map → session-keepalive-CWGzLBVU.mjs.map} +1 -1
  44. package/dist/{sqlite-D9p5a4qe.mjs → sqlite-CfKfuggn.mjs} +2 -2
  45. package/dist/{sqlite-D9p5a4qe.mjs.map → sqlite-CfKfuggn.mjs.map} +1 -1
  46. package/dist/{tools-r-7ZG8B5.mjs → tools-B52IvaU6.mjs} +3 -3
  47. package/dist/{tools-qXyHFwNj.mjs → tools-DFdPbX-X.mjs} +2 -2
  48. package/dist/{tools-qXyHFwNj.mjs.map → tools-DFdPbX-X.mjs.map} +1 -1
  49. package/dist/{vault-indexer-DHYBpRZ5.mjs → vault-indexer-mCFus_AM.mjs} +2 -2
  50. package/dist/{vault-indexer-DHYBpRZ5.mjs.map → vault-indexer-mCFus_AM.mjs.map} +1 -1
  51. package/dist/{wakeup-C48hcN6O.mjs → wakeup-DO1O4udw.mjs} +2 -2
  52. package/dist/{wakeup-C48hcN6O.mjs.map → wakeup-DO1O4udw.mjs.map} +1 -1
  53. package/dist/{work-queue-worker-DUvPiwGO.mjs → work-queue-worker-BbrC3I_R.mjs} +2 -2
  54. package/dist/{work-queue-worker-DUvPiwGO.mjs.map → work-queue-worker-BbrC3I_R.mjs.map} +1 -1
  55. package/dist/{work-queue-worker-1_VBfI_k.mjs → work-queue-worker-z-AATZ2t.mjs} +3 -3
  56. package/docs/commands/README.md +4 -0
  57. package/docs/commands/project.md +14 -5
  58. package/docs/commands/projects.md +14 -5
  59. package/docs/commands/worker.md +27 -0
  60. package/package.json +2 -2
  61. package/scripts/build-hooks.mjs +6 -5
  62. package/scripts/build-skill-stubs.mjs +6 -5
  63. package/src/hooks/ts/lib/worker-guard.test.ts +188 -0
  64. package/src/hooks/ts/lib/worker-guard.ts +315 -0
  65. package/src/hooks/ts/pre-tool-use/worker-guard.ts +75 -0
  66. package/src/hooks/ts/session-start/load-project-context.ts +23 -0
  67. package/dist/daemon-MoFF4oeg.mjs +0 -19
  68. package/dist/program-BTdspOOa.mjs.map +0 -1
  69. package/dist/run-CxxZKEZw.mjs.map +0 -1
@@ -39,6 +39,7 @@ pai project <subcommand> [options]
39
39
  | [`pai project names`](#pai-project-names) | List named projects (your curated shortlist) |
40
40
  | [`pai project config [identifier]`](#pai-project-config-identifier) | View or modify session launch config for a project. |
41
41
  | [`pai project mcp [names...]`](#pai-project-mcp-names) | View or set the MCP servers this project's interactive supervisor session |
42
+ | [`pai project pins`](#pai-project-pins) | Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned) |
42
43
  | [`pai project tools [names...]`](#pai-project-tools-names) | View or set the built-in tool schemas this project's interactive |
43
44
 
44
45
  ### pai project list
@@ -405,9 +406,9 @@ Use --options to discover available keys and presets.
405
406
  View or set the MCP servers this project's interactive supervisor session
406
407
 
407
408
  loads (run from inside the project, like `pai projects here`).
408
- No names: print the current value. Names (comma-separated or repeated
409
- args): set it, validated against ~/.claude.json's mcpServers.
410
- --clear: unset (all servers load — today's default).
409
+ No args: print the current value. --set <names> (comma-separated): set it,
410
+ validated against ~/.claude.json's mcpServers. --clear: unset (all
411
+ servers load — today's default). Positional names are an error.
411
412
 
412
413
  **Arguments**
413
414
 
@@ -419,16 +420,23 @@ args): set it, validated against ~/.claude.json's mcpServers.
419
420
 
420
421
  | Option | Description | Default |
421
422
  |--------|-------------|---------|
423
+ | `--set <names>` | Set the pin (comma-separated server or set names) | |
422
424
  | `--clear` | Unset — all MCP servers load again | |
423
425
 
424
426
 
427
+ ### pai project pins
428
+
429
+ Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned)
430
+
431
+
425
432
  ### pai project tools [names...]
426
433
 
427
434
  View or set the built-in tool schemas this project's interactive
428
435
 
429
436
  supervisor session loads (run from inside the project, like `pai projects
430
- here`). No names: print the current value. Names (comma-separated or
431
- repeated args): set it. --clear: unset (all tools load — today's default).
437
+ here`). No args: print the current value. --set <names> (comma-separated):
438
+ set it. --clear: unset (all tools load — today's default). Positional
439
+ names are an error.
432
440
 
433
441
  **Arguments**
434
442
 
@@ -440,6 +448,7 @@ repeated args): set it. --clear: unset (all tools load — today's default).
440
448
 
441
449
  | Option | Description | Default |
442
450
  |--------|-------------|---------|
451
+ | `--set <names>` | Set the pin (comma-separated tool names) | |
443
452
  | `--clear` | Unset — all built-in tools load again | |
444
453
 
445
454
 
@@ -39,6 +39,7 @@ pai projects <subcommand> [options]
39
39
  | [`pai projects names`](#pai-projects-names) | List named projects (your curated shortlist) |
40
40
  | [`pai projects config [identifier]`](#pai-projects-config-identifier) | View or modify session launch config for a project. |
41
41
  | [`pai projects mcp [names...]`](#pai-projects-mcp-names) | View or set the MCP servers this project's interactive supervisor session |
42
+ | [`pai projects pins`](#pai-projects-pins) | Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned) |
42
43
  | [`pai projects tools [names...]`](#pai-projects-tools-names) | View or set the built-in tool schemas this project's interactive |
43
44
 
44
45
  ### pai projects list
@@ -405,9 +406,9 @@ Use --options to discover available keys and presets.
405
406
  View or set the MCP servers this project's interactive supervisor session
406
407
 
407
408
  loads (run from inside the project, like `pai projects here`).
408
- No names: print the current value. Names (comma-separated or repeated
409
- args): set it, validated against ~/.claude.json's mcpServers.
410
- --clear: unset (all servers load — today's default).
409
+ No args: print the current value. --set <names> (comma-separated): set it,
410
+ validated against ~/.claude.json's mcpServers. --clear: unset (all
411
+ servers load — today's default). Positional names are an error.
411
412
 
412
413
  **Arguments**
413
414
 
@@ -419,16 +420,23 @@ args): set it, validated against ~/.claude.json's mcpServers.
419
420
 
420
421
  | Option | Description | Default |
421
422
  |--------|-------------|---------|
423
+ | `--set <names>` | Set the pin (comma-separated server or set names) | |
422
424
  | `--clear` | Unset — all MCP servers load again | |
423
425
 
424
426
 
427
+ ### pai projects pins
428
+
429
+ Print one line per active MCP/tools pin for the current directory's project (nothing when unpinned)
430
+
431
+
425
432
  ### pai projects tools [names...]
426
433
 
427
434
  View or set the built-in tool schemas this project's interactive
428
435
 
429
436
  supervisor session loads (run from inside the project, like `pai projects
430
- here`). No names: print the current value. Names (comma-separated or
431
- repeated args): set it. --clear: unset (all tools load — today's default).
437
+ here`). No args: print the current value. --set <names> (comma-separated):
438
+ set it. --clear: unset (all tools load — today's default). Positional
439
+ names are an error.
432
440
 
433
441
  **Arguments**
434
442
 
@@ -440,6 +448,7 @@ repeated args): set it. --clear: unset (all tools load — today's default).
440
448
 
441
449
  | Option | Description | Default |
442
450
  |--------|-------------|---------|
451
+ | `--set <names>` | Set the pin (comma-separated tool names) | |
443
452
  | `--clear` | Unset — all built-in tools load again | |
444
453
 
445
454
 
@@ -20,6 +20,7 @@ pai worker <subcommand> [options]
20
20
  |---------|-------------|
21
21
  | [`pai worker run [args...]`](#pai-worker-run-args) | Run one claude-code worker through the configured provider. |
22
22
  | [`pai worker ps`](#pai-worker-ps) | List workers of this session (RUNNING + FINISHED); --all for every worker |
23
+ | [`pai worker pending`](#pai-worker-pending) | Finished workers whose branch or worktree holds work not yet merged into this repo |
23
24
  | [`pai worker follow [id]`](#pai-worker-follow-id) | Live transcript of one worker, or of this session's running workers |
24
25
  | [`pai worker replay <id>`](#pai-worker-replay-id) | Print the transcript of one finished or running worker |
25
26
  | [`pai worker watch`](#pai-worker-watch) | ps refreshed every 2 seconds (plain `watch`, colors kept) |
@@ -31,6 +32,7 @@ pai worker <subcommand> [options]
31
32
  | [`pai worker merge <id>`](#pai-worker-merge-id) | Merge a worker's worktree branch (worker/<id>) into the original checkout, then remove the worktree and delete the branch |
32
33
  | [`pai worker wait <ids...>`](#pai-worker-wait-ids) | Poll workers until they finish; prints each result as one JSON line, exit 1 on failure or timeout |
33
34
  | [`pai worker discard <id>`](#pai-worker-discard-id) | Drop a worker's worktree and branch, keeping nothing |
35
+ | [`pai worker gc`](#pai-worker-gc) | Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept) |
34
36
  | [`pai worker kill <id>`](#pai-worker-kill-id) | Send SIGTERM to a running worker process |
35
37
  | [`pai worker controls <id> <who>`](#pai-worker-controls-id-who) | Hand the desktop controls (clickr) to a worker or take them back. |
36
38
  | [`pai worker resume <id> <text>`](#pai-worker-resume-id-text) | Continue a finished worker on the same provider: claude --resume <session> |
@@ -107,6 +109,19 @@ List workers of this session (RUNNING + FINISHED); --all for every worker
107
109
  | `--all` | Show workers of all sessions, not just this terminal's | |
108
110
 
109
111
 
112
+ ### pai worker pending
113
+
114
+ Finished workers whose branch or worktree holds work not yet merged into this repo
115
+
116
+ **Options**
117
+
118
+ | Option | Description | Default |
119
+ |--------|-------------|---------|
120
+ | `--all` | Every repo in the log dir, not just the current one | |
121
+ | `--json` | Machine-readable output | |
122
+ | `--gate` | Release gate: exit 1 with the list unless PAI_ALLOW_PENDING=1 | |
123
+
124
+
110
125
  ### pai worker follow [id]
111
126
 
112
127
  Live transcript of one worker, or of this session's running workers
@@ -259,6 +274,18 @@ Drop a worker's worktree and branch, keeping nothing
259
274
  | `<id>` | required |
260
275
 
261
276
 
277
+ ### pai worker gc
278
+
279
+ Archive leftover worker worktrees (state kept under refs/pai-archive/<id>; gitignored files are not kept)
280
+
281
+ **Options**
282
+
283
+ | Option | Description | Default |
284
+ |--------|-------------|---------|
285
+ | `--older-than <hours>` | minimum age of a finished worker | `24` |
286
+ | `--dry-run` | print what would be archived, change nothing | |
287
+
288
+
262
289
  ### pai worker kill <id>
263
290
 
264
291
  Send SIGTERM to a running worker process
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -61,7 +61,7 @@
61
61
  "dev": "tsdown --watch",
62
62
  "test": "vitest",
63
63
  "lint": "tsc --noEmit",
64
- "prepublishOnly": "bun run build"
64
+ "prepublishOnly": "bun run build && node dist/cli/index.mjs worker pending --gate"
65
65
  },
66
66
  "author": "Matthias Nott",
67
67
  "license": "MIT",
@@ -31,7 +31,7 @@ import {
31
31
  } from "fs";
32
32
  import { join, resolve, basename } from "path";
33
33
  import { homedir, platform } from "os";
34
- import { isInsideWorkerWorktree } from "./lib/sync-guard.mjs";
34
+ import { syncSkipReason } from "./lib/sync-guard.mjs";
35
35
 
36
36
  const HOOKS_SRC = "src/hooks/ts";
37
37
  const HOOKS_OUT = "dist/hooks";
@@ -164,10 +164,11 @@ console.log(
164
164
  // --sync: Symlink (or copy on Windows) all deployable files to ~/.claude/
165
165
  // ---------------------------------------------------------------------------
166
166
 
167
- if (doSync && isInsideWorkerWorktree(process.cwd())) {
168
- // Never repoint the live ~/.claude symlinks at a worktree that `pai worker
169
- // merge` will delete — see scripts/lib/sync-guard.mjs.
170
- console.log("✔ Hook symlinks skipped: build runs inside a worker worktree");
167
+ const skipReason = doSync ? syncSkipReason() : null;
168
+ if (skipReason) {
169
+ // Only the canonical install repoints the live ~/.claude symlinks — see
170
+ // scripts/lib/sync-guard.mjs.
171
+ console.log(skipReason.startsWith("skipping") ? skipReason : `✔ Hook symlinks skipped: ${skipReason}`);
171
172
  } else if (doSync) {
172
173
  const useSymlinks = platform() !== "win32";
173
174
  const claudeDir = join(homedir(), ".claude");
@@ -32,7 +32,7 @@ import {
32
32
  } from "fs";
33
33
  import { join, resolve } from "path";
34
34
  import { homedir, platform } from "os";
35
- import { isInsideWorkerWorktree } from "./lib/sync-guard.mjs";
35
+ import { syncSkipReason } from "./lib/sync-guard.mjs";
36
36
 
37
37
  const PROMPTS_DIR = "src/daemon-mcp/prompts";
38
38
  const CUSTOM_DIR = join(PROMPTS_DIR, "custom");
@@ -206,10 +206,11 @@ for (const [fileName, dir] of [
206
206
  const customLabel = customNames.length > 0 ? ` (${builtinNames.length} built-in + ${customNames.length} custom)` : "";
207
207
  console.log(`✔ ${generated} skill stubs generated in ${STUBS_OUT}/${customLabel}`);
208
208
 
209
- if (doSync && isInsideWorkerWorktree(process.cwd())) {
210
- // Never repoint the live ~/.claude/skills symlinks at a worktree that
211
- // `pai worker merge` will delete — see scripts/lib/sync-guard.mjs.
212
- console.log("✔ Skill symlinks skipped: build runs inside a worker worktree");
209
+ const skipReason = doSync ? syncSkipReason() : null;
210
+ if (skipReason) {
211
+ // Only the canonical install repoints the live ~/.claude/skills symlinks —
212
+ // see scripts/lib/sync-guard.mjs.
213
+ console.log(skipReason.startsWith("skipping") ? skipReason : `✔ Skill symlinks skipped: ${skipReason}`);
213
214
  } else if (doSync) {
214
215
  syncSymlinks(generatedDirNames);
215
216
  }
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { decideWorkerGuard, parseCommand, type WorkerGuardContext } from "./worker-guard.js";
7
+
8
+ const WT = "/work/wt/abc";
9
+ const MAIN = "/work/main";
10
+ const HOME = "/home/u";
11
+
12
+ const wtCtx = (over: Partial<WorkerGuardContext> = {}): WorkerGuardContext => ({
13
+ cwd: WT,
14
+ home: HOME,
15
+ worktreeRoot: WT,
16
+ inWorktree: true,
17
+ mainCheckout: MAIN,
18
+ testScript: () => "vitest",
19
+ ...over,
20
+ });
21
+ const sharedCtx = wtCtx({ cwd: MAIN, worktreeRoot: MAIN, inWorktree: false, mainCheckout: null });
22
+
23
+ const WORKER = { PAI_WORKER: "1" };
24
+ const bash = (command: string) => ({ tool_name: "Bash", tool_input: { command } });
25
+ const edit = (tool_name: string, file_path: string) => ({ tool_name, tool_input: { file_path } });
26
+
27
+ const DENY: [string, ReturnType<typeof bash>, WorkerGuardContext, string][] = [
28
+ ["ln -s into main node_modules", bash(`ln -s ${MAIN}/node_modules node_modules`), wtCtx(), "runner provisions node_modules"],
29
+ ["ln -sf relative escape", bash("ln -sf ../../../main/node_modules node_modules"), wtCtx(), "runner provisions node_modules"],
30
+ ["ln -s after cd &&", bash("cd /tmp && ln -s /elsewhere/x y"), wtCtx(), "runner provisions node_modules"],
31
+ ["cp -R worktree to /tmp", bash(`cp -R ${WT} /tmp/build`), wtCtx(), "never from a copy"],
32
+ ["cp -r . to /tmp", bash("cp -r . /tmp/build"), wtCtx(), "never from a copy"],
33
+ ["rsync -a . elsewhere", bash("rsync -a ./ /tmp/build/"), wtCtx(), "never from a copy"],
34
+ ["rsync main checkout", bash(`rsync -av ${MAIN}/ /tmp/b`), wtCtx(), "never from a copy"],
35
+ ["git push", bash("git push origin HEAD"), wtCtx(), "git push blocked"],
36
+ ["push after &&", bash("git commit -m x && git push"), wtCtx(), "git push blocked"],
37
+ ["env-prefixed push", bash("GIT_SSH=x git -C . push"), wtCtx(), "git push blocked"],
38
+ ["npm publish", bash("npm publish --access public"), wtCtx(), "publish blocked"],
39
+ ["bun publish", bash("bun publish"), wtCtx(), "publish blocked"],
40
+ ["npm version bump", bash("npm version patch"), wtCtx(), "npm version blocked"],
41
+ ["git tag create", bash("git tag v1.0.0"), wtCtx(), "git tag blocked"],
42
+ ["git stash in shared checkout", bash("git stash"), sharedCtx, "shared checkout"],
43
+ ["git reset --hard in shared checkout", bash("git reset --hard HEAD"), sharedCtx, "shared checkout"],
44
+ ["git checkout -- . in shared checkout", bash("git checkout -- ."), sharedCtx, "shared checkout"],
45
+ ["git restore . in shared checkout", bash("git restore ."), sharedCtx, "shared checkout"],
46
+ ["git clean -fd in shared checkout", bash("git clean -fd"), sharedCtx, "shared checkout"],
47
+ ["git switch in shared checkout", bash("git switch other"), sharedCtx, "shared checkout"],
48
+ ["git checkout branch in shared checkout", bash("git checkout other"), sharedCtx, "shared checkout"],
49
+ ["TODO.md via redirect", bash("echo x >> Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
50
+ ["TODO.md via tee", bash("echo x | tee Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
51
+ ["TODO.md via sed -i", bash("sed -i '' s/a/b/ Notes/TODO.md"), wtCtx(), "Notes/TODO.md"],
52
+ ["bun test on vitest repo", bash("cd sub && bun test"), wtCtx(), "vitest"],
53
+ ["bash -c wrapper", bash(`bash -c 'git push'`), wtCtx(), "git push blocked"],
54
+ ["cd into main checkout", bash(`cd ${MAIN}/src`), wtCtx(), "relative to your worktree"],
55
+ ["cd main then work", bash(`cd ${MAIN} && ls`), wtCtx(), "relative to your worktree"],
56
+ ["redirect to claude.json", bash(`echo {} > ${HOME}/.claude.json`), wtCtx(), "temp-dir copies"],
57
+ ["tilde settings.json sed -i", bash("sed -i s/a/b/ ~/.claude/settings.json"), wtCtx(), "temp-dir copies"],
58
+ ["cp onto pai yaml", bash("cp /tmp/x.yaml ~/.claude/pai/config.yaml"), wtCtx(), "temp-dir copies"],
59
+ ["mv pai json", bash("mv /tmp/x ~/.claude/pai/config.json"), wtCtx(), "temp-dir copies"],
60
+ ];
61
+
62
+ const ALLOW: [string, ReturnType<typeof bash>, WorkerGuardContext][] = [
63
+ ["ln -s inside worktree", bash("ln -s ../node_modules/x lib/x"), wtCtx()],
64
+ ["ln -s absolute inside worktree", bash(`ln -s ${WT}/a b`), wtCtx()],
65
+ ["cp -R within worktree", bash("cp -R src /work/wt/abc/tmp/src2"), wtCtx()],
66
+ ["cp -r subdir to /tmp", bash("cp -r src /tmp/src"), wtCtx()],
67
+ ["git status/diff/log", bash("git status && git diff --stat | head && git log -3"), wtCtx()],
68
+ ["git add + commit", bash(`git add -A && git commit -m "fix: mention git push and git stash"`), wtCtx()],
69
+ ["git reset in worktree", bash("git reset --hard HEAD~1"), wtCtx()],
70
+ ["git stash list in shared checkout", bash("git stash list"), sharedCtx],
71
+ ["git tag listing", bash("git tag -l 'v*'"), wtCtx()],
72
+ ["npm version (read)", bash("npm version"), wtCtx()],
73
+ ["npm run build", bash("npm run build"), wtCtx()],
74
+ ["bun test on non-vitest repo", bash("bun test"), wtCtx({ testScript: () => "bun test" })],
75
+ ["cd inside worktree", bash("cd src && ls"), wtCtx()],
76
+ ["cat TODO.md", bash("cat Notes/TODO.md"), wtCtx()],
77
+ ["read live config", bash("cat ~/.claude/settings.json ~/.claude.json | head"), wtCtx()],
78
+ ["write other Notes file", bash("echo x > Notes/other.md"), wtCtx()],
79
+ ["write deep pai file", bash("echo x > ~/.claude/pai/logs/x.json"), wtCtx()],
80
+ ["stderr dup redirect", bash("npm test 2>&1 | tail"), wtCtx()],
81
+ ["git checkout -b in worktree", bash("git checkout -b topic"), wtCtx()],
82
+ ];
83
+
84
+ describe("decideWorkerGuard", () => {
85
+ it.each(DENY)("denies: %s", (_n, input, ctx, needle) => {
86
+ const d = decideWorkerGuard(input, WORKER, () => ctx);
87
+ expect(d.decision).toBe("deny");
88
+ expect((d as { reason: string }).reason).toContain(needle);
89
+ });
90
+
91
+ it.each(ALLOW)("allows: %s", (_n, input, ctx) => {
92
+ expect(decideWorkerGuard(input, WORKER, () => ctx).decision).toBe("allow");
93
+ });
94
+
95
+ it("is a no-op when PAI_WORKER is unset", () => {
96
+ for (const [, input, ctx] of DENY) {
97
+ expect(decideWorkerGuard(input, {}, () => ctx).decision).toBe("allow");
98
+ }
99
+ });
100
+
101
+ it.each([
102
+ ["Write", `${WT}/Notes/TODO.md`, "deny"],
103
+ ["Edit", "Notes/TODO.md", "deny"],
104
+ ["MultiEdit", `${MAIN}/Notes/TODO.md`, "deny"],
105
+ ["Write", `${HOME}/.claude/settings.json`, "deny"],
106
+ ["Edit", `${HOME}/.claude.json`, "deny"],
107
+ ["Write", `${HOME}/.claude/pai/config.yaml`, "deny"],
108
+ ["Write", `${WT}/Notes/other.md`, "allow"],
109
+ ["Edit", `${WT}/src/a.ts`, "allow"],
110
+ ["Write", `${HOME}/.claude/pai/logs/x.yaml`, "allow"],
111
+ ])("%s %s -> %s", (tool, path, want) => {
112
+ expect(decideWorkerGuard(edit(tool, path), WORKER, () => wtCtx()).decision).toBe(want);
113
+ expect(decideWorkerGuard(edit(tool, path), {}, () => wtCtx()).decision).toBe("allow");
114
+ });
115
+
116
+ it("ignores other tools", () => {
117
+ expect(decideWorkerGuard({ tool_name: "Read", tool_input: { file_path: "Notes/TODO.md" } }, WORKER, () => wtCtx()).decision).toBe("allow");
118
+ });
119
+ });
120
+
121
+ describe("commit attribution", () => {
122
+ const TRAILER = "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>";
123
+ const run = (c: string) => decideWorkerGuard(bash(c), WORKER, () => wtCtx());
124
+ const dir = mkdtempSync(join(tmpdir(), "wg-"));
125
+ const bad = join(dir, "bad.txt");
126
+ const good = join(dir, "good.txt");
127
+ writeFileSync(bad, `feat: x\n\n${TRAILER}\n`);
128
+ writeFileSync(good, "feat: x\n");
129
+
130
+ it.each([
131
+ ["-m trailer", `git commit -m "feat: x" -m "${TRAILER}"`],
132
+ ["heredoc trailer", `git commit -m "$(cat <<'EOF'\nfeat: x\n\n${TRAILER}\nEOF\n)"`],
133
+ ["generated-with", `git commit -m "feat: x 🤖 Generated with [Claude Code](https://claude.com/claude-code)"`],
134
+ ["-F file trailer", `git commit -F ${bad}`],
135
+ ["--file= trailer", `git commit --file=${bad}`],
136
+ ])("denies %s", (_n, c) => {
137
+ const d = run(c);
138
+ expect(d.decision).toBe("deny");
139
+ expect((d as { reason: string }).reason).toContain("no AI attribution in commits");
140
+ });
141
+
142
+ it.each([
143
+ ["plain commit", 'git commit -m "feat: add thing"'],
144
+ ["prose mentioning claude", 'git commit -m "fix: handle claude output parsing"'],
145
+ ["-F clean file", `git commit -F ${good}`],
146
+ ["-F missing file", `git commit -F ${dir}/nope.txt`],
147
+ ])("allows %s", (_n, c) => {
148
+ expect(run(c).decision).toBe("allow");
149
+ });
150
+ });
151
+
152
+ describe("parseCommand", () => {
153
+ it("splits operators outside quotes and captures redirects", () => {
154
+ expect(parseCommand(`a 'b && c'; d > f 2>&1 || e | g`)).toEqual([
155
+ { words: ["a", "b && c"], redirects: [] },
156
+ { words: ["d"], redirects: ["f"] },
157
+ { words: ["e"], redirects: [] },
158
+ { words: ["g"], redirects: [] },
159
+ ]);
160
+ });
161
+ });
162
+
163
+ describe("worker-guard entry", () => {
164
+ const ENTRYPOINT = "src/hooks/ts/pre-tool-use/worker-guard.ts";
165
+ const run = (env: NodeJS.ProcessEnv, command: string): string => {
166
+ const { PAI_WORKER: _drop, ...base } = process.env;
167
+ return execFileSync("bun", [ENTRYPOINT], {
168
+ input: JSON.stringify({ tool_name: "Bash", cwd: process.cwd(), tool_input: { command } }),
169
+ encoding: "utf8",
170
+ timeout: 15_000,
171
+ env: { ...base, ...env },
172
+ });
173
+ };
174
+
175
+ it("denies git push for a worker", () => {
176
+ const out = JSON.parse(run({ PAI_WORKER: "1" }, "git push")).hookSpecificOutput;
177
+ expect(out.permissionDecision).toBe("deny");
178
+ expect(out.permissionDecisionReason).toContain("git push blocked");
179
+ });
180
+
181
+ it("prints nothing in an interactive session", () => {
182
+ expect(run({}, "git push")).toBe("");
183
+ });
184
+
185
+ it("prints nothing for an allowed worker command", () => {
186
+ expect(run({ PAI_WORKER: "1" }, "git status")).toBe("");
187
+ });
188
+ });