@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.
@@ -0,0 +1,513 @@
1
+ // Review lanes (#333, ledger decisions mvi7mj28 and mvk5q3io): how much
2
+ // review a change needs, computed from its diff. Under the workspace setting
3
+ // `verification: proportional` (the default) every verdict gate reads the
4
+ // lane: `pr merge`, `stack land`, the Stop hook and `policy assess`.
5
+ //
6
+ // none docs, comment or formatting-only, or generated copies: no verdict
7
+ // self a small, low-risk behavior change: the author's `--self` verdict
8
+ // focused a normal behavior change, or any lockfile or binary change: an
9
+ // accepted non-author verdict
10
+ // full a risk path, a large diff or a high-risk judgment: an accepted
11
+ // non-author verdict (a high-risk task also asks `--kind live`)
12
+ //
13
+ // The lane fails closed (review finding mvkcoxgb): any doubt about a file
14
+ // makes it a behavior change, and a file on a risk path is never below full,
15
+ // whatever else it is (cosmetic, generated, docs or a lockfile). Generated
16
+ // copies and repo risk paths are read from the base revision only, so a
17
+ // change can neither mark its own code generated nor drop a risk path.
18
+ //
19
+ // The diff sets the floor; a recorded high-risk judgment raises it to full.
20
+ // A behavior-change judgment does not move it: the diff already tells docs
21
+ // from behavior (Markdown that steers agents counts as behavior).
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import {
25
+ diffNumstat,
26
+ diffRaw,
27
+ fileAt,
28
+ mergeBase,
29
+ readBlobs,
30
+ resolveRef,
31
+ treeFilesNamed,
32
+ untrackedFiles,
33
+ } from "../../git/rev";
34
+ import type { VerificationMode } from "../../autonomy";
35
+ import { git } from "../../git/ops";
36
+ import { defaultBase, type VerdictCheck } from "../../ledger";
37
+ import { generatedByAttributes } from "./attributes";
38
+ import { cosmeticChange, hasOpaqueLineBreak } from "./substance";
39
+
40
+ export const REVIEW_LANES = ["none", "self", "focused", "full"] as const;
41
+ export type ReviewLane = (typeof REVIEW_LANES)[number];
42
+
43
+ /**
44
+ * Every lane threshold, in one place. Lines are added plus deleted lines of
45
+ * files that change behavior (code, tests, agent instructions); docs,
46
+ * cosmetic edits, generated copies and lockfiles never count.
47
+ */
48
+ export const LANE_THRESHOLDS = {
49
+ /** Above this a change is not small: self moves to focused (decision mvi7mj28). */
50
+ selfMaxLines: 100,
51
+ /** More behavior files than this is not small either: self moves to focused. */
52
+ selfMaxFiles: 5,
53
+ /** At or above this a diff is large: full. */
54
+ fullMinLines: 500,
55
+ } as const;
56
+
57
+ /**
58
+ * Path tokens that mark a risk path: merge and release gates, hooks, auth,
59
+ * secrets, migrations, CI. A token matches a whole directory name or a
60
+ * `-`/`_`/`.`-separated part of one (`auth`, `oauth-client`, `pre_commit`).
61
+ * Tests and docs never match a token.
62
+ */
63
+ export const RISK_TOKENS: ReadonlySet<string> = new Set([
64
+ "auth",
65
+ "authn",
66
+ "authz",
67
+ "oauth",
68
+ "security",
69
+ "permission",
70
+ "permissions",
71
+ "secret",
72
+ "secrets",
73
+ "crypto",
74
+ "gate",
75
+ "gates",
76
+ "policy",
77
+ "policies",
78
+ "hook",
79
+ "hooks",
80
+ "husky",
81
+ "lefthook",
82
+ "release",
83
+ "releases",
84
+ "migration",
85
+ "migrations",
86
+ "migrate",
87
+ "workflows",
88
+ ]);
89
+
90
+ /** Exact repo paths (CI definitions, the lane's own inputs) that are risk paths. */
91
+ const RISK_FILES = new Set([
92
+ ".gitlab-ci.yml",
93
+ "Jenkinsfile",
94
+ "azure-pipelines.yml",
95
+ ".pre-commit-config.yaml",
96
+ ".workit/risk-paths",
97
+ ]);
98
+ /** File names that are risk paths in any directory: they decide what is generated. */
99
+ const RISK_NAMES = new Set([".gitattributes"]);
100
+
101
+ /** The repo's own risk paths, one glob per line at the base revision (decision B3: they only tighten). */
102
+ export const RISK_PATHS_FILE = ".workit/risk-paths";
103
+
104
+ const LOCKFILES = new Set([
105
+ "bun.lock",
106
+ "bun.lockb",
107
+ "package-lock.json",
108
+ "npm-shrinkwrap.json",
109
+ "yarn.lock",
110
+ "pnpm-lock.yaml",
111
+ "Cargo.lock",
112
+ "go.sum",
113
+ "poetry.lock",
114
+ "uv.lock",
115
+ "Pipfile.lock",
116
+ "Gemfile.lock",
117
+ "composer.lock",
118
+ ]);
119
+
120
+ /** Docs are told by extension only: code under `docs/` is code. */
121
+ const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc|txt)$/iu;
122
+ const DOC_NAMES = /^(?:license|licence|notice|authors|changelog|changes|contributors)$/iu;
123
+ /** Markdown that steers agents is behavior, not docs. */
124
+ const INSTRUCTIONS =
125
+ /(?:^|\/)(?:AGENTS|CLAUDE|GEMINI|SKILL)\.md$|(?:^|\/)(?:skills|agents|commands|rules|prompts)\/[^/]+/iu;
126
+ const TEST_PATH =
127
+ /(?:^|\/)(?:test|tests|__tests__|spec|specs|fixtures)\/|\.(?:test|spec)\.[^/]+$/iu;
128
+
129
+ export type ChangedFile = {
130
+ path: string;
131
+ added: number;
132
+ deleted: number;
133
+ binary?: boolean;
134
+ /** Only whitespace, blank-line or comment changes. */
135
+ cosmetic?: boolean;
136
+ /** Marked `linguist-generated` by the base revision's `.gitattributes`. */
137
+ generated?: boolean;
138
+ };
139
+
140
+ export type LaneInput = {
141
+ files: readonly ChangedFile[];
142
+ /** The task judgment's riskTier, when one is recorded (high raises to full). */
143
+ riskTier?: "trivial" | "normal" | "high" | null;
144
+ /** Extra risk-path globs (`**`, `*`, `?`); they only tighten. */
145
+ riskPaths?: readonly string[];
146
+ };
147
+
148
+ export type LaneAssessment = {
149
+ lane: ReviewLane;
150
+ /** Why, most decisive first. */
151
+ reasons: string[];
152
+ /** Counted lines (see LANE_THRESHOLDS). */
153
+ lines: number;
154
+ /** Files that change behavior. */
155
+ files: number;
156
+ riskFiles: string[];
157
+ };
158
+
159
+ type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
160
+
161
+ const basename = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
162
+
163
+ /** Prose for people: a doc extension (or a bare LICENSE-style name), not agent instructions. */
164
+ const isDoc = (file: string): boolean =>
165
+ !INSTRUCTIONS.test(file) && (DOC_EXT.test(file) || DOC_NAMES.test(basename(file)));
166
+
167
+ /** What a file off risk paths is; risk paths are decided before (see computeLane). */
168
+ function kindOf(file: ChangedFile): FileKind {
169
+ if (file.binary) return "behavior";
170
+ if (file.generated) return "generated";
171
+ if (LOCKFILES.has(basename(file.path))) return "lockfile";
172
+ if (isDoc(file.path)) return "doc";
173
+ if (file.cosmetic) return "cosmetic";
174
+ return TEST_PATH.test(file.path) ? "test" : "behavior";
175
+ }
176
+
177
+ const globCache = new Map<string, RegExp>();
178
+ /** `**` spans directories, `*` and `?` stay within one. */
179
+ function globMatch(pattern: string, file: string): boolean {
180
+ let regex = globCache.get(pattern);
181
+ if (!regex) {
182
+ let source = "";
183
+ for (let index = 0; index < pattern.length; index += 1) {
184
+ const char = pattern[index];
185
+ if (char === "*" && pattern[index + 1] === "*") {
186
+ source += pattern[index + 2] === "/" ? "(?:[^/]*/)*" : ".*";
187
+ index += pattern[index + 2] === "/" ? 2 : 1;
188
+ } else if (char === "*") source += "[^/]*";
189
+ else if (char === "?") source += "[^/]";
190
+ else source += char.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
191
+ }
192
+ regex = new RegExp(`^${source}$`, "u");
193
+ globCache.set(pattern, regex);
194
+ }
195
+ return regex.test(file) || file.startsWith(`${pattern.replace(/\/$/u, "")}/`);
196
+ }
197
+
198
+ /**
199
+ * A risk path: a configured glob (any file, tests and docs included), a CI
200
+ * definition, a `.gitattributes`, `.workit/risk-paths`, or a path with a
201
+ * risk token (never a test or a doc).
202
+ */
203
+ export function isRiskPath(file: string, extra: readonly string[] = []): boolean {
204
+ if (extra.some((pattern) => globMatch(pattern, file))) return true;
205
+ if (RISK_FILES.has(file) || RISK_NAMES.has(basename(file))) return true;
206
+ if (TEST_PATH.test(file) || isDoc(file)) return false;
207
+ return file
208
+ .split("/")
209
+ .map((segment, index, all) =>
210
+ index === all.length - 1 ? segment.replace(/\.[^.]+$/u, "") : segment,
211
+ )
212
+ .some((segment) =>
213
+ segment
214
+ .toLowerCase()
215
+ .split(/[-_.]/u)
216
+ .some((token) => RISK_TOKENS.has(token)),
217
+ );
218
+ }
219
+
220
+ /** The globs of a risk-paths file: one per line, `#` comments and blank lines skipped. */
221
+ export const parseRiskPaths = (text: string): string[] =>
222
+ text
223
+ .split("\n")
224
+ .map((line) => line.trim())
225
+ .filter((line) => line && !line.startsWith("#"))
226
+ .map((line) => line.replace(/^\//u, ""));
227
+
228
+ /** The review lane for a set of changed files (pure; thresholds in LANE_THRESHOLDS). */
229
+ export function computeLane(input: LaneInput): LaneAssessment {
230
+ // A risk path is decided first and overrides every other kind.
231
+ const riskFiles = input.files
232
+ .filter((file) => isRiskPath(file.path, input.riskPaths))
233
+ .map((file) => file.path);
234
+ const risky = new Set(riskFiles);
235
+ const kinds = input.files.map((file) => ({
236
+ file,
237
+ kind: risky.has(file.path) ? ("behavior" as const) : kindOf(file),
238
+ }));
239
+ const behavior = kinds.filter(
240
+ (entry) => entry.kind === "behavior" || entry.kind === "test" || entry.kind === "lockfile",
241
+ );
242
+ const lines = behavior
243
+ .filter((entry) => entry.kind !== "lockfile")
244
+ .reduce((sum, entry) => sum + entry.file.added + entry.file.deleted, 0);
245
+ const opaque = behavior.filter((entry) => entry.kind === "lockfile" || entry.file.binary);
246
+ const result = (lane: ReviewLane, reasons: string[]): LaneAssessment => ({
247
+ lane,
248
+ reasons,
249
+ lines,
250
+ files: behavior.length,
251
+ riskFiles,
252
+ });
253
+ const { selfMaxLines, selfMaxFiles, fullMinLines } = LANE_THRESHOLDS;
254
+ if (input.riskTier === "high") return result("full", ["the task judgment records high risk"]);
255
+ if (riskFiles.length)
256
+ return result("full", [
257
+ `touches a risk path: ${riskFiles.slice(0, 3).join(", ")}${riskFiles.length > 3 ? ", …" : ""}`,
258
+ ]);
259
+ if (!behavior.length)
260
+ return result("none", [
261
+ input.files.length
262
+ ? "only docs, comments, formatting or generated copies changed"
263
+ : "no changes",
264
+ ]);
265
+ if (lines >= fullMinLines)
266
+ return result("full", [`large diff: ${lines} lines (≥ ${fullMinLines})`]);
267
+ if (lines > selfMaxLines)
268
+ return result("focused", [`${lines} lines changed (> ${selfMaxLines})`]);
269
+ if (behavior.length > selfMaxFiles)
270
+ return result("focused", [`${behavior.length} behavior files changed (> ${selfMaxFiles})`]);
271
+ // A lockfile or a binary has no lines to read: the author cannot vouch for it alone.
272
+ if (opaque.length)
273
+ return result("focused", [
274
+ `a lockfile or binary changed: ${opaque
275
+ .slice(0, 3)
276
+ .map((entry) => entry.file.path)
277
+ .join(", ")}${opaque.length > 3 ? ", …" : ""}`,
278
+ ]);
279
+ return result("self", [
280
+ `small change: ${lines} line${lines === 1 ? "" : "s"} in ${behavior.length} file${behavior.length === 1 ? "" : "s"}, no risk path`,
281
+ ]);
282
+ }
283
+
284
+ /** Lines of an untracked file; binary (or unreadable) when it holds a NUL byte. */
285
+ function untrackedStat(cwd: string, file: string): ChangedFile {
286
+ try {
287
+ const bytes = readFileSync(path.join(cwd, file));
288
+ if (bytes.includes(0)) return { path: file, added: 0, deleted: 0, binary: true };
289
+ const text = bytes.toString("utf8");
290
+ if (hasOpaqueLineBreak(text)) return { path: file, added: 0, deleted: 0, binary: true };
291
+ const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
292
+ return { path: file, added: lines, deleted: 0 };
293
+ } catch {
294
+ return { path: file, added: 0, deleted: 0, binary: true };
295
+ }
296
+ }
297
+
298
+ const REGULAR = new Set(["100644", "100755"]);
299
+ const ABSENT = /^0+$/u;
300
+
301
+ /**
302
+ * The files of `fork..head` (or the working tree) whose change is cosmetic:
303
+ * a regular file on both sides, same mode, and equal substance (comments
304
+ * and insignificant whitespace aside; see substance.ts). A file git lists
305
+ * but this cannot read is not cosmetic.
306
+ */
307
+ function cosmeticFiles(
308
+ cwd: string,
309
+ fork: string,
310
+ head: string | null,
311
+ ): { cosmetic: Set<string>; opaque: Set<string> } | null {
312
+ const raw = diffRaw(cwd, fork, head);
313
+ if (!raw) return null;
314
+ const candidates = raw.filter(
315
+ (entry) =>
316
+ REGULAR.has(entry.oldMode) &&
317
+ entry.oldMode === entry.newMode &&
318
+ !ABSENT.test(entry.oldBlob) &&
319
+ (head === null || !ABSENT.test(entry.newBlob)),
320
+ );
321
+ const blobs = readBlobs(
322
+ cwd,
323
+ candidates.flatMap((entry) => [entry.oldBlob, entry.newBlob]),
324
+ );
325
+ if (!blobs) return null;
326
+ const out = new Set<string>();
327
+ const opaque = new Set<string>();
328
+ for (const entry of candidates) {
329
+ const before = blobs.get(entry.oldBlob);
330
+ let after = blobs.get(entry.newBlob);
331
+ if (!after && head === null && ABSENT.test(entry.newBlob))
332
+ try {
333
+ after = readFileSync(path.join(cwd, entry.path));
334
+ } catch {
335
+ after = undefined;
336
+ }
337
+ if (before && after && cosmeticChange(entry.path, before, after)) out.add(entry.path);
338
+ // A lone CR, U+2028 or U+2029 ends a line for some readers and not for
339
+ // others: such a file is read as opaque, like a binary.
340
+ else if ([before, after].some((bytes) => bytes && hasOpaqueLineBreak(bytes.toString("utf8"))))
341
+ opaque.add(entry.path);
342
+ }
343
+ return { cosmetic: out, opaque };
344
+ }
345
+
346
+ /** Of `paths`, those the `.gitattributes` files at `fork` mark generated (none when unreadable). */
347
+ function generatedAt(cwd: string, fork: string, paths: readonly string[]): Set<string> {
348
+ const files = treeFilesNamed(cwd, fork, ".gitattributes");
349
+ const blobs = files?.size ? readBlobs(cwd, [...files.values()]) : null;
350
+ if (!files || !blobs) return new Set();
351
+ const texts = new Map<string, string>();
352
+ for (const [file, id] of files) {
353
+ const bytes = blobs.get(id);
354
+ if (bytes) texts.set(file, bytes.toString("utf8"));
355
+ }
356
+ return generatedByAttributes(texts, paths);
357
+ }
358
+
359
+ /** The fork point of `head` (or HEAD for the working tree) from `base`. */
360
+ const forkOf = (cwd: string, base: string, head: string | null): string | null =>
361
+ mergeBase(cwd, base, head ?? "HEAD");
362
+
363
+ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[] | null {
364
+ const stats = diffNumstat(cwd, fork, head);
365
+ const cosmetic = stats ? cosmeticFiles(cwd, fork, head) : null;
366
+ if (!stats || !cosmetic) return null;
367
+ const files: ChangedFile[] = [
368
+ ...stats.map((entry) => ({
369
+ ...entry,
370
+ ...(cosmetic.opaque.has(entry.path) ? { binary: true } : {}),
371
+ cosmetic: !entry.binary && cosmetic.cosmetic.has(entry.path),
372
+ })),
373
+ ...(head === null ? untrackedFiles(cwd).map((file) => untrackedStat(cwd, file)) : []),
374
+ ];
375
+ const generated = generatedAt(
376
+ cwd,
377
+ fork,
378
+ files.map((entry) => entry.path),
379
+ );
380
+ return files.map((entry) => ({
381
+ ...entry,
382
+ generated: generated.has(entry.path),
383
+ }));
384
+ }
385
+
386
+ /**
387
+ * The changed files of `merge-base(base, head)..head`, or with `head` null
388
+ * of the working tree (tracked changes plus untracked files) since
389
+ * `merge-base(base, HEAD)`. Null when git cannot read them.
390
+ */
391
+ export function changedFiles(cwd: string, base: string, head: string | null): ChangedFile[] | null {
392
+ const fork = forkOf(cwd, base, head);
393
+ return fork ? laneFiles(cwd, fork, head) : null;
394
+ }
395
+
396
+ export type BranchLane = LaneAssessment & {
397
+ base: string | null;
398
+ head: string | null;
399
+ };
400
+
401
+ /**
402
+ * The lane of `head` against `base` (a branch name; `origin/<base>` is
403
+ * preferred, then the local branch, then the repository default). The repo's
404
+ * risk paths come from `.workit/risk-paths` at the fork point. When the diff
405
+ * cannot be read the lane is focused (an independent verdict, as before
406
+ * lanes), or full under a high-risk judgment.
407
+ */
408
+ export function branchLane(
409
+ cwd: string,
410
+ options: {
411
+ base?: string | null;
412
+ /** The commit to assess; null with `workingTree` reads the working tree instead. */
413
+ head: string | null;
414
+ workingTree?: boolean;
415
+ judgment?: { riskTier: "trivial" | "normal" | "high" } | null;
416
+ riskPaths?: readonly string[];
417
+ },
418
+ ): BranchLane {
419
+ const candidates = options.base ? [`origin/${options.base}`, options.base] : [];
420
+ const base = candidates.find((ref) => resolveRef(cwd, ref) !== null) ?? defaultBase(cwd) ?? null;
421
+ const head = options.workingTree ? null : options.head;
422
+ const fork = base && (options.workingTree || options.head) ? forkOf(cwd, base, head) : null;
423
+ const files = fork ? laneFiles(cwd, fork, head) : null;
424
+ const riskTier = options.judgment?.riskTier ?? null;
425
+ if (!fork || !files) {
426
+ const high = riskTier === "high";
427
+ return {
428
+ lane: high ? "full" : "focused",
429
+ reasons: [
430
+ `the diff of ${options.head?.slice(0, 12) ?? "(no head)"} against ${base ?? "(no base)"} cannot be read`,
431
+ ...(high ? ["the task judgment records high risk"] : []),
432
+ ],
433
+ lines: 0,
434
+ files: 0,
435
+ riskFiles: [],
436
+ base,
437
+ head: options.head,
438
+ };
439
+ }
440
+ const repoRisk = parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? "");
441
+ return {
442
+ ...computeLane({
443
+ files,
444
+ riskTier,
445
+ riskPaths: [...repoRisk, ...(options.riskPaths ?? [])],
446
+ }),
447
+ base,
448
+ head: options.head,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * A warning when `.workit/risk-paths` sits in the working tree but is not
454
+ * tracked: the lane reads it from the base revision only, so it applies once
455
+ * it is committed there. Null when there is nothing to warn about.
456
+ */
457
+ export function untrackedRiskPathsWarning(cwd: string): string | null {
458
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
459
+ const root = top.ok ? top.stdout.trim() : "";
460
+ if (!root || !existsSync(path.join(root, RISK_PATHS_FILE))) return null;
461
+ const tracked = git(root, ["ls-files", "--error-unmatch", "--", RISK_PATHS_FILE]);
462
+ return tracked.ok
463
+ ? null
464
+ : `${RISK_PATHS_FILE} is not committed: it does not apply until it is committed on the base branch`;
465
+ }
466
+
467
+ /** What a verdict gate asks for in a lane under `verification: proportional`. */
468
+ export type LaneNeed = "none" | "self" | "non-author";
469
+ export const laneNeed = (lane: ReviewLane): LaneNeed =>
470
+ lane === "none" ? "none" : lane === "self" ? "self" : "non-author";
471
+
472
+ export type LaneGate = {
473
+ /** What the gate asks: nothing, the author's own `--self` verdict, or a non-author one. */
474
+ need: LaneNeed;
475
+ satisfied: boolean;
476
+ /** The verdict that satisfies it (null under lane none, or when unmet). */
477
+ verdictId: string | null;
478
+ review: "verified" | "self-reviewed" | "none";
479
+ };
480
+
481
+ /**
482
+ * One verdict gate for `pr merge`, `stack land` and the Stop hook. Under
483
+ * `verification: proportional` with a lane, the lane decides; under `self`
484
+ * or `independent` (or without a lane) an accepted non-author verdict is
485
+ * needed, as before lanes. A current independent failure or a tampered
486
+ * ledger always blocks. The caller still checks the verdict's head.
487
+ */
488
+ export function laneGate(
489
+ check: Pick<VerdictCheck, "accepted" | "selfVerdict">,
490
+ verification: VerificationMode,
491
+ lane: Pick<LaneAssessment, "lane"> | null,
492
+ ): LaneGate {
493
+ const need: LaneNeed =
494
+ verification === "proportional" && lane ? laneNeed(lane.lane) : "non-author";
495
+ const blocked = check.accepted.reasons.some(
496
+ (reason) => reason === "failing_verdict" || reason === "ledger_tampered",
497
+ );
498
+ if (check.accepted.accepted && check.accepted.verdict)
499
+ return {
500
+ need,
501
+ satisfied: true,
502
+ verdictId: String(check.accepted.verdict.id),
503
+ review: "verified",
504
+ };
505
+ if (need === "self" && check.selfVerdict && !blocked)
506
+ return {
507
+ need,
508
+ satisfied: true,
509
+ verdictId: String(check.selfVerdict.id),
510
+ review: "self-reviewed",
511
+ };
512
+ return { need, satisfied: need === "none" && !blocked, verdictId: null, review: "none" };
513
+ }
@@ -0,0 +1,32 @@
1
+ // The verdict gate of one PR head under the workspace's verification mode:
2
+ // what `pr merge`, `stack land` and `pr status` read (#333).
3
+ import { resolveAutonomy, type VerificationMode } from "../../autonomy";
4
+ import { currentBranch } from "../../git/rev";
5
+ import type { VerdictCheck } from "../../ledger";
6
+ import { currentJudgment } from "./current-judgment";
7
+ import { branchLane, laneGate, type BranchLane, type LaneGate } from "./lane";
8
+
9
+ export type PrLaneGate = LaneGate & { verification: VerificationMode; lane: BranchLane };
10
+
11
+ /**
12
+ * The review lane of `head` against the PR's `base` and what the gate asks
13
+ * for it. The checked-out task's judgment raises the lane only when `branch`
14
+ * is checked out here (it describes that work, not another branch's).
15
+ */
16
+ export function prLaneGate(
17
+ cwd: string,
18
+ check: Pick<VerdictCheck, "accepted" | "selfVerdict">,
19
+ pr: { branch: string; base: string; head: string },
20
+ ): PrLaneGate {
21
+ const verification = resolveAutonomy(cwd).verification;
22
+ const lane = branchLane(cwd, {
23
+ base: pr.base,
24
+ head: pr.head,
25
+ judgment: currentBranch(cwd) === pr.branch ? currentJudgment(cwd) : null,
26
+ });
27
+ return { ...laneGate(check, verification, lane), verification, lane };
28
+ }
29
+
30
+ /** One line naming the lane and why, for refusals and status. */
31
+ export const laneLabel = (gate: Pick<PrLaneGate, "lane">): string =>
32
+ `lane ${gate.lane.lane} (${gate.lane.reasons.join("; ")})`;