@brainervirus/workit-core 8.24.0 → 8.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.24.0",
3
+ "version": "8.25.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -40,7 +40,7 @@ units of ~100+ lines each. Coupled work stays with one owner. Workit never spawn
40
40
  chain resumes. A fix round and recheck that both fail: a decider, not a
41
41
  third round; product choices go to the user (`references/orchestration.md`).
42
42
  7. **Verify by the workspace `verification` setting** (`workit grant show`).
43
- A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused (cheap model: Claude Code `model: sonnet`) and full a verifier after the last push. `self`: a verifier that wrote none
43
+ A report is a pointer, never evidence. `proportional` (default): each slice's lane (`workit pr status`) decides; none needs no verdict, self the author's `--self`, focused (cheap model: Claude Code `model: sonnet`) and full a verifier after the last push; one verdict at a stack's top covers its unchanged lower PRs, and a stale one with `reverify: focused` re-checks only the new diff, reusing green CI. `self`: a verifier that wrote none
44
44
  of the slices, or you if you wrote none. `independent` or high risk: a
45
45
  separate verifier session, never yours (doctrine: the ledger only refuses
46
46
  authors). One verifier may take a batch of slices, one verdict per branch
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
25
25
  each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
26
26
  anything out of scope as a follow-up, not a diff.
27
27
  STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
28
- WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit --cwd <it> …` there>
28
+ WORKTREE: <the path `workit fanout worktree create <slice>` made; the worker runs `workit <verb> --cwd <it> …` there>
29
29
  <identity line, per the lead's host: see "Identity and git rules per host" below>
30
30
  ```
31
31
 
@@ -114,7 +114,7 @@ lead's host (`WORKIT_HOST`, or the host's own session variable):
114
114
 
115
115
  Once `workit fanout worktree create <slice>` made the worktree, the brief's
116
116
  WORKTREE line names it and RULES 1 and 3 run against it on every host:
117
- `workit --cwd <worktree> git commit …`, `workit --cwd <worktree> check <name>`,
117
+ `workit git --cwd <worktree> commit …`, `workit check --cwd <worktree> <name>`,
118
118
  one verb per call. The worktree is already on the branch (MODE new or resume),
119
119
  so no branch command comes first. Without one, `MODE: resume` is
120
120
  `git switch <branch>` (the branch exists), then continue from its head. A race
@@ -124,13 +124,13 @@ attempt (`--attempt <n>`) follows its host's row with the branch
124
124
  ## Worker rules (`workit fanout brief` appends them as RULES)
125
125
 
126
126
  1. Work only in WORKTREE, already on the branch, one verb per call against it
127
- (`workit --cwd <worktree> …`); Workit's hooks deny an implementer's change
127
+ (`workit <verb> --cwd <worktree> …`); Workit's hooks deny an implementer's change
128
128
  outside it. With no worktree made yet: create the branch (`MODE: new`) or
129
129
  `git switch` to it (`MODE: resume`), as the table above says.
130
130
  2. Decide ambiguities yourself and record them:
131
131
  `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
132
132
  Stop only for an irreversible action, a security-sensitive one, or a side
133
133
  effect outside the worktree.
134
- 3. Commit with `workit --cwd <worktree> git commit -m "<msg>" -- <paths in SCOPE>`;
134
+ 3. Commit with `workit git --cwd <worktree> commit -m "<msg>" -- <paths in SCOPE>`;
135
135
  push only if the brief says so.
136
136
  4. Never record a verdict on your own work.
@@ -16,6 +16,8 @@ independent; `type-check-only` never proves a behavior change.
16
16
  Depth follows the review lane in `workit pr status`: focused reads the diff and
17
17
  runs its tests; full also drives the real surface (verify-<app>). Review once
18
18
  per PR after its last push; a second pass on an unchanged head is waste.
19
+ A stale verdict showing `reverify: focused (<files>)` in `workit pr status`: re-check only
20
+ those files since the judged head and reuse green CI there (no fresh install, no full suite).
19
21
 
20
22
  1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
21
23
  for a PR, `workit pr status --json` (checks, unresolved threads).
@@ -51,7 +51,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
51
51
  allows; no sleep loop. Red: read `logTail`, classify. Flake or infra: one
52
52
  `workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
53
53
  with `workit check`, fix the root cause, batch every fix in one push.
54
- 6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code `model: sonnet` when focused. Land only when
54
+ 6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code `model: sonnet` when focused. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
55
55
  granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
56
56
  7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
57
57
  verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
