@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.
- package/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +5 -5
- package/skills/workit-fanout/references/brief.md +17 -11
- package/skills/workit-fanout/references/orchestration.md +6 -6
- package/skills/workit-implement/SKILL.md +7 -5
- package/skills/workit-review/SKILL.md +3 -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 +3 -3
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +513 -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 +23 -15
- package/src/fanout-worktree.ts +20 -6
- package/src/forge/pr-ops.ts +54 -12
- package/src/forge/report.ts +4 -2
- package/src/git/rev.ts +133 -0
- package/src/hooks/handle.ts +29 -8
- package/src/hooks/raw-git.ts +19 -0
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +363 -0
- package/src/hooks/write-gate.ts +189 -35
- package/src/ledger.ts +7 -2
- package/src/stack.ts +54 -12
- package/src/worktree-deps.ts +142 -0
package/src/forge/report.ts
CHANGED
|
@@ -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
|
package/src/hooks/handle.ts
CHANGED
|
@@ -31,7 +31,8 @@ import { seedStanding, standingDelta } from "./standing";
|
|
|
31
31
|
import { trackJudge } from "./session-marker";
|
|
32
32
|
import { stopDecision } from "./stop";
|
|
33
33
|
import { liveOwner } from "../core/live-session";
|
|
34
|
-
import { shellGate, writeGate } from "./write-gate";
|
|
34
|
+
import { liveTargets, shellGate, writeGate } from "./write-gate";
|
|
35
|
+
import { confineImplementer, confineImplementerShell, hostIsolated } from "./worktree-confine";
|
|
35
36
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
36
37
|
|
|
37
38
|
export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
@@ -68,6 +69,12 @@ const workitRole = (
|
|
|
68
69
|
return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
|
|
69
70
|
};
|
|
70
71
|
|
|
72
|
+
/** A tool call made by a Workit implementer subagent (the host names its agent type). */
|
|
73
|
+
const implementerAgent = (input: HookInput, descriptor: HostDescriptor): boolean =>
|
|
74
|
+
Boolean(input.session.agentId) &&
|
|
75
|
+
input.session.agentType !== null &&
|
|
76
|
+
workitRole(descriptor, input.session.agentType) === "implementer";
|
|
77
|
+
|
|
71
78
|
/** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
|
|
72
79
|
const subagentSession = (session: string | null, agentId: string): string =>
|
|
73
80
|
[session, agentId]
|
|
@@ -99,17 +106,22 @@ const subagentStartText = (
|
|
|
99
106
|
const session = input.session.id ?? null;
|
|
100
107
|
const role = workitRole(descriptor, event.agentType);
|
|
101
108
|
const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
|
|
109
|
+
// An implementer is host-isolated only when the host put it in its own
|
|
110
|
+
// worktree (`.claude/worktrees/agent-<id>`); by default the lead makes one.
|
|
102
111
|
const isolated =
|
|
103
112
|
descriptor.subagents.worktreeIsolation === "native" &&
|
|
104
|
-
(role === "implementer"
|
|
113
|
+
(role === "implementer"
|
|
114
|
+
? hostIsolated(input.cwd, event.agentId)
|
|
115
|
+
: CLAUDE_WORKTREE.test(input.cwd));
|
|
105
116
|
const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
|
|
106
117
|
if (role === "implementer" && isolated)
|
|
107
118
|
return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
108
119
|
if (isolated)
|
|
109
120
|
return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
|
|
110
121
|
if (role === "implementer")
|
|
111
|
-
// No host isolation: the lead makes the worktree,
|
|
112
|
-
|
|
122
|
+
// No host isolation: the lead makes the worktree, Workit's hooks confine
|
|
123
|
+
// the implementer to it (worktree-confine.ts), and workit git works there.
|
|
124
|
+
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit --cwd <worktree> git commit -m "<message>" -- <paths>\`, \`workit --cwd <worktree> check <name>\`. The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit --cwd <worktree> git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
113
125
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
114
126
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
115
127
|
const own = subagentSession(session, event.agentId);
|
|
@@ -189,6 +201,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
189
201
|
if (!postTool) settlePendingCommit(input);
|
|
190
202
|
const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
|
|
191
203
|
if (raw.kind === "deny") return raw;
|
|
204
|
+
// An implementer subagent stays in its Workit worktree (#310).
|
|
205
|
+
if (canDeny && implementerAgent(input, descriptor)) {
|
|
206
|
+
const confined = confineImplementerShell(input, event.command, event.dialect);
|
|
207
|
+
if (confined.kind === "deny") return confined;
|
|
208
|
+
}
|
|
192
209
|
// The before-write gate and the live-session guard (#274).
|
|
193
210
|
const gate = usable(descriptor.events["write.pre"].support)
|
|
194
211
|
? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
|
|
@@ -250,10 +267,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
250
267
|
if (usable(descriptor.events["shell.post"].support))
|
|
251
268
|
rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
|
|
252
269
|
return NONE;
|
|
253
|
-
case "write.pre":
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
270
|
+
case "write.pre": {
|
|
271
|
+
if (!usable(descriptor.events["write.pre"].support)) return NONE;
|
|
272
|
+
if (implementerAgent(input, descriptor)) {
|
|
273
|
+
const confined = confineImplementer(input, liveTargets(input.cwd, event.paths));
|
|
274
|
+
if (confined.kind === "deny") return confined;
|
|
275
|
+
}
|
|
276
|
+
return writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport));
|
|
277
|
+
}
|
|
257
278
|
case "subagent.start":
|
|
258
279
|
if (workitRole(descriptor, event.agentType) === "judge")
|
|
259
280
|
trackJudge(input, event.agentId, true);
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -788,6 +788,25 @@ export function workitInvocations(
|
|
|
788
788
|
return found;
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
+
/** Workit's global options before the verb: `--json`, and these (`--name value` or `--name=value`). */
|
|
792
|
+
const WORKIT_GLOBAL = /^--(?:cwd|session|token|as)(=|$)/;
|
|
793
|
+
|
|
794
|
+
/** An invocation's arguments without the global options before the verb, and the last `--cwd` among them. */
|
|
795
|
+
export function skipWorkitGlobals(args: readonly string[]): { rest: string[]; cwd: string | null } {
|
|
796
|
+
let cwd: string | null = null;
|
|
797
|
+
let at = 0;
|
|
798
|
+
for (; at < args.length; at++) {
|
|
799
|
+
const match = WORKIT_GLOBAL.exec(args[at]);
|
|
800
|
+
if (match?.[1] === "=") {
|
|
801
|
+
if (args[at].startsWith("--cwd=")) cwd = args[at].slice(6);
|
|
802
|
+
} else if (match && args[at + 1] !== undefined) {
|
|
803
|
+
if (args[at] === "--cwd") cwd = args[at + 1];
|
|
804
|
+
at++;
|
|
805
|
+
} else if (args[at] !== "--json") break;
|
|
806
|
+
}
|
|
807
|
+
return { rest: args.slice(at), cwd };
|
|
808
|
+
}
|
|
809
|
+
|
|
791
810
|
/** What one raw invocation means for workit. */
|
|
792
811
|
export type RawAction =
|
|
793
812
|
| { kind: "commit"; amend: boolean }
|
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)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 {
|
|
34
|
-
import
|
|
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
|
-
/**
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
260
|
-
*
|
|
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:
|
|
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 =
|
|
276
|
+
const judgment = currentJudgment(cwd);
|
|
272
277
|
const high = judgment?.riskTier === "high";
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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;
|