@brainervirus/workit-core 8.23.0 → 8.25.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/src/git/rev.ts CHANGED
@@ -515,6 +515,250 @@ export function resolveRef(cwd: string, ref: string): string | null {
515
515
  return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
516
516
  }
517
517
 
518
+ /** The branch origin's HEAD names (`origin/main`), or null. */
519
+ export function originHeadRef(cwd: string): string | null {
520
+ return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
521
+ }
522
+
523
+ /** The top of the working tree, or null outside one. */
524
+ export function worktreeTop(cwd: string): string | null {
525
+ return line(git(cwd, ["rev-parse", "--show-toplevel"]));
526
+ }
527
+
528
+ /** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
529
+ export function isTracked(top: string, file: string): boolean {
530
+ return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
531
+ }
532
+
533
+ /**
534
+ * The files whose own change differs between two versions of a branch: the
535
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
536
+ * `toFork`), compared by exact blob and mode, never by diff text (#334). A
537
+ * file the branch touches on one side only differs. A file it touches on
538
+ * both sides is unchanged only when the base left it alone between the forks
539
+ * and the branch's resulting blob is identical, so a hunk moved within a
540
+ * file, a re-indent or a base edit in a file the branch touches always
541
+ * counts. Null when git cannot read either side: the caller fails closed.
542
+ */
543
+ export function ownChangeDrift(
544
+ cwd: string,
545
+ range: { from: string; to: string; fromFork: string; toFork: string },
546
+ ): string[] | null {
547
+ const before = diffRaw(cwd, range.fromFork, range.from);
548
+ const after = diffRaw(cwd, range.toFork, range.to);
549
+ if (!before || !after) return null;
550
+ const judged = new Map(before.map((entry) => [entry.path, entry]));
551
+ const current = new Map(after.map((entry) => [entry.path, entry]));
552
+ const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
553
+ a !== undefined &&
554
+ b !== undefined &&
555
+ a.oldMode === b.oldMode &&
556
+ a.oldBlob === b.oldBlob &&
557
+ a.newMode === b.newMode &&
558
+ a.newBlob === b.newBlob;
559
+ return [...new Set([...judged.keys(), ...current.keys()])]
560
+ .filter((file) => !same(judged.get(file), current.get(file)))
561
+ .toSorted();
562
+ }
563
+
564
+ /**
565
+ * Whether the branch's own files are identical at `from` (on `fromBase`) and
566
+ * `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
567
+ * exactness behind every patch-equal carry; false when git cannot tell.
568
+ */
569
+ export function sameOwnChange(
570
+ cwd: string,
571
+ range: { from: string; to: string; fromBase: string; toBase: string },
572
+ ): boolean {
573
+ const fromFork = mergeBase(cwd, range.fromBase, range.from);
574
+ const toFork = mergeBase(cwd, range.toBase, range.to);
575
+ if (!fromFork || !toFork) return false;
576
+ return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
577
+ }
578
+
579
+ /**
580
+ * The verbatim patch-ids of the non-merge commits of `fork..head` (empty
581
+ * commits have none) and how many merge commits the range holds: unlike
582
+ * `--stable`, whitespace counts, so a re-indent is another patch. Diff
583
+ * options are pinned as in patchId. Null when git fails, or has no
584
+ * `patch-id --verbatim`: the caller fails closed.
585
+ */
586
+ export function commitPatchIds(
587
+ cwd: string,
588
+ fork: string,
589
+ head: string,
590
+ ): { patchIds: string[]; merges: number } | null {
591
+ if (!safeArg(fork) || !safeArg(head)) return null;
592
+ const range = `${fork}..${head}`;
593
+ const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
594
+ const log = git(
595
+ cwd,
596
+ [
597
+ "log",
598
+ "--no-merges",
599
+ "-p",
600
+ "-U3",
601
+ "--diff-algorithm=myers",
602
+ "--indent-heuristic",
603
+ "--no-color",
604
+ "--no-ext-diff",
605
+ "--no-textconv",
606
+ "--no-renames",
607
+ "--full-index",
608
+ "--src-prefix=a/",
609
+ "--dst-prefix=b/",
610
+ "--format=commit %H",
611
+ range,
612
+ "--",
613
+ ],
614
+ { timeoutMs: GIT_TIMEOUTS.worktree },
615
+ );
616
+ if (merges === null || !log.ok) return null;
617
+ if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
618
+ const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
619
+ if (!ids.ok) return null;
620
+ return {
621
+ patchIds: ids.stdout
622
+ .split("\n")
623
+ .map((entry) => entry.trim().split(/\s+/u)[0])
624
+ .filter(Boolean),
625
+ merges: Number(merges),
626
+ };
627
+ }
628
+
629
+ /** Pinned diff options for the review-lane reads: user config never changes them. */
630
+ const LANE_DIFF = [
631
+ "-c",
632
+ "core.quotePath=false",
633
+ "diff",
634
+ "--no-color",
635
+ "--no-ext-diff",
636
+ "--no-textconv",
637
+ "--no-renames",
638
+ ];
639
+
640
+ /** `fork..head`, or with `head` null the working tree's tracked changes since `fork`. */
641
+ const laneRange = (fork: string, head: string | null): string[] | null => {
642
+ if (!safeArg(fork) || (head !== null && !safeArg(head))) return null;
643
+ return head === null ? [fork] : [fork, head];
644
+ };
645
+
646
+ export type NumstatEntry = { path: string; added: number; deleted: number; binary: boolean };
647
+
648
+ /** Added and deleted lines per file of `fork..head` (no renames); null when git fails. */
649
+ export function diffNumstat(cwd: string, fork: string, head: string | null): NumstatEntry[] | null {
650
+ const range = laneRange(fork, head);
651
+ if (!range) return null;
652
+ const run = git(cwd, [...LANE_DIFF, "--numstat", "-z", ...range, "--"]);
653
+ if (!run.ok) return null;
654
+ const out: NumstatEntry[] = [];
655
+ for (const record of run.stdout.split("\0")) {
656
+ const match = /^(-|\d+)\t(-|\d+)\t(.+)$/su.exec(record.replace(/^\n/u, ""));
657
+ if (!match) continue;
658
+ const binary = match[1] === "-";
659
+ out.push({
660
+ path: match[3],
661
+ added: binary ? 0 : Number(match[1]),
662
+ deleted: binary ? 0 : Number(match[2]),
663
+ binary,
664
+ });
665
+ }
666
+ return out;
667
+ }
668
+
669
+ /** One file of `git diff --raw`: modes and blob ids on each side (zeros: absent or the working tree). */
670
+ export type RawEntry = {
671
+ path: string;
672
+ oldMode: string;
673
+ newMode: string;
674
+ oldBlob: string;
675
+ newBlob: string;
676
+ };
677
+
678
+ /**
679
+ * The changed files of `fork..head` (or the working tree) with their modes
680
+ * and blob ids, read NUL-separated so no path is ever quoted or split.
681
+ * Null when git fails.
682
+ */
683
+ export function diffRaw(cwd: string, fork: string, head: string | null): RawEntry[] | null {
684
+ const range = laneRange(fork, head);
685
+ if (!range) return null;
686
+ const run = git(cwd, [...LANE_DIFF, "--raw", "-z", "--no-abbrev", ...range, "--"]);
687
+ if (!run.ok) return null;
688
+ const fields = run.stdout.split("\0");
689
+ const out: RawEntry[] = [];
690
+ for (let index = 0; index + 1 < fields.length; index += 2) {
691
+ const meta = /^:(\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) [A-Z]\d*$/u.exec(
692
+ fields[index].replace(/^\n/u, ""),
693
+ );
694
+ if (!meta) return null;
695
+ out.push({
696
+ path: fields[index + 1],
697
+ oldMode: meta[1],
698
+ newMode: meta[2],
699
+ oldBlob: meta[3],
700
+ newBlob: meta[4],
701
+ });
702
+ }
703
+ return out;
704
+ }
705
+
706
+ /** The contents of `ids` (blob ids), by id; null when git fails. Missing objects are left out. */
707
+ export function readBlobs(cwd: string, ids: readonly string[]): Map<string, Buffer> | null {
708
+ const out = new Map<string, Buffer>();
709
+ const wanted = [...new Set(ids)].filter((id) => /^[0-9a-f]{40,64}$/u.test(id));
710
+ if (!wanted.length) return out;
711
+ const run = spawnSync("git", ["cat-file", "--batch"], {
712
+ cwd,
713
+ input: `${wanted.join("\n")}\n`,
714
+ maxBuffer: MAX_BUFFER,
715
+ timeout: GIT_TIMEOUTS.local,
716
+ killSignal: "SIGKILL",
717
+ windowsHide: true,
718
+ });
719
+ if (run.status !== 0 || !run.stdout) return null;
720
+ const bytes = run.stdout;
721
+ let at = 0;
722
+ for (const id of wanted) {
723
+ const end = bytes.indexOf(0x0a, at);
724
+ if (end < 0) return null;
725
+ const header = bytes.subarray(at, end).toString("utf8").split(" ");
726
+ at = end + 1;
727
+ if (header[1] === "missing") continue;
728
+ const size = Number(header[2]);
729
+ if (header[0] !== id || !Number.isSafeInteger(size)) return null;
730
+ out.set(id, bytes.subarray(at, at + size));
731
+ at += size + 1;
732
+ }
733
+ return out;
734
+ }
735
+
736
+ /** Blob ids of the files named `name` anywhere in the tree of `rev`, by path; null when git fails. */
737
+ export function treeFilesNamed(cwd: string, rev: string, name: string): Map<string, string> | null {
738
+ if (!safeArg(rev)) return null;
739
+ const run = git(cwd, ["ls-tree", "-r", "-z", "--full-tree", rev]);
740
+ if (!run.ok) return null;
741
+ const out = new Map<string, string>();
742
+ for (const record of run.stdout.split("\0")) {
743
+ const match = /^\d{6} blob ([0-9a-f]+)\t(.+)$/su.exec(record);
744
+ if (match && (match[2] === name || match[2].endsWith(`/${name}`))) out.set(match[2], match[1]);
745
+ }
746
+ return out;
747
+ }
748
+
749
+ /** The text of `file` (a fixed repo path) at `rev`; null when it is absent or git fails. */
750
+ export function fileAt(cwd: string, rev: string, file: string): string | null {
751
+ if (!safeArg(rev)) return null;
752
+ const run = git(cwd, ["cat-file", "blob", `${rev}:${file}`]);
753
+ return run.ok ? run.stdout : null;
754
+ }
755
+
756
+ /** Untracked, not ignored files of the working tree (repo-relative). */
757
+ export function untrackedFiles(cwd: string): string[] {
758
+ const run = git(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]);
759
+ return run.ok ? run.stdout.split("\0").filter(Boolean) : [];
760
+ }
761
+
518
762
  /**
519
763
  * Commits `head` lacks from `base` (behind) and has on top of it (ahead), and
520
764
  * whether `base` is already an ancestor of `head`. Null when either side
@@ -1,16 +1,32 @@
1
- // `cd <dir> && workit <args>` → `workit --cwd <dir> <args>` (issue #388).
2
- // On Claude Code the compound does not match a `Bash(workit:*)` allow rule
1
+ // `cd <dir> && workit <args>` → `workit <verb> --cwd <dir> <rest>` (issue #388).
2
+ // On Claude Code the compound does not match a `Bash(workit …)` allow rule
3
3
  // (every subcommand must match, and a `cd` outside the working dirs breaks
4
4
  // it), so the auto-mode classifier judged it. The rewrite is exact and
5
5
  // conservative: one literal directory, one workit call, and only read-only
6
6
  // filters after it. Anything else is left alone. Pure string work.
7
- import { segmentsOf } from "./shell-words";
7
+ //
8
+ // The verb goes first (issue #297): Workit's enumerated host rules, such as
9
+ // `Bash(workit git *)`, cannot match a variable directory before the verb.
10
+ // `--cwd` and `--json` mean the same anywhere before `--` (main.ts
11
+ // parseGlobals), so moving them never changes what runs.
12
+ import { WORKIT_ALLOWED_VERBS } from "./delivery-verbs";
13
+ import { segmentsOf, type Segment } from "./shell-words";
8
14
 
9
15
  // A directory spelled as one literal word: bare, '…' or "…" with no expansion.
10
- const COMPOUND =
11
- /^\s*cd\s+([A-Za-z0-9_./@%+:,=-]+|'[^'$`\\\n]*'|"[^"$`\\!\n]*")\s*&&\s*(workit(?:\s[\s\S]*)?)$/u;
16
+ const DIR = String.raw`[A-Za-z0-9_./@%+:,=-]+|'[^'$${"`"}\\\n]*'|"[^"$${"`"}\\!\n]*"`;
17
+ const COMPOUND = new RegExp(String.raw`^\s*cd\s+(${DIR})\s*&&\s*(workit(?:\s[\s\S]*)?)$`, "u");
18
+ // `workit` then `--json` / `--cwd <dir>` globals, then the verb.
19
+ const GLOBALS_FIRST = new RegExp(
20
+ String.raw`^(\s*workit)((?:\s+(?:--json|--cwd(?:=|\s+)(?:${DIR})))+)\s+([a-z][a-z-]*)(?=\s|$)`,
21
+ "u",
22
+ );
23
+ const VERB_FIRST = /^((?:--json\s+)*)([a-z][a-z-]*)(?=\s|$)/u;
24
+ const VERBS = new Set<string>(WORKIT_ALLOWED_VERBS);
12
25
  const FILTERS = new Set(["tail", "head", "grep"]);
13
26
 
27
+ const clean = (s: Segment): boolean =>
28
+ !s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
29
+
14
30
  // A filter that reads only stdin, so moving the cwd cannot change what it
15
31
  // reads: flags and counts for tail/head, one pattern for grep (no -r/-R/-f).
16
32
  const stdinOnly = (words: readonly string[]): boolean => {
@@ -22,8 +38,9 @@ const stdinOnly = (words: readonly string[]): boolean => {
22
38
  };
23
39
 
24
40
  /**
25
- * The command rewritten to `workit --cwd <dir> …`, or null when it is not
26
- * exactly `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
41
+ * The command rewritten to `workit <verb> --cwd <dir> …` (`workit --cwd <dir>
42
+ * …` for a verb no host rule lists), or null when it is not exactly
43
+ * `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
27
44
  */
