@brainervirus/workit-core 8.22.3 → 8.24.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.
@@ -428,7 +428,7 @@ export function buildStatusDoc(
428
428
 
429
429
  /**
430
430
  * Record what the forge said about a PR as a CLI-observed `pr.status` row
431
- * (pr, branch, head, state: open|merged|closed, checks), only when it
431
+ * (pr, branch, head, base, state: open|merged|closed, checks), only when it
432
432
  * differs from the newest row for that PR, so polling never floods the
433
433
  * ledger. Hooks read it without calling the forge: an open PR's checks
434
434
  * (passing|failing|pending; one without gating checks is not recorded), and
@@ -455,7 +455,8 @@ export function recordPrStatus(cwd: string, doc: PrStatusDoc, actor: LedgerActor
455
455
  last &&
456
456
  last.head === doc.head.sha &&
457
457
  last.checks === checks &&
458
- (last.state ?? "open") === state
458
+ (last.state ?? "open") === state &&
459
+ (last.base ?? null) === (doc.base ?? null)
459
460
  )
460
461
  return;
461
462
  appendObserved(cwd, {
@@ -468,6 +469,7 @@ export function recordPrStatus(cwd: string, doc: PrStatusDoc, actor: LedgerActor
468
469
  forge: doc.forge,
469
470
  state,
470
471
  checks,
472
+ base: doc.base,
471
473
  });
472
474
  } catch {
473
475
  // The row is an observation for hooks; the verb's answer stands without it.
package/src/git/rev.ts CHANGED
@@ -515,6 +515,139 @@ 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
+ /** Pinned diff options for the review-lane reads: user config never changes them. */
519
+ const LANE_DIFF = [
520
+ "-c",
521
+ "core.quotePath=false",
522
+ "diff",
523
+ "--no-color",
524
+ "--no-ext-diff",
525
+ "--no-textconv",
526
+ "--no-renames",
527
+ ];
528
+
529
+ /** `fork..head`, or with `head` null the working tree's tracked changes since `fork`. */
530
+ const laneRange = (fork: string, head: string | null): string[] | null => {
531
+ if (!safeArg(fork) || (head !== null && !safeArg(head))) return null;
532
+ return head === null ? [fork] : [fork, head];
533
+ };
534
+
535
+ export type NumstatEntry = { path: string; added: number; deleted: number; binary: boolean };
536
+
537
+ /** Added and deleted lines per file of `fork..head` (no renames); null when git fails. */
538
+ export function diffNumstat(cwd: string, fork: string, head: string | null): NumstatEntry[] | null {
539
+ const range = laneRange(fork, head);
540
+ if (!range) return null;
541
+ const run = git(cwd, [...LANE_DIFF, "--numstat", "-z", ...range, "--"]);
542
+ if (!run.ok) return null;
543
+ const out: NumstatEntry[] = [];
544
+ for (const record of run.stdout.split("\0")) {
545
+ const match = /^(-|\d+)\t(-|\d+)\t(.+)$/su.exec(record.replace(/^\n/u, ""));
546
+ if (!match) continue;
547
+ const binary = match[1] === "-";
548
+ out.push({
549
+ path: match[3],
550
+ added: binary ? 0 : Number(match[1]),
551
+ deleted: binary ? 0 : Number(match[2]),
552
+ binary,
553
+ });
554
+ }
555
+ return out;
556
+ }
557
+
558
+ /** One file of `git diff --raw`: modes and blob ids on each side (zeros: absent or the working tree). */
559
+ export type RawEntry = {
560
+ path: string;
561
+ oldMode: string;
562
+ newMode: string;
563
+ oldBlob: string;
564
+ newBlob: string;
565
+ };
566
+
567
+ /**
568
+ * The changed files of `fork..head` (or the working tree) with their modes
569
+ * and blob ids, read NUL-separated so no path is ever quoted or split.
570
+ * Null when git fails.
571
+ */
572
+ export function diffRaw(cwd: string, fork: string, head: string | null): RawEntry[] | null {
573
+ const range = laneRange(fork, head);
574
+ if (!range) return null;
575
+ const run = git(cwd, [...LANE_DIFF, "--raw", "-z", "--no-abbrev", ...range, "--"]);
576
+ if (!run.ok) return null;
577
+ const fields = run.stdout.split("\0");
578
+ const out: RawEntry[] = [];
579
+ for (let index = 0; index + 1 < fields.length; index += 2) {
580
+ const meta = /^:(\d{6}) (\d{6}) ([0-9a-f]+) ([0-9a-f]+) [A-Z]\d*$/u.exec(
581
+ fields[index].replace(/^\n/u, ""),
582
+ );
583
+ if (!meta) return null;
584
+ out.push({
585
+ path: fields[index + 1],
586
+ oldMode: meta[1],
587
+ newMode: meta[2],
588
+ oldBlob: meta[3],
589
+ newBlob: meta[4],
590
+ });
591
+ }
592
+ return out;
593
+ }
594
+
595
+ /** The contents of `ids` (blob ids), by id; null when git fails. Missing objects are left out. */
596
+ export function readBlobs(cwd: string, ids: readonly string[]): Map<string, Buffer> | null {
597
+ const out = new Map<string, Buffer>();
598
+ const wanted = [...new Set(ids)].filter((id) => /^[0-9a-f]{40,64}$/u.test(id));
599
+ if (!wanted.length) return out;
600
+ const run = spawnSync("git", ["cat-file", "--batch"], {
601
+ cwd,
602
+ input: `${wanted.join("\n")}\n`,
603
+ maxBuffer: MAX_BUFFER,
604
+ timeout: GIT_TIMEOUTS.local,
605
+ killSignal: "SIGKILL",
606
+ windowsHide: true,
607
+ });
608
+ if (run.status !== 0 || !run.stdout) return null;
609
+ const bytes = run.stdout;
610
+ let at = 0;
611
+ for (const id of wanted) {
612
+ const end = bytes.indexOf(0x0a, at);
613
+ if (end < 0) return null;
614
+ const header = bytes.subarray(at, end).toString("utf8").split(" ");
615
+ at = end + 1;
616
+ if (header[1] === "missing") continue;
617
+ const size = Number(header[2]);
618
+ if (header[0] !== id || !Number.isSafeInteger(size)) return null;
619
+ out.set(id, bytes.subarray(at, at + size));
620
+ at += size + 1;
621
+ }
622
+ return out;
623
+ }
624
+
625
+ /** Blob ids of the files named `name` anywhere in the tree of `rev`, by path; null when git fails. */
626
+ export function treeFilesNamed(cwd: string, rev: string, name: string): Map<string, string> | null {
627
+ if (!safeArg(rev)) return null;
628
+ const run = git(cwd, ["ls-tree", "-r", "-z", "--full-tree", rev]);
629
+ if (!run.ok) return null;
630
+ const out = new Map<string, string>();
631
+ for (const record of run.stdout.split("\0")) {
632
+ const match = /^\d{6} blob ([0-9a-f]+)\t(.+)$/su.exec(record);
633
+ if (match && (match[2] === name || match[2].endsWith(`/${name}`))) out.set(match[2], match[1]);
634
+ }
635
+ return out;
636
+ }
637
+
638
+ /** The text of `file` (a fixed repo path) at `rev`; null when it is absent or git fails. */
639
+ export function fileAt(cwd: string, rev: string, file: string): string | null {
640
+ if (!safeArg(rev)) return null;
641
+ const run = git(cwd, ["cat-file", "blob", `${rev}:${file}`]);
642
+ return run.ok ? run.stdout : null;
643
+ }
644
+
645
+ /** Untracked, not ignored files of the working tree (repo-relative). */
646
+ export function untrackedFiles(cwd: string): string[] {
647
+ const run = git(cwd, ["ls-files", "-z", "--others", "--exclude-standard"]);
648
+ return run.ok ? run.stdout.split("\0").filter(Boolean) : [];
649
+ }
650
+
518
651
  /**
519
652
  * Commits `head` lacks from `base` (behind) and has on top of it (ahead), and
520
653
  * whether `base` is already an ancestor of `head`. Null when either side
@@ -31,7 +31,8 @@ import { seedStanding, standingDelta } from "./standing";
31
31
  import { trackJudge } from "./session-marker";
32
32
  import { stopDecision } from "./stop";
33
33
  import { liveOwner } from "../core/live-session";
34
- import { shellGate, writeGate } from "./write-gate";
34
+ import { liveTargets, shellGate, writeGate } from "./write-gate";
35
+ import { confineImplementer, confineImplementerShell, hostIsolated } from "./worktree-confine";
35
36
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
36
37
 
37
38
  export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
@@ -68,6 +69,12 @@ const workitRole = (
68
69
  return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
69
70
  };
70
71
 
72
+ /** A tool call made by a Workit implementer subagent (the host names its agent type). */
73
+ const implementerAgent = (input: HookInput, descriptor: HostDescriptor): boolean =>
74
+ Boolean(input.session.agentId) &&
75
+ input.session.agentType !== null &&
76
+ workitRole(descriptor, input.session.agentType) === "implementer";
77
+
71
78
  /** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
72
79
  const subagentSession = (session: string | null, agentId: string): string =>
73
80
  [session, agentId]
@@ -99,17 +106,22 @@ const subagentStartText = (
99
106
  const session = input.session.id ?? null;
100
107
  const role = workitRole(descriptor, event.agentType);
101
108
  const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
109
+ // An implementer is host-isolated only when the host put it in its own
110
+ // worktree (`.claude/worktrees/agent-<id>`); by default the lead makes one.
102
111
  const isolated =
103
112
  descriptor.subagents.worktreeIsolation === "native" &&
104
- (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
113
+ (role === "implementer"
114
+ ? hostIsolated(input.cwd, event.agentId)
115
+ : CLAUDE_WORKTREE.test(input.cwd));
105
116
  const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
106
117
  if (role === "implementer" && isolated)
107
118
  return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
108
119
  if (isolated)
109
120
  return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
110
121
  if (role === "implementer")
111
- // No host isolation: the lead makes the worktree, and workit git works there.
112
- return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
122
+ // No host isolation: the lead makes the worktree, Workit's hooks confine
123
+ // the implementer to it (worktree-confine.ts), and workit git works there.
124
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit --cwd <worktree> git commit -m "<message>" -- <paths>\`, \`workit --cwd <worktree> check <name>\`. The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit --cwd <worktree> git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
113
125
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
114
126
  if (role !== "judge" || !event.agentId) return readOnly;
115
127
  const own = subagentSession(session, event.agentId);
@@ -189,6 +201,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
189
201
  if (!postTool) settlePendingCommit(input);
190
202
  const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
191
203
  if (raw.kind === "deny") return raw;
204
+ // An implementer subagent stays in its Workit worktree (#310).
205
+ if (canDeny && implementerAgent(input, descriptor)) {
206
+ const confined = confineImplementerShell(input, event.command, event.dialect);
207
+ if (confined.kind === "deny") return confined;
208
+ }
192
209
  // The before-write gate and the live-session guard (#274).
193
210
  const gate = usable(descriptor.events["write.pre"].support)
194
211
  ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
@@ -250,10 +267,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
250
267
  if (usable(descriptor.events["shell.post"].support))
251
268
  rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
252
269
  return NONE;
253
- case "write.pre":
254
- return usable(descriptor.events["write.pre"].support)
255
- ? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
256
- : NONE;
270
+ case "write.pre": {
271
+ if (!usable(descriptor.events["write.pre"].support)) return NONE;
272
+ if (implementerAgent(input, descriptor)) {
273
+ const confined = confineImplementer(input, liveTargets(input.cwd, event.paths));
274
+ if (confined.kind === "deny") return confined;
275
+ }
276
+ return writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport));
277
+ }
257
278
  case "subagent.start":
258
279
  if (workitRole(descriptor, event.agentType) === "judge")
259
280
  trackJudge(input, event.agentId, true);
@@ -788,6 +788,25 @@ export function workitInvocations(
788
788
  return found;
789
789
  }
790
790
 
791
+ /** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
792
+ const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
793
+
794
+ /** An invocation's arguments without the global options before the verb, and the last `--cwd` among them. */
795
+ export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cwd: string | null } {
796
+ let cwd: string | null = null;
797
+ let at = 0;
798
+ for (; at < args.length; at++) {
799
+ const match = WORKIT_GLOBAL.exec(args[at]);
800
+ if (match?.[1] === "=") {
801
+ if (args[at].startsWith("--cwd=")) cwd = args[at].slice(6);
802
+ } else if (match && args[at + 1] !== undefined) {
803
+ if (args[at] === "--cwd") cwd = args[at + 1];
804
+ at++;
805
+ } else if (args[at] !== "--json") break;
806
+ }
807
+ return { rest: args.slice(at), cwd };
808
+ }
809
+
791
810
  /** What one raw invocation means for workit. */
