@tekmidian/pai 0.61.0 → 0.62.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 (67) 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-CuaDawK9.mjs} +2 -2
  4. package/dist/{chain-C5oeyUg4.mjs.map → chain-CuaDawK9.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-DSsIEqzW.mjs → daemon-Bf7Eock4.mjs} +32 -18
  11. package/dist/{daemon-DSsIEqzW.mjs.map → daemon-Bf7Eock4.mjs.map} +1 -1
  12. package/dist/daemon-bJi1gDkG.mjs +19 -0
  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-F0TWGVM2.mjs} +2 -2
  18. package/dist/{fallback-DDbJVaR8.mjs.map → fallback-F0TWGVM2.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 +8513 -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-BG9ivPj0.mjs} +4 -4
  35. package/dist/{planner-D8jAr_sx.mjs.map → planner-BG9ivPj0.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-BRvCzhjx.mjs} +157 -40
  39. package/dist/program-BRvCzhjx.mjs.map +1 -0
  40. package/dist/{run-CxxZKEZw.mjs → run-BVQZI79D.mjs} +281 -85
  41. package/dist/run-BVQZI79D.mjs.map +1 -0
  42. package/dist/{session-keepalive-urk--pfa.mjs → session-keepalive-DJA97K2X.mjs} +2 -2
  43. package/dist/{session-keepalive-urk--pfa.mjs.map → session-keepalive-DJA97K2X.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 +2 -0
  57. package/docs/commands/worker.md +27 -0
  58. package/package.json +2 -2
  59. package/scripts/build-hooks.mjs +6 -5
  60. package/scripts/build-skill-stubs.mjs +6 -5
  61. package/src/hooks/ts/lib/worker-guard.test.ts +188 -0
  62. package/src/hooks/ts/lib/worker-guard.ts +315 -0
  63. package/src/hooks/ts/pre-tool-use/worker-guard.ts +75 -0
  64. package/src/hooks/ts/session-start/load-project-context.ts +14 -0
  65. package/dist/daemon-MoFF4oeg.mjs +0 -19
  66. package/dist/program-BTdspOOa.mjs.map +0 -1
  67. package/dist/run-CxxZKEZw.mjs.map +0 -1
@@ -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.62.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
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * worker-guard.ts — the decision behind the worker-only PreToolUse guard.
3
+ *
4
+ * Pure but for reading a `git commit -F <file>` message: no stdin, no git — the hook entry (pre-tool-use/worker-guard.ts)
5
+ * gathers the context, this module decides, same split as sleep-poll-gate.ts.
6
+ * Spec prose ("never symlink node_modules", "do not push", ...) was broken by
7
+ * workers anyway; this is the enforcement. Active only for PAI_WORKER=1.
8
+ *
9
+ * Parsing is conservative on purpose: a command is split on ; && || | & and
10
+ * newlines outside quotes, `bash -c '<cmd>'` is unwrapped, `cd` is tracked
11
+ * across segments. Not caught (false negatives chosen over blocking real
12
+ * work): $(...) / backtick substitutions, subshell/brace grouping, variables
13
+ * other than ~ and $HOME in paths, scripts that do the forbidden thing
14
+ * internally, `git -C <other>` targeting another repo's cwd, cp/rsync with
15
+ * -t or a source that is a subdirectory of the tree.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import { type GateDecision } from "./sleep-poll-gate.js";
21
+ import { decideWorkerGitGuard } from "./worker-git-guard.js";
22
+
23
+ export interface WorkerGuardInput {
24
+ tool_name?: string;
25
+ cwd?: string;
26
+ tool_input?: { command?: string; file_path?: string; path?: string };
27
+ }
28
+
29
+ export interface WorkerGuardContext {
30
+ /** Hook cwd. */
31
+ cwd: string;
32
+ home: string;
33
+ /** Top level of the git tree containing cwd, null outside git. */
34
+ worktreeRoot: string | null;
35
+ /** True when cwd is a linked worktree (git-dir differs from common dir). */
36
+ inWorktree: boolean;
37
+ /** The main checkout the worktree hangs off; null when not in a worktree. */
38
+ mainCheckout: string | null;
39
+ /** package.json `scripts.test` of the tree, null when unknown. */
40
+ testScript: () => string | null;
41
+ }
42
+
43
+ interface Segment {
44
+ words: string[];
45
+ /** Redirection targets (`> f`, `>> f`, `2>f`). */
46
+ redirects: string[];
47
+ }
48
+
49
+ const deny = (reason: string): GateDecision => ({ decision: "deny", reason });
50
+ const ALLOW: GateDecision = { decision: "allow" };
51
+
52
+ /** Split into segments; quotes group words, operators outside quotes split. */
53
+ export function parseCommand(command: string): Segment[] {
54
+ const segs: Segment[] = [];
55
+ let seg: Segment = { words: [], redirects: [] };
56
+ let tok = "";
57
+ let has = false; // a token is in progress (covers empty quoted "")
58
+ let quote: string | null = null;
59
+ let target: "redir" | "skip" | null = null;
60
+
61
+ const flushTok = (): void => {
62
+ if (!has) return;
63
+ if (target === "redir") seg.redirects.push(tok);
64
+ else if (target !== "skip") seg.words.push(tok);
65
+ target = null;
66
+ tok = "";
67
+ has = false;
68
+ };
69
+ const endSeg = (): void => {
70
+ flushTok();
71
+ if (seg.words.length || seg.redirects.length) segs.push(seg);
72
+ seg = { words: [], redirects: [] };
73
+ };
74
+
75
+ for (let i = 0; i < command.length; i++) {
76
+ const ch = command[i];
77
+ if (quote) {
78
+ if (ch === quote) quote = null;
79
+ else if (ch === "\\" && quote === '"' && i + 1 < command.length) tok += command[++i];
80
+ else tok += ch;
81
+ continue;
82
+ }
83
+ if (ch === "'" || ch === '"') {
84
+ quote = ch;
85
+ has = true;
86
+ } else if (ch === "\\" && i + 1 < command.length) {
87
+ tok += command[++i];
88
+ has = true;
89
+ } else if (/\s/.test(ch) && ch !== "\n") {
90
+ flushTok();
91
+ } else if (ch === ";" || ch === "\n" || ch === "|" || ch === "&") {
92
+ if (ch === "&" && command[i + 1] === ">") continue; // `&>` redirects
93
+ endSeg();
94
+ if ((ch === "|" || ch === "&") && command[i + 1] === ch) i++;
95
+ } else if (ch === ">" || ch === "<") {
96
+ if (has && /^\d+$/.test(tok)) {
97
+ tok = "";
98
+ has = false;
99
+ } else flushTok();
100
+ if (ch === ">" && command[i + 1] === ">") i++;
101
+ if (command[i + 1] === "&") {
102
+ i++;
103
+ target = "skip"; // fd duplication: `2>&1`
104
+ } else target = ch === ">" ? "redir" : "skip";
105
+ } else {
106
+ tok += ch;
107
+ has = true;
108
+ }
109
+ }
110
+ endSeg();
111
+ return segs;
112
+ }
113
+
114
+ const WRAPPERS = new Set(["sudo", "command", "env", "time", "nohup", "exec", "builtin"]);
115
+
116
+ /** Drop env assignments and wrapper commands from the front. */
117
+ function stripPrefix(words: string[]): string[] {
118
+ let i = 0;
119
+ while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || WRAPPERS.has(words[i]))) i++;
120
+ return words.slice(i);
121
+ }
122
+
123
+ const isFlag = (w: string): boolean => w.startsWith("-") && w.length > 1;
124
+
125
+ function expand(p: string, home: string): string {
126
+ return p.replace(/^~(?=\/|$)/, home).replace(/^\$\{?HOME\}?(?=\/|$)/, home);
127
+ }
128
+
129
+ const hasUnresolvedVar = (p: string): boolean => /[$`]/.test(p);
130
+
131
+ const isInside = (p: string, root: string): boolean => p === root || p.startsWith(root.replace(/\/$/, "") + "/");
132
+
133
+ /** Absolute, or null when the path holds variables we cannot resolve. */
134
+ function abs(p: string, cwd: string, home: string): string | null {
135
+ const e = expand(p, home);
136
+ return hasUnresolvedVar(e) ? null : resolve(cwd, e);
137
+ }
138
+
139
+ /** Live PAI/Claude config that tests and workers must not write. */
140
+ export function isLiveConfig(p: string, home: string): boolean {
141
+ if (p === `${home}/.claude.json` || p === `${home}/.claude/settings.json`) return true;
142
+ return dirname(p) === `${home}/.claude/pai` && /\.(ya?ml|json)$/.test(p);
143
+ }
144
+
145
+ const isTodo = (p: string): boolean => p === "Notes/TODO.md" || p.endsWith("/Notes/TODO.md");
146
+
147
+ function protectedPathReason(p: string, home: string): string | null {
148
+ if (isTodo(p)) return "Notes/TODO.md is the orchestrator's: report what should change in your result instead";
149
+ if (isLiveConfig(p, home)) return "live PAI/Claude config must not be written: tests use temp-dir copies";
150
+ return null;
151
+ }
152
+
153
+ /** Paths a segment writes to (redirects plus the write-side of common tools). */
154
+ function writeTargets(words: string[], redirects: string[]): string[] {
155
+ const [cmd, ...args] = words;
156
+ const pos = args.filter((a) => !isFlag(a));
157
+ const out = [...redirects];
158
+ if (cmd === "tee" || cmd === "mv") out.push(...pos);
159
+ else if (cmd === "cp" && pos.length) out.push(pos[pos.length - 1]);
160
+ else if (cmd === "sed" && args.some((a) => /^-[a-zA-Z]*i|^--in-place/.test(a))) out.push(...pos);
161
+ return out;
162
+ }
163
+
164
+ const RECURSIVE_CP = /^-[a-zA-Z]*[rRa]|^--recursive$|^--archive$/;
165
+ const RECURSIVE_RSYNC = /^-[a-zA-Z]*[ra]|^--recursive$|^--archive$/;
166
+
167
+ const AI_ATTRIBUTION = /Co-Authored-By:[^\n]*(claude|anthropic)|Generated with[^\n]*Claude Code/i;
168
+
169
+ /** Message text a `git commit` carries: the raw command (-m, heredoc) plus any -F/--file body. */
170
+ function commitMessageText(rest: string[], cwd: string, raw: string, home: string): string {
171
+ let text = raw;
172
+ for (let i = 0; i < rest.length; i++) {
173
+ const f = rest[i] === "-F" || rest[i] === "--file" ? rest[i + 1] : rest[i].startsWith("--file=") ? rest[i].slice(7) : undefined;
174
+ const p = f ? abs(f, cwd, home) : null;
175
+ if (!p) continue;
176
+ try {
177
+ text += "\n" + readFileSync(p, "utf8");
178
+ } catch {
179
+ // missing file: nothing to check
180
+ }
181
+ }
182
+ return text;
183
+ }
184
+
185
+ const GIT_TREE_CMDS = new Set(["reset", "clean", "restore", "switch", "rebase", "merge", "checkout", "stash"]);
186
+
187
+ function gitSub(args: string[]): { sub: string; rest: string[] } | null {
188
+ for (let i = 0; i < args.length; i++) {
189
+ const a = args[i];
190
+ if (a === "-C" || a === "-c" || a === "--git-dir" || a === "--work-tree") i++;
191
+ else if (!isFlag(a)) return { sub: a, rest: args.slice(i + 1) };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /** Decide one segment; `st.cwd` is the tracked working directory. */
197
+ function decideSegment(seg: Segment, st: { cwd: string; raw: string }, ctx: WorkerGuardContext): GateDecision {
198
+ const words = stripPrefix(seg.words);
199
+ const [cmd, ...args] = words;
200
+ const boundary = ctx.worktreeRoot ?? ctx.cwd;
201
+
202
+ for (const t of writeTargets(words.length ? words : [""], seg.redirects)) {
203
+ const p = abs(t, st.cwd, ctx.home);
204
+ const why = p && protectedPathReason(p, ctx.home);
205
+ if (why) return deny(`write to ${t} blocked: ${why}`);
206
+ }
207
+ if (!cmd) return ALLOW;
208
+
209
+ if ((cmd === "bash" || cmd === "sh" || cmd === "zsh") && args[0] === "-c" && args[1]) {
210
+ return decideCommand(args[1], { ...ctx, cwd: st.cwd });
211
+ }
212
+
213
+ const pos = args.filter((a) => !isFlag(a));
214
+
215
+ if (cmd === "cd") {
216
+ const p = pos[0] ? abs(pos[0], st.cwd, ctx.home) : null;
217
+ if (!p) return ALLOW;
218
+ if (ctx.mainCheckout && ctx.worktreeRoot && isInside(p, ctx.mainCheckout) && !isInside(p, ctx.worktreeRoot)) {
219
+ return deny(`cd ${pos[0]} blocked: it leaves your worktree for the main checkout; use paths relative to your worktree`);
220
+ }
221
+ st.cwd = p;
222
+ return ALLOW;
223
+ }
224
+
225
+ if (cmd === "ln" && args.some((a) => /^-[a-zA-Z]*s|^--symbolic$/.test(a)) && pos.length) {
226
+ const link = pos[1] ? abs(pos[1], st.cwd, ctx.home) : null;
227
+ const from = link ? dirname(link) : st.cwd;
228
+ const target = abs(pos[0], from, ctx.home);
229
+ if (target && !isInside(target, boundary)) {
230
+ return deny("ln -s to a target outside your worktree blocked: the runner provisions node_modules in your worktree");
231
+ }
232
+ return ALLOW;
233
+ }
234
+
235
+ if ((cmd === "cp" && args.some((a) => RECURSIVE_CP.test(a))) || (cmd === "rsync" && args.some((a) => RECURSIVE_RSYNC.test(a)))) {
236
+ if (pos.length >= 2) {
237
+ const dest = abs(pos[pos.length - 1], st.cwd, ctx.home);
238
+ const roots = [boundary, ctx.mainCheckout].filter((r): r is string => !!r);
239
+ const copiesRoot = pos.slice(0, -1).some((s) => {
240
+ const p = abs(s, st.cwd, ctx.home);
241
+ return !!p && roots.includes(p);
242
+ });
243
+ if (copiesRoot && dest && !isInside(dest, boundary)) {
244
+ return deny(`${cmd} of the repo tree out of your worktree blocked: build and test inside your worktree, never from a copy`);
245
+ }
246
+ }
247
+ return ALLOW;
248
+ }
249
+
250
+ if ((cmd === "npm" || cmd === "bun" || cmd === "pnpm" || cmd === "yarn") && pos[0] === "publish") {
251
+ return deny(`${cmd} publish blocked: releases are the orchestrator's`);
252
+ }
253
+ if (cmd === "npm" && pos[0] === "version" && pos.length > 1) {
254
+ return deny("npm version blocked: version bumps are the orchestrator's");
255
+ }
256
+
257
+ if (cmd === "bun" && pos[0] === "test" && /vitest/.test(ctx.testScript() ?? "")) {
258
+ return deny("bun test blocked: this repo's tests are vitest; run `npx vitest run <files>`");
259
+ }
260
+
261
+ if (cmd === "git") {
262
+ const g = gitSub(args);
263
+ if (!g) return ALLOW;
264
+ if (g.sub === "push") return deny("git push blocked: pushing is the orchestrator's; commit on your branch and stop");
265
+ if (g.sub === "commit" && AI_ATTRIBUTION.test(commitMessageText(g.rest, st.cwd, st.raw, ctx.home))) {
266
+ return deny("no AI attribution in commits (operator rule): drop the Co-Authored-By / Generated-with line and commit again");
267
+ }
268
+ if (g.sub === "tag") {
269
+ const listing = !g.rest.some((a) => !isFlag(a)) || g.rest.some((a) => /^(-l|--list|-n\d*|--contains|--points-at)$/.test(a));
270
+ if (!listing) return deny("git tag blocked: releases are the orchestrator's");
271
+ }
272
+ if (!ctx.inWorktree) {
273
+ if (GIT_TREE_CMDS.has(g.sub)) {
274
+ const r = decideWorkerGitGuard(words.join(" "), true, st.cwd, null);
275
+ if (r.blocked) return deny(`${r.cmd} blocked: you run in a shared checkout, not a worktree; it can destroy other workers' edits, use --worktree`);
276
+ }
277
+ if (g.sub === "checkout" && g.rest.some((a) => !isFlag(a))) {
278
+ return deny("git checkout <branch> blocked: you run in a shared checkout, not a worktree; use --worktree");
279
+ }
280
+ }
281
+ }
282
+ return ALLOW;
283
+ }
284
+
285
+ function decideCommand(command: string, ctx: WorkerGuardContext): GateDecision {
286
+ const st = { cwd: ctx.cwd, raw: command };
287
+ for (const seg of parseCommand(command)) {
288
+ const d = decideSegment(seg, st, ctx);
289
+ if (d.decision === "deny") return d;
290
+ }
291
+ return ALLOW;
292
+ }
293
+
294
+ export function decideWorkerGuard(
295
+ input: WorkerGuardInput,
296
+ env: Record<string, string | undefined>,
297
+ ctx: () => WorkerGuardContext
298
+ ): GateDecision {
299
+ if (env.PAI_WORKER !== "1") return ALLOW;
300
+ const tool = input.tool_name;
301
+ const ti = input.tool_input ?? {};
302
+
303
+ if (tool === "Bash") {
304
+ return typeof ti.command === "string" ? decideCommand(ti.command, ctx()) : ALLOW;
305
+ }
306
+ if (tool === "Edit" || tool === "Write" || tool === "MultiEdit" || tool === "NotebookEdit") {
307
+ const f = ti.file_path ?? ti.path;
308
+ if (typeof f !== "string") return ALLOW;
309
+ const c = ctx();
310
+ const p = abs(f, c.cwd, c.home);
311
+ const why = p && protectedPathReason(p, c.home);
312
+ return why ? deny(`edit of ${f} blocked: ${why}`) : ALLOW;
313
+ }
314
+ return ALLOW;
315
+ }