@brainervirus/workit-core 8.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.3.0",
3
+ "version": "8.4.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": [
@@ -107,16 +107,19 @@ const mcp = fs.existsSync(mcpPath)
107
107
  const mergedMcp = mergeCursorMcp(mcp, "workit", server);
108
108
  fs.writeFileSync(mcpPath, JSON.stringify(mergedMcp.config, null, 2) + "\n");
109
109
 
110
- // The plugin'"'"'s own hooks-cursor.json ships the npx pin; the local-dist
111
- // install swaps its sessionStart entry to node against the installed dist.
112
- if (localDist) {
113
- const hooksPath = path.join(pluginDir, "hooks", "hooks-cursor.json");
114
- const hooks = fs.existsSync(hooksPath)
115
- ? JSON.parse(fs.readFileSync(hooksPath, "utf8"))
116
- : { version: 1, hooks: {} };
117
- const mergedHooks = mergeCursorHooks(hooks, cursorHookLocalDistEntry(pluginDir));
110
+ // The shipped hooks-cursor.json reaches the launcher through
111
+ // ${CURSOR_PLUGIN_ROOT}, which only a Marketplace install needs. A local
112
+ // install writes the absolute launcher path into every event so hooks do not
113
+ // depend on Cursor expanding it; --local-dist also points sessionStart at the
114
+ // installed dist.
115
+ const hooksPath = path.join(pluginDir, "hooks", "hooks-cursor.json");
116
+ const hooks = fs.existsSync(hooksPath)
117
+ ? JSON.parse(fs.readFileSync(hooksPath, "utf8"))
118
+ : { version: 1, hooks: {} };
119
+ const sessionStart = localDist ? cursorHookLocalDistEntry(pluginDir) : cursorHooksEntry(pluginDir);
120
+ const mergedHooks = mergeCursorHooks(hooks, { command: sessionStart.command }, pluginDir);
121
+ if (mergedHooks.changed.length > 0)
118
122
  fs.writeFileSync(hooksPath, JSON.stringify(mergedHooks.config, null, 2) + "\n");
119
- }
120
123
  '
121
124
 
122
125
  # DG-09: verify the just-written Cursor registration with the shared offline doctor.
@@ -11,9 +11,9 @@ description: Build a requested change in small verified steps - follow local pat
11
11
  neighbour that already does something similar. Copy its patterns, names and
12
12
  error handling. Repo rules (AGENTS.md, CLAUDE.md, lint config) win.
13
13
  2. On the default branch? Branch first: `workit git branch --kind feature --slug <s>`.
14
- Tracking it? Judge once: `workit policy assess --judge risk=normal
15
- behavior=yes product-choice=no plan=no` (trivial and no for a mechanical
16
- fix); Workit derives what close needs.
14
+ Tracking it? Judge once: `workit policy assess --judge risk=normal behavior=yes
15
+ product-choice=no plan=no` (trivial and no for a mechanical fix). Already
16
+ judged? Cite the plan with `--ref <path>` or record the decision; don't re-judge.
17
17
  3. Small steps that each leave the tree green. Behavior change: write the
18
18
  acceptance as Given/When/Then and see a test fail first (workit-bdd).
19
19
  Mechanical change: the existing checks are enough. No tautological tests:
@@ -226,7 +226,7 @@ const productDecision = (task: TaskRecord, ledger: LedgerView): RuleStatus => {
226
226
  : {
227
227
  met: false,
228
228
  reason:
229
- 'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or re-judge productChoiceOpen=false if it is not open)',
229
+ 'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or, if it is not open, `workit policy assess --judge product-choice=no --why "<reason>"`)',
230
230
  decisionIds: [],
231
231
  };
232
232
  };
@@ -190,3 +190,57 @@ export const judgeTokens = (tokens: string[]): Json => {
190
190
  }
191
191
  return out;
192
192
  };
