@brainervirus/workit-core 8.22.2 → 8.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.22.2",
3
+ "version": "8.23.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -20,14 +20,14 @@ units of ~100+ lines each. Coupled work stays with one owner. Workit never spawn
20
20
  PR (`references/orchestration.md`).
21
21
  2. **Record standing orders** (`workit ledger standing add "<order>"`): every
22
22
  user directive all workers share. Directives decay across resumes.
23
- 3. **Brief each worker verbatim** with `workit fanout brief <slice>`: plan
24
- fields, standing orders, SCRATCH, session id, fan-in and heavy-lease rules.
23
+ 3. **Brief each worker verbatim**: `workit fanout worktree create <slice>`
24
+ (every host), then `workit fanout brief <slice>` (WORKTREE, SCRATCH, rules).
25
25
  Never hand-edit it; change the plan or the standing orders and render again.
26
26
  4. **Spawn in a rolling window**, in the background, each in its own worktree:
27
27
  keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
28
28
  each one finishes. Model: the brief's `model:` line, else TIER
29
- (`references/orchestration.md`). Claude Code: the `implementer` agent;
30
- elsewhere first `workit fanout worktree create <slice>`.
29
+ (`references/orchestration.md`). Claude Code: the `implementer` agent, never
30
+ with `isolation: "worktree"`; Workit's hooks confine it to its WORKTREE.
31
31
  5. **Judge liveness by side effects only:** `workit fanout status` (head age,
32
32
  PR, CI, verdict, landed; STUCK past the TIMEBOX). Stop a stuck worker and
33
33
  observe that it exited (a timeout is not proof). Removing a worktree drops
@@ -25,6 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
25
25
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
26
26
  anything out of scope as a follow-up, not a diff.
27
27
  STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
28
+ WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit --cwd <it> …` there>
28
29
  <identity line, per the lead's host: see "Identity and git rules per host" below>
29
30
  ```
30
31
 
@@ -48,6 +49,7 @@ FORBIDDEN: no edits outside SCOPE; no schema migration; no new packages
48
49
  FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.
49
50
  REPORT: as in the template
50
51
  STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
