@brainervirus/workit-core 8.1.1 → 8.4.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/src/ledger.ts CHANGED
@@ -19,7 +19,8 @@
19
19
  // lines and rows without the required keys are skipped and counted, and the
20
20
  // file is never rewritten. `seq` is the row's position among valid rows (a
21
21
  // lock-free appender cannot allocate one); a row supersedes an older one by
22
- // its `id`, and only a row of the same type from the same session may.
22
+ // its `id`, and only a row of the same type, session and branch (and verdict
23
+ // kind) may.
23
24
  // - Trust (D18): the threat model is honest mistakes and accidental
24
25
  // self-certification, not adversarial agents. `ledger verdict` rows are
25
26
  // `observer:"agent_asserted"`; `observer:"workit_cli"` is reserved for rows
@@ -33,13 +34,24 @@
33
34
  // `fresh` (same head; verdicts on a dirty worktree are refused, so the head
34
35
  // is the judged tree), `carried` (new head, same patch-id AND same exact
35
36
  // diff: a rebase that only moved the base) or `stale`.
37
+ // - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
38
+ // the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
39
+ // running chain. A row without it after the chain began, or whose hashes do
40
+ // not match, is reported as unverified (`workit ledger verify-integrity`, a
41
+ // warning in `ledger check`). Nothing is refused: this catches a hand edit
42
+ // made by mistake, it is not a defense against a forger.
43
+ // - Branch renames: rows keyed to a branch's earlier names (from its reflog)
44
+ // count for it, up to the rename.
36
45
  //
37
- // Plain TS over the git binary: no zod, no task store, no config reads.
46
+ // Plain TS over the git binary, plus the configured trunk (vcsConfig) for the
47
+ // default base; no task store.
38
48
  import { spawnSync } from "node:child_process";
39
49
  import { createHash, randomBytes } from "node:crypto";
40
50
  import fs from "node:fs";
41
51
  import path from "node:path";
52
+ import { vcsConfig } from "./core/vcs-config";
42
53
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
54
+ import { hostSessionFromEnv } from "./host-session";
43
55
  import { resolveStore } from "./store/paths";
44
56
 
45
57
  export const LEDGER_VERSION = 1;
@@ -99,7 +111,7 @@ type RowCommon = CodeKey & {
99
111
  type: string;
100
112
  actor: LedgerActor;
101
113
  pr?: number;
102
- /** The `id` of an older row of the same type and session that this one replaces. */
114
+ /** The `id` of an older row of the same type, session and branch (and kind) that this one replaces. */
103
115
  supersedes?: string;
104
116
  };
105
117
 
@@ -117,6 +129,12 @@ export type RulingRow = RowCommon & {
117
129
  refs: string[];
118
130
  };
119
131
  export type SelfReason = "flag" | "no_session";
