@brainervirus/workit-core 8.22.3 → 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 +1 -1
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/brief.md +17 -11
- package/skills/workit-fanout/references/orchestration.md +6 -6
- package/skills/workit-implement/SKILL.md +2 -0
- package/src/fanout-brief.ts +23 -15
- package/src/fanout-worktree.ts +20 -6
- package/src/hooks/handle.ts +29 -8
- package/src/hooks/raw-git.ts +19 -0
- package/src/hooks/worktree-confine.ts +363 -0
- package/src/hooks/write-gate.ts +189 -35
- package/src/worktree-deps.ts +142 -0
package/package.json
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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
|
-
|
|
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) |
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
128
|
-
|
|
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>` (
|
|
39
|
-
|
|
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
|
|
73
|
-
`
|
|
74
|
-
|
|
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;
|
|
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
|
package/src/fanout-brief.ts
CHANGED
|
@@ -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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
238
|
+
...rules(mode, branch, slice.base, exists ? planned : null),
|
|
231
239
|
];
|
|
232
240
|
return {
|
|
233
241
|
ok: true,
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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) =>
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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"
|
|
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,
|
|
112
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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);
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -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
|
|
90
|
-
if (
|
|
91
|
-
|
|
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
|
-
/**
|
|
329
|
-
|
|
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 (
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
(
|
|
413
|
-
args[0] === "git" &&
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
}
|