28
45
  export const cwdRewrite = (command: string): string | null => {
29
46
  const match = COMPOUND.exec(command);
@@ -36,10 +53,27 @@ export const cwdRewrite = (command: string): string | null => {
36
53
  const [head, ...filters] = segments;
37
54
  if (!head || head.words[0] !== "workit") return null;
38
55
  if (head.words.some((w) => w === "--cwd" || w.startsWith("--cwd="))) return null;
39
- const clean = (s: (typeof segments)[number]) =>
40
- !s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
41
56
  if (!segments.every(clean)) return null;
42
57
  if (!segments.slice(0, -1).every((s) => s.pipe)) return null;
43
58
  if (!filters.every((s) => FILTERS.has(s.words[0] ?? "") && stdinOnly(s.words))) return null;
44
- return `workit --cwd ${dir} ${rest.slice("workit".length).trimStart()}`.trimEnd();
59
+ const args = rest.slice("workit".length).trimStart();
60
+ const verb = VERB_FIRST.exec(args);
61
+ if (!verb?.[2] || !VERBS.has(verb[2])) return `workit --cwd ${dir} ${args}`.trimEnd();
62
+ const json = verb[1] ? " --json" : "";
63
+ return `workit ${verb[2]} --cwd ${dir}${json}${args.slice(verb[0].length)}`.trimEnd();
64
+ };
65
+
66
+ /**
67
+ * `workit --cwd <dir> <verb> …` (with any `--json` there) rewritten with the
68
+ * verb first, for a verb the host rules list; null otherwise. Only the words
69
+ * before the first verb move, and only in a command that is that one call
70
+ * (no `;`, `&&`, `|`, newline, substitution or redirect).
71
+ */
72
+ export const verbFirst = (command: string): string | null => {
73
+ const match = GLOBALS_FIRST.exec(command);
74
+ const [whole, lead, globals, verb] = match ?? [];
75
+ if (!whole || !verb || !VERBS.has(verb) || !globals?.includes("--cwd")) return null;
76
+ const segments = segmentsOf(command);
77
+ if (segments.length !== 1 || !segments.every(clean)) return null;
78
+ return `${lead} ${verb}${globals}${command.slice(whole.length)}`;
45
79
  };
@@ -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. */