193
+
194
+ /** The judgments that, while true, block working-tree writes (S17). */
195
+ const BLOCKING = ["productChoiceOpen", "needsPlan"] as const;
196
+ type Blocking = (typeof BLOCKING)[number];
197
+
198
+ /** The before-write blockers a judgment lifts: true before, false now. */
199
+ export const liftedBlockers = (previous: Judgment | null, next: Judgment): Blocking[] =>
200
+ BLOCKING.filter((field) => previous?.[field] === true && next[field] === false);
201
+
202
+ /**
203
+ * Of the lifted blockers, those `session` itself judged true earlier on this
204
+ * task (M6): lifting your own blocker needs a recorded reason. Another
205
+ * session (a lead, the user's) may lift it; the ledger still records it.
206
+ */
207
+ export const ownLiftedBlockers = (
208
+ history: ReadonlyArray<{ provenance: { session: unknown }; data: Judgment }>,
209
+ lifted: readonly Blocking[],
210
+ session: string,
211
+ ): Blocking[] => {
212
+ const handle = (value: unknown): unknown => (isObject(value) ? value.handle : undefined);
213
+ return lifted.filter((field) =>
214
+ history.some((entry) => handle(entry.provenance.session) === session && entry.data[field]),
215
+ );
216
+ };
217
+
218
+ /** The reason sent with this judgment (`--why`, `note=`), not one kept from before. */
219
+ export const givenReason = (raw: unknown): string | null => {
220
+ if (!isObject(raw)) return null;
221
+ for (const [key, value] of Object.entries(raw))
222
+ if (ALIASES.note.includes(squash(key)) && typeof value === "string" && value.trim())
223
+ return value.trim();
224
+ return null;
225
+ };
226
+
227
+ /** Longest call value kept in a ledger summary of a judgment as sent. */
228
+ const SUMMARY_VALUE_CHARS = 80;
229
+
230
+ /**
231
+ * The judgment as sent, for the `policy.judged` ledger row: the calls only.
232
+ * The reason and refs are recorded elsewhere in the row, and long values are
233
+ * cut so the row fits one ledger line.
234
+ */
235
+ export const judgeInputSummary = (raw: unknown): Json => {
236
+ const out: Json = {};
237
+ if (!isObject(raw)) return out;
238
+ for (const [key, value] of Object.entries(raw)) {
239
+ const name = squash(key);
240
+ if (ALIASES.note.includes(name) || ALIASES.refs.includes(name) || LEGACY_KEYS.has(key))
241
+ continue;
242
+ if (typeof value === "string") out[key.slice(0, 40)] = value.slice(0, SUMMARY_VALUE_CHARS);
243
+ else if (typeof value === "boolean" || typeof value === "number") out[key.slice(0, 40)] = value;
244
+ }
245
+ return out;
246
+ };
@@ -157,10 +157,24 @@ export type LockOwnerState = {
157
157
  reason: string;
158
158
  };
159
159
 
160
+ export type ClassifyOptions = {
161
+ /**
162
+ * Task-store locks only: a holder keeps one for a single mutation, so a
163
+ * same-host lock past FOREIGN_LOCK_TTL_MS whose start time cannot be
164
+ * compared is taken as a reused pid (Windows has no start time). Never for
165
+ * the stack lock, which a long synchronous rebase holds without heartbeat.
166
+ */
167
+ reclaimUnverifiedAfterTtl?: boolean;
168
+ };
169
+
170
+ /** How task-store locks (and doctor, which inspects them) classify an owner. */
171
+ export const TASK_STORE_LOCK: ClassifyOptions = { reclaimUnverifiedAfterTtl: true };
172
+
160
173
  export const classifyLockOwner = (
161
174
  payload: unknown,
162
175
  ageMs: number | null,
163
176
  localHost: string = localLockHost(),
177
+ options: ClassifyOptions = {},
164
178
  ): LockOwnerState => {
165
179
  const lock = metadataLockSchema.safeParse(payload);
166
180
  if (!lock.success)
@@ -188,6 +202,20 @@ export const classifyLockOwner = (
188
202
  const currentStart = processStartOf(pid);
189
203
  if (processStart !== null && currentStart !== null && processStart !== currentStart)
190
204
  return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
205
+ // Without both start times (Windows has none) a reused pid looks alive. A
206
+ // task-store holder keeps the lock for one mutation, never minutes, so past
207
+ // the TTL the pid is taken to be reused and the crashed writer's lock is
208
+ // reclaimed (opt-in: see ClassifyOptions).
209
+ if (
210
+ options.reclaimUnverifiedAfterTtl === true &&
211
+ (processStart === null || currentStart === null) &&
212
+ ageMs !== null &&
213
+ ageMs > FOREIGN_LOCK_TTL_MS
214
+ )
215
+ return {
216
+ state: "stale",
217
+ reason: `pid ${pid} is running but the lock is older than its TTL and the pid's start time cannot be checked (reused pid)`,
218
+ };
191
219
  return { state: "live", reason: `held by running pid ${pid}` };
192
220
  };
193
221
 
@@ -257,7 +285,8 @@ export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataL
257
285
  }
