@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.
@@ -18,6 +18,12 @@ export type Segment = {
18
18
  pipe?: true;
19
19
  /** Text it reads on standard input: heredoc bodies and here-strings. */
20
20
  stdin?: string[];
21
+ /**
22
+ * Indexes into `words` of the words the shell expands before the command
23
+ * runs: an unquoted `$`, `*`, `?`, `[` or backtick, or a `$(…)`, `<(…)` or
24
+ * double-quoted `$`/backtick. The text of such a word is not what runs.
25
+ */
26
+ expands?: number[];
21
27
  /** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
22
28
  subs?: string[];
23
29
  /** The command could not be analyzed (too deeply nested): why. The only segment then. */
@@ -291,6 +297,20 @@ const substitutions = (body: string): string[] => {
291
297
  const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
292
298
  const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
293
299
 
300
+ /** Does a double-quoted body hold an expansion (`$x`, `$(…)`, a backtick)? */
301
+ const expandsInQuotes = (body: string, dialect: ShellDialect): boolean => {
302
+ const escape = dialect === "posix" ? "\\" : "`";
303
+ for (let index = 0; index < body.length; index++) {
304
+ const char = body[index];
305
+ if (char === escape) index++;
306
+ else if (char === "$" || (dialect === "posix" && char === "`")) return true;
307
+ }
308
+ return false;
309
+ };
310
+
311
+ /** An unquoted glob or variable character in a plain run. */
312
+ const EXPANDING_RUN = /[*?[]/u;
313
+
294
314
  /** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
295
315
  const arithmeticEnd = (command: string, start: number): number => {
296
316
  const end = substitutionEnd(command, start);
@@ -316,6 +336,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
316
336
  let current: Segment = { words: [], redirects: [] };
317
337
  let word = "";
318
338
  let inWord = false;
339
+ let expanding = false;
319
340
  let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
320
341
  const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
321
342
  const escape = posix ? "\\" : "`";
@@ -327,11 +348,16 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
327
348
  } else if (pending === "heredoc")
328
349
  heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
329
350
  else if (pending === "herestring") (current.stdin ??= []).push(text);
330
- else if (pending !== "input")
351
+ else if (pending !== "input") {
352
+ const at = current.words.length;
331
353
  current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
354
+ if (expanding || (!posix && word.startsWith("@")))
355
+ (current.expands ??= []).push(...current.words.slice(at).map((_, i) => at + i));
356
+ }
332
357
  pending = null;
333
358
  word = "";
334
359
  inWord = false;
360
+ expanding = false;
335
361
  };
336
362
  const flushSegment = () => {
337
363
  flushWord();
@@ -347,6 +373,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
347
373
  /** A substitution read apart: its command runs, its text stays in the word. */
348
374
  const substitution = (inner: string, text: string) => {
349
375
  (current.subs ??= []).push(inner);
376
+ expanding = true;
350
377
  word += text;
351
378
  inWord = true;
352
379
  };
@@ -358,6 +385,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
358
385
  if (run) {
359
386
  word += run[0];
360
387
  inWord = true;
388
+ if (EXPANDING_RUN.test(run[0])) expanding = true;
361
389
  index += run[0].length - 1;
362
390
  continue;
363
391
  }
@@ -383,6 +411,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
383
411
  const body = command.slice(index + 1, end < 0 ? undefined : end);
384
412
  if (posix && char === '"' && /\$\(|`/.test(body))
385
413
  (current.subs ??= []).push(...substitutions(body));
414
+ if (char === '"' && expandsInQuotes(body, dialect)) expanding = true;
386
415
  word += unquote(body, char, dialect);
387
416
  inWord = true;
388
417
  index = end < 0 ? command.length : end;
@@ -428,6 +457,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
428
457
  if (char === "(" && inWord && word.endsWith("$")) {
429
458
  word = word.slice(0, -1);
430
459
  inWord = word !== "";
460
+ if (!inWord) expanding = false;
431
461
  }
432
462
  group(char === "(" ? "open" : "close");
433
463
  } else if (!posix && (char === "{" || char === "}")) {
@@ -474,6 +504,7 @@ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
474
504
  } else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
475
505
  else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
476
506
  else {
507
+ if (char === "$") expanding = true;
477
508
  word += char;
478
509
  inWord = true;
479
510
  }
package/src/hooks/stop.ts CHANGED
@@ -11,11 +11,15 @@
11
11
  // or merged;
12
12
  // (c) verdict: the session opened the PR (or verified delivery) but the
13
13
  // head lacks the verdict the policy asks for, while the endpoint is
14
- // green or merged (a PR opens without one): the author's own `--self`
15
- // verdict at normal risk under `verification: self`, an accepted
16
- // non-author verdict at high risk or under `verification: independent`.
17
- // Not raised when the task's latest judgment records no behavior
18
- // change at normal risk (policy/derive.ts asks for none either), nor while a verifier or reviewer this lead started still runs.
14
+ // green or merged (a PR opens without one). Under `verification:
15
+ // proportional` (the default) the review lane of the head decides
16
+ // (core/policy/lane.ts): none owes nothing, self the author's own
17
+ // `--self` verdict, focused and full an accepted non-author verdict.
18
+ // Under `self`: the author's own at normal risk; at high risk or under
19
+ // `independent`: a non-author one, and not raised when the task's
20
+ // latest judgment records no behavior change at normal risk
21
+ // (policy/derive.ts asks for none either). Never raised while a
22
+ // verifier or reviewer this lead started still runs.
19
23
  // Each obligation blocks once per head and session (a marker keyed by kind
20
24
  // and head), not once per turn.
21
25
  // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
@@ -29,10 +33,9 @@
29
33
  // the ledger and local git only (no forge call), and every failure allows
30
34
  // the stop.
31
35
  import { spawnSync } from "node:child_process";
32
- import { resolveAutonomy } from "../autonomy";
33
- import { latestJudgment } from "../core/policy/derive";
34
- import type { Judgment } from "../core/task-contract";
35
- import { TaskStore } from "../core/task-store";
36
+ import { resolveAutonomy, type VerificationMode } from "../autonomy";
37
+ import { currentJudgment } from "../core/policy/current-judgment";
38
+ import { branchLane, laneNeed } from "../core/policy/lane";
36
39
  import { currentBranch, pushRemoteName } from "../git/rev";
37
40
  import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
38
41
  import type { HostDescriptor } from "./descriptor";
@@ -237,43 +240,57 @@ const redChecks = (
237
240
  };
238
241
  };
239
242
 
240
- /** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. */
241
- const taskJudgment = (cwd: string): Judgment | null => {
242
- try {
243
- const store = new TaskStore(cwd);
244
- const listed = store.listTaskIndex();
245
- const key = store.currentKey();
246
- if (!listed.ok || !key.ok) return null;
247
- const entry = listed.data
248
- .filter((item) => item.key === key.data.key && item.status !== "closed")
249
- .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
250
- const task = entry ? store.readTask(entry.id) : null;
251
- return task?.ok === true ? latestJudgment(task.data) : null;
252
- } catch {
253
- return null;
254
- }
243
+ /**
244
+ * The base the branch's PR targets: the newest the forge reported (a
245
+ * `pr.status` row, so a PR retargeted on the forge is read against its
246
+ * current base), else the one it was opened with (`pr.created`), else null
247
+ * (the default base). Read from the ledger: the hook never calls the forge.
248
+ */
249
+ const prBase = (rows: readonly ReadRow[]): string | null => {
250
+ const seen = rows.findLast(
251
+ (row) =>
252
+ (row.type === "pr.status" || row.type === "pr.created") &&
253
+ row.observer === "workit_cli" &&
254
+ typeof row.base === "string",
255
+ );
256
+ return typeof seen?.base === "string" ? seen.base : null;
255
257
  };
256
258
 
257
259
  /**
258
260
  * (c) Delivered by this session, yet the head lacks the verdict the policy
259
- * asks for: the author's own at normal risk when the workspace's
260
- * verification is `self`, else an accepted non-author one.
261
+ * asks for. Under `verification: proportional` the review lane of the head
262
+ * decides (core/policy/lane.ts): none owes nothing, self the author's own,
263
+ * focused and full a non-author one. Under `self`, the author's own at
264
+ * normal risk; under `independent` or at high risk, a non-author one.
261
265
  */
262
266
  const unverified = (
263
267
  cwd: string,
264
268
  branch: string,
265
269
  all: readonly ReadRow[],
266
270
  descriptor: HostDescriptor,
267
- verification: "self" | "independent",
271
+ verification: VerificationMode,
272
+ head: string | null,
268
273
  ): StopObligation | null => {
269
274
  const check = checkVerdicts(cwd, branch, all);
270
275
  if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
271
- const judgment = taskJudgment(cwd);
276
+ const judgment = currentJudgment(cwd);
272
277
  const high = judgment?.riskTier === "high";
273
- // Like policy/derive.ts: a verdict is asked for a behavior change or high
274
- // risk. A recorded judgment with neither (docs only) owes none.
275
- if (judgment && !judgment.behaviorChange && !high) return null;
276
- const independent = verification === "independent" || high;
278
+ let independent: boolean;
279
+ if (verification === "proportional") {
280
+ const lane = branchLane(cwd, {
281
+ base: prBase(all.filter((row) => row.branch === branch)),
282
+ head,
283
+ judgment,
284
+ });
285
+ const need = laneNeed(lane.lane);
286
+ if (need === "none") return null;
287
+ independent = need === "non-author";
288
+ } else {
289
+ // Like policy/derive.ts: a verdict is asked for a behavior change or high
290
+ // risk. A recorded judgment with neither (docs only) owes none.
291
+ if (judgment && !judgment.behaviorChange && !high) return null;
292
+ independent = verification === "independent" || high;
293
+ }
277
294
  if (!independent && check.review === "self-reviewed") return null;
278
295
  const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
279
296
  const verifier = `${descriptor.subagents.agentPrefix}verifier`;
@@ -332,7 +349,7 @@ export function stopObligation(
332
349
  }
333
350
  // A PR opens without a verdict: only green and merged need one.
334
351
  if (!delivered || (endpoint !== "green" && endpoint !== "merged")) return null;
335
- const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification);
352
+ const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification, head);
336
353
  return left ? { ...left, head } : null;
337
354
  } catch {
338
355
  return null;
@@ -182,7 +182,7 @@ const shown = (target: string): string =>
182
182
 
183
183
  const unboundDeny = (input: HookInput, target: string): HookDecision => {
184
184
  const unblock =
185
- "Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit --cwd <worktree> git commit -m … -- <paths>`, `workit --cwd <worktree> check <name>`). If your brief names no worktree, stop and report that to the lead.";
185
+ "Work only in the worktree your brief names, which the lead made with `workit fanout worktree create <slice>`: edit files under it and run one verb per call against it (`workit git --cwd <worktree> commit -m … -- <paths>`, `workit check --cwd <worktree> <name>`). If your brief names no worktree, stop and report that to the lead.";
186
186
  return {
187
187
  kind: "deny",
188
188
  reason: `workit worktree confinement: implementer ${input.session.agentId} is not working in a Workit worktree yet, and ${shown(target)} is outside every worktree \`workit fanout worktree create\` made, so it would change the lead's checkout. ${unblock}`,
@@ -191,7 +191,7 @@ const unboundDeny = (input: HookInput, target: string): HookDecision => {
191
191
  };
192
192
 
193
193
  const outsideDeny = (input: HookInput, worktree: string, target: string): HookDecision => {
194
- const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit --cwd ${worktree} git commit -m … -- <paths>\`, \`workit --cwd ${worktree} check <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
194
+ const unblock = `Edit only under ${worktree} and run git there, one verb per call: \`workit git --cwd ${worktree} commit -m … -- <paths>\`, \`workit check --cwd ${worktree} <name>\` (or \`git -C ${worktree} …\`); a command whose writes Workit cannot see is judged by the directory it runs in. Anything outside it is a follow-up for your report.`;
195
195
  return {
196
196
  kind: "deny",
197
197
  reason: `workit worktree confinement: implementer ${input.session.agentId} is bound to the worktree ${worktree}, and ${shown(target)} is outside it. ${unblock}`,
@@ -201,7 +201,7 @@ const outsideDeny = (input: HookInput, worktree: string, target: string): HookDe
201
201
 
202
202
  const unknownDirDeny = (input: HookInput, worktree: string | null): HookDecision => {
203
203
  const wt = worktree ?? "<worktree>";
204
- const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit --cwd ${wt}\`.`;
204
+ const unblock = `Workit cannot resolve \`cd -\`, \`cd "$X"\` or \`popd\`: write by absolute path, \`git -C ${wt}\` or \`workit <verb> --cwd ${wt}\`.`;
205
205
  return {
206
206
  kind: "deny",
207
207
  reason: `workit worktree confinement: implementer ${input.session.agentId} writes after an unknown directory change. ${unblock}`,
@@ -509,7 +509,7 @@ const segmentTargets = (
509
509
  }
510
510
  const inner = depth < 4 ? (wrappedScript(words) ?? (strict ? shellScript(words) : null)) : null;
511
511
  if (inner !== null && dir !== undefined)
512
- targets.push(...segmentTargets(dir, inner, "posix", depth + 1, strict));
512
+ targets.push(...segmentTargets(workitRunDir(dir, words), inner, "posix", depth + 1, strict));
513
513
  if (strict && dir !== undefined) {
514
514
  const runIn = dir;
515
515
  for (const sub of depth < 4 ? (segment.subs ?? []) : [])
@@ -550,6 +550,15 @@ const segmentTargets = (
550
550
  return targets;
551
551
  };
552
552
 
553
+ /** Where `workit check|lock run [--cwd <dir>] -- <cmd>` runs its command: `--cwd` resolved against `dir`. */
554
+ const workitRunDir = (dir: string, words: string[]): string => {
555
+ for (const invocation of workitInvocations(words.map(quoteWord).join(" "), "posix")) {
556
+ const { at, args } = workitCwd(dir, invocation);
557
+ if (args[0] === "check" || (args[0] === "lock" && args[1] === "run")) return at;
558
+ }
559
+ return dir;
560
+ };
561
+
553
562
  /** A workit invocation's global `--cwd <dir>` (or `--cwd=<dir>`), resolved against `dir`. */
554
563
  const workitCwd = (dir: string, args: string[]): { at: string; args: string[] } => {
555
564
  const { rest, cwd } = skipWorkitGlobals(args);
package/src/ledger.ts CHANGED
@@ -33,7 +33,10 @@
33
33
  // `base`, the stable patch-id of `base...head` and an exact diff hash. It is
34
34
  // `fresh` (same head; verdicts on a dirty worktree are refused, so the head
35
35
  // is the judged tree), `carried` (new head, same patch-id AND same exact
36
- // diff: a rebase that only moved the base) or `stale`.
36
+ // diff AND, for a pass, byte-identical own files the base left alone: a
37
+ // rebase that only moved the base; or a trusted verdict whose
38
+ // follow-up change is lane none, or test-only for a unit verdict, see
39
+ // core/policy/carry.ts) or `stale`.
37
40
  // - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
38
41
  // the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
39
42
  // running chain. A row without it after the chain began, or whose hashes do
@@ -54,8 +57,16 @@ import { createHash, randomBytes } from "node:crypto";
54
57
  import fs from "node:fs";
55
58
  import path from "node:path";
56
59
  import { hostAwareLockTimeout } from "./core/store-lock";
57
- import { vcsConfig } from "./core/vcs-config";
58
- import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
60
+ import { verdictDrift, type CarryRule, type Drift } from "./core/policy/carry";
61
+ import { defaultBase } from "./git/default-base";
62
+ import {
63
+ GIT_TIMEOUTS,
64
+ currentBranch,
65
+ headSha,
66
+ patchId,
67
+ sameOwnChange,
68
+ worktreeTree,
69
+ } from "./git/rev";
59
70
  import { hostSessionFromEnv, issuedBinding } from "./host-session";
60
71
  import { bumpStandingRev } from "./hooks/standing-signal";
61
72
  import { resolveStore } from "./store/paths";
@@ -281,47 +292,8 @@ export function branchHead(cwd: string, branch: string): string | null {
281
292
  );
282
293
  }
283
294
 
284
- /**
285
- * The PR target of the release track the checkout is on, when the workspace
286
- * configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
287
- * plain workspace default does not override origin's default branch.
288
- */
289
- const trackTrunk = (cwd: string): string | null => {
290
- try {
291
- const resolved = vcsConfig("resolve", cwd);
292
- if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
293
- return null;
294
- const target = String(resolved.defaultTargetBranch ?? "").trim();
295
- return target && safeRef(target) ? target : null;
296
- } catch {
297
- return null;
298
- }
299
- };
300
-
301
- /**
302
- * The default base for patch-ids and author trailers: the release track's PR
303
- * target (origin's copy, else the local one) when tracks are configured, else
304
- * origin's HEAD branch, else the first of origin/main, origin/master, main,
305
- * master, origin/develop, develop that exists.
306
- */
307
- export function defaultBase(cwd: string): string | null {
308
- const trunk = trackTrunk(cwd);
309
- if (trunk)
310
- for (const candidate of [`origin/${trunk}`, trunk])
311
- if (resolveCommit(cwd, candidate)) return candidate;
312
- const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
313
- if (originHead && resolveCommit(cwd, originHead)) return originHead;
314
- for (const candidate of [
315
- "origin/main",
316
- "origin/master",
317
- "main",
318
- "master",
319
- "origin/develop",
320
- "develop",
321
- ])
322
- if (resolveCommit(cwd, candidate)) return candidate;
323
- return null;
324
- }
295
+ // The default base lives in git/default-base.ts (shared with the review lane).
296
+ export { defaultBase };
325
297
 
326
298
  /**
327
299
  * sha256 of the exact `base...head` diff with the pinned options rev.ts uses
@@ -1136,9 +1108,21 @@ export type VerdictEntry = {
1136
1108
  /** current AND passing AND independent. */
1137
1109
  accepted: boolean;
1138
1110
  reasons: RejectReason[];
1111
+ /** Why a verdict on an older head still applies after a follow-up change (core/policy/carry.ts). */
1112
+ carry: CarryNote | null;
1139
1113
  verdict: ReadRow;
1140
1114
  };
1141
1115
 
1116
+ /** A verdict carried over a follow-up change: by which rule, from which judged head, over which files. */
1117
+ export type CarryNote = { rule: CarryRule; since: string; files: string[] };
1118
+
1119
+ /** A stale verdict whose follow-up change is small and off risk paths: re-verify only these files. */
1120
+ export type Reverify = { scope: "focused"; since: string; files: string[] };
1121
+
1122
+ /** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
1123
+ export const carryLabel = (carry: CarryNote): string =>
1124
+ `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1125
+
1142
1126
  export type VerdictCheck = {
1143
1127
  branch: string;
1144
1128
  head: string | null;
@@ -1146,6 +1130,10 @@ export type VerdictCheck = {
1146
1130
  patchId: string | null;
1147
1131
  /** Newest current verdict (else newest verdict): does it still apply? */
1148
1132
  current: { basis: VerdictBasis; verdict: ReadRow | null };
1133
+ /** The carry of the accepted verdict (else of the newest current one), when a follow-up change carried it. */
1134
+ carry: CarryNote | null;
1135
+ /** No verdict applies, but the newest passing one is stale only by a small change: re-verify that. */
1136
+ reverify: Reverify | null;
1149
1137
  /**
1150
1138
  * What merge gates read: an accepted strong (`verified`/`tests-verified`)
1151
1139
  * verdict and no current independent failure. `type-check-only` never counts.
@@ -1276,7 +1264,7 @@ const derivesFrom = (derived: string, bare: string): boolean =>
1276
1264
  * child that committed as `<lead>:<child>` and judges as `<child>` is still
1277
1265
  * the author). Two derived ids never match on a shared last segment.
1278
1266
  */
1279
- const authoredBy = (authors: Set<string>, session: string): boolean => {
1267
+ export const authoredBy = (authors: Set<string>, session: string): boolean => {
1280
1268
  if (authors.has(session)) return true;
1281
1269
  for (const author of authors)
1282
1270
  if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
@@ -1414,15 +1402,39 @@ function effectiveOf(
1414
1402
  * each restack itself and records `stack.restacked` with `patchEqual` when
1415
1403
  * the change relative to the parent is identical (same patch-id AND same
1416
1404
  * exact diff hash, D18). A verdict carries when such observed rows link its
1417
- * head to the current head.
1405
+ * head to the current head. With `exactIn` (the checkout), each link is
1406
+ * re-read with exact blobs (sameOwnChange, #334), so a row recorded before
1407
+ * that check carries a pass only when the branch's own files are identical
1408
+ * across the restack. Null keeps every observed link: a failure keeps
1409
+ * applying through them (failing closed the other way).
1418
1410
  */
1411
+ const restackExact = new WeakMap<ReadRow, boolean>();
1419
1412
  function restackCarries(
1413
+ exactIn: string | null,
1420
1414
  rows: readonly ReadRow[],
1421
1415
  branch: string,
1422
1416
  from: string | null,
1423
1417
  to: string | null,
1424
1418
  ): boolean {
1425
1419
  if (!from || !to) return false;
1420
+ const exact = (row: ReadRow, fromHead: string, head: string): boolean => {
1421
+ if (exactIn === null) return true;
1422
+ const cwd = exactIn;
1423
+ let known = restackExact.get(row);
1424
+ if (known === undefined) {
1425
+ known =
1426
+ typeof row.fromBase === "string" &&
1427
+ typeof row.toBase === "string" &&
1428
+ sameOwnChange(cwd, {
1429
+ from: fromHead,
1430
+ to: head,
1431
+ fromBase: row.fromBase,
1432
+ toBase: row.toBase,
1433
+ });
1434
+ restackExact.set(row, known);
1435
+ }
1436
+ return known;
1437
+ };
1426
1438
  const edges = new Map<string, string[]>();
1427
1439
  for (const row of rows)
1428
1440
  if (
@@ -1432,7 +1444,8 @@ function restackCarries(
1432
1444
  row.branch === branch &&
1433
1445
  row.patchEqual === true &&
1434
1446
  typeof row.fromHead === "string" &&
1435
- row.head
1447
+ row.head &&
1448
+ exact(row, row.fromHead, row.head)
1436
1449
  )
1437
1450
  edges.set(row.fromHead, [...(edges.get(row.fromHead) ?? []), row.head]);
1438
1451
  const seen = new Set<string>([from]);
@@ -1471,9 +1484,73 @@ export function checkVerdicts(
1471
1484
  return key;
1472
1485
  };
1473
1486
  const keyFor = (base: string | null) => keysAt(base, head);
1474
- const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1487
+ // The branch's own change since a trusted verdict's head, read once per
1488
+ // row (#334). A row the gates cannot trust never carries.
1489
+ const driftCache = new Map<ReadRow, Drift | null>();
1490
+ const driftOf = (row: ReadRow): Drift | null => {
1491
+ if (driftCache.has(row)) return driftCache.get(row) ?? null;
1492
+ let drift: Drift | null = null;
1493
+ const toBase = row.base ?? fallbackBase;
1494
+ const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1495
+ const fromBase = row.baseSha ?? toBaseSha;
1496
+ if (
1497
+ row.head &&
1498
+ head &&
1499
+ row.head !== head &&
1500
+ toBaseSha &&
1501
+ fromBase &&
1502
+ !untrusted(reasonsOf(row))
1503
+ )
1504
+ drift = verdictDrift(cwd, {
1505
+ from: row.head,
1506
+ to: head,
1507
+ fromBase,
1508
+ toBase: toBaseSha,
1509
+ kind: typeof row.kind === "string" ? row.kind : "review",
1510
+ });
1511
+ driftCache.set(row, drift);
1512
+ return drift;
1513
+ };
1514
+ // The same patch-id and diff hash are not enough to carry a pass (#334):
1515
+ // both drop hunk line numbers, so an edit moved within a file keeps them.
1516
+ // The branch's own files must be identical blobs too (sameOwnChange). A
1517
+ // failure keeps the looser carry: it fails closed by applying more.
1518
+ const exactCache = new Map<ReadRow, boolean>();
1519
+ const exactCarry = (row: ReadRow): boolean => {
1520
+ if (FAILING.has(String(row.result))) return true;
1521
+ let exact = exactCache.get(row);
1522
+ if (exact === undefined) {
1523
+ const toBase = row.base ?? fallbackBase;
1524
+ const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1525
+ const fromBase = row.baseSha ?? toBaseSha;
1526
+ exact = Boolean(
1527
+ row.head &&
1528
+ head &&
1529
+ toBaseSha &&
1530
+ fromBase &&
1531
+ sameOwnChange(cwd, { from: row.head, to: head, fromBase, toBase: toBaseSha }),
1532
+ );
1533
+ exactCache.set(row, exact);
1534
+ }
1535
+ return exact;
1536
+ };
1537
+ const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1475
1538
  const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
1476
- return basis === "stale" && restackCarries(rows, branch, row.head, head) ? "carried" : basis;
1539
+ return basis === "carried" && !exactCarry(row) ? "stale" : basis;
1540
+ };
1541
+ const restacked = (row: ReadRow): boolean =>
1542
+ restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
1543
+ /** The follow-up carry of a verdict the exact keys and restacks do not carry. */
1544
+ const carryOf = (row: ReadRow): CarryNote | null => {
1545
+ if (keyBasis(row) !== "stale") return null;
1546
+ if (restacked(row)) return null;
1547
+ const drift = driftOf(row);
1548
+ return drift?.carries ? { rule: drift.rule, since: drift.since, files: drift.files } : null;
1549
+ };
1550
+ const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1551
+ const basis = keyBasis(row);
1552
+ if (basis !== "stale") return basis;
1553
+ return restacked(row) || carryOf(row) ? "carried" : basis;
1477
1554
  };
1478
1555
  // A newer row is judged under the failure's own base, so a row recorded
1479
1556
  // with a different --base cannot make unchanged code look changed. When
@@ -1487,7 +1564,7 @@ export function checkVerdicts(
1487
1564
  const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
1488
1565
  return (
1489
1566
  verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
1490
- restackCarries(rows, branch, failed.head, row.head)
1567
+ restackCarries(null, rows, branch, failed.head, row.head)
1491
1568
  );
1492
1569
  };
1493
1570
  const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
@@ -1566,6 +1643,7 @@ export function checkVerdicts(
1566
1643
  independent: independence.length === 0,
1567
1644
  accepted: reasons.length === 0,
1568
1645
  reasons,
1646
+ carry: basis === "carried" ? carryOf(row) : null,
1569
1647
  verdict: row,
1570
1648
  });
1571
1649
  // The author's own failure never displaces an independent pass, but it
@@ -1622,15 +1700,38 @@ export function checkVerdicts(
1622
1700
  ? newestCurrent.reasons
1623
1701
  : (["type_check_only"] as RejectReason[]),
1624
1702
  };
1625
- const selfEntry = failing
1703
+ // The author's latest pass or failure on the current code wins, across
1704
+ // kinds: a newer failure cancels an older pass (review finding mvkcoxgb).
1705
+ const latestSelf = failing
1626
1706
  ? undefined
1627
1707
  : verdicts.findLast(
1628
1708
  (entry) =>
1629
1709
  entry.current &&
1630
1710
  !entry.independent &&
1631
1711
  !untrusted(entry.reasons) &&
1632
- STRONG_RESULTS.has(String(entry.verdict.result)),
1712
+ (STRONG_RESULTS.has(String(entry.verdict.result)) ||
1713
+ FAILING.has(String(entry.verdict.result))),
1633
1714
  );
1715
+ const selfEntry =
1716
+ latestSelf && STRONG_RESULTS.has(String(latestSelf.verdict.result)) ? latestSelf : undefined;
1717
+ const carryEntry =
1718
+ verdicts.find((entry) => entry.verdict === accepted.verdict && entry.accepted) ?? newestCurrent;
1719
+ // A focused re-verify: nothing applies now, and the newest trusted pass is
1720
+ // stale only by a small change off risk paths (the drift's lane is self).
1721
+ const lastPass =
1722
+ accepted.accepted || failing || tampered.length
1723
+ ? undefined
1724
+ : verdicts.findLast(
1725
+ (entry) =>
1726
+ !entry.current &&
1727
+ !untrusted(entry.reasons) &&
1728
+ STRONG_RESULTS.has(String(entry.verdict.result)),
1729
+ );
1730
+ const drift = lastPass ? driftOf(lastPass.verdict) : null;
1731
+ const reverify: Reverify | null =
1732
+ drift && !drift.carries && drift.focused && drift.files
1733
+ ? { scope: "focused", since: drift.since, files: drift.files }
1734
+ : null;
1634
1735
  return {
1635
1736
  branch,
1636
1737
  head,
@@ -1639,6 +1740,8 @@ export function checkVerdicts(
1639
1740
  base: newestCurrent?.verdict.base ?? fallbackBase,
1640
1741
  patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
1641
1742
  current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },
1743
+ carry: carryEntry?.carry ?? null,
1744
+ reverify,
1642
1745
  accepted,
1643
1746
  verdicts,
1644
1747
  authors: [...authors].toSorted(),