52
+ WORKTREE: ../app-wt/usage-endpoint (made by `workit fanout worktree create usage-endpoint`; yours alone)
51
53
  ```
52
54
 
53
55
  ## Plan file
@@ -106,25 +108,29 @@ lead's host (`WORKIT_HOST`, or the host's own session variable):
106
108
 
107
109
  | Lead's host | Identity line | RULES 1 and 3 |
108
110
  | --- | --- | --- |
109
- | Claude Code | `Session: your env already has WORKIT_SESSION_ID (the lead's id)`; no `export`, no hand-minted id (SubagentStart issues `<lead>:<agent id>` only to verifiers and reviewers, never to an implementer, so the worker's commits carry the lead's id) | plain git: `git fetch origin && git switch -c <branch> origin/<base>` and `git commit -m <msg> --trailer "Workit-Session: <the env WORKIT_SESSION_ID>" --trailer "<host attribution>" -- <paths in SCOPE>`, because worktree isolation refuses every `workit git` form |
111
+ | Claude Code | `Session: your env already has WORKIT_SESSION_ID (the lead's id)`; no `export`, no hand-minted id (SubagentStart issues `<lead>:<agent id>` only to verifiers and reviewers, never to an implementer, so the worker's commits carry the lead's id) | `workit git branch <branch> --base <base>`, `workit git commit -m <msg> -- <paths in SCOPE>`: the implementer runs without Claude Code's worktree isolation, which refused every `workit git` form |
110
112
  | OpenCode, Pi, Codex | `Session: your host already sets WORKIT_SESSION_ID in your shell`; export nothing (the host derives the id and an export cannot replace it) | `workit git branch <branch> --base <base>`, `workit git commit -m <msg> -- <paths in SCOPE>` |
111
113
  | Cursor, CI, a plain terminal | `export WORKIT_SESSION_ID=<lead>-w-<slice>` (`+try<n>` for a race attempt): declared, not checked | the same `workit git` forms |
112
114
 
113
- `MODE: resume` is `git switch <branch>` on every host (the branch exists; the
114
- lead removed the dead worker's worktree after its exit, so the switch
115
- succeeds), then continue from its head. A race attempt (`--attempt <n>`)
116
- follows its host's row with the branch `<branch>-try<n>`.
115
+ Once `workit fanout worktree create <slice>` made the worktree, the brief's
116
+ WORKTREE line names it and RULES 1 and 3 run against it on every host:
117
+ `workit --cwd <worktree> git commit …`, `workit --cwd <worktree> check <name>`,
118
+ one verb per call. The worktree is already on the branch (MODE new or resume),
119
+ so no branch command comes first. Without one, `MODE: resume` is
120
+ `git switch <branch>` (the branch exists), then continue from its head. A race
121
+ attempt (`--attempt <n>`) follows its host's row with the branch
122
+ `<branch>-try<n>`.
117
123
 
118
124
  ## Worker rules (`workit fanout brief` appends them as RULES)
119
125
 
120
- 1. First command: create the branch (`MODE: new`; the worktree may start on a
121
- name that breaks branch policy) or `git switch` to it (`MODE: resume`),
122
- as the table above says for your host.
126
+ 1. Work only in WORKTREE, already on the branch, one verb per call against it
127
+ (`workit --cwd <worktree> …`); Workit's hooks deny an implementer's change
128
+ outside it. With no worktree made yet: create the branch (`MODE: new`) or
129
+ `git switch` to it (`MODE: resume`), as the table above says.
123
130
  2. Decide ambiguities yourself and record them:
124
131
  `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
125
132
  Stop only for an irreversible action, a security-sensitive one, or a side
126
133
  effect outside the worktree.
127
- 3. Commit as the table says (`workit git commit -m "<msg>" -- <paths in SCOPE>`,
128
- or plain git with the trailers on Claude Code); push only if the brief says
129
- so.
134
+ 3. Commit with `workit --cwd <worktree> git commit -m "<msg>" -- <paths in SCOPE>`;
135
+ push only if the brief says so.
130
136
  4. Never record a verdict on your own work.
@@ -35,8 +35,8 @@ parent is verified, appears there by itself.
35
35
 
36
36
  A slice is retried when it is STUCK, its worker exits without meeting
37
37
  ACCEPTANCE, or its verdict fails. Stop the worker and observe that it exited.
38
- Release its worktree: `workit fanout worktree release <slice>` (native
39
- worktrees: record `git -C <wt> status --short` first). Then spawn one fresh
38
+ Release its worktree: `workit fanout worktree release <slice>` (it records
39
+ `git status` first), and create it again for the retry. Then spawn one fresh
40
40
  worker with `workit fanout brief <slice> --mode resume`: same branch, and the
41
41
  brief points at its head and its ledger rows (last report, verdicts,
42
42
  rulings). If the retry fails too, escalate instead of a third worker:
@@ -69,9 +69,9 @@ list. The fixer applies that list once, then one focused recheck follows.
69
69
  When a hard slice has an uncertain approach and a cheap VERIFY, run 2-3
70
70
  attempts at once instead of retrying in series. `workit fanout brief <slice>
71
71
  --attempt <n>` gives each attempt its own branch (`<branch>-try<n>`), session
72
- and scratch dir. Each attempt needs its own worktree: Claude Code's
73
- `implementer` isolates natively; on other hosts `fanout worktree create` makes
74
- only the slice's own worktree, so run the attempts one after another there.
72
+ and scratch dir. Each attempt needs its own worktree, and `fanout worktree
73
+ create` makes only the slice's own one, so run the attempts one after another
74
+ there on every host.
75
75
  Verify every attempt, keep the one with the best verdict (accepted first,
76
76
  then the fewest changed files, then the earliest), point the slice branch at
77
77
  it (`git branch <branch> <winner>`), have the verifier record the verdict on
@@ -109,7 +109,7 @@ Which session id a verdict carries, per host:
109
109
  `WORKIT_SESSION_ID=<lead>:verifier-<random hex>`, a fresh suffix per
110
110
  verifier. Cursor cannot check it: it is declared, not derived.
111
111
  - A worker's commits: on Claude Code the lead's `WORKIT_SESSION_ID` already in
112
- its env (SubagentStart issues no implementer id; plain git, no export, so the
112
+ its env (SubagentStart issues no implementer id; no export, so the
113
113
  lead authored those commits: a verifier or reviewer records the verdict, not
114
114
  the lead); on OpenCode, Pi and Codex the id the host already
115
115
  puts in its shell; on Cursor, CI and a plain terminal the brief's
@@ -47,6 +47,8 @@ description: Build a requested change in small verified steps - follow local pat
47
47
  `workit verify-delivery push`).
48
48
 
49
49
  Independent slices that could run in parallel: fan them out (workit-fanout).
50
+ One `implementer` slice: `workit fanout worktree create <slice>` before spawning,
51
+ then `workit fanout brief <slice>`; no host isolation (Workit's hooks confine it).
50
52
 
51
53
  Stay inline by default (workspace `delegation: inline-first`). Hand work off
52
54
  only past the thresholds: an explorer after about 3 tool calls or 10k tokens
@@ -1068,10 +1068,6 @@ export const advertisedOperationSchemas = {
1068
1068
  } as const;
1069
1069
  export type TaskStartRequest = z.infer<typeof taskOperations.start>;
1070
1070
 
1071
- const compiledOperationSchemas = Object.fromEntries(
1072
- OPERATION_FAMILIES.map((family) => [family, z.compile(operationSchemas[family])]),
1073
- ) as typeof operationSchemas;
1074
-
1075
1071
  export type Id = string;
1076
1072
  export type Revision = string;
1077
1073
  export type Digest = string;
@@ -1165,8 +1161,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
1165
1161
  (input as { schemaVersion?: unknown }).schemaVersion !== SCHEMA_VERSION
1166
1162
  )
1167
1163
  return failure("unsupported_version", "unsupported schema version", { operation: family });
1168
- const compiled = compiledOperationSchemas[family].safeParse(input);
1169
- const parsed = compiled.success ? operationSchemas[family].safeParse(input) : compiled;
1164
+ const parsed = operationSchemas[family].safeParse(input);
1170
1165
  if (parsed.success) return success(null, null, parsed.data);
1171
1166
  const fields = parsed.error.issues.map((issue) => ({
1172
1167
  path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
@@ -8,9 +8,12 @@
8
8
  // `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
9
9
  // contain `+`, so no attempt collides with another slice's path or session.
10
10
  // The lead's host (WORKIT_HOST, or its own session variable) picks the identity
11
- // and git rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the lead's: SubagentStart issues ids only to judges) and plain
12
- // git; OpenCode, Pi and Codex workers export nothing (the host derives the id);
13
- // Cursor, CI and plain terminals export `<lead>-w-<slice>`.
11
+ // rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the
12
+ // lead's: SubagentStart issues ids only to judges); OpenCode, Pi and Codex
13
+ // workers export nothing (the host derives the id);
14
+ // Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
15
+ // `workit fanout worktree create` made the slice's worktree, WORKTREE and the
16
+ // RULES name it: every git and check command runs as `workit --cwd <worktree> …`.
14
17
  // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
15
18
  // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
16
19
  // one PR per slice forbids every merge; integration mode has the worker merge
@@ -64,8 +67,7 @@ const REPORT = [
64
67
  * docs/agents/hosts.md):
65
68
  * - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
66
69
  * reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
67
- * env; worktree isolation refuses every `workit git` form, so it uses
68
- * plain git and never mints an id;
70
+ * env and never mints an id;
69
71
  * - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
70
72
  * session id, which exporting WORKIT_SESSION_ID cannot replace;
71
73
  * - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
@@ -83,7 +85,7 @@ const identityOf = (host: string | null): BriefIdentity => {
83
85
  const identityLines = (identity: BriefIdentity, session: string): string[] => {
84
86
  if (identity === "issued")
85
87
  return [
86
- " Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is, in your Workit-Session trailer and workit commands; never export or hand-mint one.",
88
+ " Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is: workit commands read it and `workit git commit` adds the Workit-Session trailer; never export or hand-mint one.",
87
89
  ];
88
90
  if (identity === "derived")
89
91
  return [
@@ -92,25 +94,28 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
92
94
  return [` export WORKIT_SESSION_ID=${session}`];
93
95
  };
94
96
 
97
+ /**
98
+ * RULES. With the slice's worktree made (`worktree`), every git and check
99
+ * command names it (`workit --cwd <worktree> …`, one verb per call) and it is
100
+ * already on the branch; Workit's hooks keep an implementer inside it (#310).
101
+ */
95
102
  const rules = (
96
- identity: BriefIdentity,
97
103
  mode: BriefMode,
98
104
  branch: string,
99
105
  base: string,
106
+ worktree: string | null,
100
107
  ): string[] => {
101
- const plain = identity === "issued";
108
+ const workit = worktree ? `workit --cwd ${worktree}` : "workit";
102
109
  let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
103
- if (mode === "resume")
110
+ if (worktree)
111
+ first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit} git commit …\`, \`${workit} check <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
112
+ else if (mode === "resume")
104
113
  first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
105
- else if (plain)
106
- first = ` 1. First command: \`git fetch origin && git switch -c ${branch} origin/${base}\` (plain git: worktree isolation refuses every \`workit git\` form).`;
107
114
  return [
108
115
  "RULES:",
109
116
  first,
110
117
  ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
111
- plain
112
- ? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
113
- : " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
118
+ ` 3. Commit with \`${workit} git commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
114
119
  " 4. Never record a verdict on your own work.",
115
120
  " 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
116
121
  " 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
@@ -226,8 +231,11 @@ export function renderBrief(
226
231
  ...(standing.length
227
232
  ? standing.map((order) => ` - ${order.what}`)
228
233
  : [" - none recorded beyond this brief"]),
234
+ exists
235
+ ? `WORKTREE: ${planned} (made by \`workit fanout worktree create ${slice.id}\`; yours alone)`
236
+ : `WORKTREE: ${planned} (not made yet: the lead runs \`workit fanout worktree create ${slice.id}\` before spawning, then renders this brief again)`,
229
237
  ...identityLines(identity, minted),
230
- ...rules(identity, mode, branch, slice.base),
238
+ ...rules(mode, branch, slice.base, exists ? planned : null),
231
239
  ];
232
240
  return {
233
241
  ok: true,
@@ -28,6 +28,8 @@ import path from "node:path";
28
28
  import { gitBranch } from "./git/ops";
29
29
  import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
30
30
  import { appendObserved, readLedger, type LedgerActor } from "./ledger";
31
+ import { FANOUT_TOKEN_FILE } from "./hooks/worktree-confine";
32
+ import { linkWorktreeDependencies, type LinkedDependencies } from "./worktree-deps";
31
33
  import {
32
34
  branchRef,
33
35
  fanoutFail,
@@ -154,8 +156,6 @@ function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string)
154
156
  return { row, exact: false };
155
157
  }
156
158
 
157
- const TOKEN_FILE = "workit-fanout-token";
158
-
159
159
  /** The filesystem calls identity relies on; tests stub them. */
160
160
  export const worktreeDeps = {
161
161
  stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
@@ -179,7 +179,7 @@ function adminDir(worktree: string): AdminDir | null {
179
179
  const file = dir.stdout.trim();
180
180
  let token: string | null = null;
181
181
  try {
182
- token = fs.readFileSync(path.join(file, TOKEN_FILE), "utf8").trim() || null;
182
+ token = fs.readFileSync(path.join(file, FANOUT_TOKEN_FILE), "utf8").trim() || null;
183
183
  } catch {
184
184
  token = null;
185
185
  }
@@ -204,7 +204,7 @@ function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
204
204
  if (!admin) return null;
205
205
  const token = randomBytes(16).toString("hex");
206
206
  try {
207
- fs.writeFileSync(path.join(admin.path, TOKEN_FILE), `${token}\n`);
207
+ fs.writeFileSync(path.join(admin.path, FANOUT_TOKEN_FILE), `${token}\n`);
208
208
  } catch {
209
209
  return null;
210
210
  }
@@ -253,6 +253,8 @@ export type CreateOutcome = {
253
253
  mode: "new" | "resume";
254
254
  /** False when the worktree already existed on the slice's branch. */
255
255
  created: boolean;
256
+ /** node_modules linked from the main checkout (worktree-deps.ts); null when none were. */
257
+ dependencies: LinkedDependencies | null;
256
258
  notes: string[];
257
259
  };
258
260
 
@@ -301,6 +303,7 @@ export function createSliceWorktree(
301
303
  `remove it yourself (git worktree remove ${target}) once you know what it holds`,
302
304
  );
303
305
  excludeScratch(cwd);
306
+ const deps = linkWorktreeDependencies(mainCheckout(cwd), target);
304
307
  return {
305
308
  ok: true,
306
309
  data: {
@@ -309,7 +312,13 @@ export function createSliceWorktree(
309
312
  head: existing.head ?? "",
310
313
  mode: resolveRef(cwd, localRef) ? "resume" : "new",
311
314
  created: false,
312
- notes: ["the worktree already exists; nothing was changed"],
315
+ dependencies: deps.linked,
316
+ notes: [
317
+ deps.linked
318
+ ? "the worktree already exists; only its missing dependencies were linked"
319
+ : "the worktree already exists; nothing was changed",
320
+ ...deps.notes,
321
+ ],
313
322
  },
314
323
  };
315
324
  }
@@ -383,6 +392,8 @@ export function createSliceWorktree(
383
392
  }
384
393
  excludeScratch(cwd);
385
394
  const scratch = ensureScratch(target);
395
+ const deps = linkWorktreeDependencies(mainCheckout(cwd), target);
396
+ notes.push(...deps.notes);
386
397
  const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
387
398
  const baseSha = mode === "new" ? head : null;
388
399
  const admin = markAdminDir(target);
@@ -402,7 +413,10 @@ export function createSliceWorktree(
402
413
  ...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
403
414
  ...(baseSha ? { baseSha } : {}),
404
415
  });
405
- return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
416
+ return {
417
+ ok: true,
418
+ data: { ...base, scratch, head, mode, created: true, dependencies: deps.linked, notes },
419
+ };
406
420
  }
407
421
 
408
422
  const worktreeFailed = (stderr: string) =>
@@ -31,7 +31,8 @@ import { seedStanding, standingDelta } from "./standing";
31
31
  import { trackJudge } from "./session-marker";
32
32
  import { stopDecision } from "./stop";
33
33
  import { liveOwner } from "../core/live-session";
34
- import { shellGate, writeGate } from "./write-gate";
34
+ import { liveTargets, shellGate, writeGate } from "./write-gate";
35
+ import { confineImplementer, confineImplementerShell, hostIsolated } from "./worktree-confine";
35
36
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
36
37
 
37
38
  export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
@@ -68,6 +69,12 @@ const workitRole = (
68
69
  return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
69
70
  };
70
71
 
72
+ /** A tool call made by a Workit implementer subagent (the host names its agent type). */
73
+ const implementerAgent = (input: HookInput, descriptor: HostDescriptor): boolean =>
74
+ Boolean(input.session.agentId) &&
75
+ input.session.agentType !== null &&
76
+ workitRole(descriptor, input.session.agentType) === "implementer";
77
+
71
78
  /** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
72
79
  const subagentSession = (session: string | null, agentId: string): string =>
73
80
  [session, agentId]
@@ -99,17 +106,22 @@ const subagentStartText = (
99
106
  const session = input.session.id ?? null;
100
107
  const role = workitRole(descriptor, event.agentType);
101
108
  const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
109
+ // An implementer is host-isolated only when the host put it in its own
110
+ // worktree (`.claude/worktrees/agent-<id>`); by default the lead makes one.
102
111
  const isolated =
103
112
  descriptor.subagents.worktreeIsolation === "native" &&
104
- (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
113
+ (role === "implementer"
114
+ ? hostIsolated(input.cwd, event.agentId)
115
+ : CLAUDE_WORKTREE.test(input.cwd));
105
116
  const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
106
117
  if (role === "implementer" && isolated)
107
118
  return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
108
119
  if (isolated)
109
120
  return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
110
121
  if (role === "implementer")
111
- // No host isolation: the lead makes the worktree, and workit git works there.
112
- return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
122
+ // No host isolation: the lead makes the worktree, Workit's hooks confine
123
+ // the implementer to it (worktree-confine.ts), and workit git works there.
124
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit --cwd <worktree> git commit -m "<message>" -- <paths>\`, \`workit --cwd <worktree> check <name>\`. The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit --cwd <worktree> git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
113
125
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
114
126
  if (role !== "judge" || !event.agentId) return readOnly;
115
127
  const own = subagentSession(session, event.agentId);
@@ -189,6 +201,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
189
201
  if (!postTool) settlePendingCommit(input);
190
202
  const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
191
203
  if (raw.kind === "deny") return raw;
204
+ // An implementer subagent stays in its Workit worktree (#310).
205
+ if (canDeny && implementerAgent(input, descriptor)) {
206
+ const confined = confineImplementerShell(input, event.command, event.dialect);
207
+ if (confined.kind === "deny") return confined;
208
+ }
192
209
  // The before-write gate and the live-session guard (#274).
193
210
  const gate = usable(descriptor.events["write.pre"].support)
194
211
  ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
@@ -250,10 +267,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
250
267
  if (usable(descriptor.events["shell.post"].support))
251
268
  rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
252
269
  return NONE;
253
- case "write.pre":
254
- return usable(descriptor.events["write.pre"].support)
255
- ? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
256
- : NONE;
270
+ case "write.pre": {
271
+ if (!usable(descriptor.events["write.pre"].support)) return NONE;
272
+ if (implementerAgent(input, descriptor)) {
273
+ const confined = confineImplementer(input, liveTargets(input.cwd, event.paths));
274
+ if (confined.kind === "deny") return confined;
275
+ }
276
+ return writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport));
277
+ }
257
278
  case "subagent.start":
258
279
  if (workitRole(descriptor, event.agentType) === "judge")
259
280
  trackJudge(input, event.agentId, true);
@@ -788,6 +788,25 @@ export function workitInvocations(
788
788
  return found;
789
789
  }
790
790
 
791
+ /** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
792
+ const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
793
+
794
+ /** An invocation's arguments without the global options before the verb, and the last `--cwd` among them. */
795
+ export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cwd: string | null } {
796
+ let cwd: string | null = null;
797
+ let at = 0;
798
+ for (; at < args.length; at++) {
799
+ const match = WORKIT_GLOBAL.exec(args[at]);
800
+ if (match?.[1] === "=") {
801
+ if (args[at].startsWith("--cwd=")) cwd = args[at].slice(6);
802
+ } else if (match && args[at + 1] !== undefined) {
803
+ if (args[at] === "--cwd") cwd = args[at + 1];
804
+ at++;
805
+ } else if (args[at] !== "--json") break;
806
+ }
807
+ return { rest: args.slice(at), cwd };
808
+ }
809
+
791
810
  /** What one raw invocation means for workit. */
792
811
  export type RawAction =
793
812
  | { kind: "commit"; amend: boolean }
@@ -0,0 +1,363 @@
1
+ // Worktree confinement for implementer subagents (#310). Without host
2
+ // worktree isolation (Claude Code's refused every command it could not
3
+ // verify, `workit git` included), the lead makes the implementer's worktree
4
+ // with `workit fanout worktree create` and Workit's own hooks keep the
5
+ // implementer in it:
6
+ //
7
+ // - binding: the first edit or mutating git command an implementer makes
8
+ // inside a worktree that `workit fanout worktree create` made (its admin
9
+ // dir holds the fanout token) binds that agent to it, in
10
+ // `<state dir>/worktree-bind/<session+agent>.json`, with a ledger row;
11
+ // - after binding, an edit or mutating git command outside that worktree is
12
+ // denied naming it; the host's temp dir stays writable;
13
+ // - before binding (ruling: the safe default), an edit outside every Workit
14
+ // worktree is denied too, so an implementer never edits the lead's checkout;
15
+ // - always, what the lead owns is denied: worktree, ref and config changes
16
+ // (`git worktree add|remove`, `branch -D`, `update-ref`, a delete push…)
17
+ // and `workit fanout plan|worktree`.
18
+ //
19
+ // An agent the host isolated itself (a real linked worktree at Claude Code's
20
+ // `<repo>/.claude/worktrees/agent-<id>`) is left to the host. Reads only the
21
+ // filesystem; fails open on any error.
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { resolveStateDir } from "../core/logger";
26
+ import { appendHookObserved } from "../ledger";
27
+ import type { HookDecision, HookInput } from "./protocol";
28
+ import { inWorkitWorkspace, rawInvocations, skipWorkitGlobals, workitInvocations } from "./raw-git";
29
+ import type { ShellDialect } from "./shell-words";
30
+ import { shellMutationTargets, UNKNOWN_DIR } from "./write-gate";
31
+
32
+ /** Written by `workit fanout worktree create` into git's admin dir for the worktree. */
33
+ export const FANOUT_TOKEN_FILE = "workit-fanout-token";
34
+
35
+ const NONE: HookDecision = { kind: "none" };
36
+ const CASE_INSENSITIVE = process.platform === "darwin" || process.platform === "win32";
37
+
38
+ /** The real path of `file`'s nearest existing ancestor, plus the rest. */
39
+ const canonical = (file: string): string => {
40
+ let rest = "";
41
+ for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
42
+ try {
43
+ return path.join(fs.realpathSync.native(dir), rest);
44
+ } catch {
45
+ if (path.dirname(dir) === dir) return path.resolve(file);
46
+ rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
47
+ }
48
+ }
49
+ };
50
+
51
+ const within = (child: string, parent: string): boolean => {
52
+ const fold = (value: string) => (CASE_INSENSITIVE ? value.toLowerCase() : value);
53
+ const relative = path.relative(fold(parent), fold(child));
54
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
55
+ };
56
+
57
+ /**
58
+ * The git checkout `file` is in (the nearest directory above it with a
59
+ * `.git`), and whether `workit fanout worktree create` made it: its `.git`
60
+ * file names an admin dir that holds the fanout token. Null outside any
61
+ * repository.
62
+ */
63
+ const checkoutOf = (file: string): { root: string; workit: boolean } | null => {
64
+ for (let dir = canonical(file); ; dir = path.dirname(dir)) {
65
+ const dotGit = path.join(dir, ".git");
66
+ if (fs.existsSync(dotGit)) {
67
+ if (!fs.statSync(dotGit).isFile()) return { root: dir, workit: false };
68
+ const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
69
+ return {
70
+ root: dir,
71
+ workit:
72
+ Boolean(admin) && fs.existsSync(path.join(path.resolve(dir, admin!), FANOUT_TOKEN_FILE)),
73
+ };
74
+ }
75
+ if (path.dirname(dir) === dir) return null;
76
+ }
77
+ };
78
+
79
+ /** The root of the Workit-made worktree `file` is in, else null. */
80
+ const workitWorktreeOf = (file: string): string | null => {
81
+ try {
82
+ const checkout = checkoutOf(file);
83
+ return checkout?.workit ? checkout.root : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ };
88
+
89
+ type Binding = { worktree: string; session: string; agentId: string; at: string };
90
+
91
+ // Bindings live in Workit's machine state dir, keyed by session and agent, so
92
+ // one is kept wherever the hook's cwd is (even outside every repository).
93
+ const bindingDir = (): string => path.join(resolveStateDir(), "worktree-bind");
94
+
95
+ const bindingFile = (session: string, agentId: string): string =>
96
+ path.join(
97
+ bindingDir(),
98
+ `${createHash("sha256").update(`${session}\0${agentId}`).digest("hex").slice(0, 32)}.json`,
99
+ );
100
+
101
+ const readBinding = (file: string): Binding | null => {
102
+ try {
103
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Binding>;
104
+ return typeof value.worktree === "string" ? (value as Binding) : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ };
109
+
110
+ /** The worktree `input`'s agent is bound to, while that worktree is still a Workit one. */
111
+ const boundWorktree = (input: HookInput): string | null => {
112
+ const agentId = input.session.agentId;
113
+ if (!agentId) return null;
114
+ const binding = readBinding(bindingFile(input.session.id, agentId));
115
+ return binding && workitWorktreeOf(binding.worktree) === binding.worktree
116
+ ? binding.worktree
117
+ : null;
118
+ };
119
+
120
+ const bind = (input: HookInput, worktree: string): void => {
121
+ const agentId = input.session.agentId!;
122
+ const file = bindingFile(input.session.id, agentId);
123
+ const binding: Binding = {
124
+ worktree,
125
+ session: input.session.id,
126
+ agentId,
127
+ at: new Date().toISOString(),
128
+ };
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ fs.writeFileSync(file, JSON.stringify(binding));
131
+ try {
132
+ if (inWorkitWorkspace(input.cwd))
133
+ appendHookObserved(input.cwd, {
134
+ type: "worktree.bound",
135
+ actor: { host: input.host, session: input.session.id, agentId },
136
+ agentType: input.session.agentType,
137
+ worktree,
138
+ });
139
+ } catch {
140
+ // The binding file is what the hook reads; the row is evidence only.
141
+ }
142
+ };
143
+
144
+ /**
145
+ * Claude Code's own isolation: the agent runs in a linked worktree (its `.git`
146
+ * is a file) whose root is exactly `<repo>/.claude/worktrees/agent-<its id>`.
147
+ * A directory that only looks like one is not.
148
+ */
149
+ export const hostIsolated = (cwd: string, agentId: string | null): boolean => {
150
+ try {
151
+ if (agentId === null) return false;
152
+ const checkout = checkoutOf(cwd);
153
+ if (!checkout) return false;
154
+ const dotGit = path.join(checkout.root, ".git");
155
+ if (!fs.statSync(dotGit).isFile()) return false;
156
+ const admin = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
157
+ if (!admin) return false;
158
+ const adminDir = path.resolve(checkout.root, admin);
159
+ // The admin dir must point back at this checkout: a forged `.git` file
160
+ // naming a sibling's admin dir does not make a directory that sibling's.
161
+ const back = fs.readFileSync(path.join(adminDir, "gitdir"), "utf8").trim();
162
+ if (path.relative(canonical(path.resolve(adminDir, back)), canonical(dotGit)) !== "")
163
+ return false;
164
+ const common = path.join(adminDir, "commondir");
165
+ const gitDir = fs.existsSync(common)
166
+ ? path.resolve(adminDir, fs.readFileSync(common, "utf8").trim())
167
+ : adminDir;
168
+ const expected = path.join(
169
+ path.dirname(canonical(gitDir)),
170
+ ".claude",
171
+ "worktrees",
172
+ `agent-${agentId}`,
173
+ );
174
+ return path.relative(canonical(expected), checkout.root) === "";
175
+ } catch {
176
+ return false;
177
+ }
178
+ };
179
+
180
+ const shown = (target: string): string =>
181
+ path.basename(target) === ".workit-live-target" ? path.dirname(target) : target;
182
+
183
+ const unboundDeny = (input: HookInput, target: string): HookDecision => {
184
+ const unblock =
185
+ "Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit --cwd <worktree> git commit -m … -- <paths>`, `workit --cwd <worktree> check <name>`). If your brief names no worktree, stop and report that to the lead.";
186
+ return {
187
+ kind: "deny",
188
+ reason: `workit worktree confinement: implementer ${input.session.agentId} is not working in a Workit worktree yet, and ${shown(target)} is outside every worktree \`workit fanout worktree create\` made, so it would change the lead's checkout. ${unblock}`,
189
+ unblock,
190
+ };
191
+ };
192
+
193
+ const outsideDeny = (input: HookInput, worktree: string, target: string): HookDecision => {
194
+ const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit --cwd ${worktree} git commit -m … -- <paths>\`, \`workit --cwd ${worktree} check <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
195
+ return {
196
+ kind: "deny",
197
+ reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
198
+ unblock,
199
+ };
200
+ };
201
+
202
+ const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
203
+ const wt = worktree ?? "<worktree>";
204
+ const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit --cwd ${wt}\`.`;
205
+ return {
206
+ kind: "deny",
207
+ reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
208
+ unblock,
209
+ };
210
+ };
211
+
212
+ /**
213
+ * Judge an implementer's edit or mutating command by the absolute paths it
214
+ * changes (`targets`). Only targets inside a git checkout, or Workit's
215
+ * binding records, are judged (scratch and temp files elsewhere are host
216
+ * policy). Binds on the first target inside a Workit worktree; denies a
217
+ * target outside the bound worktree (or, unbound, outside every Workit
218
+ * worktree). Deny reasons name a target as the agent spelled it.
219
+ */
220
+ export function confineImplementer(input: HookInput, targets: readonly string[]): HookDecision {
221
+ try {
222
+ if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
223
+ if (targets.some((target) => within(path.resolve(target), UNKNOWN_DIR)))
224
+ return unknownDirDeny(input, boundWorktree(input));
225
+ const bindings = canonical(bindingDir());
226
+ const judged = targets
227
+ .map((named) => ({ named, real: canonical(named) }))
228
+ .filter(({ real }) => within(real, bindings) || checkoutOf(real) !== null);
229
+ if (judged.length === 0) return NONE;
230
+ let worktree = boundWorktree(input);
231
+ if (!worktree) {
232
+ for (const { real } of judged) {
233
+ worktree = within(real, bindings) ? null : workitWorktreeOf(real);
234
+ if (worktree) break;
235
+ }
236
+ if (!worktree) return unboundDeny(input, judged[0].named);
237
+ bind(input, worktree);
238
+ }
239
+ const root = canonical(worktree);
240
+ const outside = judged.find(({ real }) => !within(real, root) || within(real, bindings));
241
+ return outside ? outsideDeny(input, worktree, outside.named) : NONE;
242
+ } catch {
243
+ return NONE;
244
+ }
245
+ }
246
+
247
+ const MUTATING_REMOTE = new Set([
248
+ "add",
249
+ "remove",
250
+ "rm",
251
+ "rename",
252
+ "set-url",
253
+ "set-head",
254
+ "set-branches",
255
+ "prune",
256
+ ]);
257
+ const CONFIG_READ = /^(?:--get(?:-all|-regexp|-urlmatch)?|--list|-l|get|list)$/;
258
+ const CONFIG_WRITE =
259
+ /^(?:--(?:unset(?:-all)?|add|replace-all|rename-section|remove-section|edit)|-e|set|unset|rename-section|remove-section|edit)$/;
260
+ const CONFIG_VALUE = /^(?:--file|-f|--blob|--type|--default|--comment)$/;
261
+
262
+ /**
263
+ * Whether git `args` (the subcommand first) change what the lead owns: the
264
+ * repository's worktrees, refs other than by commit, the shared stash, or
265
+ * its configuration and remotes.
266
+ */
267
+ const leadOwnedGit = (args: readonly string[]): boolean => {
268
+ const [sub = "", ...rest] = args;
269
+ if (rest.some((arg) => arg === "--help" || arg === "-h")) return false;
270
+ const names = rest.filter((arg) => !arg.startsWith("-"));
271
+ const flag = (pattern: RegExp) => rest.some((arg) => pattern.test(arg));
272
+ switch (sub) {
273
+ case "worktree":
274
+ return names[0] !== undefined && names[0] !== "list";
275
+ case "update-ref":
276
+ return true;
277
+ case "symbolic-ref":
278
+ return names.length > 1 || flag(/^(?:-d|--delete)$/);
279
+ case "branch":
280
+ return flag(/^-[a-zA-Z]*[dDfmMcC]|^--(?:delete|force|move|copy)$/);
281
+ case "tag":
282
+ return flag(/^-[a-zA-Z]*[df]|^--(?:delete|force)$/);
283
+ case "push": {
284
+ if (flag(/^(?:-d|--delete|--mirror|--prune)$/)) return true;
285
+ // names[0] is the remote; the rest are refspecs. `git push [-u] [remote]` and
286
+ // `git push --force-with-lease [remote]` (the current branch) stay the implementer's.
287
+ const refspecs = names.slice(1);
288
+ return (
289
+ refspecs.some((name) => name.includes(":") || name.startsWith("+")) ||
290
+ (refspecs.length > 0 && flag(/^-[a-zA-Z]*f|^--force/))
291
+ );
292
+ }
293
+ case "replace":
294
+ case "filter-branch":
295
+ case "gc":
296
+ case "prune":
297
+ case "repack":
298
+ case "sparse-checkout":
299
+ return true;
300
+ case "notes":
301
+ case "reflog":
302
+ case "submodule":
303
+ return !/^(?:list|show|status|exists)$/.test(names[0] ?? "list");
304
+ case "stash":
305
+ return names[0] === "drop" || names[0] === "clear";
306
+ case "remote":
307
+ return MUTATING_REMOTE.has(names[0] ?? "");
308
+ case "config": {
309
+ if (rest.some((arg) => CONFIG_READ.test(arg))) return false;
310
+ if (rest.some((arg) => CONFIG_WRITE.test(arg))) return true;
311
+ let operands = 0;
312
+ for (let index = 0; index < rest.length; index++)
313
+ if (CONFIG_VALUE.test(rest[index])) index++;
314
+ else if (!rest[index].startsWith("-")) operands++;
315
+ return operands > 1;
316
+ }
317
+ default:
318
+ return false;
319
+ }
320
+ };
321
+
322
+ /** A `workit [globals] fanout plan|worktree …` in `args` (also behind `check --`). */
323
+ const leadOwnedWorkit = (args: readonly string[]): boolean =>
324
+ !args.includes("--help") &&
325
+ args.some((_, start) => {
326
+ if (start > 0 && !/(?:^|[\\/])workit(?:\.exe|\.cmd)?$/.test(args[start - 1])) return false;
327
+ const [verb, sub] = skipWorkitGlobals(args.slice(start)).rest;
328
+ return verb === "fanout" && /^(?:plan|worktree)$/.test(sub ?? "");
329
+ });
330
+
331
+ const leadOwnedDeny = (input: HookInput, command: string): HookDecision => {
332
+ const unblock =
333
+ "Worktrees, branches other than your own, refs, tags, remotes, git config, the shared stash and the fan-out plan belong to the lead: report what you need changed as a follow-up instead.";
334
+ return {
335
+ kind: "deny",
336
+ reason: `workit worktree confinement: implementer ${input.session.agentId} may not run \`${command.slice(0, 160)}\`. ${unblock}`,
337
+ unblock,
338
+ };
339
+ };
340
+
341
+ /**
342
+ * Judge an implementer's shell command: deny what the lead owns, then confine
343
+ * every path it may change (the strict analysis, see `shellMutationTargets`).
344
+ */
345
+ export function confineImplementerShell(
346
+ input: HookInput,
347
+ command: string,
348
+ dialect: ShellDialect = "posix",
349
+ ): HookDecision {
350
+ try {
351
+ if (!input.session.agentId || hostIsolated(input.cwd, input.session.agentId)) return NONE;
352
+ if (
353
+ rawInvocations(command, dialect).some(
354
+ (invocation) => invocation.tool === "git" && leadOwnedGit(invocation.args),
355
+ ) ||
356
+ workitInvocations(command, dialect).some(leadOwnedWorkit)
357
+ )
358
+ return leadOwnedDeny(input, command);
359
+ } catch {
360
+ // An unanalysable command falls through to the path confinement.
361
+ }
362
+ return confineImplementer(input, shellMutationTargets(input.cwd, command, dialect, true));
363
+ }
@@ -4,7 +4,7 @@
4
4
  // unblock. History moves (commit, merge, rebase, stash pop) are not edits.
5
5
  // Reads only the task index, one task snapshot and the ledger; never
6
6
  // migrates, never captures a candidate, and fails open on any state error.
7
- import { realpathSync } from "node:fs";
7
+ import { existsSync, realpathSync } from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { fileSignature, TaskStore } from "../core/task-store";
@@ -25,6 +25,7 @@ import {
25
25
  inWorkitWorkspace,
26
26
  rawInvocations,
27
27
  workitCheckTail,
28
+ skipWorkitGlobals,
28
29
  workitInvocations,
29
30
  type RawInvocation,
30
31
  } from "./raw-git";
@@ -70,7 +71,7 @@ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<stri
70
71
  );
71
72
  };
72
73
 
73
- const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs"]);
74
+ const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs", "busybox"]);
74
75
  const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
75
76
 
76
77
  const SCRIPT_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
@@ -86,12 +87,9 @@ const wrappedScript = (input: string[]): string | null => {
86
87
  const tail = workitCheckTail(words);
87
88
  if (!tail) return null;
88
89
  if ("script" in tail) return tail.script;
89
- const [shell, flag, script] = tail.argv;
90
- if (
91
- SCRIPT_SHELLS.has((shell.split(/[\\/]/).at(-1) ?? "").toLowerCase()) &&
92
- /^-[a-z]*c$/.test(flag ?? "")
93
- )
94
- return script ?? null;
90
+ const script = shellScript(tail.argv);
91
+ if (SCRIPT_SHELLS.has((tail.argv[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase()) && script)
92
+ return script;
95
93
  return tail.argv.map(quoteWord).join(" ");
96
94
  };
97
95
 
@@ -167,6 +165,112 @@ const editsOf = (input: string[]): string[] | null => {
167
165
  }
168
166
  };
169
167
 
168
+ /** A `bash -c '<script>'` (any SCRIPT_SHELLS) script, else null. */
169
+ const shellScript = (words: readonly string[]): string | null => {
170
+ if (!SCRIPT_SHELLS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) return null;
171
+ // Options may come before `-c` (`bash --norc -c`, `sh -e -c`); the script follows it.
172
+ for (let index = 1; index < words.length; index++) {
173
+ const word = words[index];
174
+ if (/^-[a-z]*c$/.test(word)) return words[index + 1] ?? null;
175
+ if (/^[-+][oO]$/.test(word)) index++;
176
+ else if (!/^[-+]/.test(word)) return null;
177
+ }
178
+ return null;
179
+ };
180
+
181
+ /** xargs options that take the next word as their value. */
182
+ const XARGS_VALUE = /^-[IinLPdsEa]$/;
183
+
184
+ /** The command `xargs` (or `find -exec`) runs with `{}` filled in by a path. */
185
+ const runsWriter = (words: readonly string[]): boolean => {
186
+ const filled = [...words.filter((word) => word !== "{}" && word !== ";" && word !== "+"), "x"];
187
+ const edits = editsOf(filled);
188
+ return edits === null || edits.length > 0 || opaqueWrites(filled) !== null;
189
+ };
190
+
191
+ /**
192
+ * For an implementer's confinement: commands that may write where the edit
193
+ * analysis cannot see (interpreters, chmod, rsync, `find -delete`, `xargs rm`,
194
+ * `awk -i`, a `-c` shell script): the paths they may change, "." standing for
195
+ * the directory they run in; null when the command is none of them.
196
+ */
197
+ const opaqueWrites = (input: readonly string[]): string[] | null => {
198
+ let words = input;
199
+ while (words.length && (PREFIXES.has(words[0]) || /^\w+=/.test(words[0]))) {
200
+ if (words[0] === "xargs") {
201
+ let at = 1;
202
+ while (words[at]?.startsWith("-")) at += XARGS_VALUE.test(words[at]) ? 2 : 1;
203
+ return runsWriter(words.slice(at)) ? ["."] : null;
204
+ }
205
+ words = words.slice(1);
206
+ }
207
+ const [verb, ...args] = words;
208
+ const name = (verb?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
209
+ const files = operands(args);
210
+ const has = (pattern: RegExp) => args.some((arg) => pattern.test(arg));
211
+ const listed = (named: string[]) => (named.length ? named : ["."]);
212
+ if (name === "unlink" || name === "shred") return listed(files);
213
+ // The mode or owner comes first, unless `--reference` names it.
214
+ if (/^(?:chmod|chown|chgrp|chattr)$/.test(name))
215
+ return listed(has(/^--reference/) ? files : files.slice(1));
216
+ if (name === "rsync") {
217
+ const dest = files.at(-1);
218
+ // `host:path` is remote; a drive letter (`C:/…`, `C:\…`) is a local path.
219
+ const remote = /^[\w.@-]*:/.test(dest ?? "") && !/^[A-Za-z]:[\\/]/.test(dest ?? "");
220
+ return dest === undefined ? ["."] : remote ? [] : [dest];
221
+ }
222
+ if (name === "find") {
223
+ const exec = args.findIndex((arg) => /^-(?:exec|execdir|ok|okdir)$/.test(arg));
224
+ const writes =
225
+ has(/^-(?:delete|fprint\w*|fls)$/) || (exec >= 0 && runsWriter(args.slice(exec + 1)));
226
+ const end = args.findIndex((arg) => /^[-(!]/.test(arg));
227
+ return writes ? listed(end < 0 ? args : args.slice(0, end)) : null;
228
+ }
229
+ if (/^g?awk$/.test(name)) {
230
+ if (!has(/^-i|^--include/)) return null;
231
+ const named: string[] = [];
232
+ let program = !has(/^-f$|^--file/);
233
+ for (let index = 0; index < args.length; index++)
234
+ if (/^(?:-[ifvF]|--(?:include|file|assign|field-separator))$/.test(args[index])) index++;
235
+ else if (args[index].startsWith("-")) continue;
236
+ else if (program) program = false;
237
+ else named.push(args[index]);
238
+ return listed(named);
239
+ }
240
+ if (name === "perl") return has(/^-[a-zA-Z]*[eE]/) ? ["."] : null;
241
+ if (/^(?:python[\d.]*|node|bun|deno|ruby|php)$/.test(name))
242
+ return has(/^-[a-zA-Z]*[ce]$|^--eval$|^-p$/) ? ["."] : null;
243
+ return shellScript(words) === null ? null : ["."];
244
+ };
245
+
246
+ /**
247
+ * `ln`'s link sources, resolved as the link will: a relative symbolic source
248
+ * against the directory the link lands in. Writes through such a link change
249
+ * its source, so an implementer's links are judged by where they point.
250
+ */
251
+ const linkSources = (args: readonly string[], at: (file: string) => string | null): string[] => {
252
+ const names = operands([...args]);
253
+ const into = args.indexOf("-t");
254
+ const dest = into >= 0 ? args[into + 1] : names.length > 1 ? names.at(-1) : ".";
255
+ const sources = names.filter((name, index) =>
256
+ into >= 0 ? name !== dest : index < names.length - 1 || names.length === 1,
257
+ );
258
+ const target = dest === undefined ? null : at(dest);
259
+ const symbolic = args.some((arg) => /^-[a-zA-Z]*s|^--symbolic$/.test(arg));
260
+ const base =
261
+ target === null
262
+ ? null
263
+ : into >= 0 || sources.length > 1 || existsSync(target) || /[\\/]$/.test(dest ?? "")
264
+ ? target
265
+ : path.dirname(target);
266
+ return sources.flatMap((source) => {
267
+ if (symbolic && base !== null && !path.isAbsolute(homeExpanded(source)))
268
+ return [path.resolve(base, source)];
269
+ const resolved = at(source);
270
+ return resolved ? [resolved] : [];
271
+ });
272
+ };
273
+
170
274
  /**
171
275
  * Whether a shell command recognizably edits files (redirects, tee, touch,
172
276
  * mkdir, cp/mv/rm, `sed -i`/`perl -i`, dd, and working-tree git: apply,
@@ -325,13 +429,25 @@ const CD = new Set(["cd", "chdir", "set-location", "sl"]);
325
429
  const PUSHD = new Set(["pushd", "push-location"]);
326
430
  const POPD = new Set(["popd", "pop-location"]);
327
431
 
328
- /** A `cd` operand resolved against `dir`; undefined when it cannot be known. */
329
- const cdInto = (dir: string | undefined, words: string[]): string | undefined => {
432
+ /**
433
+ * Stands for a directory an implementer's command moved to but the analysis
434
+ * cannot name (`cd -`, `cd "$X"`, a bare `popd`): a write that lands under it
435
+ * is denied by `confineImplementer`, as the shell may be anywhere.
436
+ */
437
+ export const UNKNOWN_DIR = path.resolve(path.parse(process.cwd()).root, ".workit-unknown-dir");
438
+
439
+ /**
440
+ * A `cd` operand resolved against `dir`. A target that cannot be known is
441
+ * undefined, or `UNKNOWN_DIR` when `strict`.
442
+ */
443
+ const cdInto = (dir: string | undefined, words: string[], strict = false): string | undefined => {
444
+ const lost = strict ? UNKNOWN_DIR : undefined;
330
445
  const named = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
331
446
  // Only a leading `~` or `$HOME` expands; a mid-path `~` is literal (Windows 8.3
332
447
  // short names like RUNNER~1).
333
448
  const target = named === undefined ? undefined : homeExpanded(named);
334
- if (dir === undefined || target === "-" || (target && /^~|[$`*?]/.test(target))) return undefined;
449
+ if (target === "-" || (target && /^~|[$`*?]/.test(target))) return lost;
450
+ if (dir === undefined) return lost;
335
451
  return target === undefined ? os.homedir() : path.resolve(dir, target);
336
452
  };
337
453
 
@@ -355,6 +471,7 @@ const segmentTargets = (
355
471
  command: string,
356
472
  dialect: ShellDialect,
357
473
  depth = 0,
474
+ strict = false,
358
475
  ): string[] => {
359
476
  const targets: string[] = [];
360
477
  let dir: string | undefined = cwd;
@@ -378,21 +495,34 @@ const segmentTargets = (
378
495
  const words = lead < 0 ? [] : segment.words.slice(lead);
379
496
  const name = (words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase();
380
497
  if (CD.has(name)) {
381
- dir = cdInto(dir, words);
498
+ dir = cdInto(dir, words, strict);
382
499
  continue;
383
500
  }
384
501
  if (PUSHD.has(name)) {
385
502
  stack.push(dir);
386
- dir = cdInto(dir, words);
503
+ dir = cdInto(dir, words, strict);
387
504
  continue;
388
505
  }
389
506
  if (POPD.has(name)) {
390
- dir = stack.length ? stack.pop() : undefined;
507
+ dir = stack.length ? stack.pop() : strict ? UNKNOWN_DIR : undefined;
391
508
  continue;
392
509
  }
393
- const inner = depth < 4 ? wrappedScript(words) : null;
510
+ const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
394
511
  if (inner !== null && dir !== undefined)
395
- targets.push(...segmentTargets(dir, inner, "posix", depth + 1));
512
+ targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
513
+ if (strict && dir !== undefined) {
514
+ const runIn = dir;
515
+ for (const sub of depth < 4 ? (segment.subs ?? []) : [])
516
+ targets.push(...segmentTargets(runIn, sub, dialect, depth + 1, true));
517
+ const opaque = segment.unanalyzable ? ["."] : inner === null ? opaqueWrites(words) : null;
518
+ for (const file of opaque ?? []) {
519
+ const resolved = at(file);
520
+ if (resolved) targets.push(file === "." ? path.join(resolved, DIR_TARGET) : resolved);
521
+ }
522
+ const ln = words.findIndex((word) => /^ln$/i.test(word.split(/[\\/]/).at(-1) ?? ""));
523
+ if (ln >= 0 && words.slice(0, ln).every((word) => PREFIXES.has(word)))
524
+ targets.push(...linkSources(words.slice(ln + 1), at));
525
+ }
396
526
  for (const file of segment.redirects)
397
527
  if (!/^\/dev\/(?:null|stdout|stderr|tty)$/.test(file)) {
398
528
  const resolved = at(file);
@@ -406,18 +536,51 @@ const segmentTargets = (
406
536
  const resolved = at(file);
407
537
  if (resolved) targets.push(resolved);
408
538
  }
409
- if (
410
- dir !== undefined &&
411
- workitInvocations(segment.words.map(quoteWord).join(" "), "posix").some(
412
- (args) =>
413
- args[0] === "git" && MUTATING_WORKIT_GIT.has(args[1] ?? "") && !inert(args.slice(2), ""),
414
- )
415
- )
416
- targets.push(path.join(dir, DIR_TARGET));
539
+ if (dir !== undefined)
540
+ for (const invocation of workitInvocations(segment.words.map(quoteWord).join(" "), "posix")) {
541
+ const { at: runsIn, args } = workitCwd(dir, invocation);
542
+ if (
543
+ args[0] === "git" &&
544
+ MUTATING_WORKIT_GIT.has(args[1] ?? "") &&
545
+ !inert(args.slice(2), "")
546
+ )
547
+ targets.push(path.join(runsIn, DIR_TARGET));
548
+ }
417
549
  }
418
550
  return targets;
419
551
  };
420
552
 
553
+ /** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
554
+ const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
555
+ const { rest, cwd } = skipWorkitGlobals(args);
556
+ return { at: cwd === null ? dir : path.resolve(dir, homeExpanded(cwd)), args: rest };
557
+ };
558
+
559
+ /**
560
+ * Where one shell command changes files: the files it writes and, as
561
+ * `<dir>/.workit-live-target`, each directory a mutating git or
562
+ * `workit git commit|branch` runs in or an unknown write happens in.
563
+ * `strict` (an implementer's confinement) also reads `-c` shell scripts and
564
+ * `$(…)`, counts writers the analysis cannot see in the directory they run
565
+ * in, and judges `ln` sources. Absolute paths; [] when the command cannot be
566
+ * analysed (fail open).
567
+ */
568
+ export const shellMutationTargets = (
569
+ cwd: string,
570
+ command: string,
571
+ dialect: ShellDialect = "posix",
572
+ strict = false,
573
+ ): string[] => {
574
+ try {
575
+ return [
576
+ ...segmentTargets(cwd, command, dialect, 0, strict),
577
+ ...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
578
+ ];
579
+ } catch {
580
+ return [];
581
+ }
582
+ };
583
+
421
584
  /** The command is one simple command led by `WORKIT_LIVE_SESSION=override`. */
422
585
  const overridden = (command: string, dialect: ShellDialect): boolean => {
423
586
  const segments = segmentsOf(command, dialect);
@@ -482,7 +645,7 @@ const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps)
482
645
  };
483
646
 
484
647
  /** The absolute paths an edit touches: its files, or cwd itself when unknown. */
485
- const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
648
+ export const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
486
649
  paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
487
650
 
488
651
  // --- the issued-registry guard (#292) ---------------------------------------
@@ -593,16 +756,7 @@ export function shellGate(
593
756
  }
594
757
  const writes = shellWrites(command);
595
758
  let targets: string[] = [];
596
- if (owner) {
597
- try {
598
- targets = [
599
- ...segmentTargets(cwd, command, dialect),
600
- ...gitMutationDirs(cwd, command, dialect).map((dir) => path.join(dir, DIR_TARGET)),
601
- ];
602
- } catch {
603
- targets = [];
604
- }
605
- }
759
+ if (owner) targets = shellMutationTargets(cwd, command, dialect);
606
760
  if (owner && targets.length) {
607
761
  const live = liveGate(owner, targets, deps);
608
762
  if (live.kind !== "none") {
@@ -0,0 +1,142 @@
1
+ // Dependencies for a fresh worktree (issue #310): a linked worktree has no
2
+ // node_modules, so a resolver walks up to whatever node_modules is above it
3
+ // (the main checkout's, for a worktree nested inside it) and a workspace
4
+ // package resolves to the main checkout's source: tests then run the wrong
5
+ // code. Instead of a package-manager install, the worktree gets its own
6
+ // node_modules that mirrors the main checkout's one level deep:
7
+ //
8
+ // - a symlink is copied as it is: a relative one (bun, npm and yarn link
9
+ // workspace packages as `../../packages/<name>`, and `.bin` entries as
10
+ // `../<pkg>/bin/x`) then resolves inside the worktree, which has the same
11
+ // layout; an absolute one into the main checkout (a Windows junction) is
12
+ // re-pointed at the worktree's counterpart;
13
+ // - `@scope` directories and `.bin` are mirrored the same way, one level;
14
+ // - anything else (a third-party package) is linked to the main checkout's,
15
+ // so installed packages are shared, never copied.
16
+ //
17
+ // It runs only where the worktree has no node_modules of its own and git
18
+ // ignores node_modules there (else the links would show as changes and block
19
+ // release). `git worktree remove` unlinks the links and never follows them.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { gitRun } from "./fanout";
23
+
24
+ const MODULES = "node_modules";
25
+ /** Directories searched for node_modules below the checkout root (packages/<name>). */
26
+ const MAX_DEPTH = 2;
27
+
28
+ export type LinkedDependencies = {
29
+ /** The main checkout the links point at. */
30
+ from: string;
31
+ /** The node_modules directories made, relative to the worktree. */
32
+ dirs: string[];
33
+ /** Links made across them. */
34
+ entries: number;
35
+ ms: number;
36
+ };
37
+
38
+ const within = (child: string, parent: string): boolean => {
39
+ const relative = path.relative(parent, child);
40
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
41
+ };
42
+
43
+ /** node_modules directories of the main checkout, as paths relative to it. */
44
+ function moduleDirs(main: string): string[] {
45
+ const out: string[] = [];
46
+ const walk = (relative: string, depth: number) => {
47
+ let entries: fs.Dirent[];
48
+ try {
49
+ entries = fs.readdirSync(path.join(main, relative), { withFileTypes: true });
50
+ } catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.isDirectory()) continue;
55
+ const child = path.join(relative, entry.name);
56
+ if (entry.name === MODULES) out.push(child);
57
+ else if (depth < MAX_DEPTH && !entry.name.startsWith(".")) walk(child, depth + 1);
58
+ }
59
+ };
60
+ walk("", 0);
61
+ return out;
62
+ }
63
+
64
+ function linkEntry(main: string, worktree: string, source: string, target: string): void {
65
+ const stat = fs.lstatSync(source);
66
+ if (stat.isSymbolicLink()) {
67
+ const link = fs.readlinkSync(source);
68
+ // An absolute link into the main checkout (outside its node_modules) is a
69
+ // workspace package: point it at the worktree's copy.
70
+ const outside =
71
+ path.isAbsolute(link) &&
72
+ within(link, main) &&
73
+ !path.relative(main, link).split(path.sep).includes(MODULES);
74
+ const to = outside ? path.join(worktree, path.relative(main, link)) : link;
75
+ fs.symlinkSync(to, target, process.platform === "win32" ? "junction" : undefined);
76
+ return;
77
+ }
78
+ fs.symlinkSync(
79
+ source,
80
+ target,
81
+ process.platform === "win32" ? (stat.isDirectory() ? "junction" : "file") : undefined,
82
+ );
83
+ }
84
+
85
+ /** Mirror `source` into a new directory `target`; returns the links made. */
86
+ function mirror(main: string, worktree: string, source: string, target: string, top: boolean) {
87
+ fs.mkdirSync(target);
88
+ let count = 0;
89
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
90
+ const from = path.join(source, entry.name);
91
+ const to = path.join(target, entry.name);
92
+ if (top && entry.isDirectory() && (entry.name.startsWith("@") || entry.name === ".bin"))
93
+ count += mirror(main, worktree, from, to, false);
94
+ else {
95
+ linkEntry(main, worktree, from, to);
96
+ count += 1;
97
+ }
98
+ }
99
+ return count;
100
+ }
101
+
102
+ /**
103
+ * Give `worktree` node_modules that resolve its own workspace packages and
104
+ * share the main checkout's installed ones. Null when there is nothing to
105
+ * link (no node_modules in the main checkout), with a note when it was
106
+ * skipped; never throws.
107
+ */
108
+ export function linkWorktreeDependencies(
109
+ main: string,
110
+ worktree: string,
111
+ ): { linked: LinkedDependencies | null; notes: string[] } {
112
+ const started = performance.now();
113
+ const notes: string[] = [];
114
+ const dirs: string[] = [];
115
+ let entries = 0;
116
+ for (const relative of moduleDirs(main)) {
117
+ const target = path.join(worktree, relative);
118
+ if (!fs.existsSync(path.dirname(target)) || fs.existsSync(target)) continue;
119
+ // The trailing slash: the directory does not exist yet, and `node_modules/` only matches one.
120
+ if (!gitRun(worktree, ["check-ignore", "-q", `${relative.split(path.sep).join("/")}/`]).ok) {
121
+ notes.push(
122
+ `${relative.split(path.sep).join("/")} is not ignored by git, so it was not linked from ${main}; install dependencies in the worktree yourself`,
123
+ );
124
+ continue;
125
+ }
126
+ try {
127
+ entries += mirror(main, worktree, path.join(main, relative), target, true);
128
+ dirs.push(relative.split(path.sep).join("/"));
129
+ } catch (error) {
130
+ fs.rmSync(target, { recursive: true, force: true });
131
+ notes.push(
132
+ `could not link ${relative} from ${main} (${(error as Error).message}); install dependencies in the worktree yourself`,
133
+ );
134
+ }
135
+ }
136
+ if (dirs.length === 0) return { linked: null, notes };
137
+ const ms = Math.round(performance.now() - started);
138
+ notes.push(
139
+ `linked ${dirs.join(", ")} from ${main} (${entries} entries, ${ms} ms): workspace packages resolve to this worktree, installed packages are shared`,
140
+ );
141
+ return { linked: { from: main, dirs, entries, ms }, notes };
142
+ }