258
286
  const owner = parseMetadataLockOrNull(raw);
259
287
  const ageMs = ageOf(lockPath, nowMs);
260
- const verdict = classifyLockOwner(owner, ageMs);
288
+ // The task store's own reclaim rule, so doctor reports what a write will do.
289
+ const verdict = classifyLockOwner(owner, ageMs, localLockHost(), TASK_STORE_LOCK);
261
290
  return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
262
291
  };
263
292
 
@@ -295,7 +324,12 @@ export const clearStaleMetadataLock = (
295
324
  const before = fs.readFileSync(status.path, "utf8");
296
325
  if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
297
326
  if (!options.force) {
298
- const verdict = classifyLockOwner(parseMetadataLockOrNull(before), ageOf(status.path, nowMs));
327
+ const verdict = classifyLockOwner(
328
+ parseMetadataLockOrNull(before),
329
+ ageOf(status.path, nowMs),
330
+ localLockHost(),
331
+ TASK_STORE_LOCK,
332
+ );
299
333
  if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
300
334
  }
301
335
  if (fs.readFileSync(status.path, "utf8") !== before)
@@ -48,7 +48,18 @@ import {
48
48
  import { diffPolicy, resolvePolicy } from "./policy-resolver";
49
49
  import { normalizeOperationInput } from "./operation-input";
50
50
  import { latestJudgment } from "./policy/derive";
51
- import { normalizeJudgment } from "./policy/judgment";
51
+ import {
52
+ givenReason,
53
+ judgeInputSummary,
54
+ liftedBlockers,
55
+ normalizeJudgment,
56
+ ownLiftedBlockers,
57
+ } from "./policy/judgment";
58
+ import { MAX_LINE_BYTES, appendObserved, observedRowBytes } from "../ledger";
59
+
60
+ /** Refs kept in a `policy.judged` row (the task store keeps all of them). */
61
+ const MAX_JUDGED_REFS = 5;
62
+ import { currentBranch, headSha } from "../git/rev";
52
63
  import { resolveAutonomy, type VerificationMode } from "../autonomy";
53
64
 
54
65
  import { sameDirectoryIdentity, TaskStore } from "./task-store";
@@ -959,7 +970,8 @@ export class WorkitCore {
959
970
  if ((this.context.workerId ?? null) !== null)
960
971
  return failure("permission_denied", "helpers cannot change task requirements");
961
972
  this.fillRevisions(input, task.data);
962
- const judged = normalizeJudgment(input.judgment, latestJudgment(task.data));
973
+ const previous = latestJudgment(task.data);
974
+ const judged = normalizeJudgment(input.judgment, previous);
963
975
  if (!judged.ok) return judged;
964
976
  const judgment = judged.data.judgment;
965
977
  const resolved = this.resolve(task.data, judgment);
@@ -967,6 +979,32 @@ export class WorkitCore {
967
979
  if (input.action === "preview") return success(null, null, resolved.data);
968
980
  if (input.action !== "assess")
969
981
  return failure("invalid_transition", "unsupported policy action");
982
+ // M6: a session lifting a before-write blocker it judged itself gives a
983
+ // reason; every assess is recorded in the ledger below either way.
984
+ const lifted = liftedBlockers(previous, judgment);
985
+ const why = givenReason(input.judgment);
986
+ const own = ownLiftedBlockers(task.data.judgments ?? [], lifted, this.context.caller.actor);
987
+ if (own.length && !why)
988
+ return failure(
989
+ "invalid_input",
990
+ `this session judged ${own.join(" and ")} true on this task; lifting it needs a reason: re-run with --why "<reason>" (recorded in the ledger)`,
991
+ );
992
+ // The audit row is sized before anything changes, so a blocker is never
993
+ // lifted with a reason too long to record.
994
+ const audit = this.judgedRow(
995
+ task.data.id,
996
+ input.judgment,
997
+ judgment,
998
+ resolved.data,
999
+ lifted,
1000
+ why,
1001
+ );
1002
+ const bytes = observedRowBytes(audit);
1003
+ if (bytes > MAX_LINE_BYTES)
1004
+ return failure(
1005
+ "invalid_input",
1006
+ `the judgment is too long to record in the ledger (${bytes} bytes; the limit is ${MAX_LINE_BYTES}): shorten --why and pass details by --ref <path>`,
1007
+ );
970
1008
  const changed = this.store.mutateTask(
971
1009
  task.data.id,
972
1010
  input.expectedRevision,
@@ -1001,9 +1039,46 @@ export class WorkitCore {
1001
1039
  trustedNow(this.context),
1002
1040
  );
1003
1041
  if (!changed.ok) return changed;
1042
+ // Best effort: the assessment already stands in the task store.
1043
+ appendObserved(this.store.root, audit);
1004
1044
  return success(changed.data.revision, null, changed.data.policy);
1005
1045
  }
1006
1046
 
1047
+ /**
1048
+ * The ledger row that makes a judge call auditable (M6): who judged, what
1049
+ * was sent (the reason once, in `why`), what it resolved to, the
1050
+ * requirements and the blockers it lifted. Refs are capped to keep the row
1051
+ * within one ledger line.
1052
+ */
1053
+ private judgedRow(
1054
+ taskId: string,
1055
+ input: unknown,
1056
+ judgment: Judgment,
1057
+ policy: Policy | null,
1058
+ lifted: readonly string[],
1059
+ why: string | null,
1060
+ ) {
1061
+ const root = this.store.root;
1062
+ const branch = currentBranch(root);
1063
+ const { note: _note, refs, ...calls } = judgment;
1064
+ return {
1065
+ type: "policy.judged",
1066
+ actor: {
1067
+ host: this.context.caller.host,
1068
+ session: this.context.caller.actor,
1069
+ agentId: this.context.workerId ?? null,
1070
+ },
1071
+ branch,
1072
+ head: branch ? headSha(root) : null,
1073
+ taskId,
1074
+ input: judgeInputSummary(input),
1075
+ judgment: { ...calls, refs: refs.slice(0, MAX_JUDGED_REFS), refCount: refs.length },
1076
+ requirements: (policy?.requirements ?? []).map((requirement) => requirement.ruleId),
1077
+ lifted,
1078
+ why,
1079
+ };
1080
+ }
1081
+
1007
1082
  evidence(request: unknown): Result<Entry<Evidence>> {
1008
1083
  const resolved = this.implicitTask("evidence", request);
1009
1084
  if (!resolved.ok) return resolved;
@@ -57,6 +57,7 @@ import {
57
57
  type WorkspaceRecord,
58
58
  } from "./task-contract";
59
59
  import {
60
+ TASK_STORE_LOCK,
60
61
  classifyLockOwner,
61
62
  clearAbandonedReclaimGuard,
62
63
  defaultLockTimeout,
@@ -2146,7 +2147,9 @@ export class TaskStore {
2146
2147
  try {
2147
2148
  ageMs = nowMs - fs.lstatSync(lockPath).mtimeMs;
2148
2149
  } catch {}
2149
- return classifyLockOwner(payload, ageMs).state === "stale";
2150
+ return (
2151
+ classifyLockOwner(payload, ageMs, localLockHost(), TASK_STORE_LOCK).state === "stale"
2152
+ );
2150
2153
  },
2151
2154
  // The library re-checks the bytes before removal, so a lock replaced
2152
2155
  // after classification is never deleted.
@@ -77,8 +77,10 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
77
77
  interaction: { questions: "none", writeBoundary: "partial" },
78
78
  stopControl: "undocumented",
79
79
  shellAvailable: "native",
80
- // Every hook spawns `npx -y --prefer-online …@latest`: a registry round-trip per event.
81
- perEventCost: "npx-network",
80
+ // Hooks run the plugin's launcher against a local runtime (bundled dist or a
81
+ // global bin); only an install with neither falls back to a pinned,
82
+ // --prefer-offline npx.
83
+ perEventCost: "low",
82
84
  capabilities: [
83
85
  {
84
86
  name: "known_product_writes",
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,12 +34,22 @@
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";
43
54
  import { hostSessionFromEnv } from "./host-session";
44
55
  import { resolveStore } from "./store/paths";
@@ -100,7 +111,7 @@ type RowCommon = CodeKey & {
100
111
  type: string;
101
112
  actor: LedgerActor;
102
113
  pr?: number;
103
- /** 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. */
104
115
  supersedes?: string;
105
116
  };
106
117
 
@@ -118,6 +129,12 @@ export type RulingRow = RowCommon & {
118
129
  refs: string[];
119
130
  };
120
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";
121
138
  export type VerdictRow = RowCommon & {
122
139
  type: "verdict";
123
140
  kind: VerdictKind;
@@ -128,6 +145,7 @@ export type VerdictRow = RowCommon & {
128
145
  /** Not an independent verdict: `--self`, or no session to tell it from the author. */
129
146
  self: boolean;
130
147
  selfReason: SelfReason | null;
148
+ sessionSource: SessionSource;
131
149
  evidenceRefs: string[];
132
150
  };
133
151
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
@@ -147,6 +165,14 @@ export type LedgerRow =
147
165
  | HandoffRow
148
166
  | StandingRow
149
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";
150
176
  /** A row as read back: any known or future type, plus its position. */
151
177
  export type ReadRow = RowCommon &
152
178
  Record<string, unknown> & {
@@ -154,6 +180,8 @@ export type ReadRow = RowCommon &
154
180
  superseded: boolean;
155
181
  /** The row names a `supersedes` target the reader refused (D18 supersede rules). */
156
182
  supersedeIgnored: boolean;
183
+ /** Set only when read with `{ integrity: true }`. */
184
+ integrity?: RowIntegrity;
157
185
  };
158
186
 
159
187
  export type LedgerError = {
@@ -223,13 +251,43 @@ export function branchHead(cwd: string, branch: string): string | null {
223
251
  }
224
252
 
225
253
  /**
226
- * The default base for patch-ids: origin's HEAD branch when known, else the
227
- * 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.
228
275
  */
229
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;
230
281
  const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
231
282
  if (originHead && resolveCommit(cwd, originHead)) return originHead;
232
- 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
+ ])
233
291
  if (resolveCommit(cwd, candidate)) return candidate;
234
292
  return null;
235
293
  }
@@ -466,10 +524,58 @@ export function withLedgerLock<T>(
466
524
 
467
525
  type AppendOptions = { now?: Date; forceLock?: boolean };
468
526
 
469
- 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> {
470
576
  const write = (): LedgerResult<void> => {
471
577
  // a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
472
- // 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.
473
579
  const fd = fs.openSync(file, "a+");
474
580
  try {
475
581
  const size = fs.fstatSync(fd).size;
@@ -479,7 +585,9 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
479
585
  fs.readSync(fd, last, 0, 1, size - 1);
480
586
  if (last[0] !== 0x0a) fence = "\n";
481
587
  }
482
- 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);
483
591
  const written = fs.writeSync(fd, bytes);
484
592
  if (written !== bytes.length)
485
593
  return err(
@@ -510,22 +618,40 @@ function appendLine(file: string, line: string, options: AppendOptions): LedgerR
510
618
 
511
619
  export type NewRow<T extends LedgerRow> = Omit<T, "v" | "id" | "at">;
512
620
 
513
- const serialize = (row: object, now: Date) => {
514
- 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)) };
515
631
  return { full, line: `${JSON.stringify(full)}\n` };
516
632
  };
517
633
 
518
634
  function appendRaw<T>(cwd: string, row: object, options: AppendOptions): LedgerResult<T> {
519
- const { full, line } = serialize(row, options.now ?? new Date());
520
- const bytes = Buffer.byteLength(line);
521
- if (bytes > MAX_LINE_BYTES)
522
- return err(
523
- "invalid_input",
524
- `ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
525
- );
526
635
  const file = ledgerPath(cwd);
527
636
  if (!file.ok) return file;
528
- 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
+ );
529
655
  return written.ok ? { ok: true, value: full as T } : written;
530
656
  }
531
657
 
@@ -540,6 +666,12 @@ export function appendRow<T extends LedgerRow>(
540
666
  return appendRaw<T>(cwd, row, options);
541
667
  }
542
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
+
543
675
  /**
544
676
  * Internal API for observing verbs only (S9b `check`, S10 `ci`, S11 `pr`/`git
545
677
  * commit`): append a row the CLI itself observed, stamped
@@ -582,26 +714,63 @@ const normalizeActor = (value: unknown): LedgerActor => {
582
714
  };
583
715
  };
584
716
 
717
+ type SupersedeRow = Pick<ReadRow, "type" | "actor"> & {
718
+ self?: unknown;
719
+ branch?: unknown;
720
+ kind?: unknown;
721
+ at?: unknown;
722
+ };
723
+
585
724
  /**
586
- * May `by` supersede `target`? Same type, same known session, later in the
587
- * 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.
588
729
  */
589
730
  export function supersedeAllowed(
590
- by: Pick<ReadRow, "type" | "actor"> & { self?: unknown },
591
- 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),
592
735
  ): string | null {
593
736
  if (by.type !== target.type) return `a ${by.type} row cannot supersede a ${target.type} row`;
594
737
  if (!by.actor.session || by.actor.session !== target.actor.session)
595
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")}`;
596
743
  if (by.type === "verdict" && by.self === true && target.self !== true)
597
744
  return "a self verdict cannot replace an independent verdict";
598
745
  return null;
599
746
  }
600
747
 
601
- 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
+ };
602
764
 
603
- /** Every valid row in append order. A missing file is empty. */
604
- 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> {
605
774
  const file = ledgerPath(cwd);
606
775
  if (!file.ok) return file;
607
776
  let text = "";
@@ -609,14 +778,32 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
609
778
  text = fs.readFileSync(file.value, "utf8");
610
779
  } catch (error) {
611
780
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
612
- 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
+ };
613
790
  return err("unavailable", `cannot read ${file.value}: ${(error as Error).message}`);
614
791
  }
615
792
  const rows: ReadRow[] = [];
616
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;
617
801
  for (const raw of text.split("\n")) {
618
802
  const trimmed = raw.trim();
619
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));
620
807
  let parsed: unknown;
621
808
  try {
622
809
  parsed = JSON.parse(trimmed);
@@ -628,8 +815,21 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
628
815
  skipped += 1;
629
816
  continue;
630
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";
631
831
  const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
632
- rows.push({
832
+ const row: ReadRow = {
633
833
  ...parsed,
634
834
  v: typeof parsed.v === "number" ? parsed.v : LEDGER_VERSION,
635
835
  id: parsed.id as string,
@@ -649,17 +849,42 @@ export function readLedger(cwd: string): LedgerResult<LedgerRead> {
649
849
  seq: rows.length + 1,
650
850
  superseded: false,
651
851
  supersedeIgnored: false,
652
- });
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
+ });
653
866
  }
654
- // 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
+ };
655
876
  const byId = new Map<string, ReadRow>();
656
877
  for (const row of rows) {
657
878
  const target = row.supersedes ? byId.get(row.supersedes) : undefined;
658
- if (target && supersedeAllowed(row, target) === null) target.superseded = true;
879
+ if (target && supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null)
880
+ target.superseded = true;
659
881
  else if (row.supersedes) row.supersedeIgnored = true;
660
882
  if (!byId.has(row.id)) byId.set(row.id, row);
661
883
  }
662
- 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
+ };
663
888
  }
664
889
 
665
890
  export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
@@ -809,6 +1034,12 @@ export type VerdictCheck = {
809
1034
  selfVerdict: ReadRow | null;
810
1035
  verdicts: VerdictEntry[];
811
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[];
812
1043
  };
813
1044
 
814
1045
  const isLiveVerdict = (row: ReadRow): boolean =>
@@ -828,22 +1059,80 @@ function trailerSessions(cwd: string, base: string | null, head: string | null):
828
1059
  .filter(Boolean);
829
1060
  }
830
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
+
831
1119
  /**
832
1120
  * Sessions that authored `branch`: the union of `commit.recorded` /
833
- * `task.opened` ledger rows and `Workit-Session:` commit trailers on
834
- * `base..head`. Git author name/email is not used: every agent on
835
- * 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.
836
1125
  */
837
1126
  export function authorSessions(
838
1127
  cwd: string,
839
1128
  rows: readonly ReadRow[],
840
1129
  branch: string,
841
1130
  range: { base: string | null; head: string | null } = { base: null, head: null },
1131
+ scope: BranchScope = branchScope(cwd, branch),
842
1132
  ): Set<string> {
843
1133
  const sessions = new Set<string>();
844
1134
  for (const row of rows) {
845
- if (row.branch !== branch || (row.type !== "commit.recorded" && row.type !== "task.opened"))
846
- continue;
1135
+ if (!scope(row) || (row.type !== "commit.recorded" && row.type !== "task.opened")) continue;
847
1136
  const session = str(row.session) ?? row.actor.session;
848
1137
  if (session) sessions.add(session);
849
1138
  }
@@ -861,7 +1150,8 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
861
1150
 
862
1151
  /**
863
1152
  * The effective verdict of one kind, walking rows in order: a self verdict
864
- * 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
865
1155
  * verdict sticks, for the code it judged, until an independent verdict from a
866
1156
  * different session (or its own session's supersede) replaces it. A verdict
867
1157
  * from the same session on different code (`sameCode` false: a real change,
@@ -872,6 +1162,7 @@ function effectiveOf(
872
1162
  rows: readonly ReadRow[],
873
1163
  authors: Set<string>,
874
1164
  sameCode: (failed: ReadRow, row: ReadRow) => boolean,
1165
+ isCurrent: (row: ReadRow) => boolean,
875
1166
  ): ReadRow | null {
876
1167
  let effective: ReadRow | null = null;
877
1168
  for (const row of rows) {
@@ -881,7 +1172,7 @@ function effectiveOf(
881
1172
  }
882
1173
  const rowIndependent = independenceReasons(row, authors).length === 0;
883
1174
  const effIndependent = independenceReasons(effective, authors).length === 0;
884
- if (effIndependent && !rowIndependent) continue;
1175
+ if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
885
1176
  if (
886
1177
  effIndependent &&
887
1178
  FAILING.has(String(effective.result)) &&
@@ -939,6 +1230,7 @@ function restackCarries(
939
1230
  export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
940
1231
  const head = branchHead(cwd, branch);
941
1232
  const fallbackBase = defaultBase(cwd);
1233
+ const scope = branchScope(cwd, branch);
942
1234
  const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
943
1235
  const keysAt = (base: string | null, at: string | null) => {
944
1236
  if (!base || !at) return { patchId: null, diffHash: null };
@@ -951,26 +1243,38 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
951
1243
  return key;
952
1244
  };
953
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
+ };
954
1250
  // A newer row is judged under the failure's own base, so a row recorded
955
- // with a different --base cannot make unchanged code look changed.
956
- const sameCode = (failed: ReadRow, row: ReadRow): boolean =>
957
- verdictBasis(failed, {
958
- head: row.head,
959
- ...keysAt(failed.base ?? fallbackBase, row.head),
960
- }) !== "stale" || restackCarries(rows, branch, failed.head, row.head);
961
- 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);
962
1266
  const byKind = new Map<string, ReadRow[]>();
963
1267
  for (const row of rows)
964
- if (row.branch === branch && isLiveVerdict(row)) {
1268
+ if (isLiveVerdict(row) && scope(row)) {
965
1269
  const kind = typeof row.kind === "string" ? row.kind : "review";
966
1270
  byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
967
1271
  }
968
1272
  const verdicts: VerdictEntry[] = [];
1273
+ const warnings: string[] = [];
969
1274
  for (const [kind, list] of byKind) {
970
- const row = effectiveOf(list, authors, sameCode);
1275
+ const row = effectiveOf(list, authors, sameCode, (candidate) => basisOf(candidate) !== "stale");
971
1276
  if (!row) continue;
972
- let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
973
- if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
1277
+ const basis = basisOf(row);
974
1278
  const current = basis !== "stale";
975
1279
  const independence = independenceReasons(row, authors);
976
1280
  const reasons: RejectReason[] = [
@@ -987,7 +1291,25 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
987
1291
  reasons,
988
1292
  verdict: row,
989
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
+ );
990
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
+ );
991
1313
  verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
992
1314
  const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
993
1315
  const failing = verdicts.find(
@@ -1028,6 +1350,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
1028
1350
  accepted,
1029
1351
  verdicts,
1030
1352
  authors: [...authors].toSorted(),
1353
+ warnings,
1031
1354
  };
1032
1355
  }
1033
1356
 
@@ -1041,6 +1364,8 @@ export type RecordContext = {
1041
1364
  base?: string | null;
1042
1365
  pr?: number;
1043
1366
  supersedes?: string;
1367
+ /** Where `actor.session` came from (verdicts record it). Default: the environment. */
1368
+ sessionSource?: SessionSource;
1044
1369
  now?: Date;
1045
1370
  };
1046
1371
 
@@ -1072,13 +1397,23 @@ const required = (value: string | undefined | null, flag: string): LedgerResult<
1072
1397
  };