792
811
  export type RawAction =
793
812
  | { kind: "commit"; amend: boolean }
package/src/hooks/stop.ts CHANGED
@@ -11,11 +11,15 @@
11
11
  // or merged;
12
12
  // (c) verdict: the session opened the PR (or verified delivery) but the
13
13
  // head lacks the verdict the policy asks for, while the endpoint is
14
- // green or merged (a PR opens without one): the author's own `--self`
15
- // verdict at normal risk under `verification: self`, an accepted
16
- // non-author verdict at high risk or under `verification: independent`.
17
- // Not raised when the task's latest judgment records no behavior
18
- // change at normal risk (policy/derive.ts asks for none either), nor while a verifier or reviewer this lead started still runs.
14
+ // green or merged (a PR opens without one). Under `verification:
15
+ // proportional` (the default) the review lane of the head decides
16
+ // (core/policy/lane.ts): none owes nothing, self the author's own
17
+ // `--self` verdict, focused and full an accepted non-author verdict.
18
+ // Under `self`: the author's own at normal risk; at high risk or under
19
+ // `independent`: a non-author one, and not raised when the task's
20
+ // latest judgment records no behavior change at normal risk
21
+ // (policy/derive.ts asks for none either). Never raised while a
22
+ // verifier or reviewer this lead started still runs.
19
23
  // Each obligation blocks once per head and session (a marker keyed by kind
