@brainervirus/workit-core 8.24.0 → 8.26.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.
@@ -0,0 +1,129 @@
1
+ // The Workit verbs a host may run without asking (issue #297, decision
2
+ // mvk5q3io): delivery and state verbs whose own gates (grants, non-author
3
+ // verdicts, branch policy) decide. One list for every host's permission
4
+ // block: Claude Code `Bash(workit <verb> *)`, Codex `prefix_rule`, Cursor
5
+ // `Shell(workit <verb>)`, and the OpenCode plugin's ask-to-allow.
6
+ //
7
+ // Never listed: verbs that run an arbitrary command (`check -- <cmd>`,
8
+ // `lock run -- <cmd>`, `test-audit`, `fanout check`), change authority
9
+ // (`grant`), install or remove host config (`init`, `upgrade`, `setup`,
10
+ // `uninstall`, `launch`), delete data (`gc`), import state (`state`) or write
11
+ // outside the forge flow (`youtrack`, `changelog`, `knowledge`, `handoff`).
12
+ // Pure string work: the OpenCode plugin bundles it.
13
+ import { segmentsOf, type ShellDialect } from "./shell-words";
14
+
15
+ export const WORKIT_ALLOWED_VERBS = [
16
+ "git",
17
+ "pr",
18
+ "ci",
19
+ "stack",
20
+ "release",
21
+ "task",
22
+ "ledger",
23
+ "evidence",
24
+ "finding",
25
+ "decision",
26
+ "worker",
27
+ "verify-delivery",
28
+ "policy",
29
+ "doctor",
30
+ "help",
31
+ ] as const;
32
+
33
+ /**
34
+ * Flags that override a Workit gate: a host never allows them unasked.
35
+ * Workit has no `--admin` flag and refuses a bare `--force` push itself.
36
+ */
37
+ export const WORKIT_BYPASS_FLAGS = ["--unverified", "--overwrite-unintegrated"] as const;
38
+
39
+ const ALLOWED = new Set<string>(WORKIT_ALLOWED_VERBS);
40
+
41
+ const isBypass = (word: string): boolean =>
42
+ WORKIT_BYPASS_FLAGS.some((flag) => word === flag || word.startsWith(`${flag}=`));
43
+
44
+ /** `workit`, `workit-cli`, or either by path. */
45
+ const isWorkitWord = (word: string): boolean => /^(?:.*[\\/])?workit(?:-cli)?$/u.test(word);
46
+
47
+ /**
48
+ * Is `words` one Workit call of an allowed verb? Global `--json` and
49
+ * `--cwd <dir>` may come first (they mean the same anywhere before `--`);
50
+ * `--session` may not (it changes the acting identity). A bypass flag before
51
+ * `--` disqualifies it, and so does a word the shell expands there (`expands`,
52
+ * indexes into `words`): `${F:---unverified}` could become one.
53
+ */
54
+ export const isAllowedWorkitCall = (
55
+ words: readonly string[],
56
+ expands: readonly number[] = [],
57
+ ): boolean => {
58
+ if (words[0] !== "workit") return false;
59
+ let at = 1;
60
+ for (;;) {
61
+ const word = words[at];
62
+ if (word === "--json") at += 1;
63
+ else if (word === "--cwd") at += 2;
64
+ else if (word?.startsWith("--cwd=")) at += 1;
65
+ else break;
66
+ }
67
+ const verb = words[at];
68
+ if (verb === undefined) return false;
69
+ if (verb === "--version") return at === words.length - 1;
70
+ if (!ALLOWED.has(verb)) return false;
71
+ const end = words.indexOf("--");
72
+ const limit = end < 0 ? words.length : end;
73
+ return !words.slice(0, limit).some(isBypass) && !expands.some((index) => index < limit);
74
+ };
75
+
76
+ const bypassReason = (flag: string) =>
77
+ `Workit: \`${flag}\` overrides a Workit gate, so the user decides this call (no allow rule covers it).`;
78
+ const EXPANSION_REASON =
79
+ "Workit: an expansion before `--` could become a bypass flag, so the user decides this call (no allow rule covers it).";
80
+
81
+ /**
82
+ * The ask reason for a Workit call in `command` that carries a bypass flag
83
+ * before its `--` or a word the shell expands there, or null. Words are read after shell quoting, so `"--unverified"`,
84
+ * `--unverif''ied` or a tab-separated flag are found where a host's string
85
+ * rule could miss them. A command the tokenizer cannot analyze is judged on
86
+ * its text with quotes and backslashes removed.
87
+ */
88
+ export const workitBypassFlag = (
89
+ command: string,
90
+ dialect: ShellDialect = "posix",
91
+ ): string | null => {
92
+ for (const segment of segmentsOf(command, dialect)) {
93
+ if (segment.unanalyzable) {
94
+ const text = command.replaceAll(/['"\\`]/gu, "");
95
+ if (!/\bworkit\b/u.test(text)) return null;
96
+ const found = WORKIT_BYPASS_FLAGS.find((flag) => text.includes(flag));
97
+ return found ? bypassReason(found) : null;
98
+ }
99
+ const start = segment.words.findIndex(isWorkitWord);
100
+ if (start < 0) continue;
101
+ const words = segment.words.slice(start + 1);
102
+ const end = words.indexOf("--");
103
+ const flag = (end < 0 ? words : words.slice(0, end)).find(isBypass);
104
+ if (flag) return bypassReason(flag.split("=")[0] ?? flag);
105
+ const limit = start + 1 + (end < 0 ? words.length : end);
106
+ if (segment.expands?.some((index) => index > start && index < limit)) return EXPANSION_REASON;
107
+ }
108
+ return null;
109
+ };
110
+
111
+ /**
112
+ * Is the whole shell `command` allowed Workit calls only? Every segment must
113
+ * be one, with no substitution, redirect, group or heredoc anywhere.
114
+ */
115
+ export const isAllowedWorkitCommand = (command: string): boolean => {
116
+ const segments = segmentsOf(command);
117
+ return (
118
+ segments.length > 0 &&
119
+ segments.every(
120
+ (s) =>
121
+ !s.unanalyzable &&
122
+ !s.group &&
123
+ !s.subs?.length &&
124
+ !s.stdin?.length &&
125
+ !s.redirects.length &&
126
+ isAllowedWorkitCall(s.words, s.expands),
127
+ )
128
+ );
129
+ };
@@ -9,7 +9,8 @@ import type { HostDescriptor, Support } from "./descriptor";
9
9
  import { branchGuidance } from "../core/branch";
10
10
  import { workspaceTrailers } from "../core/workspaces";
11
11
  import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
12
- import { cwdRewrite } from "./cwd-normalize";
12
+ import { cwdRewrite, verbFirst } from "./cwd-normalize";
13
+ import { workitBypassFlag } from "./delivery-verbs";
13
14
  import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
14
15
  import { shellPolicy } from "./policy";
15
16
  import {
@@ -121,7 +122,7 @@ const subagentStartText = (
121
122
  if (role === "implementer")
122
123
  // No host isolation: the lead makes the worktree, Workit's hooks confine
123
124
  // 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.`;
125
+ 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 git --cwd <worktree> commit -m "<message>" -- <paths>\`, \`workit check --cwd <worktree> <name>\` (verb first, so host allow rules match). The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit git --cwd <worktree> 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.`;
125
126
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
126
127
  if (role !== "judge" || !event.agentId) return readOnly;
127
128
  const own = subagentSession(session, event.agentId);
@@ -152,7 +153,8 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
152
153
  if (
153
154
  deps.descriptor.transport !== "hook-process" ||
154
155
  !PRE_TOOL.has(input.event.kind) ||
155
- decision.kind === "deny"
156
+ decision.kind === "deny" ||
157
+ decision.kind === "ask"
156
158
  )
157
159
  return decision;
158
160
  const delta = standingDelta(input);
@@ -223,10 +225,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
223
225
  const rewrites = usable(descriptor.shellPolicy.rewrite);
224
226
  if (plan.kind === "rewrite" && !rewrites && canDeny) return heavyDenial(event.command, plan);
225
227
  const lease = plan.kind === "rewrite" && rewrites ? plan : null;
226
- // `cd <dir> && workit …` becomes `workit --cwd <dir> …` (issue #388). A
227
- // workit command is never heavy itself, so the lease plan wins when set.
228
+ // `cd <dir> && workit …` becomes `workit <verb> --cwd <dir> …` (issue
229
+ // #388), and `workit --cwd <dir> <verb>` puts the verb first (#297), so
230
+ // Workit's enumerated allow rules match. A workit command is never heavy
231
+ // itself, so the lease plan wins when set.
228
232
  const cwdCommand =
229
- !lease && rewrites && input.host === "claude_code" ? cwdRewrite(event.command) : null;
233
+ !lease && rewrites && input.host === "claude_code"
234
+ ? (cwdRewrite(event.command) ?? verbFirst(event.command))
235
+ : null;
230
236
  const aside = plan.kind === "passthrough" ? plan.note : null;
231
237
  // The command will run: note HEAD so the post-tool hook (or the next
232
238
  // command, without one) can tell whether a raw commit moved it.
@@ -235,6 +241,15 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
235
241
  event.command,
236
242
  postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
237
243
  );
244
+ // Claude Code: a Workit bypass flag always reaches the user, whatever
245
+ // the allow rules match (issue #297). Words, not text, so quoting
246
+ // cannot hide it; a deny above still wins. A leased command runs under
247
+ // `workit lock run`, which no Workit rule allows.
248
+ const bypass =
249
+ !lease && input.host === "claude_code"
250
+ ? workitBypassFlag(event.command, event.dialect)
251
+ : null;
252
+ if (bypass) return { kind: "ask", reason: bypass };
238
253
  const rewrittenTo = lease?.command ?? cwdCommand;
239
254
  const rewritten = (text: string | null): HookDecision =>
240
255
  rewrittenTo
@@ -298,6 +298,17 @@ const render = (decision: HookDecision, native: string | null) => {
298
298
  },
299
299
  exitCode: 0,
300
300
  };
301
+ if (decision.kind === "ask" && native === "PreToolUse")
302
+ return {
303
+ json: {
304
+ hookSpecificOutput: {
305
+ hookEventName: "PreToolUse",
306
+ permissionDecision: "ask",
307
+ permissionDecisionReason: decision.reason,
308
+ },
309
+ },
310
+ exitCode: 0,
311
+ };
301
312
  if (decision.kind === "context" && native !== null && CONTEXT_EVENTS.has(native))
302
313
  return {
303
314
  json: { hookSpecificOutput: { hookEventName: native, additionalContext: decision.text } },
@@ -19,6 +19,12 @@ export {
19
19
  type SessionHandle,
20
20
  } from "./context";
21
21
  export { shellPolicy } from "./policy";
22
+ export {
23
+ isAllowedWorkitCall,
24
+ isAllowedWorkitCommand,
25
+ WORKIT_ALLOWED_VERBS,
26
+ WORKIT_BYPASS_FLAGS,
27
+ } from "./delivery-verbs";
22
28
  export { heavyDenial, heavyGate, splitDenial } from "./heavy-gate";
23
29
  export { shellGate, shellWrites, writeGate } from "./write-gate";
24
30
  export { liveOwner } from "../core/live-session";
@@ -97,6 +97,8 @@ export type HookDecision =
97
97
  | { kind: "context"; text: string }
98
98
  /** The reason text always carries its own correction (principle 3). */
99
99
  | { kind: "deny"; reason: string; unblock: string | null }
100
+ /** shell.pre: let the user decide, whatever the host's rules allow. Never an allow. */
101
+ | { kind: "ask"; reason: string }
100
102
  /**
101
103
  * shell.pre: run `command` instead (the heavy-command lease rewrite).
102
104
  * `input` is the host's tool input with the command replaced, where the
@@ -602,8 +602,15 @@ function walk(
602
602
  // (The heavy-command reader, `seen`, stops at workit: a wrapped command
603
603
  // already runs under the lease.)
604
604
  const wrapped = seen ? null : workitCheckTail(words);
605
- if (wrapped)
606
- return "script" in wrapped ? nested(wrapped.script, "posix") : runs([wrapped.argv]);
605
+ if (wrapped) {
606
+ // `--cwd <dir>` is where the wrapped command runs, not the shell's directory.
607
+ const outer = dir;
608
+ if (wrapped.cwd !== null) dir = joinDir(dir, wrapped.cwd);
609
+ if ("script" in wrapped) nested(wrapped.script, "posix");
610
+ else runs([wrapped.argv]);
611
+ dir = outer;
612
+ return;
613
+ }
607
614
  if (CD.has(name)) {
608
615
  const target = cdTarget(words);
609
616
  dir = target === null ? os.homedir() : joinDir(dir, target);
@@ -699,22 +706,25 @@ const PACKAGE_RUNNERS = new Set(["npx", "bunx", "pnpx"]);
699
706
  /**
700
707
  * What a `workit check [flags] -- <cmd…>` (or `workit lock run -- <cmd…>`,
701
708
  * also through `npx @brainervirus/workit-cli`) runs: the script of `--shell`,
702
- * else the argv. Null for any other command.
709
+ * else the argv, and the `--cwd` it runs in (null: the shell's). Null for any
710
+ * other command.
703
711
  */
704
712
  export function workitCheckTail(
705
713
  words: readonly string[],
706
- ): { script: string } | { argv: string[] } | null {
714
+ ): (({ script: string } | { argv: string[] }) & { cwd: string | null }) | null {
707
715
  let at = 0;
708
716
  if (PACKAGE_RUNNERS.has(commandName(words[0]))) {
709
717
  at = words.findIndex((word, index) => index > 0 && !word.startsWith("-"));
710
718
  if (at < 0 || !WORKIT_PACKAGE.test(words[at])) return null;
711
719
  } else if (!/^workit(?:\.exe|\.cmd)?$/.test(commandName(words[0]))) return null;
712
- const args = words.slice(at + 1);
720
+ const { rest: args, cwd } = skipWorkitGlobals(words.slice(at + 1));
713
721
  if (args[0] !== "check" && !(args[0] === "lock" && args[1] === "run")) return null;
714
722
  const split = args.indexOf("--");
715
723
  const tail = split < 0 ? [] : args.slice(split + 1);
716
724
  if (!tail.length) return null;
717
- return args.slice(0, split).includes("--shell") ? { script: tail.join(" ") } : { argv: tail };
725
+ return args.slice(0, split).includes("--shell")
726
+ ? { script: tail.join(" "), cwd }
727
+ : { argv: tail, cwd };
718
728
  }
719
729
 
720
730
  /**
@@ -791,7 +801,12 @@ export function workitInvocations(
791
801
  /** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
792
802
  const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
793
803
 
794
- /** An invocation's arguments without the global options before the verb, and the last `--cwd` among them. */
804
+ /**
805
+ * An invocation's arguments without the global options before the verb and
806
+ * without any `--cwd` before `--`, and the last `--cwd`. Like the CLI's
807
+ * parseGlobals, `--cwd` counts anywhere before `--`, so `workit <verb> --cwd
808
+ * <dir> …` (the form host allow rules match) runs in `<dir>` here too.
809
+ */
795
810
  export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cwd: string | null } {
796
811
  let cwd: string | null = null;
797
812
  let at = 0;
@@ -804,7 +819,18 @@ export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cw
804
819
  at++;
805
820
  } else if (args[at] !== "--json") break;
806
821
  }
807
- return { rest: args.slice(at), cwd };
822
+ const rest: string[] = [];
823
+ for (; at < args.length; at++) {
824
+ const arg = args[at];
825
+ if (arg === "--") {
826
+ rest.push(...args.slice(at));
827
+ break;
828
+ }
829
+ if (arg.startsWith("--cwd=")) cwd = arg.slice("--cwd=".length);
830
+ else if (arg === "--cwd" && args[at + 1] !== undefined) cwd = args[++at];
831
+ else rest.push(arg);
832
+ }
833
+ return { rest, cwd };
808
834
  }
