@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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +1 -1
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-implement/SKILL.md +5 -5
- package/skills/workit-review/SKILL.md +5 -0
- package/skills/workit-ship/SKILL.md +1 -2
- package/src/autonomy.ts +22 -11
- package/src/check-config.ts +9 -2
- package/src/core/methods.ts +5 -5
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/workspaces.ts +1 -1
- package/src/fanout-brief.ts +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +65 -14
- package/src/forge/report.ts +4 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +244 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +155 -52
- package/src/stack.ts +68 -16
- package/src/verdict-cover.ts +151 -0
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> <
|
|
2
|
-
// On Claude Code the compound does not match a `Bash(workit
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
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>
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
+
};
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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>
|
|
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
|
|
227
|
-
//
|
|
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"
|
|
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 } },
|
package/src/hooks/index.ts
CHANGED
|
@@ -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";
|
package/src/hooks/protocol.ts
CHANGED
|
@@ -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
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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")
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|