132
+ /**
133
+ * Where a verdict's session came from: the environment (the host's hook),
134
+ * `--session` (named by the caller, shown in `ledger check` so a human can
135
+ * spot a claimed verifier id) or `--as` (minted from the environment's).
136
+ */
137
+ export type SessionSource = "env" | "flag" | "as";
120
138
  export type VerdictRow = RowCommon & {
121
139
  type: "verdict";
122
140
  kind: VerdictKind;
@@ -127,6 +145,7 @@ export type VerdictRow = RowCommon & {
127
145
  /** Not an independent verdict: `--self`, or no session to tell it from the author. */
128
146
  self: boolean;
129
147
  selfReason: SelfReason | null;
148
+ sessionSource: SessionSource;
130
149
  evidenceRefs: string[];
131
150
  };
132
151
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
@@ -146,6 +165,14 @@ export type LedgerRow =
146
165
  | HandoffRow
147
166
  | StandingRow
148
167
  | StandingClearedRow;
168
+ /**
169
+ * A row's place in the hash chain: `verified` (the CLI wrote it and it is
170
+ * unchanged), `unverified` (see UnverifiedReason) or `legacy` (written before
171
+ * the ledger had a chain).
172
+ */
173
+ export type RowIntegrity = "verified" | "unverified" | "legacy";
174
+ /** unsigned: no chain fields (hand-appended) · hash_mismatch: edited · broken_link: a row before it was edited or removed. */
175
+ export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link";
149
176
  /** A row as read back: any known or future type, plus its position. */
150
177
  export type ReadRow = RowCommon &
151
178
  Record<string, unknown> & {
@@ -153,6 +180,8 @@ export type ReadRow = RowCommon &
153
180
  superseded: boolean;
154
181
  /** The row names a `supersedes` target the reader refused (D18 supersede rules). */
155
182
  supersedeIgnored: boolean;
183
+ /** Set only when read with `{ integrity: true }`. */
184
+ integrity?: RowIntegrity;
156
185
  };
157
186
 
158
187
  export type LedgerError = {
@@ -222,13 +251,43 @@ export function branchHead(cwd: string, branch: string): string | null {
222
251
  }
223
252
 
224
253
  /**
225
- * The default base for patch-ids: origin's HEAD branch when known, else the
226
- * first of origin/main, origin/master, main, master that exists.
254
+ * The PR target of the release track the checkout is on, when the workspace
255
+ * configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
256
+ * plain workspace default does not override origin's default branch.
257
+ */
258
+ const trackTrunk = (cwd: string): string | null => {
259
+ try {
260
+ const resolved = vcsConfig("resolve", cwd);
261
+ if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
262
+ return null;
263
+ const target = String(resolved.defaultTargetBranch ?? "").trim();
264
+ return target && safeRef(target) ? target : null;
265
+ } catch {
266
+ return null;
267
+ }
268
+ };
269
+
270
+ /**
271
+ * The default base for patch-ids and author trailers: the release track's PR
272
+ * target (origin's copy, else the local one) when tracks are configured, else
273
+ * origin's HEAD branch, else the first of origin/main, origin/master, main,
274
+ * master, origin/develop, develop that exists.
227
275
  */
228
276
  export function defaultBase(cwd: string): string | null {
277
+ const trunk = trackTrunk(cwd);
278
+ if (trunk)
279
+ for (const candidate of [`origin/${trunk}`, trunk])
280
+ if (resolveCommit(cwd, candidate)) return candidate;
229
281
  const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
230
282
  if (originHead && resolveCommit(cwd, originHead)) return originHead;
231
- for (const candidate of ["origin/main", "origin/master", "main", "master"])
283
+ for (const candidate of [
284
+ "origin/main",
285
+ "origin/master",
286
+ "main",
287
+ "master",
288
+ "origin/develop",
289
+ "develop",
290
+ ])
232
291
  if (resolveCommit(cwd, candidate)) return candidate;
233
292
  return null;
234
293
  }
@@ -304,15 +363,16 @@ export function ledgerPath(cwd: string): LedgerResult<string> {
304
363
  // ---------------------------------------------------------------------------
305
364
  // append
306
365
 
307
- /** The acting identity from the environment. */
366
+ /** The acting identity from the environment (host-session.ts: WORKIT_SESSION_ID, else the host's own shell session id). */
308
367
  export function actorFromEnv(env: NodeJS.ProcessEnv): LedgerActor {
309
368
  const value = (key: string): string | null => {
310
369
  const raw = env[key]?.trim();
311
370
  return raw ? raw : null;
312
371
  };
372
+ const acting = hostSessionFromEnv(env);
313
373
  return {
314
- host: value("WORKIT_HOST") ?? "cli",
315
- session: value("WORKIT_SESSION_ID"),
374
+ host: acting.host ?? "cli",
375
+ session: acting.session,
316
376
  agentId: value("WORKIT_AGENT_ID"),
317
377
  };
318
378
  }
@@ -464,10 +524,58 @@ export function withLedgerLock<T>(
464
524
 
465
525
  type AppendOptions = { now?: Date; forceLock?: boolean };
466
526
 
467
- function appendLine(file: string, line: string, options: AppendOptions): LedgerResult<void> {
527
+ const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");
528
+ /** The chain hash of a row: over the previous line's hash and the row without `rowHash`. */
529
+ const chainHash = (prevHash: string | null, body: string): string =>
530
+ sha256(`${prevHash ?? ""}\n${body}`);
531
+ /** Enough tail to hold the previous row (rows are at most MAX_LINE_BYTES). */
532
+ const TAIL_BYTES = 64 * 1024;
533
+
534
+ /**
535
+ * The last complete (newline-terminated) non-empty line before `size`, read
536
+ * from the tail. An unterminated tail is a torn row or another appender's
537
+ * write still in flight, so it is not chained onto. A line longer than the
538
+ * window (a hand-written one) is read whole by widening the window, so the
539
+ * link stays exact.
540
+ */
541
+ const lastLine = (fd: number, size: number): string | null => {
542
+ for (let span = Math.min(size, TAIL_BYTES); span > 0; span = Math.min(size, span * 2)) {
543
+ const buffer = Buffer.alloc(span);
544
+ fs.readSync(fd, buffer, 0, span, size - span);
545
+ const end = buffer.lastIndexOf(0x0a);
546
+ if (end < 0) {
547
+ if (span === size) return null;
548
+ continue;
549
+ }
550
+ // Skip blank lines (a fence) back to the last non-empty complete line.
551
+ let stop = end;
552
+ let text = "";
553
+ while (stop >= 0) {
554
+ const start = buffer.lastIndexOf(0x0a, stop - 1);
555
+ text = buffer.toString("utf8", start + 1, stop).trim();
556
+ if (text) {
557
+ // Its start must be inside the window (or be the file's start).
558
+ if (start >= 0 || span === size) return text;
559
+ break;
560
+ }
561
+ if (start < 0) {
562
+ if (span === size) return null;
563
+ break;
564
+ }
565
+ stop = start;
566
+ }
567
+ if (span === size) return text || null;
568
+ }
569
+ return null;
570
+ };
571
+
572
+ /** The line to write, given the file's current last line (null: empty file). */
573
+ type LineBuilder = (previous: string | null) => LedgerResult<string>;
574
+
575
+ function appendLine(file: string, build: LineBuilder, options: AppendOptions): LedgerResult<void> {
468
576
  const write = (): LedgerResult<void> => {
469
577
  // a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
470
- // peek at the last byte to fence off a torn tail.
578
+ // peek at the tail to chain onto the last row and fence off a torn one.
471
579
  const fd = fs.openSync(file, "a+");
472
580
  try {
473
581
  const size = fs.fstatSync(fd).size;
@@ -477,7 +585,9 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
477
585
  fs.readSync(fd, last, 0, 1, size - 1);
478
586
  if (last[0] !== 0x0a) fence = "\n";
479
587
  }
480
- const bytes = Buffer.from(fence + line);
588
+ const line = build(lastLine(fd, size));
589
+ if (!line.ok) return line;
590
+ const bytes = Buffer.from(fence + line.value);
481
591
  const written = fs.writeSync(fd, bytes);
482
592
  if (written !== bytes.length)
483
593
  return err(
@@ -508,22 +618,40 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
508
618
 
509
619
  export type NewRow<T extends LedgerRow> = Omit<T, "v" | "id" | "at">;
510
620
 
511
- const serialize = (row: object, now: Date) => {
512
- const full = { v: LEDGER_VERSION, id: newId(), at: now.toISOString(), ...row };
621
+ /**
622
+ * Serialize a row chained onto `previous` (the file's last complete line).
623
+ * Concurrent lock-free appenders may chain onto the same or an older line;
624
+ * the reader accepts a link to any earlier line.
625
+ */
626
+ const sealed = (row: object, id: string, at: string, previous: string | null) => {
627
+ const { prevHash: _prev, rowHash: _row, ...fields } = row as Record<string, unknown>;
628
+ const prevHash = previous === null ? null : sha256(previous);
629
+ const body = { v: LEDGER_VERSION, id, at, ...fields, prevHash };
630
+ const full = { ...body, rowHash: chainHash(prevHash, JSON.stringify(body)) };
513
631
  return { full, line: `${JSON.stringify(full)}\n` };
514
632
  };
515
633
 
516
634
  function appendRaw<T>(cwd: string, row: object, options: AppendOptions): LedgerResult<T> {
517
- const { full, line } = serialize(row, options.now ?? new Date());
518
- const bytes = Buffer.byteLength(line);
519
- if (bytes > MAX_LINE_BYTES)
520
- return err(
521
- "invalid_input",
522
- `ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
523
- );
524
635
  const file = ledgerPath(cwd);
525
636
  if (!file.ok) return file;
526
- const written = appendLine(file.value, line, options);
637
+ const id = newId();
638
+ const at = (options.now ?? new Date()).toISOString();
639
+ let full: unknown = null;
640
+ const written = appendLine(
641
+ file.value,
642
+ (previous) => {
643
+ const next = sealed(row, id, at, previous);
644
+ const bytes = Buffer.byteLength(next.line);
645
+ if (bytes > MAX_LINE_BYTES)
646
+ return err(
647
+ "invalid_input",
648
+ `ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
649
+ );
650
+ full = next.full;
651
+ return { ok: true, value: next.line };
652
+ },
653
+ options,
654
+ );
527
655
  return written.ok ? { ok: true, value: full as T } : written;
528
656
  }
529
657
 
@@ -538,6 +666,12 @@ export function appendRow<T extends LedgerRow>(
538
666
  return appendRaw<T>(cwd, row, options);
539
667
  }
540
668
 
669
+ /** The bytes `appendObserved` would write for `row` (its chain fields included). */
670
+ export function observedRowBytes(row: Record<string, unknown>): number {
671
+ const sample = sealed({ ...row, observer: "workit_cli" }, newId(), new Date().toISOString(), "");
672
+ return Buffer.byteLength(sample.line);
673
+ }
674
+
541
675
  /**
542
676
  * Internal API for observing verbs only (S9b `check`, S10 `ci`, S11 `pr`/`git
543
677
  * commit`): append a row the CLI itself observed, stamped
@@ -551,6 +685,18 @@ export function appendObserved(
551
685
  return appendRaw(cwd, { ...row, observer: "workit_cli" }, options);
552
686
  }
553
687
 
688
+ /**
689
+ * A row a host hook observed (a raw shell `git commit`), stamped
690
+ * `observer:"host_hook"`: weaker than the CLI's own observation, but enough
691
+ * to name the session that authored a commit (D18 honest-agent model).
692
+ */
693
+ export function appendHookObserved(
694
+ cwd: string,
695
+ row: Record<string, unknown> & { type: string; actor: LedgerActor },
696
+ ): LedgerResult<Record<string, unknown>> {
697
+ return appendRaw(cwd, { ...row, observer: "host_hook" }, {});
698
+ }
699
+
554
700
  // ---------------------------------------------------------------------------
555
701
  // read (D17: tolerant)
556
702
 
@@ -568,26 +714,63 @@ const normalizeActor = (value: unknown): LedgerActor => {
568
714
  };
569
715
  };
570
716
 
717
+ type SupersedeRow = Pick<ReadRow, "type" | "actor"> & {
718
+ self?: unknown;
719
+ branch?: unknown;
720
+ kind?: unknown;
721
+ at?: unknown;
722
+ };
723
+
571
724
  /**
572
- * May `by` supersede `target`? Same type, same known session, later in the
573
- * file, and a self verdict never replaces an independent one.
725
+ * May `by` supersede `target`? Same type, same known session, same branch
726
+ * (`onBranch`: the target belongs to `by`'s branch, which follows renames),
727
+ * for a verdict the same kind, later in the file, and a self verdict never
728
+ * replaces an independent one.
574
729
  */
575
730
  export function supersedeAllowed(
576
- by: Pick<ReadRow, "type" | "actor"> & { self?: unknown },
577
- target: Pick<ReadRow, "type" | "actor"> & { self?: unknown },
731
+ by: SupersedeRow,
732
+ target: SupersedeRow,
733
+ onBranch: (target: SupersedeRow) => boolean = (row) =>
734
+ (by.branch ?? null) === (row.branch ?? null),
578
735
  ): string | null {
579
736
  if (by.type !== target.type) return `a ${by.type} row cannot supersede a ${target.type} row`;
580
737
  if (!by.actor.session || by.actor.session !== target.actor.session)
581
738
  return "only the session that wrote a row can supersede it";
739
+ if (!onBranch(target))
740
+ return `the row is on another branch (${String(target.branch ?? "none")}); a row supersedes only one on its own branch`;
741
+ if (by.type === "verdict" && (by.kind ?? "review") !== (target.kind ?? "review"))
742
+ return `a ${String(by.kind ?? "review")} verdict cannot supersede a ${String(target.kind ?? "review")} verdict; record it with --kind ${String(target.kind ?? "review")}`;
582
743
  if (by.type === "verdict" && by.self === true && target.self !== true)
583
744
  return "a self verdict cannot replace an independent verdict";
584
745
  return null;
585
746
  }
586
747
 
587
- export type LedgerRead = { path: string; rows: ReadRow[]; skipped: number };
748
+ export type UnverifiedRow = {
749
+ seq: number;
750
+ id: string;
751
+ type: string;
752
+ branch: string | null;
753
+ reason: UnverifiedReason;
754
+ };
755
+ /** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
756
+ export type LedgerIntegrity = { chained: number; legacy: number; unverified: UnverifiedRow[] };
757
+ export type LedgerRead = {
758
+ path: string;
759
+ rows: ReadRow[];
760
+ skipped: number;
761
+ /** Only when read with `{ integrity: true }` (hashing every line has a cost). */
762
+ integrity: LedgerIntegrity | null;
763
+ };
588
764
 
589
- /** Every valid row in append order. A missing file is empty. */
590
- export function readLedger(cwd: string): LedgerResult<LedgerRead> {
765
+ /**
766
+ * Every valid row in append order. A missing file is empty. `integrity`
767
+ * verifies the hash chain too (`ledger check`, `verify-integrity`); other
768
+ * readers, such as the write gate, skip that cost.
769
+ */
770
+ export function readLedger(
771
+ cwd: string,
772
+ options: { integrity?: boolean } = {},
773
+ ): LedgerResult<LedgerRead> {
591
774
  const file = ledgerPath(cwd);
592
775
  if (!file.ok) return file;
593
776
  let text = "";
@@ -595,14 +778,32 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
595
778
  text = fs.readFileSync(file.value, "utf8");
596
779
  } catch (error) {
597
780
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
598
- return { ok: true, value: { path: file.value, rows: [], skipped: 0 } };
781
+ return {
782
+ ok: true,
783
+ value: {
784
+ path: file.value,
785
+ rows: [],
786
+ skipped: 0,
787
+ integrity: options.integrity ? { chained: 0, legacy: 0, unverified: [] } : null,
788
+ },
789
+ };
599
790
  return err("unavailable", `cannot read ${file.value}: ${(error as Error).message}`);
600
791
  }
601
792
  const rows: ReadRow[] = [];
602
793
  let skipped = 0;
794
+ const integrity: LedgerIntegrity = { chained: 0, legacy: 0, unverified: [] };
795
+ // Hashes of every earlier line; null stands for the start of the file.
796
+ // Lock-free appenders racing on one file chain onto whatever line they saw
797
+ // last, which can be dozens of lines back, so any earlier line is a valid
798
+ // link. An edited row fails its own hash and breaks every link to it.
799
+ const earlier = new Set<string | null>([null]);
800
+ let chainStarted = false;
603
801
  for (const raw of text.split("\n")) {
604
802
  const trimmed = raw.trim();
605
803
  if (!trimmed) continue;
804
+ // Checked after this line is added: a row cannot name its own line.
805
+ const linked = (prevHash: string | null): boolean => earlier.has(prevHash);
806
+ if (options.integrity) earlier.add(sha256(trimmed));
606
807
  let parsed: unknown;
607
808
  try {
608
809
  parsed = JSON.parse(trimmed);
@@ -614,8 +815,21 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
614
815
  skipped += 1;
615
816
  continue;
616
817
  }
818
+ let rowIntegrity: RowIntegrity | undefined = options.integrity ? "verified" : undefined;
819
+ let reason: UnverifiedReason | null = null;
820
+ if (!options.integrity) {
821
+ // not verified on this read
822
+ } else if (typeof parsed.rowHash === "string") {
823
+ chainStarted = true;
824
+ const { rowHash, ...body } = parsed;
825
+ const prevHash = str(parsed.prevHash);
826
+ if (chainHash(prevHash, JSON.stringify(body)) !== rowHash) reason = "hash_mismatch";
827
+ else if (!linked(prevHash)) reason = "broken_link";
828
+ } else if (chainStarted) reason = "unsigned";
829
+ else rowIntegrity = "legacy";
830
+ if (reason) rowIntegrity = "unverified";
617
831
  const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
618
- rows.push({
832
+ const row: ReadRow = {
619
833
  ...parsed,
620
834
  v: typeof parsed.v === "number" ? parsed.v : LEDGER_VERSION,
621
835
  id: parsed.id as string,
@@ -635,17 +849,42 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
635
849
  seq: rows.length + 1,
636
850
  superseded: false,
637
851
  supersedeIgnored: false,
638
- });
852
+ ...(rowIntegrity ? { integrity: rowIntegrity } : {}),
853
+ };
854
+ rows.push(row);
855
+ if (!options.integrity) continue;
856
+ if (rowIntegrity === "verified") integrity.chained += 1;
857
+ else if (rowIntegrity === "legacy") integrity.legacy += 1;
858
+ else if (reason)
859
+ integrity.unverified.push({
860
+ seq: row.seq,
861
+ id: row.id,
862
+ type: row.type,
863
+ branch: row.branch,
864
+ reason,
865
+ });
639
866
  }
640
- // Only a well-formed supersede link counts; anything else is ignored.
867
+ // Only a well-formed supersede link counts; anything else is ignored. A
868
+ // link across branch names is checked against the renames (reflog read
869
+ // only for such links, once per branch).
870
+ const scopes = new Map<string, BranchScope>();
871
+ const scopeOf = (branch: string): BranchScope => {
872
+ let scope = scopes.get(branch);
873
+ if (!scope) scopes.set(branch, (scope = branchScope(cwd, branch)));
874
+ return scope;
875
+ };
641
876
  const byId = new Map<string, ReadRow>();
642
877
  for (const row of rows) {
643
878
  const target = row.supersedes ? byId.get(row.supersedes) : undefined;
644
- if (target && supersedeAllowed(row, target) === null) target.superseded = true;
879
+ if (target && supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null)
880
+ target.superseded = true;
645
881
  else if (row.supersedes) row.supersedeIgnored = true;
646
882
  if (!byId.has(row.id)) byId.set(row.id, row);
647
883
  }
648
- return { ok: true, value: { path: file.value, rows, skipped } };
884
+ return {
885
+ ok: true,
886
+ value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
887
+ };
649
888
  }
650
889
 
651
890
  export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
@@ -795,6 +1034,12 @@ export type VerdictCheck = {
795
1034
  selfVerdict: ReadRow | null;
796
1035
  verdicts: VerdictEntry[];
797
1036
  authors: string[];
1037
+ /**
1038
+ * Things a human should read before trusting the result; none of them
1039
+ * blocks (D18): unverified ledger rows on the branch, and an author's own
1040
+ * failure newer than the accepted pass.
1041
+ */
1042
+ warnings: string[];
798
1043
  };
799
1044
 
800
1045
  const isLiveVerdict = (row: ReadRow): boolean =>
@@ -814,22 +1059,80 @@ function trailerSessions(cwd: string, base: string | null, head: string | null):
814
1059
  .filter(Boolean);
815
1060
  }
816
1061
 
1062
+ /** Does a row belong to a branch? Its own name, or an earlier name up to the rename. */
1063
+ export type BranchScope = (row: Pick<ReadRow, "branch" | "at">) => boolean;
1064
+
1065
+ const RENAMED = /^branch: renamed refs\/heads\/(.+) to refs\/heads\/(.+)$/iu;
1066
+
1067
+ /**
1068
+ * The rows of `branch`, following `git branch -m` through the branch's
1069
+ * reflog (git moves the reflog with the branch): a row keyed to an earlier
1070
+ * name counts until that rename, so a name reused later does not leak in.
1071
+ */
1072
+ export function branchScope(cwd: string, branch: string): BranchScope {
1073
+ const renamedAt = new Map<string, number>();
1074
+ // The branch's oldest reflog entry (its creation): rows of an older,
1075
+ // deleted branch that once had one of its earlier names predate it.
1076
+ let since = Number.NEGATIVE_INFINITY;
1077
+ if (safeRef(branch)) {
1078
+ const log = git(cwd, [
1079
+ "log",
1080
+ "-g",
1081
+ "--date=unix",
1082
+ "--format=%gd%x09%gs",
1083
+ `refs/heads/${branch}`,
1084
+ "--",
1085
+ ]);
1086
+ for (const line of (log ?? "").split("\n")) {
1087
+ const [selector = "", subject = ""] = line.split("\t");
1088
+ const seconds = Number(/@\{(\d+)\}$/u.exec(selector)?.[1]);
1089
+ if (!Number.isFinite(seconds)) continue;
1090
+ // Newest first: the last entry read is the oldest.
1091
+ since = seconds * 1000;
1092
+ const match = RENAMED.exec(subject);
1093
+ if (!match || match[1] === branch) continue;
1094
+ // Reflog times are whole seconds: the rename's second still counts.
1095
+ const until = (seconds + 1) * 1000;
1096
+ renamedAt.set(match[1], Math.max(renamedAt.get(match[1]) ?? 0, until));
1097
+ }
1098
+ }
1099
+ return (row) => {
1100
+ if (row.branch === branch) return true;
1101
+ const until = row.branch === null ? undefined : renamedAt.get(row.branch);
1102
+ if (until === undefined) return false;
1103
+ const at = Date.parse(row.at);
1104
+ return at >= since && at <= until;
1105
+ };
1106
+ }
1107
+
1108
+ /** `supersedeAllowed`'s branch test for rows on `branch`, following its renames. */
1109
+ const renameAwareBranch = (scopeOf: (branch: string) => BranchScope, branch: unknown) => {
1110
+ return (target: SupersedeRow): boolean => {
1111
+ const own = typeof branch === "string" ? branch : null;
1112
+ const theirs = typeof target.branch === "string" ? target.branch : null;
1113
+ if (own === theirs) return true;
1114
+ if (own === null || theirs === null || typeof target.at !== "string") return false;
1115
+ return scopeOf(own)({ branch: theirs, at: target.at });
1116
+ };
1117
+ };
1118
+
817
1119
  /**
818
1120
  * Sessions that authored `branch`: the union of `commit.recorded` /
819
- * `task.opened` ledger rows and `Workit-Session:` commit trailers on
820
- * `base..head`. Git author name/email is not used: every agent on
821
- * a machine shares it, so it cannot tell sessions apart.
1121
+ * `task.opened` ledger rows (under its current or an earlier name) and
1122
+ * `Workit-Session:` commit trailers on `base..head`. Git author name/email is
1123
+ * not used: every agent on a machine shares it, so it cannot tell sessions
1124
+ * apart.
822
1125
  */
823
1126
  export function authorSessions(
824
1127
  cwd: string,
825
1128
  rows: readonly ReadRow[],
826
1129
  branch: string,
827
1130
  range: { base: string | null; head: string | null } = { base: null, head: null },
1131
+ scope: BranchScope = branchScope(cwd, branch),
828
1132
  ): Set<string> {
829
1133
  const sessions = new Set<string>();
830
1134
  for (const row of rows) {
831
- if (row.branch !== branch || (row.type !== "commit.recorded" && row.type !== "task.opened"))
832
- continue;
1135
+ if (!scope(row) || (row.type !== "commit.recorded" && row.type !== "task.opened")) continue;
833
1136
  const session = str(row.session) ?? row.actor.session;
834
1137
  if (session) sessions.add(session);
835
1138
  }
@@ -847,7 +1150,8 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
847
1150
 
848
1151
  /**
849
1152
  * The effective verdict of one kind, walking rows in order: a self verdict
850
- * never displaces an independent one, and an independent failed/blocked
1153
+ * never displaces a current independent one (a stale one it does, so the
1154
+ * author's pass on new code is not hidden behind it), and an independent failed/blocked
851
1155
  * verdict sticks, for the code it judged, until an independent verdict from a
852
1156
  * different session (or its own session's supersede) replaces it. A verdict
853
1157
  * from the same session on different code (`sameCode` false: a real change,
@@ -858,6 +1162,7 @@ function effectiveOf(
858
1162
  rows: readonly ReadRow[],
859
1163
  authors: Set<string>,
860
1164
  sameCode: (failed: ReadRow, row: ReadRow) => boolean,
1165
+ isCurrent: (row: ReadRow) => boolean,
861
1166
  ): ReadRow | null {
862
1167
  let effective: ReadRow | null = null;
863
1168
  for (const row of rows) {
@@ -867,7 +1172,7 @@ function effectiveOf(
867
1172
  }
868
1173
  const rowIndependent = independenceReasons(row, authors).length === 0;
869
1174
  const effIndependent = independenceReasons(effective, authors).length === 0;
870
- if (effIndependent && !rowIndependent) continue;
1175
+ if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
871
1176
  if (
872
1177
  effIndependent &&
873
1178
  FAILING.has(String(effective.result)) &&
@@ -925,6 +1230,7 @@ function restackCarries(
925
1230
  export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
926
1231
  const head = branchHead(cwd, branch);
927
1232
  const fallbackBase = defaultBase(cwd);
1233
+ const scope = branchScope(cwd, branch);
928
1234
  const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
929
1235
  const keysAt = (base: string | null, at: string | null) => {
930
1236
  if (!base || !at) return { patchId: null, diffHash: null };
@@ -937,26 +1243,38 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
937
1243
  return key;
938
1244
  };
939
1245
  const keyFor = (base: string | null) => keysAt(base, head);
1246
+ const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1247
+ const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
1248
+ return basis === "stale" && restackCarries(rows, branch, row.head, head) ? "carried" : basis;
1249
+ };
940
1250
  // A newer row is judged under the failure's own base, so a row recorded
941
- // with a different --base cannot make unchanged code look changed.
942
- const sameCode = (failed: ReadRow, row: ReadRow): boolean =>
943
- verdictBasis(failed, {
944
- head: row.head,
945
- ...keysAt(failed.base ?? fallbackBase, row.head),
946
- }) !== "stale" || restackCarries(rows, branch, failed.head, row.head);
947
- const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
1251
+ // with a different --base cannot make unchanged code look changed. When
1252
+ // both rows were keyed against the same base commit, the newer row's own
1253
+ // stored keys are that comparison already: no git call per row.
1254
+ const sameCode = (failed: ReadRow, row: ReadRow): boolean => {
1255
+ if (failed.head && failed.head === row.head) return true;
1256
+ const base = failed.base ?? fallbackBase;
1257
+ const stored =
1258
+ (row.base ?? fallbackBase) === base && row.baseSha !== null && row.baseSha === failed.baseSha;
1259
+ const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
1260
+ return (
1261
+ verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
1262
+ restackCarries(rows, branch, failed.head, row.head)
1263
+ );
1264
+ };
1265
+ const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
948
1266
  const byKind = new Map<string, ReadRow[]>();
949
1267
  for (const row of rows)
950
- if (row.branch === branch && isLiveVerdict(row)) {
1268
+ if (isLiveVerdict(row) && scope(row)) {
951
1269
  const kind = typeof row.kind === "string" ? row.kind : "review";
952
1270
  byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
953
1271
  }
954
1272
  const verdicts: VerdictEntry[] = [];
1273
+ const warnings: string[] = [];
955
1274
  for (const [kind, list] of byKind) {
956
- const row = effectiveOf(list, authors, sameCode);
1275
+ const row = effectiveOf(list, authors, sameCode, (candidate) => basisOf(candidate) !== "stale");
957
1276
  if (!row) continue;
958
- let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
959
- if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
1277
+ const basis = basisOf(row);
960
1278
  const current = basis !== "stale";
961
1279
  const independence = independenceReasons(row, authors);
962
1280
  const reasons: RejectReason[] = [
@@ -973,7 +1291,25 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
973
1291
  reasons,
974
1292
  verdict: row,
975
1293
  });
1294
+ // The author's own failure never displaces an independent pass, but it
1295
+ // must not vanish either.
1296
+ if (reasons.length === 0)
1297
+ for (const later of list)
1298
+ if (
1299
+ later.seq > row.seq &&
1300
+ independenceReasons(later, authors).length > 0 &&
1301
+ FAILING.has(String(later.result)) &&
1302
+ basisOf(later) !== "stale"
1303
+ )
1304
+ warnings.push(
1305
+ `the author's own (self) ${String(later.result)} ${kind} verdict ${later.id} by ${later.actor.session ?? "no session"} on the current code is newer than the accepted pass ${row.id}: read it before merging`,
1306
+ );
976
1307
  }
1308
+ for (const row of rows)
1309
+ if (row.integrity === "unverified" && scope(row))
1310
+ warnings.push(
1311
+ `ledger row ${row.id} (${row.type}) on ${row.branch ?? branch} is unverified: the workit CLI did not write it or it changed afterwards (workit ledger verify-integrity)`,
1312
+ );
977
1313
  verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
978
1314
  const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
979
1315
  const failing = verdicts.find(
@@ -1014,6 +1350,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1014
1350
  accepted,
1015
1351
  verdicts,
1016
1352
  authors: [...authors].toSorted(),
1353
+ warnings,
1017
1354
  };
1018
1355
  }
1019
1356
 
@@ -1027,6 +1364,8 @@ export type RecordContext = {
1027
1364
  base?: string | null;
1028
1365
  pr?: number;
1029
1366
  supersedes?: string;
1367
+ /** Where `actor.session` came from (verdicts record it). Default: the environment. */
1368
+ sessionSource?: SessionSource;
1030
1369
  now?: Date;
1031
1370
  };
1032
1371
 
@@ -1058,13 +1397,23 @@ const required = (value: string | undefined | null, flag: string): LedgerResult<
1058
1397
  };
1059
1398
 
1060
1399
  /** Refuse a supersede link that the reader would ignore. */
1061
- function checkSupersede(context: RecordContext, type: string, self?: boolean): LedgerResult<void> {
1400
+ function checkSupersede(
1401
+ context: RecordContext,
1402
+ type: string,
1403
+ row: { self?: boolean; kind?: string; branch?: string | null } = {},
1404
+ ): LedgerResult<void> {
1062
1405
  if (!context.supersedes) return { ok: true, value: undefined };
1063
1406
  const ledger = readLedger(context.cwd);
1064
1407
  if (!ledger.ok) return ledger;
1065
- const target = ledger.value.rows.find((row) => row.id === context.supersedes);
1408
+ const target = ledger.value.rows.find((candidate) => candidate.id === context.supersedes);
1066
1409
  if (!target) return err("not_found", `no ledger row ${context.supersedes} to supersede`);
1067
- const problem = supersedeAllowed({ type, actor: context.actor, self }, target);
1410
+ const branch =
1411
+ row.branch === undefined ? (context.branch ?? currentBranch(context.cwd)) : row.branch;
1412
+ const problem = supersedeAllowed(
1413
+ { type, actor: context.actor, ...row, branch },
1414
+ target,
1415
+ renameAwareBranch((name) => branchScope(context.cwd, name), branch),
1416
+ );
1068
1417
  return problem
1069
1418
  ? err("invalid_input", `--supersedes: ${problem}`)
1070
1419
  : { ok: true, value: undefined };
@@ -1255,6 +1604,23 @@ export function clearStanding(
1255
1604
  );
1256
1605
  }
1257
1606
 
1607
+ /**
1608
+ * Is `branch` checked out, with uncommitted changes, in another worktree of
1609
+ * this repository? (`codeKey` sees only the cwd's own worktree.)
1610
+ */
1611
+ function dirtyElsewhere(cwd: string, branch: string): boolean {
1612
+ const list = git(cwd, ["worktree", "list", "--porcelain"]);
1613
+ let worktree: string | null = null;
1614
+ for (const line of (list ?? "").split("\n")) {
1615
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
1616
+ else if (line === `branch refs/heads/${branch}` && worktree) {
1617
+ const status = gitRun(worktree, ["status", "--porcelain", "--untracked-files=normal"]);
1618
+ return status.ok && status.stdout.trim().length > 0;
1619
+ }
1620
+ }
1621
+ return false;
1622
+ }
1623
+
1258
1624
  /**
1259
1625
  * Record an agent-asserted verdict on a branch's committed head. Refused on a
1260
1626
  * dirty worktree (the head would not be what was judged) and, for an author
@@ -1302,7 +1668,7 @@ export function recordVerdict(
1302
1668
  if (!ledger.ok) return ledger;
1303
1669
  const key = codeKey(context.cwd, { branch, base: context.base });
1304
1670
  if (!key.head) return err("not_found", `branch "${branch}" has no commit to judge`);
1305
- if (key.dirty === true)
1671
+ if (key.dirty === true || (key.dirty === null && dirtyElsewhere(context.cwd, branch)))
1306
1672
  return err(
1307
1673
  "blocked",
1308
1674
  `dirty_worktree: ${branch} has uncommitted changes, so its head is not what was judged`,
@@ -1327,7 +1693,7 @@ export function recordVerdict(
1327
1693
  ? "have a non-author session record the verdict: spawn a verifier with its own WORKIT_SESSION_ID=<lead>-v<n> (Claude Code: subagents get one from the SubagentStart hook); or, as the author, pass --self (self-reviewed, never accepted)"
1328
1694
  : "run the verifier as a separate session: the lead spawns it with its own WORKIT_SESSION_ID (Claude Code: subagents get one from the SubagentStart hook)",
1329
1695
  );
1330
- const link = checkSupersede(context, "verdict", self);
1696
+ const link = checkSupersede(context, "verdict", { self, kind, branch });
1331
1697
  if (!link.ok) return link;
1332
1698
  return appendRow<VerdictRow>(
1333
1699
  context.cwd,
@@ -1341,6 +1707,7 @@ export function recordVerdict(
1341
1707
  observer: "agent_asserted",
1342
1708
  self,
1343
1709
  selfReason,
1710
+ sessionSource: context.sessionSource ?? "env",
1344
1711
  evidenceRefs: input.evidenceRefs ?? [],
1345
1712
  },
1346
1713
  { now: context.now },
@@ -1373,7 +1740,12 @@ export type RowSummary = {
1373
1740
  };
1374
1741
 
1375
1742
  /** Row types that count only when an observing verb wrote them. */
1376
- const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", "merge.unverified", ...PR_ROW_TYPES]);
1743
+ const OBSERVED_ONLY: ReadonlySet<string> = new Set([
1744
+ "check",
1745
+ "merge.unverified",
1746
+ "policy.judged",
1747
+ ...PR_ROW_TYPES,
1748
+ ]);
1377
1749
 
1378
1750
  export function rowLabels(row: ReadRow): RowSummary["labels"] {
1379
1751
  const labels: RowSummary["labels"] = [];
@@ -1395,8 +1767,14 @@ export function summarizeRow(row: ReadRow): RowSummary {
1395
1767
  summary = `${text("what")} (why: ${text("why")}; cost if wrong: ${text("costIfWrong")})`;
1396
1768
  break;
1397
1769
  case "verdict":
1398
- summary = `${text("result")} [${text("kind") || "review"}] at ${(row.head ?? "?").slice(0, 12)}${row.self === true ? ` (self${text("selfReason") === "no_session" ? ": no session" : ""})` : ""}: ${text("how")}`;
1770
+ summary = `${text("result")} [${text("kind") || "review"}] at ${(row.head ?? "?").slice(0, 12)}${row.self === true ? ` (self${text("selfReason") === "no_session" ? ": no session" : ""})` : ""}${text("sessionSource") === "flag" ? ` (session ${row.actor.session ?? "?"} named by --session)` : ""}: ${text("how")}`;
1399
1771
  break;
1772
+ case "policy.judged": {
1773
+ const lifted = Array.isArray(row.lifted) ? row.lifted.map(String) : [];
1774
+ const requirements = Array.isArray(row.requirements) ? row.requirements.map(String) : [];
1775
+ summary = `judged by ${row.actor.session ?? "no session"}: ${requirements.length ? requirements.join(", ") : "no requirements"}${lifted.length ? `; lifted ${lifted.join(", ")} (why: ${text("why") || "none given"})` : ""}`;
1776
+ break;
1777
+ }
1400
1778
  case "handoff":
1401
1779
  summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
1402
1780
  break;