809
835
 
810
836
  /** What one raw invocation means for workit. */
@@ -18,6 +18,12 @@ export type Segment = {
18
18
  pipe?: true;
19
19
  /** Text it reads on standard input: heredoc bodies and here-strings. */
20
20
  stdin?: string[];
21
+ /**
22
+ * Indexes into `words` of the words the shell expands before the command
23
+ * runs: an unquoted `$`, `*`, `?`, `[` or backtick, or a `$(…)`, `<(…)` or
24
+ * double-quoted `$`/backtick. The text of such a word is not what runs.
25
+ */
26
+ expands?: number[];
21
27
  /** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
22
28
  subs?: string[];
23
29
  /** The command could not be analyzed (too deeply nested): why. The only segment then. */
@@ -291,6 +297,20 @@ const substitutions = (body: string): string[] => {
291
297
  const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
292
298
  const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
293
299
 
300
+ /** Does a double-quoted body hold an expansion (`$x`, `$(…)`, a backtick)? */
301
+ const expandsInQuotes = (body: string, dialect: ShellDialect): boolean => {
302
+ const escape = dialect === "posix" ? "\\" : "`";
303
+ for (let index = 0; index < body.length; index++) {
304
+ const char = body[index];
305
+ if (char === escape) index++;
306
+ else if (char === "$" || (dialect === "posix" && char === "`")) return true;
307
+ }
308
+ return false;
309
+ };
310
+
311
+ /** An unquoted glob or variable character in a plain run. */
312
+ const EXPANDING_RUN = /[*?[]/u;
313
+
294
314
  /** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
295
315
  const arithmeticEnd = (command: string, start: number): number => {
296
316
  const end = substitutionEnd(command, start);
@@ -316,6 +336,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
316
336
  let current: Segment = { words: [], redirects: [] };
317
337
  let word = "";
318
338
  let inWord = false;
339
+ let expanding = false;
319
340
  let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
320
341
  const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
321
342
  const escape = posix ? "\\" : "`";
@@ -327,11 +348,16 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
327
348
  } else if (pending === "heredoc")
328
349
  heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
329
350
  else if (pending === "herestring") (current.stdin ??= []).push(text);
330
- else if (pending !== "input")
351
+ else if (pending !== "input") {
352
+ const at = current.words.length;
331
353
  current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
354
+ if (expanding || (!posix && word.startsWith("@")))
355
+ (current.expands ??= []).push(...current.words.slice(at).map((_, i) => at + i));
356
+ }
332
357
  pending = null;
333
358
  word = "";
334
359
  inWord = false;
360
+ expanding = false;
335
361
  };
336
362
  const flushSegment = () => {
337
363
  flushWord();
@@ -347,6 +373,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
347
373
  /** A substitution read apart: its command runs, its text stays in the word. */
348
374
  const substitution = (inner: string, text: string) => {
349
375
  (current.subs ??= []).push(inner);
376
+ expanding = true;
350
377
  word += text;
351
378
  inWord = true;
352
379
  };
@@ -358,6 +385,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
358
385
  if (run) {
359
386
  word += run[0];
360
387
  inWord = true;
388
+ if (EXPANDING_RUN.test(run[0])) expanding = true;
361
389
  index += run[0].length - 1;
362
390
  continue;
363
391
  }
@@ -383,6 +411,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
383
411
  const body = command.slice(index + 1, end < 0 ? undefined : end);
384
412
  if (posix && char === '"' && /\$\(|`/.test(body))
385
413
  (current.subs ??= []).push(...substitutions(body));
414
+ if (char === '"' && expandsInQuotes(body, dialect)) expanding = true;
386
415
  word += unquote(body, char, dialect);
387
416
  inWord = true;
388
417
  index = end < 0 ? command.length : end;
@@ -428,6 +457,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
428
457
  if (char === "(" && inWord && word.endsWith("$")) {
429
458
  word = word.slice(0, -1);
430
459
  inWord = word !== "";
460
+ if (!inWord) expanding = false;
431
461
  }
432
462
  group(char === "(" ? "open" : "close");
433
463
  } else if (!posix && (char === "{" || char === "}")) {
@@ -474,6 +504,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
474
504
  } else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
475
505
  else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
476
506
  else {
507
+ if (char === "$") expanding = true;
477
508
  word += char;
478
509
  inWord = true;
479
510
  }
@@ -182,7 +182,7 @@ const shown = (target: string): string =>
182
182
 
183
183
  const unboundDeny = (input: HookInput, target: string): HookDecision => {
184
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.";
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 git --cwd <worktree> commit -m … -- <paths>`, `workit check --cwd <worktree> <name>`). If your brief names no worktree, stop and report that to the lead.";
186
186
  return {
187
187
  kind: "deny",
188
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}`,
@@ -191,7 +191,7 @@ const unboundDeny = (input: HookInput, target: string): HookDecision => {
191
191
  };
192
192
 
193
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.`;
194
+ const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit git --cwd ${worktree} commit -m … -- <paths>\`, \`workit check --cwd ${worktree} <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
195
  return {
196
196
  kind: "deny",
197
197
  reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
@@ -201,7 +201,7 @@ const outsideDeny = (input: HookInput, worktree: string, target: string): HookDe
201
201
 
202
202
  const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
203
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}\`.`;
204
+ const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit <verb> --cwd ${wt}\`.`;
205
205
  return {
206
206
  kind: "deny",
207
207
  reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
@@ -509,7 +509,7 @@ const segmentTargets = (
509
509
  }
510
510
  const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
511
511
  if (inner !== null && dir !== undefined)
512
- targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
512
+ targets.push(...segmentTargets(workitRunDir(dir, words), inner, "posix", depth + 1, strict));
513
513
  if (strict && dir !== undefined) {
514
514
  const runIn = dir;
515
515
  for (const sub of depth < 4 ? (segment.subs ?? []) : [])
@@ -550,6 +550,15 @@ const segmentTargets = (
550
550
  return targets;
551
551
  };
552
552
 
553
+ /** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
554
+ const workitRunDir = (dir: string, words: string[]): string => {
555
+ for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
556
+ const { at, args } = workitCwd(dir, invocation);
557
+ if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
558
+ }
559
+ return dir;
560
+ };
561
+
553
562
  /** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
554
563
  const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
555
564
  const { rest, cwd } = skipWorkitGlobals(args);