1073
1398
 
1074
1399
  /** Refuse a supersede link that the reader would ignore. */
1075
- 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> {
1076
1405
  if (!context.supersedes) return { ok: true, value: undefined };
1077
1406
  const ledger = readLedger(context.cwd);
1078
1407
  if (!ledger.ok) return ledger;
1079
- const target = ledger.value.rows.find((row) => row.id === context.supersedes);
1408
+ const target = ledger.value.rows.find((candidate) => candidate.id === context.supersedes);
1080
1409
  if (!target) return err("not_found", `no ledger row ${context.supersedes} to supersede`);
1081
- 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
+ );
1082
1417
  return problem
1083
1418
  ? err("invalid_input", `--supersedes: ${problem}`)
1084
1419
  : { ok: true, value: undefined };
@@ -1269,6 +1604,23 @@ export function clearStanding(
1269
1604
  );
1270
1605
  }
1271
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
+
1272
1624
  /**
1273
1625
  * Record an agent-asserted verdict on a branch's committed head. Refused on a
1274
1626
  * dirty worktree (the head would not be what was judged) and, for an author
@@ -1316,7 +1668,7 @@ export function recordVerdict(
1316
1668
  if (!ledger.ok) return ledger;
1317
1669
  const key = codeKey(context.cwd, { branch, base: context.base });
1318
1670
  if (!key.head) return err("not_found", `branch "${branch}" has no commit to judge`);
1319
- if (key.dirty === true)
1671
+ if (key.dirty === true || (key.dirty === null && dirtyElsewhere(context.cwd, branch)))
1320
1672
  return err(
1321
1673
  "blocked",
1322
1674
  `dirty_worktree: ${branch} has uncommitted changes, so its head is not what was judged`,
@@ -1341,7 +1693,7 @@ export function recordVerdict(
1341
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)"
1342
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)",
1343
1695
  );
1344
- const link = checkSupersede(context, "verdict", self);
1696
+ const link = checkSupersede(context, "verdict", { self, kind, branch });
1345
1697
  if (!link.ok) return link;
1346
1698
  return appendRow<VerdictRow>(
1347
1699
  context.cwd,
@@ -1355,6 +1707,7 @@ export function recordVerdict(
1355
1707
  observer: "agent_asserted",
1356
1708
  self,
1357
1709
  selfReason,
1710
+ sessionSource: context.sessionSource ?? "env",
1358
1711
  evidenceRefs: input.evidenceRefs ?? [],
1359
1712
  },
1360
1713
  { now: context.now },
@@ -1387,7 +1740,12 @@ export type RowSummary = {
1387
1740
  };
1388
1741
 
1389
1742
  /** Row types that count only when an observing verb wrote them. */
1390
- 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
+ ]);
1391
1749
 
1392
1750
  export function rowLabels(row: ReadRow): RowSummary["labels"] {
1393
1751
  const labels: RowSummary["labels"] = [];
@@ -1409,8 +1767,14 @@ export function summarizeRow(row: ReadRow): RowSummary {
1409
1767
  summary = `${text("what")} (why: ${text("why")}; cost if wrong: ${text("costIfWrong")})`;
1410
1768
  break;
1411
1769
  case "verdict":
1412
- 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")}`;
1413
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
+ }
1414
1778
  case "handoff":
1415
1779
  summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
1416
1780
  break;