@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.
@@ -0,0 +1,365 @@
1
+ // Is a changed file cosmetic (#333, review finding mvkcoxgb)? Each side of
2
+ // the file is reduced to its substance by a small lexer that knows the
3
+ // file's strings and comments: comments drop out, whitespace outside strings
4
+ // is normalized, strings stay verbatim. Equal substance on both sides is a
5
+ // cosmetic change. The lexer fails closed: a syntax it does not know keeps
6
+ // its comments, an indentation-sensitive or unknown type keeps all but
7
+ // trailing whitespace, and anything it cannot read (invalid UTF-8, a NUL
8
+ // byte, an unterminated string or comment, a regex across lines) is
9
+ // substantive. Languages it would read wrongly stay unlexed (C-family, Rust,
10
+ // SQL, shell, Ruby, YAML): only trailing whitespace is cosmetic there.
11
+
12
+ type Syntax = {
13
+ /** Line comment openers. */
14
+ line: readonly string[];
15
+ /** Block comment delimiters (not nested). */
16
+ block: readonly (readonly [string, string])[];
17
+ /** String quotes with backslash escapes. */
18
+ quotes: string;
19
+ /** String quotes without escapes (TOML literal strings). */
20
+ literal?: string;
21
+ /** `"""` and `'''` strings. */
22
+ triple?: boolean;
23
+ /** JavaScript template literals with `${}` and regex literals. */
24
+ js?: boolean;
25
+ /** Go raw strings in backticks. */
26
+ rawBacktick?: boolean;
27
+ /** Strings may span lines. */
28
+ multiline?: boolean;
29
+ /** Python: an f-string with an unbalanced `{` holds a nested string this cannot read. */
30
+ fstring?: boolean;
31
+ /** CSS: an unquoted `url(` body is no string or comment this can read. */
32
+ cssUrl?: boolean;
33
+ /** Only trailing whitespace is cosmetic (indentation matters). */
34
+ indented?: boolean;
35
+ };
36
+
37
+ const SLASH_BLOCK = [["/*", "*/"]] as const;
38
+ const C_LIKE: Syntax = { line: ["//"], block: SLASH_BLOCK, quotes: `"'` };
39
+ const SYNTAXES: Record<string, Syntax> = Object.fromEntries([
40
+ // Not jsx or tsx: JSX text (`don't`, `</a>`) is no lexable string or regex.
41
+ ...["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => [ext, { ...C_LIKE, js: true }]),
42
+ ...["java", "kt", "kts", "scala", "swift", "dart", "groovy"].map((ext) => [
43
+ ext,
44
+ { ...C_LIKE, triple: true },
45
+ ]),
46
+ ["go", { ...C_LIKE, rawBacktick: true }],
47
+ ...["css", "scss", "less"].map((ext) => [
48
+ ext,
49
+ { line: [], block: SLASH_BLOCK, quotes: `"'`, cssUrl: true },
50
+ ]),
51
+ ...["py", "pyi"].map((ext) => [
52
+ ext,
53
+ { line: ["#"], block: [], quotes: `"'`, triple: true, indented: true, fstring: true },
54
+ ]),
55
+ ["toml", { line: ["#"], block: [], quotes: `"`, literal: "'", triple: true }],
56
+ ...["html", "htm", "xml", "svg", "xhtml"].map((ext) => [
57
+ ext,
58
+ // Text and <pre> whitespace can matter: only trailing whitespace is cosmetic.
59
+ { line: [], block: [["<!--", "-->"]], quotes: "", indented: true },
60
+ ]),
61
+ ]);
62
+
63
+ /** Comments that steer a compiler, linter, bundler or runtime are code. */
64
+ const DIRECTIVE =
65
+ /@ts-|eslint|prettier|biome|oxlint|istanbul|\bc8\b|\bv8\b|@jsx|@flow|@vite|webpack|__PURE__|__NO_SIDE_EFFECTS__|source(?:Mapping)?URL|^\/\s*<|go:|\+build|nolint|noqa|type:|pyright|mypy|pylint|pragma|coding[:=]|fmt:|NOSONAR|@license|@preserve|@refresh|environment|language=|cython|ruff|isort|deno-|@vitest|@jest|swiftlint|ktlint|checkstyle|suppress|nullable|#(?:end)?region/iu;
66
+
67
+ const extensionOf = (file: string): string => {
68
+ const name = file.slice(file.lastIndexOf("/") + 1);
69
+ const dot = name.lastIndexOf(".");
70
+ return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
71
+ };
72
+
73
+ /** CI and workflow definitions are read as written: their comments are never stripped. */
74
+ const CI_PATH =
75
+ /^\.github\/|(?:^|\/)(?:\.gitlab-ci\.ya?ml|Jenkinsfile|azure-pipelines\.ya?ml|\.pre-commit-config\.ya?ml|\.circleci\/.*)$/u;
76
+
77
+ /** The syntax `file` is lexed with; null keeps comments and all but trailing whitespace. */
78
+ const syntaxOf = (file: string): Syntax | null =>
79
+ CI_PATH.test(file) ? null : (SYNTAXES[extensionOf(file)] ?? null);
80
+
81
+ const SPACE = /[ \t\r\f\v]/u;
82
+ const WORD = /[\w$]/u;
83
+ const NO_SPACE_AFTER = "([{,;";
84
+ const NO_SPACE_BEFORE = ")]},;";
85
+ const REGEX_AFTER = "(,=:[!&|?{};+-*%<>~^";
86
+ const REGEX_KEYWORDS = new Set([
87
+ "return",
88
+ "typeof",
89
+ "instanceof",
90
+ "case",
91
+ "do",
92
+ "else",
93
+ "in",
94
+ "of",
95
+ "new",
96
+ "delete",
97
+ "void",
98
+ "throw",
99
+ "yield",
100
+ "await",
101
+ ]);
102
+
103
+ type Frame = { kind: "code"; braces: number } | { kind: "template" };
104
+
105
+ /**
106
+ * The substance of `text` for `file`: what stays after comments and
107
+ * insignificant whitespace are removed. Null when it cannot be read with
108
+ * certainty (the caller then counts the file as a behavior change).
109
+ */
110
+ export function substanceOf(file: string, text: string): string | null {
111
+ const syntax = syntaxOf(file);
112
+ if (!syntax) return plainSubstance(text);
113
+ const markup = syntax.block.some(([open]) => open === "<!--");
114
+ // Markup with scripts, styles, CDATA or processing instructions holds
115
+ // other languages; a comment opener after anything but a tag end may sit
116
+ // in an attribute value.
117
+ if (
118
+ markup &&
119
+ (/<(?:script|style)\b|<!\[CDATA\[|<\?/iu.test(text) || /[^>\s][ \t]*<!--/u.test(text))
120
+ )
121
+ return plainSubstance(text);
122
+ return lex(syntax, text);
123
+ }
124
+
125
+ /** No lexer: only trailing whitespace and blank lines are cosmetic. */
126
+ function plainSubstance(text: string): string {
127
+ return text
128
+ .split("\n")
129
+ .map((line) => line.replace(/[ \t\r\f\v]+$/u, ""))
130
+ .filter(Boolean)
131
+ .join("\n");
132
+ }
133
+
134
+ function lex(syntax: Syntax, text: string): string | null {
135
+ const collapse = !syntax.indented;
136
+ let out = "";
137
+ let lineStart = 0;
138
+ /** Output before this index is string content: trailing-space trimming stops there. */
139
+ let kept = 0;
140
+ let pendingSpace = false;
141
+ const frames: Frame[] = [{ kind: "code", braces: 0 }];
142
+ let index = 0;
143
+
144
+ // A shebang line is code, read as written.
145
+ if (text.startsWith("#!")) {
146
+ const end = text.indexOf("\n");
147
+ out = end < 0 ? text : text.slice(0, end);
148
+ kept = out.length;
149
+ index = end < 0 ? text.length : end;
150
+ }
151
+
152
+ const emitCode = (chunk: string) => {
153
+ if (collapse && pendingSpace && out.length > lineStart) {
154
+ const before = out[out.length - 1];
155
+ if (!NO_SPACE_AFTER.includes(before) && !NO_SPACE_BEFORE.includes(chunk[0])) out += " ";
156
+ }
157
+ pendingSpace = false;
158
+ out += chunk;
159
+ };
160
+ const emitVerbatim = (chunk: string) => {
161
+ emitCode(chunk);
162
+ kept = out.length;
163
+ };
164
+ const newline = () => {
165
+ if (!collapse) {
166
+ let end = out.length;
167
+ while (end > Math.max(lineStart, kept) && SPACE.test(out[end - 1])) end -= 1;
168
+ out = out.slice(0, end);
169
+ }
170
+ pendingSpace = false;
171
+ if (out.length > lineStart) {
172
+ out += "\n";
173
+ lineStart = out.length;
174
+ }
175
+ };
176
+ /** Can a `/` start a regex here? After an operator or a keyword, not after a value. */
177
+ const regexAllowed = (): boolean => {
178
+ let end = out.length;
179
+ while (end > 0 && /\s/u.test(out[end - 1])) end -= 1;
180
+ if (end === 0 || REGEX_AFTER.includes(out[end - 1])) return true;
181
+ let start = end;
182
+ while (start > 0 && WORD.test(out[start - 1])) start -= 1;
183
+ return REGEX_KEYWORDS.has(out.slice(start, end));
184
+ };
185
+ /** Scan a quoted string from `index`; returns false when it does not close. */
186
+ const scanString = (quote: string, escapes: boolean, multiline: boolean): boolean => {
187
+ const prefix = text.slice(Math.max(0, index - 2), index).match(/[a-z]*$/iu)?.[0] ?? "";
188
+ const fstring = syntax.fstring === true && /f/iu.test(prefix);
189
+ let end = index + quote.length;
190
+ while (end < text.length) {
191
+ const char = text[end];
192
+ if (escapes && char === "\\") {
193
+ end += 2;
194
+ continue;
195
+ }
196
+ if (text.startsWith(quote, end)) {
197
+ if (fstring && unbalanced(text.slice(index + quote.length, end))) return false;
198
+ emitVerbatim(text.slice(index, end + quote.length));
199
+ index = end + quote.length;
200
+ return true;
201
+ }
202
+ if (char === "\n" && !multiline) return false;
203
+ end += 1;
204
+ }
205
+ return false;
206
+ };
207
+
208
+ while (index < text.length) {
209
+ const frame = frames[frames.length - 1];
210
+ const char = text[index];
211
+ if (frame.kind === "template") {
212
+ if (char === "\\") {
213
+ emitVerbatim(text.slice(index, index + 2));
214
+ index += 2;
215
+ } else if (char === "`") {
216
+ emitVerbatim(char);
217
+ frames.pop();
218
+ index += 1;
219
+ } else if (text.startsWith("${", index)) {
220
+ emitVerbatim("${");
221
+ frames.push({ kind: "code", braces: 0 });
222
+ index += 2;
223
+ } else {
224
+ // Template text, newlines included, is the string's value.
225
+ out += char;
226
+ kept = out.length;
227
+ index += 1;
228
+ }
229
+ continue;
230
+ }
231
+ if (char === "\n") {
232
+ newline();
233
+ index += 1;
234
+ continue;
235
+ }
236
+ if (SPACE.test(char)) {
237
+ if (collapse) pendingSpace = true;
238
+ else out += char;
239
+ index += 1;
240
+ continue;
241
+ }
242
+ const lineOpener = syntax.line.find((opener) => text.startsWith(opener, index));
243
+ if (lineOpener) {
244
+ const end = text.indexOf("\n", index);
245
+ const comment = text.slice(index, end < 0 ? text.length : end);
246
+ if (DIRECTIVE.test(comment.slice(lineOpener.length))) emitVerbatim(comment);
247
+ else pendingSpace = true;
248
+ index = end < 0 ? text.length : end;
249
+ continue;
250
+ }
251
+ const block = syntax.block.find(([open]) => text.startsWith(open, index));
252
+ if (block) {
253
+ const end = text.indexOf(block[1], index + block[0].length);
254
+ if (end < 0) return null;
255
+ const comment = text.slice(index, end + block[1].length);
256
+ if (DIRECTIVE.test(comment.slice(block[0].length, -block[1].length))) emitVerbatim(comment);
257
+ else pendingSpace = true;
258
+ // A newline inside the comment still ends the code line before it.
259
+ if (comment.includes("\n")) newline();
260
+ index = end + block[1].length;
261
+ continue;
262
+ }
263
+ if (syntax.triple && (char === '"' || char === "'") && text.startsWith(char.repeat(3), index)) {
264
+ if (!scanString(char.repeat(3), !syntax.literal?.includes(char), true)) return null;
265
+ continue;
266
+ }
267
+ if (syntax.quotes.includes(char)) {
268
+ if (!scanString(char, true, Boolean(syntax.multiline))) return null;
269
+ continue;
270
+ }
271
+ if (syntax.literal?.includes(char)) {
272
+ if (!scanString(char, false, false)) return null;
273
+ continue;
274
+ }
275
+ if (syntax.rawBacktick && char === "`") {
276
+ if (!scanString("`", false, true)) return null;
277
+ continue;
278
+ }
279
+ if (syntax.js && char === "`") {
280
+ emitVerbatim(char);
281
+ frames.push({ kind: "template" });
282
+ index += 1;
283
+ continue;
284
+ }
285
+ if (syntax.cssUrl && /^url\(\s*[^"')\s]/iu.test(text.slice(index, index + 64))) return null;
286
+ if (syntax.js && char === "/" && !regexAllowed() && out.trimEnd().endsWith(")")) {
287
+ // Division after `)` may be a regex after `if (x)`: with another `/` on
288
+ // the line the two readings differ on what is code, so give up.
289
+ const lineEnd = text.indexOf("\n", index);
290
+ if (text.slice(index + 1, lineEnd < 0 ? text.length : lineEnd).includes("/")) return null;
291
+ }
292
+ if (syntax.js && char === "/" && regexAllowed()) {
293
+ let end = index + 1;
294
+ let inClass = false;
295
+ while (end < text.length && (inClass || text[end] !== "/")) {
296
+ if (text[end] === "\n") return null;
297
+ if (text[end] === "\\") end += 1;
298
+ else if (text[end] === "[") inClass = true;
299
+ else if (text[end] === "]") inClass = false;
300
+ end += 1;
301
+ }
302
+ if (end >= text.length) return null;
303
+ emitVerbatim(text.slice(index, end + 1));
304
+ index = end + 1;
305
+ continue;
306
+ }
307
+ if (frames.length > 1 && frame.kind === "code") {
308
+ if (char === "{") frame.braces += 1;
309
+ else if (char === "}") {
310
+ if (frame.braces === 0) {
311
+ emitVerbatim(char);
312
+ frames.pop();
313
+ index += 1;
314
+ continue;
315
+ }
316
+ frame.braces -= 1;
317
+ }
318
+ }
319
+ emitCode(char);
320
+ index += 1;
321
+ }
322
+ if (frames.length !== 1) return null;
323
+ newline();
324
+ return out;
325
+ }
326
+
327
+ /** Does an f-string body hold a `{` that never closes, once `{{` and `}}` are set aside? */
328
+ function unbalanced(body: string): boolean {
329
+ let depth = 0;
330
+ for (const part of body.replaceAll("{{", "").replaceAll("}}", "")) {
331
+ if (part === "{") depth += 1;
332
+ else if (part === "}") depth = Math.max(0, depth - 1);
333
+ }
334
+ return depth > 0;
335
+ }
336
+
337
+ /**
338
+ * A lone CR (not followed by LF), U+2028 or U+2029: some readers end a line
339
+ * there and others do not, so a comment can hide code from one of them.
340
+ */
341
+ export function hasOpaqueLineBreak(text: string): boolean {
342
+ return /\r(?!\n)|[\u2028\u2029]/u.test(text);
343
+ }
344
+
345
+ const decoder = new TextDecoder("utf-8", { fatal: true });
346
+
347
+ /** The UTF-8 text of `bytes`, or null for binary (a NUL byte) or invalid UTF-8. */
348
+ export function textOf(bytes: Uint8Array): string | null {
349
+ if (bytes.includes(0)) return null;
350
+ try {
351
+ return decoder.decode(bytes);
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ /** Is the change from `before` to `after` of `file` cosmetic (equal substance)? */
358
+ export function cosmeticChange(file: string, before: Uint8Array, after: Uint8Array): boolean {
359
+ const left = textOf(before);
360
+ const right = textOf(after);
361
+ if (left === null || right === null) return false;
362
+ if (hasOpaqueLineBreak(left) || hasOpaqueLineBreak(right)) return false;
363
+ const a = substanceOf(file, left);
364
+ return a !== null && a === substanceOf(file, right);
365
+ }
@@ -142,7 +142,7 @@ const workspaceConfigSchema = z
142
142
  */
143
143
  defaultEndpoint: z.unknown().optional(),
144
144
  /** How a normal-risk behavior change is verified (S17); same rules as grants. */
145
- verification: z.enum(["self", "independent"]).optional(),
145
+ verification: z.enum(["proportional", "self", "independent"]).optional(),
146
146
  /**
147
147
  * Who does the work (skills and `fanout plan` read it): `inline-first`
148
148
  * (default) keeps the lead working itself and refuses a slice without a
@@ -13,7 +13,7 @@
13
13
  // workers export nothing (the host derives the id);
14
14
  // Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
15
15
  // `workit fanout worktree create` made the slice's worktree, WORKTREE and the
16
- // RULES name it: every git and check command runs as `workit --cwd <worktree> …`.
16
+ // RULES name it: every git and check command runs as `workit <verb> --cwd <worktree> …`.
17
17
  // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
18
18
  // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
19
19
  // one PR per slice forbids every merge; integration mode has the worker merge
@@ -96,8 +96,9 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
96
96
 
97
97
  /**
98
98
  * RULES. With the slice's worktree made (`worktree`), every git and check
99
- * command names it (`workit --cwd <worktree> …`, one verb per call) and it is
100
- * already on the branch; Workit's hooks keep an implementer inside it (#310).
99
+ * command names it (`workit <verb> --cwd <worktree> …`, verb first so host
100
+ * allow rules match, one verb per call) and it is already on the branch;
101
+ * Workit's hooks keep an implementer inside it (#310).
101
102
  */
102
103
  const rules = (
103
104
  mode: BriefMode,
@@ -105,17 +106,18 @@ const rules = (
105
106
  base: string,
106
107
  worktree: string | null,
107
108
  ): string[] => {
108
- const workit = worktree ? `workit --cwd ${worktree}` : "workit";
109
+ const workit = (verb: string) =>
110
+ worktree ? `workit ${verb} --cwd ${worktree}` : `workit ${verb}`;
109
111
  let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
110
112
  if (worktree)
111
- first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit} git commit …\`, \`${workit} check <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
113
+ first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit("git")} commit …\`, \`${workit("check")} <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
112
114
  else if (mode === "resume")
113
115
  first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
114
116
  return [
115
117
  "RULES:",
116
118
  first,
117
119
  ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
118
- ` 3. Commit with \`${workit} git commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
120
+ ` 3. Commit with \`${workit("git")} commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
119
121
  " 4. Never record a verdict on your own work.",
120
122
  " 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
121
123
  " 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
@@ -480,7 +480,7 @@ export function releaseSliceWorktree(
480
480
  return fanoutFail(
481
481
  "blocked",
482
482
  `this command runs inside ${entry.path}`,
483
- `run it from the main checkout: workit --cwd <main checkout> fanout worktree release ${slice.id}`,
483
+ `run it from the main checkout: workit fanout --cwd <main checkout> worktree release ${slice.id}`,
484
484
  );
485
485
  if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
486
486
  return fanoutFail(
@@ -24,6 +24,7 @@ import {
24
24
  resolveAutonomy,
25
25
  type AutonomySource,
26
26
  type DefaultEndpoint,
27
+ type VerificationMode,
27
28
  } from "../autonomy";
28
29
  import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
29
30
  import { vcsConfig } from "../core/vcs-config";
@@ -34,12 +35,15 @@ import { spawnSync } from "node:child_process";
34
35
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
35
36
  import {
36
37
  appendObserved,
37
- checkVerdicts,
38
38
  tamperedUnblock,
39
39
  readLedger,
40
+ type CarryNote,
40
41
  type LedgerActor,
41
42
  type VerdictCheck,
42
43
  } from "../ledger";
44
+ import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
45
+ import type { LaneNeed, ReviewLane } from "../core/policy/lane";
46
+ import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
43
47
  import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
44
48
  import type { ResolvedForge } from "./resolve";
45
49
  import { failure, success, type ForgeResult, type MergeMethod, type PrRef } from "./types";
@@ -361,7 +365,26 @@ export type MergeOutcome = {
361
365
  head: string;
362
366
  method: MergeMethod;
363
367
  mergeSha: string | null;
364
- verdict: { required: boolean; accepted: boolean; verdictId: string | null };
368
+ /**
369
+ * `required`: the gate asked for a verdict (false under lane none or a
370
+ * bypass); `accepted`: an accepted independent verdict covers the head;
371
+ * `verdictId`: the verdict the merge relied on (the author's own under lane
372
+ * self, shown as self-reviewed); `lane`/`need`: the review lane and what it
373
+ * asked (`need` is non-author outside `verification: proportional`).
374
+ */
375
+ verdict: {
376
+ required: boolean;
377
+ accepted: boolean;
378
+ verdictId: string | null;
379
+ review: "verified" | "self-reviewed" | "none";
380
+ lane: ReviewLane;
381
+ need: LaneNeed;
382
+ verification: VerificationMode;
383
+ /** The stack verdict above this PR that covers it (#334), when it has none of its own. */
384
+ cover?: StackCover | null;
385
+ /** How a verdict on an older head carried to this one over a follow-up change (#334). */
386
+ carry?: CarryNote | null;
387
+ };
365
388
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
366
389
  unverified: { reason: string; recorded: string } | null;
367
390
  grant: { source: AutonomySource };
@@ -644,14 +667,29 @@ export async function mergePullRequest(
644
667
  }
645
668
  const ledger = readLedger(cwd, { integrity: true });
646
669
  if (!ledger.ok) return ledger;
647
- const check = checkVerdicts(cwd, branch, ledger.value.rows);
648
- const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
649
- const covered = check.head === head && check.accepted.accepted;
670
+ // A lower PR of a stack may be covered by the verdict at its top (#334).
671
+ const check = checkVerdictsCovered(cwd, branch, ledger.value.rows);
672
+ const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
673
+ const summary = {
674
+ head: check.head,
675
+ accepted: check.accepted,
676
+ authors: check.authors,
677
+ lane: gate.lane.lane,
678
+ need: gate.need,
679
+ };
680
+ // Lane none needs no verdict, so no verdict head either.
681
+ const covered = gate.satisfied && (gate.need === "none" || check.head === head);
650
682
  let verdict: MergeOutcome["verdict"] = {
651
- required: true,
652
- accepted: covered,
653
- verdictId:
654
- covered && typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
683
+ required: gate.need !== "none",
684
+ accepted: check.head === head && check.accepted.accepted,
685
+ verdictId: covered ? gate.verdictId : null,
686
+ review: covered ? gate.review : "none",
687
+ lane: gate.lane.lane,
688
+ need: gate.need,
689
+ verification: gate.verification,
690
+ ...(covered && gate.review === "verified"
691
+ ? { cover: check.cover, carry: check.cover ? null : check.carry }
692
+ : {}),
655
693
  };
656
694
  let unverified: MergeOutcome["unverified"] = null;
657
695
  // The bypass covers a missing verdict, never a rejection.
@@ -679,9 +717,9 @@ export async function mergePullRequest(
679
717
  "failed",
680
718
  `unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
681
719
  );
682
- verdict = { required: false, accepted: false, verdictId: null };
720
+ verdict = { ...verdict, required: false, accepted: false, verdictId: null, review: "none" };
683
721
  unverified = { reason, recorded: String(recorded.value.id) };
684
- } else if (check.head !== head)
722
+ } else if (!covered && check.head !== head)
685
723
  return refuse(
686
724
  `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
687
725
  `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
@@ -693,12 +731,21 @@ export async function mergePullRequest(
693
731
  tamperedUnblock(cwd),
694
732
  { reason: "ledger_tampered", verdict: summary },
695
733
  );
696
- else if (!covered)
734
+ else if (!covered) {
735
+ const bypassHint = grant.allowUnverified
736
+ ? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"`
737
+ : "";
738
+ const lane = gate.verification === "proportional" ? `, ${laneLabel(gate)}` : "";
697
739
  return refuse(
698
- `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
699
- `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${grant.allowUnverified ? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"` : ""}`,
740
+ gate.need === "self"
741
+ ? `NEEDS_VERDICT: ${label} has no verdict for ${head.slice(0, 12)}${lane}: the author's own --self verdict is enough (${check.accepted.reasons.join(", ")})`
742
+ : `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)}${lane} (${check.accepted.reasons.join(", ")})`,
743
+ gate.need === "self"
744
+ ? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${branch} (or a verifier records one)${bypassHint}`
745
+ : `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${bypassHint}`,
700
746
  { reason: "needs_verdict", verdict: summary },
701
747
  );
748
+ }
702
749
 
703
750
  const squashMessage = squashMessageFor(cwd, resolved, input, doc);
704
751
  if (squashMessage && !squashMessage.ok) return squashMessage;
@@ -714,6 +761,8 @@ export async function mergePullRequest(
714
761
  base: doc.base,
715
762
  method: input.method,
716
763
  verdictId: verdict.verdictId,
764
+ review: verdict.review,
765
+ lane: verdict.lane,
717
766
  ...(unverified ? { unverified: unverified.recorded } : {}),
718
767
  grant: grant.source,
719
768
  });
@@ -747,6 +796,8 @@ export async function mergePullRequest(
747
796
  mergeSha: merged.data.mergeSha,
748
797
  method: input.method,
749
798
  verdictId: verdict.verdictId,
799
+ review: verdict.review,
800
+ lane: verdict.lane,
750
801
  ...(unverified ? { unverified: unverified.recorded } : {}),
751
802
  grant: grant.source,
752
803
  intent: intentId,
@@ -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.
@@ -0,0 +1,49 @@
1
+ // The default base for patch-ids, author trailers and review lanes. Its own
2
+ // module so the lane (core/policy/lane.ts) and the ledger can both read it
3
+ // without importing each other.
4
+ import { vcsConfig } from "../core/vcs-config";
5
+ import { originHeadRef, resolveRef } from "./rev";
6
+
7
+ const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
8
+
9
+ /**
10
+ * The PR target of the release track the checkout is on, when the workspace
11
+ * configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
12
+ * plain workspace default does not override origin's default branch.
13
+ */
14
+ const trackTrunk = (cwd: string): string | null => {
15
+ try {
16
+ const resolved = vcsConfig("resolve", cwd);
17
+ if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
18
+ return null;
19
+ const target = String(resolved.defaultTargetBranch ?? "").trim();
20
+ return target && safeRef(target) ? target : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+
26
+ /**
27
+ * The default base for patch-ids and author trailers: the release track's PR
28
+ * target (origin's copy, else the local one) when tracks are configured, else
29
+ * origin's HEAD branch, else the first of origin/main, origin/master, main,
30
+ * master, origin/develop, develop that exists.
31
+ */
32
+ export function defaultBase(cwd: string): string | null {
33
+ const trunk = trackTrunk(cwd);
34
+ if (trunk)
35
+ for (const candidate of [`origin/${trunk}`, trunk])
36
+ if (resolveRef(cwd, candidate)) return candidate;
37
+ const originHead = originHeadRef(cwd);
38
+ if (originHead && resolveRef(cwd, originHead)) return originHead;
39
+ for (const candidate of [
40
+ "origin/main",
41
+ "origin/master",
42
+ "main",
43
+ "master",
44
+ "origin/develop",
45
+ "develop",
46
+ ])
47
+ if (resolveRef(cwd, candidate)) return candidate;
48
+ return null;
49
+ }