20
24
  // and head), not once per turn.
21
25
  // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
@@ -29,10 +33,9 @@
29
33
  // the ledger and local git only (no forge call), and every failure allows
30
34
  // the stop.
31
35
  import { spawnSync } from "node:child_process";
32
- import { resolveAutonomy } from "../autonomy";
33
- import { latestJudgment } from "../core/policy/derive";
34
- import type { Judgment } from "../core/task-contract";
35
- import { TaskStore } from "../core/task-store";
36
+ import { resolveAutonomy, type VerificationMode } from "../autonomy";
37
+ import { currentJudgment } from "../core/policy/current-judgment";
38
+ import { branchLane, laneNeed } from "../core/policy/lane";
36
39
  import { currentBranch, pushRemoteName } from "../git/rev";
37
40
  import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
38
41
  import type { HostDescriptor } from "./descriptor";
@@ -237,43 +240,57 @@ const redChecks = (
237
240
  };
238
241
  };
239
242
 
240
- /** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. */
241
- const taskJudgment = (cwd: string): Judgment | null => {
242
- try {
243
- const store = new TaskStore(cwd);
244
- const listed = store.listTaskIndex();
245
- const key = store.currentKey();
246
- if (!listed.ok || !key.ok) return null;
247
- const entry = listed.data
248
- .filter((item) => item.key === key.data.key && item.status !== "closed")
249
- .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
250
- const task = entry ? store.readTask(entry.id) : null;
251
- return task?.ok === true ? latestJudgment(task.data) : null;
252
- } catch {
253
- return null;
254
- }
243
+ /**
244
+ * The base the branch's PR targets: the newest the forge reported (a
245
+ * `pr.status` row, so a PR retargeted on the forge is read against its
246
+ * current base), else the one it was opened with (`pr.created`), else null
247
+ * (the default base). Read from the ledger: the hook never calls the forge.
248
+ */
249
+ const prBase = (rows: readonly ReadRow[]): string | null => {
250
+ const seen = rows.findLast(
251
+ (row) =>
252
+ (row.type === "pr.status" || row.type === "pr.created") &&
253
+ row.observer === "workit_cli" &&
254
+ typeof row.base === "string",
255
+ );
256
+ return typeof seen?.base === "string" ? seen.base : null;
255
257
  };
256
258
 
257
259
  /**
258
260
  * (c) Delivered by this session, yet the head lacks the verdict the policy
259
- * asks for: the author's own at normal risk when the workspace's
260
- * verification is `self`, else an accepted non-author one.
261
+ * asks for. Under `verification: proportional` the review lane of the head
262
+ * decides (core/policy/lane.ts): none owes nothing, self the author's own,
263
+ * focused and full a non-author one. Under `self`, the author's own at
264
+ * normal risk; under `independent` or at high risk, a non-author one.
261
265
  */
262
266
  const unverified = (
263
267
  cwd: string,
264
268
  branch: string,
265
269
  all: readonly ReadRow[],
266
270
  descriptor: HostDescriptor,
267
- verification: "self" | "independent",
271
+ verification: VerificationMode,
272
+ head: string | null,
268
273
  ): StopObligation | null => {
269
274
  const check = checkVerdicts(cwd, branch, all);
270
275
  if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
271
- const judgment = taskJudgment(cwd);
276
+ const judgment = currentJudgment(cwd);
272
277
  const high = judgment?.riskTier === "high";
273
- // Like policy/derive.ts: a verdict is asked for a behavior change or high
274
- // risk. A recorded judgment with neither (docs only) owes none.
275
- if (judgment && !judgment.behaviorChange && !high) return null;
276
- const independent = verification === "independent" || high;
278
+ let independent: boolean;
279
+ if (verification === "proportional") {
280
+ const lane = branchLane(cwd, {
281
+ base: prBase(all.filter((row) => row.branch === branch)),
282
+ head,
283
+ judgment,
284
+ });
285
+ const need = laneNeed(lane.lane);
286
+ if (need === "none") return null;
287
+ independent = need === "non-author";
288
+ } else {
289
+ // Like policy/derive.ts: a verdict is asked for a behavior change or high
290
+ // risk. A recorded judgment with neither (docs only) owes none.
291
+ if (judgment && !judgment.behaviorChange && !high) return null;
292
+ independent = verification === "independent" || high;
293
+ }
277
294
  if (!independent && check.review === "self-reviewed") return null;
278
295
  const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
279
296
  const verifier = `${descriptor.subagents.agentPrefix}verifier`;
@@ -332,7 +349,7 @@ export function stopObligation(
332
349
  }
333
350
  // A PR opens without a verdict: only green and merged need one.
334
351
  if (!delivered || (endpoint !== "green" && endpoint !== "merged")) return null;
335
- const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification);
352
+ const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification, head);
336
353
  return left ? { ...left, head } : null;
337
354
  } catch {
338
355
  return null;