@@ -125,7 +125,7 @@ Autonomy contract:
125
125
  CLI first: if a step has one right answer, use the \`workit\` verb instead of
126
126
  hand-running git, gh or glab (\`workit git|pr|ci|check|help\`;
127
127
  else \`npx -y @brainervirus/workit-cli\`).
128
- Other checkout: \`workit --cwd <dir> <verb>\` (Codex: \`<verb> --cwd <dir>\`); one workit
128
+ Other checkout: \`workit <verb> --cwd <dir> …\` (verb first: host rules match it); one workit
129
129
  verb per shell call, never \`cd <dir> && workit\`. zsh-safe: no \`echo =====\`
130
130
  (\`printf '%s\\n' ---\`), quote globs and URLs, no variable named \`status\` or \`path\`.
131
131
  Add Co-Authored-By via \`workit git commit --trailer "K: v" -- <paths>\`, never
@@ -137,7 +137,7 @@ authorizes an effect. Judge work once with
137
137
  \`workit policy assess --judge risk= behavior= product-choice= plan=\`.
138
138
  Across repositories, bind items to checkout, branch and endpoint, resolve
139
139
  competing targets before a mutation, and reconcile an uncertain
140
- external effect before a retry.
140
+ effect before a retry.
141
141
 
142
142
  Mid-task: never silently drop or resume an objective (workit-continue).
143
143
 
@@ -0,0 +1,74 @@
1
+ // Verdict carry-over across a follow-up change (#334, decision mvk5q3io): a
2
+ // verdict judged head H; the branch is now at H'. The branch's own change
3
+ // since H (what came from the base left out, see lane.ts driftFiles) is
4
+ // classified with the review lane, and the verdict still applies when:
5
+ //
6
+ // lane-none that change is lane `none`: docs, comment or formatting-only
7
+ // edits, generated copies (by the base's .gitattributes), or
8
+ // nothing at all (an empty commit);
9
+ // tests-only the verdict is kind `unit` and the change is lane `self`
10
+ // (small) with every behavior file a test: test files off risk
11
+ // paths, no lockfile, no binary.
12
+ //
13
+ // Everything else is stale, failing closed: a behavior or risk-path change,
14
+ // an opaque file, an unreadable diff, a judged head git no longer has. A
15
+ // stale verdict whose change is lane `self` (small, no risk path, nothing
16
+ // opaque) asks only a focused re-verify of the changed files.
17
+ import { mergeBase, fileAt } from "../../git/rev";
18
+ import { computeLane, driftFiles, kindOf, parseRiskPaths, RISK_PATHS_FILE } from "./lane";
19
+
20
+ export type CarryRule = "lane-none" | "tests-only";
21
+
22
+ export type Drift =
23
+ | { carries: true; rule: CarryRule; since: string; files: string[] }
24
+ | {
25
+ carries: false;
26
+ since: string;
27
+ /** The changed files, or null when they cannot be read. */
28
+ files: string[] | null;
29
+ /** A focused re-verify of `files` is enough (lane self). */
30
+ focused: boolean;
31
+ reason: string;
32
+ };
33
+
34
+ const TEST_ONLY_KINDS: ReadonlySet<string> = new Set(["doc", "cosmetic", "generated", "test"]);
35
+
36
+ /**
37
+ * Whether a `kind` verdict at `from` (keyed against `fromBase`, a commit)
38
+ * still applies at `to` (against `toBase`).
39
+ */
40
+ export function verdictDrift(
41
+ cwd: string,
42
+ input: { from: string; to: string; fromBase: string; toBase: string; kind: string },
43
+ ): Drift {
44
+ const since = input.from;
45
+ const stale = (reason: string, files: string[] | null = null, focused = false): Drift => ({
46
+ carries: false,
47
+ since,
48
+ files,
49
+ focused,
50
+ reason,
51
+ });
52
+ const fromFork = mergeBase(cwd, input.fromBase, input.from);
53
+ const toFork = mergeBase(cwd, input.toBase, input.to);
54
+ const files =
55
+ fromFork && toFork
56
+ ? driftFiles(cwd, { from: input.from, to: input.to, fromFork, toFork })
57
+ : null;
58
+ if (!fromFork || !toFork || !files)
59
+ return stale(`the change since ${since.slice(0, 12)} cannot be read`);
60
+ // Repo risk paths from both base revisions: they only tighten.
61
+ const riskPaths = [fromFork, toFork].flatMap((fork) =>
62
+ parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? ""),
63
+ );
64
+ const lane = computeLane({ files, riskPaths });
65
+ const paths = files.map((file) => file.path);
66
+ if (lane.lane === "none") return { carries: true, rule: "lane-none", since, files: paths };
67
+ if (
68
+ input.kind === "unit" &&
69
+ lane.lane === "self" &&
70
+ files.every((file) => TEST_ONLY_KINDS.has(kindOf(file)))
71
+ )
72
+ return { carries: true, rule: "tests-only", since, files: paths };
73
+ return stale(lane.reasons[0] ?? `lane ${lane.lane}`, paths, lane.lane === "self");
74
+ }
@@ -25,15 +25,18 @@ import {
25
25
  diffNumstat,
26
26
  diffRaw,
27
27
  fileAt,
28
+ isTracked,
28
29
  mergeBase,
30
+ ownChangeDrift,
29
31
  readBlobs,
30
32
  resolveRef,
31
33
  treeFilesNamed,
32
34
  untrackedFiles,
35
+ worktreeTop,
33
36
  } from "../../git/rev";
34
37
  import type { VerificationMode } from "../../autonomy";
35
- import { git } from "../../git/ops";
36
- import { defaultBase, type VerdictCheck } from "../../ledger";
38
+ import { defaultBase } from "../../git/default-base";
39
+ import type { VerdictCheck } from "../../ledger";
37
40
  import { generatedByAttributes } from "./attributes";
38
41
  import { cosmeticChange, hasOpaqueLineBreak } from "./substance";
39
42
 
@@ -117,14 +120,21 @@ const LOCKFILES = new Set([
117
120
  "composer.lock",
118
121
  ]);
119
122
 
120
- /** Docs are told by extension only: code under `docs/` is code. */
121
- const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc|txt)$/iu;
123
+ /**
124
+ * Docs are told by prose extension only: code under `docs/` is code. `.txt`
125
+ * is not a doc: requirements.txt, CMakeLists.txt or robots.txt change behavior.
126
+ */
127
+ const DOC_EXT = /\.(?:md|mdx|markdown|rst|adoc)$/iu;
122
128
  const DOC_NAMES = /^(?:license|licence|notice|authors|changelog|changes|contributors)$/iu;
123
129
  /** Markdown that steers agents is behavior, not docs. */
124
130
  const INSTRUCTIONS =
125
131
  /(?:^|\/)(?: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;
132
+ /**
133
+ * A test file: under a `test/`, `tests/` or `__tests__/` folder, or named
134
+ * `*.test.*`, `*.spec.*` or `*_test.*`. A `fixtures/` or `spec/` folder can
135
+ * hold runtime data or code, so it is not a test path (it reads as behavior).
136
+ */
137
+ const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__)\/|\.(?:test|spec)\.[^/]+$|_test\.[^/]+$/iu;
128
138
 
129
139
  export type ChangedFile = {
130
140
  path: string;
@@ -156,7 +166,7 @@ export type LaneAssessment = {
156
166
  riskFiles: string[];
157
167
  };
158
168
 
159
- type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
169
+ export type FileKind = "doc" | "cosmetic" | "generated" | "lockfile" | "test" | "behavior";
160
170
 
161
171
  const basename = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
162
172
 
@@ -165,7 +175,7 @@ const isDoc = (file: string): boolean =>
165
175
  !INSTRUCTIONS.test(file) && (DOC_EXT.test(file) || DOC_NAMES.test(basename(file)));
166
176
 
167
177
  /** What a file off risk paths is; risk paths are decided before (see computeLane). */
168
- function kindOf(file: ChangedFile): FileKind {
178
+ export function kindOf(file: ChangedFile): FileKind {
169
179
  if (file.binary) return "behavior";
170
180
  if (file.generated) return "generated";
171
181
  if (LOCKFILES.has(basename(file.path))) return "lockfile";
@@ -360,7 +370,13 @@ function generatedAt(cwd: string, fork: string, paths: readonly string[]): Set<s
360
370
  const forkOf = (cwd: string, base: string, head: string | null): string | null =>
361
371
  mergeBase(cwd, base, head ?? "HEAD");
362
372
 
363
- function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[] | null {
373
+ /** The files of `fork..head`; generated copies are read from `attributesAt` (the base revision). */
374
+ function laneFiles(
375
+ cwd: string,
376
+ fork: string,
377
+ head: string | null,
378
+ attributesAt: string = fork,
379
+ ): ChangedFile[] | null {
364
380
  const stats = diffNumstat(cwd, fork, head);
365
381
  const cosmetic = stats ? cosmeticFiles(cwd, fork, head) : null;
366
382
  if (!stats || !cosmetic) return null;
@@ -374,7 +390,7 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
374
390
  ];
375
391
  const generated = generatedAt(
376
392
  cwd,
377
- fork,
393
+ attributesAt,
378
394
  files.map((entry) => entry.path),
379
395
  );
380
396
  return files.map((entry) => ({
@@ -383,6 +399,41 @@ function laneFiles(cwd: string, fork: string, head: string | null): ChangedFile[
383
399
  }));
384
400
  }
385
401
 
402
+ /**
403
+ * The files whose own change differs between two versions of a branch: the
404
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
405
+ * `toFork`). Generated copies are read from `toFork` only.
406
+ *
407
+ * - Same fork: the plain `from..to` diff, comments and formatting read as
408
+ * in the lane.
409
+ * - Moved fork (a rebase): each file the branch touches is compared by exact
410
+ * blob (see ownChangeDrift): it counts when the branch's resulting file
411
+ * differs, or when the base changed it too between the forks. Their lines
412
+ * come from `from..to`, which may include base edits (over-counting is the
413
+ * safe direction), and none is cosmetic.
414
+ *
415
+ * Null when git cannot read either side.
416
+ */
417
+ export function driftFiles(
418
+ cwd: string,
419
+ range: { from: string; to: string; fromFork: string; toFork: string },
420
+ ): ChangedFile[] | null {
421
+ if (range.fromFork === range.toFork) return laneFiles(cwd, range.from, range.to, range.toFork);
422
+ const drifted = ownChangeDrift(cwd, range);
423
+ const stats = drifted ? diffNumstat(cwd, range.from, range.to) : null;
424
+ if (!drifted || !stats) return null;
425
+ const byPath = new Map(stats.map((entry) => [entry.path, entry]));
426
+ const generated = generatedAt(cwd, range.toFork, drifted);
427
+ return drifted.map((file) => ({
428
+ path: file,
429
+ added: byPath.get(file)?.added ?? 0,
430
+ deleted: byPath.get(file)?.deleted ?? 0,
431
+ ...(byPath.get(file)?.binary ? { binary: true } : {}),
432
+ cosmetic: false,
433
+ generated: generated.has(file),
434
+ }));
435
+ }
436
+
386
437
  /**
387
438
  * The changed files of `merge-base(base, head)..head`, or with `head` null
388
439
  * of the working tree (tracked changes plus untracked files) since
@@ -455,11 +506,9 @@ export function branchLane(
455
506
  * it is committed there. Null when there is nothing to warn about.
456
507
  */
457
508
  export function untrackedRiskPathsWarning(cwd: string): string | null {
458
- const top = git(cwd, ["rev-parse", "--show-toplevel"]);
459
- const root = top.ok ? top.stdout.trim() : "";
509
+ const root = worktreeTop(cwd);
460
510
  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
511
+ return isTracked(root, RISK_PATHS_FILE)
463
512
  ? null
464
513
  : `${RISK_PATHS_FILE} is not committed: it does not apply until it is committed on the base branch`;
465
514
  }
@@ -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(
@@ -35,12 +35,13 @@ import { spawnSync } from "node:child_process";
35
35
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
36
36
  import {
37
37
  appendObserved,
38
- checkVerdicts,
39
38
  tamperedUnblock,
40
39
  readLedger,
40
+ type CarryNote,
41
41
  type LedgerActor,
42
42
  type VerdictCheck,
43
43
  } from "../ledger";
44
+ import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
44
45
  import type { LaneNeed, ReviewLane } from "../core/policy/lane";
45
46
  import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
46
47
  import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
@@ -379,6 +380,10 @@ export type MergeOutcome = {
379
380
  lane: ReviewLane;
380
381
  need: LaneNeed;
381
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;
382
387
  };
383
388
  /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
384
389
  unverified: { reason: string; recorded: string } | null;
@@ -662,7 +667,8 @@ export async function mergePullRequest(
662
667
  }
663
668
  const ledger = readLedger(cwd, { integrity: true });
664
669
  if (!ledger.ok) return ledger;
665
- const check = checkVerdicts(cwd, branch, ledger.value.rows);
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);
666
672
  const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
667
673
  const summary = {
668
674
  head: check.head,
@@ -681,6 +687,9 @@ export async function mergePullRequest(
681
687
  lane: gate.lane.lane,
682
688
  need: gate.need,
683
689
  verification: gate.verification,
690
+ ...(covered && gate.review === "verified"
691
+ ? { cover: check.cover, carry: check.cover ? null : check.carry }
692
+ : {}),
684
693
  };
685
694
  let unverified: MergeOutcome["unverified"] = null;
686
695
  // The bypass covers a missing verdict, never a rejection.
@@ -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
+ }
package/src/git/rev.ts CHANGED
@@ -515,6 +515,117 @@ export function resolveRef(cwd: string, ref: string): string | null {
515
515
  return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
516
516
  }
517
517
 
518
+ /** The branch origin's HEAD names (`origin/main`), or null. */
519
+ export function originHeadRef(cwd: string): string | null {
520
+ return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
521
+ }
522
+
523
+ /** The top of the working tree, or null outside one. */
524
+ export function worktreeTop(cwd: string): string | null {
525
+ return line(git(cwd, ["rev-parse", "--show-toplevel"]));
526
+ }
527
+
528
+ /** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
529
+ export function isTracked(top: string, file: string): boolean {
530
+ return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
531
+ }
532
+
533
+ /**
534
+ * The files whose own change differs between two versions of a branch: the
535
+ * judged `from` (forked from `fromFork`) and the current `to` (forked from
536
+ * `toFork`), compared by exact blob and mode, never by diff text (#334). A
537
+ * file the branch touches on one side only differs. A file it touches on
538
+ * both sides is unchanged only when the base left it alone between the forks
539
+ * and the branch's resulting blob is identical, so a hunk moved within a
540
+ * file, a re-indent or a base edit in a file the branch touches always
541
+ * counts. Null when git cannot read either side: the caller fails closed.
542
+ */
543
+ export function ownChangeDrift(
544
+ cwd: string,
545
+ range: { from: string; to: string; fromFork: string; toFork: string },
546
+ ): string[] | null {
547
+ const before = diffRaw(cwd, range.fromFork, range.from);
548
+ const after = diffRaw(cwd, range.toFork, range.to);
549
+ if (!before || !after) return null;
550
+ const judged = new Map(before.map((entry) => [entry.path, entry]));
551
+ const current = new Map(after.map((entry) => [entry.path, entry]));
552
+ const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
553
+ a !== undefined &&
554
+ b !== undefined &&
555
+ a.oldMode === b.oldMode &&
556
+ a.oldBlob === b.oldBlob &&
557
+ a.newMode === b.newMode &&
558
+ a.newBlob === b.newBlob;
559
+ return [...new Set([...judged.keys(), ...current.keys()])]
560
+ .filter((file) => !same(judged.get(file), current.get(file)))
561
+ .toSorted();
562
+ }
563
+
564
+ /**
565
+ * Whether the branch's own files are identical at `from` (on `fromBase`) and
566
+ * `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
567
+ * exactness behind every patch-equal carry; false when git cannot tell.
568
+ */
569
+ export function sameOwnChange(
570
+ cwd: string,
571
+ range: { from: string; to: string; fromBase: string; toBase: string },
572
+ ): boolean {
573
+ const fromFork = mergeBase(cwd, range.fromBase, range.from);
574
+ const toFork = mergeBase(cwd, range.toBase, range.to);
575
+ if (!fromFork || !toFork) return false;
576
+ return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
577
+ }
578
+
579
+ /**
580
+ * The verbatim patch-ids of the non-merge commits of `fork..head` (empty
581
+ * commits have none) and how many merge commits the range holds: unlike
582
+ * `--stable`, whitespace counts, so a re-indent is another patch. Diff
583
+ * options are pinned as in patchId. Null when git fails, or has no
584
+ * `patch-id --verbatim`: the caller fails closed.
585
+ */
586
+ export function commitPatchIds(
587
+ cwd: string,
588
+ fork: string,
589
+ head: string,
590
+ ): { patchIds: string[]; merges: number } | null {
591
+ if (!safeArg(fork) || !safeArg(head)) return null;
592
+ const range = `${fork}..${head}`;
593
+ const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
594
+ const log = git(
595
+ cwd,
596
+ [
597
+ "log",
598
+ "--no-merges",
599
+ "-p",
600
+ "-U3",
601
+ "--diff-algorithm=myers",
602
+ "--indent-heuristic",
603
+ "--no-color",
604
+ "--no-ext-diff",
605
+ "--no-textconv",
606
+ "--no-renames",
607
+ "--full-index",
608
+ "--src-prefix=a/",
609
+ "--dst-prefix=b/",
610
+ "--format=commit %H",
611
+ range,
612
+ "--",
613
+ ],
614
+ { timeoutMs: GIT_TIMEOUTS.worktree },
615
+ );
616
+ if (merges === null || !log.ok) return null;
617
+ if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
618
+ const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
619
+ if (!ids.ok) return null;
620
+ return {
621
+ patchIds: ids.stdout
622
+ .split("\n")
623
+ .map((entry) => entry.trim().split(/\s+/u)[0])
624
+ .filter(Boolean),
625
+ merges: Number(merges),
626
+ };
627
+ }
628
+
518
629
  /** Pinned diff options for the review-lane reads: user config never changes them. */
519
630
  const LANE_DIFF = [
520
631
  "-c",