@workit-dev/core 0.0.1 → 1.0.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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
package/src/ledger.ts ADDED
@@ -0,0 +1,2913 @@
1
+ // The run ledger (design §2.1 S13, §2.2; D13, D14, D17, D18): an append-only,
2
+ // repo-wide JSONL file of decisions, rulings, verdicts and handoff notes.
3
+ //
4
+ // - Location (D13): `$(git rev-parse --git-common-dir)/workit/ledger/ledger.jsonl`
5
+ // in a git repo, so every worktree shares one ledger and it survives worktree
6
+ // removal and `git clean`. `<cwd>/.workit/ledger/` is used only when git
7
+ // says the directory is not a repository; any other git failure is
8
+ // `unavailable`, never a silent split into a second ledger.
9
+ // - Concurrency: one row (at most MAX_LINE_BYTES, plus its fence newline) is
10
+ // one `write(2)` on an O_APPEND descriptor. POSIX promises atomicity only
11
+ // for pipes (PIPE_BUF), not regular files; what we rely on is that local
12
+ // filesystems (ext4, xfs, btrfs, apfs, NTFS via FILE_APPEND_DATA) position
13
+ // every O_APPEND write at the end under the inode lock, so small single
14
+ // writes never interleave. That does not hold on network filesystems (NFS,
15
+ // SMB, FUSE), so when statfs reports one the append runs under an advisory
16
+ // lock directory instead. A short write is reported as `unavailable`. Every row is written as
17
+ // `\n<row>\n` in that one write, so a row always starts on a fresh line:
18
+ // a torn tail a killed writer leaves, even one that lands after this
19
+ // writer read the tail, stays on its own line and is skipped, and never
20
+ // swallows an acknowledged row. The cost is a blank line between rows,
21
+ // which the reader skips; ledgers written before the fence read the same.
22
+ // - Reader tolerance (D17): unknown keys are kept and ignored, unparsable
23
+ // lines and rows without the required keys are skipped and counted, and the
24
+ // file is never rewritten. `seq` is the row's position among valid rows (a
25
+ // lock-free appender cannot allocate one); a row supersedes an older one by
26
+ // its `id`, and only a row of the same type, session and branch (and verdict
27
+ // kind) may.
28
+ // - Trust (D18): the threat model is honest mistakes and accidental
29
+ // self-certification, not adversarial agents. `ledger verdict` rows are
30
+ // `observer:"agent_asserted"`; `observer:"workit_cli"` is reserved for rows
31
+ // written by observing verbs through `appendObserved`. `actor.attested` is
32
+ // never trusted. A verdict is `current` when it still applies to the code and
33
+ // `accepted` only when it is current, passing, independent (not self, a
34
+ // known session, not an author) and no current independent verdict fails.
35
+ // Merge gates read only `accepted`.
36
+ // - Verdict keying (§2.2): a verdict stores the HEAD SHA it judged plus
37
+ // `base`, the stable patch-id of `base...head` and an exact diff hash. It is
38
+ // `fresh` (same head; verdicts on a dirty worktree are refused, so the head
39
+ // is the judged tree), `carried` (new head, same patch-id AND same exact
40
+ // diff AND, for a pass, byte-identical own files the base left alone, or
41
+ // a head that is exactly the judged head cleanly rebased (cleanRebase): a
42
+ // rebase that only moved the base; or a trusted verdict whose
43
+ // follow-up change is lane none, or test-only for a unit verdict, see
44
+ // core/policy/carry.ts) or `stale`.
45
+ // - Integrity (D18): every row the CLI appends carries `prevHash` (sha256 of
46
+ // the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
47
+ // running chain. A row without it after the chain began, or whose hashes do
48
+ // not match, is reported as unverified (`workit ledger verify-integrity`, a
49
+ // warning in `ledger check`). Appends are never refused, but verdict gates
50
+ // (`checkVerdicts`) never accept an unverified or pre-chain verdict, nor a
51
+ // `--session` verdict whose id no SubagentStart hook issued, and ignore
52
+ // an unverified row's `supersedes`. The chain is keyless: it stops a row
53
+ // appended or edited by hand, not a forger who recomputes the hashes.
54
+ // The file is created 0600 (POSIX).
55
+ // - Branch renames: rows keyed to a branch's earlier names (from its reflog)
56
+ // count for it, up to the rename.
57
+ //
58
+ // Plain TS over the git binary, plus the configured trunk (vcsConfig) for the
59
+ // default base; no task store.
60
+ import { spawnSync } from "node:child_process";
61
+ import { createHash, randomBytes } from "node:crypto";
62
+ import fs from "node:fs";
63
+ import path from "node:path";
64
+ import { resolveStateDir } from "./core/logger";
65
+ import { hostAwareLockTimeout } from "./core/store-lock";
66
+ import { verdictDrift, type CarryRule, type Drift } from "./core/policy/carry";
67
+ import { defaultBase } from "./git/default-base";
68
+ import { gitDirs, originUrl, withinCall } from "./git/facts";
69
+ import { readBranchCommit } from "./git/refs-read";
70
+ import {
71
+ GIT_TIMEOUTS,
72
+ currentBranch,
73
+ headSha,
74
+ parseRemoteUrl,
75
+ PINNED_DIFF,
76
+ patchId,
77
+ patchIdOfDiff,
78
+ sameOwnChange,
79
+ worktreeTree,
80
+ } from "./git/rev";
81
+ import { hostSessionFromEnv, issuedBinding } from "./host-session";
82
+ import { bumpStandingRev } from "./hooks/standing-signal";
83
+ import { resolveStore } from "./store/paths";
84
+
85
+ export const LEDGER_VERSION = 1;
86
+ /** The largest row (including its newline) that one append may carry. */
87
+ export const MAX_LINE_BYTES = 4096;
88
+
89
+ export const VERDICT_RESULTS = [
90
+ "verified",
91
+ "tests-verified",
92
+ "type-check-only",
93
+ "blocked",
94
+ "failed",
95
+ ] as const;
96
+ export type VerdictResult = (typeof VERDICT_RESULTS)[number];
97
+ const PASSING: ReadonlySet<string> = new Set(["verified", "tests-verified", "type-check-only"]);
98
+ const FAILING: ReadonlySet<string> = new Set(["failed", "blocked"]);
99
+ /** Results that prove a behavior change; `type-check-only` never does (S17). */
100
+ export const STRONG_RESULTS: ReadonlySet<string> = new Set(["verified", "tests-verified"]);
101
+
102
+ /**
103
+ * How the branch's current code was reviewed: `verified` only with an
104
+ * accepted independent verdict; `self-reviewed` when the only current strong
105
+ * verdict is the author's own (`--self`), never shown as verified.
106
+ */
107
+ export type ReviewLabel = "verified" | "self-reviewed" | "unreviewed";
108
+ export const VERDICT_KINDS = ["unit", "live", "perf", "review"] as const;
109
+ export type VerdictKind = (typeof VERDICT_KINDS)[number];
110
+ export const VERDICT_SURFACES = ["ui", "cli", "api"] as const;
111
+ export type VerdictSurface = (typeof VERDICT_SURFACES)[number];
112
+ export type Observer = "agent_asserted" | "workit_cli";
113
+
114
+ /** Row types written by the CLI's own PR verbs (S10/S11); the only PR→branch source. */
115
+ export const PR_ROW_TYPES: ReadonlySet<string> = new Set([
116
+ "pr.created",
117
+ "pr.status",
118
+ "pr.merged",
119
+ "pr.linked",
120
+ ]);
121
+
122
+ /** Who wrote a row. Unverified: the environment says so (D18). */
123
+ export type LedgerActor = {
124
+ host: string;
125
+ session: string | null;
126
+ agentId: string | null;
127
+ };
128
+
129
+ /** The code state a row was recorded against (§2.2). */
130
+ export type CodeKey = {
131
+ branch: string | null;
132
+ head: string | null;
133
+ /** The base ref the patch-id was computed against (e.g. `origin/main`). */
134
+ base: string | null;
135
+ baseSha: string | null;
136
+ patchId: string | null;
137
+ /** sha256 of the exact `base...head` diff (whitespace-sensitive), minus line positions. */
138
+ diffHash: string | null;
139
+ /** Worktree tree key, only when recorded on the checked-out branch. */
140
+ tree: string | null;
141
+ dirty: boolean | null;
142
+ };
143
+
144
+ type RowCommon = CodeKey & {
145
+ v: number;
146
+ id: string;
147
+ at: string;
148
+ type: string;
149
+ actor: LedgerActor;
150
+ pr?: number;
151
+ /** The `id` of an older row of the same type, session and branch (and kind) that this one replaces. */
152
+ supersedes?: string;
153
+ };
154
+
155
+ export type DecisionRow = RowCommon & {
156
+ type: "decision";
157
+ what: string;
158
+ why: string;
159
+ refs: string[];
160
+ /** A tie-breaker agent's call (`--by decider`): never the user's answer, never a verdict. */
161
+ by?: "decider";
162
+ /** Each decider's view when the call was recorded after weighing more than one. */
163
+ views?: string[];
164
+ };
165
+ export type RulingRow = RowCommon & {
166
+ type: "ruling";
167
+ what: string;
168
+ why: string;
169
+ costIfWrong: string;
170
+ refs: string[];
171
+ };
172
+ export type SelfReason = "flag" | "no_session";
173
+ /**
174
+ * Where a verdict's session came from: the environment (the host's hook),
175
+ * `--session` (named by the caller, shown in `ledger check` so a human can
176
+ * spot a claimed verifier id) or `--as` (minted from the environment's).
177
+ */
178
+ export type SessionSource = "env" | "flag" | "as";
179
+ export type VerdictRow = RowCommon & {
180
+ type: "verdict";
181
+ kind: VerdictKind;
182
+ result: VerdictResult;
183
+ how: string;
184
+ surface: VerdictSurface | null;
185
+ observer: Observer;
186
+ /** Not an independent verdict: `--self`, or no session to tell it from the author. */
187
+ self: boolean;
188
+ selfReason: SelfReason | null;
189
+ sessionSource: SessionSource;
190
+ /** The issued session's own subagent presented the token its hook bound the id to. */
191
+ sessionBound?: boolean;
192
+ evidenceRefs: string[];
193
+ /** The configured checks' evidence on the head when an independent verified verdict was recorded (#509). */
194
+ checkEvidence?: { name: string; status: string; via: string | null }[];
195
+ /** Untracked files in the judged worktree when the verdict was recorded (they do not block it). */
196
+ untracked?: string[];
197
+ untrackedCount?: number;
198
+ };
199
+ export type HandoffRow = RowCommon & {
200
+ type: "handoff";
201
+ note: string | null;
202
+ next: string;
203
+ };
204
+ /**
205
+ * A lead's standing order for every worker of a fanout ("no new dependencies").
206
+ * `stop` marks the stop line: running workers stop and report.
207
+ */
208
+ export type StandingRow = RowCommon & {
209
+ type: "standing";
210
+ fanout: string;
211
+ what: string;
212
+ stop?: true;
213
+ };
214
+ /** Ends one standing order (`target`), or every one recorded before it (null). */
215
+ export type StandingClearedRow = RowCommon & {
216
+ type: "standing.cleared";
217
+ fanout: string;
218
+ target: string | null;
219
+ };
220
+
221
+ export type LedgerRow =
222
+ | DecisionRow
223
+ | RulingRow
224
+ | VerdictRow
225
+ | HandoffRow
226
+ | StandingRow
227
+ | StandingClearedRow;
228
+ /**
229
+ * A row's place in the hash chain: `verified` (the CLI wrote it and it is
230
+ * unchanged), `unverified` (see UnverifiedReason) or `legacy` (written before
231
+ * the ledger had a chain).
232
+ */
233
+ export type RowIntegrity = "verified" | "unverified" | "legacy";
234
+ /**
235
+ * unsigned: no chain fields (hand-appended) · hash_mismatch: edited ·
236
+ * broken_link: a row before it was edited or removed · duplicate_id: a copy
237
+ * of an earlier row (a replayed line).
238
+ */
239
+ export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link" | "duplicate_id";
240
+ /** A row as read back: any known or future type, plus its position. */
241
+ export type ReadRow = RowCommon &
242
+ Record<string, unknown> & {
243
+ seq: number;
244
+ superseded: boolean;
245
+ /** The row names a `supersedes` target the reader refused (D18 supersede rules). */
246
+ supersedeIgnored: boolean;
247
+ /** Set only when read with `{ integrity: true }`. */
248
+ integrity?: RowIntegrity;
249
+ /** Why an `unverified` row is unverified. */
250
+ integrityReason?: UnverifiedReason;
251
+ };
252
+
253
+ export type LedgerError = {
254
+ ok: false;
255
+ code: "invalid_input" | "blocked" | "busy" | "unavailable" | "not_found";
256
+ error: string;
257
+ unblock?: string;
258
+ };
259
+ export type LedgerResult<T> = { ok: true; value: T } | LedgerError;
260
+
261
+ const err = (code: LedgerError["code"], error: string, unblock?: string): LedgerError => ({
262
+ ok: false,
263
+ code,
264
+ error,
265
+ ...(unblock ? { unblock } : {}),
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // git plumbing (bounded, never prompts, C locale so messages are matchable)
270
+
271
+ type GitRun = { ok: boolean; stdout: string; stderr: string };
272
+
273
+ const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
274
+ const run = spawnSync("git", args, {
275
+ cwd,
276
+ encoding: "utf8",
277
+ timeout: timeoutMs,
278
+ killSignal: "SIGKILL",
279
+ windowsHide: true,
280
+ maxBuffer: 256 * 1024 * 1024,
281
+ env: {
282
+ ...process.env,
283
+ GIT_TERMINAL_PROMPT: "0",
284
+ GIT_OPTIONAL_LOCKS: "0",
285
+ LC_ALL: "C",
286
+ LANGUAGE: "C",
287
+ },
288
+ });
289
+ return {
290
+ ok: run.status === 0,
291
+ stdout: run.stdout ?? "",
292
+ stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
293
+ };
294
+ };
295
+
296
+ const git = (cwd: string, args: string[]): string | null => {
297
+ const run = gitRun(cwd, args);
298
+ const value = run.ok ? run.stdout.trim() : "";
299
+ return value ? value : null;
300
+ };
301
+
302
+ const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
303
+
304
+ /** The commit a ref resolves to, or null. A local branch is read from the git files when they are plain. */
305
+ export function resolveCommit(cwd: string, ref: string): string | null {
306
+ if (!safeRef(ref)) return null;
307
+ const branch = /^refs\/heads\/(.+)$/u.exec(ref)?.[1];
308
+ return (
309
+ (branch ? readBranchCommit(cwd, branch) : null) ??
310
+ git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`])
311
+ );
312
+ }
313
+
314
+ /** A local branch's tip, falling back to its remote-tracking ref on origin. */
315
+ export function branchHead(cwd: string, branch: string): string | null {
316
+ if (!safeRef(branch)) return null;
317
+ return (
318
+ resolveCommit(cwd, `refs/heads/${branch}`) ??
319
+ resolveCommit(cwd, `refs/remotes/origin/${branch}`)
320
+ );
321
+ }
322
+
323
+ // The default base lives in git/default-base.ts (shared with the review lane).
324
+ export { defaultBase };
325
+
326
+ /**
327
+ * sha256 of the exact `base...head` diff with the pinned options rev.ts uses
328
+ * for patch-ids, plus `--binary`. Unlike `git patch-id --stable` it keeps
329
+ * whitespace, so a re-indent (meaningful in Python, YAML, Makefiles) changes
330
+ * it. Hunk headers lose their line numbers and `index` lines are dropped, so a
331
+ * base change elsewhere in the same file does not. Deterministic across git
332
+ * versions (no `patch-id --verbatim` dependency).
333
+ */
334
+ export function diffHash(cwd: string, base: string, head: string): string | null {
335
+ const diff = binaryRangeDiff(cwd, base, head);
336
+ return diff ? hashOfDiff(diff) : null;
337
+ }
338
+
339
+ /** The pinned `base...head` diff with `--binary`, or null when it fails or is empty. */
340
+ const binaryRangeDiff = (cwd: string, base: string, head: string): string | null => {
341
+ if (!safeRef(base) || !safeRef(head)) return null;
342
+ const diff = gitRun(
343
+ cwd,
344
+ ["diff", ...PINNED_DIFF, "--binary", `${base}...${head}`, "--"],
345
+ GIT_TIMEOUTS.worktree,
346
+ );
347
+ return diff.ok && diff.stdout ? diff.stdout : null;
348
+ };
349
+
350
+ const hashOfDiff = (diff: string): string => {
351
+ const hash = createHash("sha256");
352
+ for (const line of diff.split("\n")) {
353
+ if (line.startsWith("index ")) continue;
354
+ hash.update(line.startsWith("@@ ") ? "@@" : line);
355
+ hash.update("\n");
356
+ }
357
+ return `sha256:${hash.digest("hex")}`;
358
+ };
359
+
360
+ /**
361
+ * patchId and diffHash of one range from a single `git diff` (#284 N6): with
362
+ * `--full-index` already pinned, `--binary` changes only binary files'
363
+ * sections, so a diff without a `GIT binary patch` section is byte for byte
364
+ * the one patchId reads. A range touching a binary file still asks patchId's
365
+ * own diff: git 2.43's patch-id skips binary sections (range-keys.test.ts
366
+ * shows equal ids), but that is not a documented guarantee, and a patch-id
367
+ * must never change (verdicts carry on it).
368
+ */
369
+ export function rangeKeys(
370
+ cwd: string,
371
+ base: string,
372
+ head: string,
373
+ ): { patchId: string | null; diffHash: string | null } {
374
+ const diff = binaryRangeDiff(cwd, base, head);
375
+ if (!diff) return { patchId: patchId(cwd, base, head), diffHash: null };
376
+ return {
377
+ patchId: /^GIT binary patch$/mu.test(diff)
378
+ ? patchId(cwd, base, head)
379
+ : patchIdOfDiff(cwd, diff),
380
+ diffHash: hashOfDiff(diff),
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Whether `to` (on `toBase`) is exactly the judged `from` (on `fromBase`)
386
+ * rebased cleanly onto the moved base: a 3-way merge of `from` onto
387
+ * `merge-base(toBase, to)` with `merge-base(fromBase, from)` as the ancestor
388
+ * has no conflict and yields `to`'s tree. Hunks are anchored to the
389
+ * ancestor, so an edit moved elsewhere in a file, a conflict resolved by
390
+ * hand, or any other change in the branch's files makes the trees differ.
391
+ * False when git cannot tell (no `merge-tree --merge-base`, before 2.40).
392
+ */
393
+ function cleanRebase(
394
+ cwd: string,
395
+ range: { from: string; to: string; fromBase: string; toBase: string },
396
+ ): boolean {
397
+ if (!Object.values(range).every(safeRef)) return false;
398
+ const fromFork = git(cwd, ["merge-base", range.fromBase, range.from]);
399
+ const toFork = git(cwd, ["merge-base", range.toBase, range.to]);
400
+ if (!fromFork || !toFork || fromFork === toFork) return false;
401
+ const merged = gitRun(
402
+ cwd,
403
+ ["merge-tree", "--write-tree", "--no-messages", `--merge-base=${fromFork}`, toFork, range.from],
404
+ GIT_TIMEOUTS.worktree,
405
+ );
406
+ const tree = merged.ok ? merged.stdout.split("\n")[0]?.trim() : null;
407
+ return Boolean(tree) && tree === git(cwd, ["rev-parse", "--verify", "-q", `${range.to}^{tree}`]);
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // store root (D13)
412
+
413
+ export type StoreRoot = { root: string; shared: boolean };
414
+
415
+ /**
416
+ * Where workit state lives for `cwd`: `<git common dir>/workit` (shared by all
417
+ * worktrees of the repo), or `<cwd>/.workit` only when git reports that `cwd`
418
+ * is not inside a repository (or git is not installed). A broken repository
419
+ * or a refused (dubious-ownership) repository is `unavailable`. The task
420
+ * store resolves the same way (store/paths.ts).
421
+ */
422
+ export function storeRoot(cwd: string): LedgerResult<StoreRoot> {
423
+ const location = resolveStore(path.resolve(cwd));
424
+ if (location instanceof Error)
425
+ return err(
426
+ "unavailable",
427
+ location.message.replace(/; fix the repository.*$/u, ""),
428
+ "fix the repository (git status should work here) or run from a non-git directory",
429
+ );
430
+ return { ok: true, value: { root: location.dir, shared: location.shared } };
431
+ }
432
+
433
+ export function ledgerPath(cwd: string): LedgerResult<string> {
434
+ const root = storeRoot(cwd);
435
+ return root.ok ? { ok: true, value: path.join(root.value.root, "ledger", "ledger.jsonl") } : root;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // append
440
+
441
+ /** The acting identity from the environment (host-session.ts: WORKIT_SESSION_ID, else the host's own shell session id). */
442
+ export function actorFromEnv(env: NodeJS.ProcessEnv): LedgerActor {
443
+ const value = (key: string): string | null => {
444
+ const raw = env[key]?.trim();
445
+ return raw ? raw : null;
446
+ };
447
+ const acting = hostSessionFromEnv(env);
448
+ return {
449
+ host: acting.host ?? "cli",
450
+ session: acting.session,
451
+ agentId: value("WORKIT_AGENT_ID"),
452
+ };
453
+ }
454
+
455
+ const newId = (): string => `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
456
+
457
+ // statfs magic numbers of network/userspace filesystems (linux/magic.h).
458
+ const NETWORK_FS_TYPES: ReadonlySet<number> = new Set([
459
+ 0x6969, // NFS
460
+ 0x517b, // SMB
461
+ 0xfe534d42, // SMB2
462
+ 0xff534d42, // CIFS
463
+ 0x65735546, // FUSE (sshfs, rclone, …)
464
+ 0x5346414f, // AFS
465
+ 0x00c36400, // Ceph
466
+ 0x01021997, // 9P
467
+ 0x47504653, // GPFS
468
+ 0x0bd00bd0, // Lustre
469
+ ]);
470
+
471
+ /** True when `type` (statfs f_type on Linux) names a network or FUSE filesystem. */
472
+ export const isNetworkFsType = (type: number): boolean => NETWORK_FS_TYPES.has(type >>> 0);
473
+
474
+ const onNetworkFs = (dir: string): boolean => {
475
+ if (process.platform !== "linux") return false;
476
+ try {
477
+ return isNetworkFsType(fs.statfsSync(dir).type);
478
+ } catch {
479
+ return false;
480
+ }
481
+ };
482
+
483
+ const LOCK_WAIT_MS = 5_000;
484
+ const LOCK_STALE_MS = 30_000;
485
+
486
+ const LOCK_OWNER = "owner";
487
+
488
+ const lockToken = (lock: string): string | null => {
489
+ try {
490
+ return fs.readFileSync(path.join(lock, LOCK_OWNER), "utf8");
491
+ } catch {
492
+ return null;
493
+ }
494
+ };
495
+
496
+ /** Last sign of life: the owner file's mtime, else the directory's. */
497
+ const lockAgeMs = (lock: string): number | null => {
498
+ for (const target of [path.join(lock, LOCK_OWNER), lock]) {
499
+ try {
500
+ return Date.now() - fs.statSync(target).mtimeMs;
501
+ } catch {
502
+ // try the next
503
+ }
504
+ }
505
+ return null;
506
+ };
507
+
508
+ /**
509
+ * Advisory lock directory for network filesystems (`mkdir` and `rename` are
510
+ * atomic on NFS too). The holder writes a random ownership token into it and
511
+ * releases only a lock that still carries its own token. A lock idle for
512
+ * LOCK_STALE_MS is taken over by `reap`, which renames it aside, checks that
513
+ * the renamed lock still holds the token the taker judged stale, and puts it
514
+ * back otherwise. So of two takers racing for one stale lock, at most one
515
+ * removes it, and neither removes a lock the other has since acquired.
516
+ */
517
+ export const ledgerLock = {
518
+ /** mkdir + token; null when the lock is held. */
519
+ acquire(lock: string): string | null {
520
+ try {
521
+ fs.mkdirSync(lock);
522
+ } catch (error) {
523
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
524
+ throw error;
525
+ }
526
+ const token = `${process.pid}-${randomBytes(8).toString("hex")}`;
527
+ fs.writeFileSync(path.join(lock, LOCK_OWNER), token);
528
+ return token;
529
+ },
530
+ /** Remove the lock only if it still carries `token`. */
531
+ release(lock: string, token: string): boolean {
532
+ if (lockToken(lock) !== token) return false;
533
+ const tomb = `${lock}.release-${randomBytes(6).toString("hex")}`;
534
+ try {
535
+ fs.renameSync(lock, tomb);
536
+ } catch {
537
+ return false;
538
+ }
539
+ if (lockToken(tomb) !== token) {
540
+ // Lost a race with a reaper and a new holder: give the lock back.
541
+ try {
542
+ fs.renameSync(tomb, lock);
543
+ } catch {
544
+ // A third holder exists; the renamed lock is not ours to keep.
545
+ }
546
+ return false;
547
+ }
548
+ fs.rmSync(tomb, { recursive: true, force: true });
549
+ return true;
550
+ },
551
+ /** The lock's current token (null when absent or not yet written). */
552
+ token: lockToken,
553
+ /** Take over a lock judged stale while it carried `observed`. */
554
+ reap(lock: string, observed: string | null): boolean {
555
+ const tomb = `${lock}.reap-${randomBytes(6).toString("hex")}`;
556
+ try {
557
+ fs.renameSync(lock, tomb);
558
+ } catch {
559
+ return false; // another taker got there first, or the holder released
560
+ }
561
+ if (lockToken(tomb) === observed) {
562
+ fs.rmSync(tomb, { recursive: true, force: true });
563
+ return true;
564
+ }
565
+ try {
566
+ fs.renameSync(tomb, lock);
567
+ } catch {
568
+ // Someone acquired a fresh lock meanwhile; leave theirs alone.
569
+ fs.rmSync(tomb, { recursive: true, force: true });
570
+ }
571
+ return false;
572
+ },
573
+ };
574
+
575
+ /**
576
+ * Run `fn` holding the ledger's advisory lock; null when it stays held for
577
+ * `waitMs`. The wait blocks the thread, so in-process hosts (OpenCode, Pi,
578
+ * MCP) wait at most 250 ms by default; the CLI keeps LOCK_WAIT_MS.
579
+ */
580
+ export function withLedgerLock<T>(
581
+ file: string,
582
+ fn: () => T,
583
+ waitMs: number = hostAwareLockTimeout(LOCK_WAIT_MS),
584
+ ): T | null {
585
+ const lock = `${file}.lock`;
586
+ const deadline = Date.now() + waitMs;
587
+ let token: string | null = null;
588
+ for (;;) {
589
+ token = ledgerLock.acquire(lock);
590
+ if (token) break;
591
+ const observed = lockToken(lock);
592
+ const age = lockAgeMs(lock);
593
+ if (age !== null && age > LOCK_STALE_MS && ledgerLock.reap(lock, observed)) continue;
594
+ if (Date.now() >= deadline) return null;
595
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
596
+ }
597
+ try {
598
+ return fn();
599
+ } finally {
600
+ ledgerLock.release(lock, token);
601
+ }
602
+ }
603
+
604
+ /** `heldLock`: the caller holds the file's lock (withLedgerLock), so never take it again. */
605
+ type AppendOptions = { now?: Date; forceLock?: boolean; heldLock?: boolean };
606
+
607
+ const sha256 = (text: string): string => createHash("sha256").update(text).digest("hex");
608
+ /** The chain hash of a row: over the previous line's hash and the row without `rowHash`. */
609
+ const chainHash = (prevHash: string | null, body: string): string =>
610
+ sha256(`${prevHash ?? ""}\n${body}`);
611
+ /** Enough tail to hold the previous row (rows are at most MAX_LINE_BYTES). */
612
+ const TAIL_BYTES = 64 * 1024;
613
+
614
+ /**
615
+ * The last complete (newline-terminated) non-empty line before `size`, read
616
+ * from the tail. An unterminated tail is a torn row or another appender's
617
+ * write still in flight, so it is not chained onto. A line longer than the
618
+ * window (a hand-written one) is read whole by widening the window, so the
619
+ * link stays exact.
620
+ */
621
+ const lastLine = (fd: number, size: number): string | null => {
622
+ for (let span = Math.min(size, TAIL_BYTES); span > 0; span = Math.min(size, span * 2)) {
623
+ const buffer = Buffer.alloc(span);
624
+ fs.readSync(fd, buffer, 0, span, size - span);
625
+ const end = buffer.lastIndexOf(0x0a);
626
+ if (end < 0) {
627
+ if (span === size) return null;
628
+ continue;
629
+ }
630
+ // Skip blank lines (a fence) back to the last non-empty complete line.
631
+ let stop = end;
632
+ let text = "";
633
+ while (stop >= 0) {
634
+ const start = buffer.lastIndexOf(0x0a, stop - 1);
635
+ text = buffer.toString("utf8", start + 1, stop).trim();
636
+ if (text) {
637
+ // Its start must be inside the window (or be the file's start).
638
+ if (start >= 0 || span === size) return text;
639
+ break;
640
+ }
641
+ if (start < 0) {
642
+ if (span === size) return null;
643
+ break;
644
+ }
645
+ stop = start;
646
+ }
647
+ if (span === size) return text || null;
648
+ }
649
+ return null;
650
+ };
651
+
652
+ /** A ledger created before 0600 loses its group/other bits on the next append. */
653
+ const tighten = (fd: number): void => {
654
+ try {
655
+ if ((fs.fstatSync(fd).mode & 0o077) !== 0) fs.fchmodSync(fd, 0o600);
656
+ } catch {
657
+ // Not the owner (a shared store): the append still goes through.
658
+ }
659
+ };
660
+
661
+ /** The line to write, given the file's current last line (null: empty file). */
662
+ type LineBuilder = (previous: string | null) => LedgerResult<string>;
663
+
664
+ function appendLine(file: string, build: LineBuilder, options: AppendOptions): LedgerResult<void> {
665
+ const write = (): LedgerResult<void> => {
666
+ // a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
667
+ // peek at the tail to chain onto the last row.
668
+ // 0600: rows name sessions, branches and verdict notes (audit N14).
669
+ const fd = fs.openSync(file, "a+", 0o600);
670
+ try {
671
+ if (process.platform !== "win32") tighten(fd);
672
+ const line = build(lastLine(fd, fs.fstatSync(fd).size));
673
+ if (!line.ok) return line;
674
+ // The leading newline is unconditional and part of the same write: a
675
+ // torn fragment can land after any tail check, so only a fence written
676
+ // with the row keeps the row from starting mid-line (#495).
677
+ const bytes = Buffer.from(`\n${line.value}`);
678
+ const written = fs.writeSync(fd, bytes);
679
+ if (written !== bytes.length)
680
+ return err(
681
+ "unavailable",
682
+ `short write to ${file}: ${written} of ${bytes.length} bytes; the row is torn and will be skipped`,
683
+ );
684
+ return { ok: true, value: undefined };
685
+ } finally {
686
+ fs.closeSync(fd);
687
+ }
688
+ };
689
+ try {
690
+ fs.mkdirSync(path.dirname(file), { recursive: true });
691
+ if (options.heldLock || (!options.forceLock && !onNetworkFs(path.dirname(file))))
692
+ return write();
693
+ const locked = withLedgerLock(file, write);
694
+ return (
695
+ locked ??
696
+ err(
697
+ "busy",
698
+ `ledger lock ${file}.lock is held`,
699
+ "retry; remove the .lock directory if no workit process is running",
700
+ )
701
+ );
702
+ } catch (error) {
703
+ return err("unavailable", `cannot append to ${file}: ${(error as Error).message}`);
704
+ }
705
+ }
706
+
707
+ export type NewRow<T extends LedgerRow> = Omit<T, "v" | "id" | "at">;
708
+
709
+ /**
710
+ * Serialize a row chained onto `previous` (the file's last complete line).
711
+ * Concurrent lock-free appenders may chain onto the same or an older line;
712
+ * the reader accepts a link to any earlier line.
713
+ */
714
+ const sealed = (row: object, id: string, at: string, previous: string | null) => {
715
+ const { prevHash: _prev, rowHash: _row, ...fields } = row as Record<string, unknown>;
716
+ const prevHash = previous === null ? null : sha256(previous);
717
+ const body = { v: LEDGER_VERSION, id, at, ...fields, prevHash };
718
+ const full = { ...body, rowHash: chainHash(prevHash, JSON.stringify(body)) };
719
+ return { full, line: `${JSON.stringify(full)}\n` };
720
+ };
721
+
722
+ function appendRaw<T>(cwd: string, row: object, options: AppendOptions): LedgerResult<T> {
723
+ const file = ledgerPath(cwd);
724
+ if (!file.ok) return file;
725
+ const id = newId();
726
+ const at = (options.now ?? new Date()).toISOString();
727
+ let full: unknown = null;
728
+ const written = appendLine(
729
+ file.value,
730
+ (previous) => {
731
+ const next = sealed(row, id, at, previous);
732
+ const bytes = Buffer.byteLength(next.line);
733
+ if (bytes > MAX_LINE_BYTES)
734
+ return err(
735
+ "invalid_input",
736
+ `ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
737
+ );
738
+ full = next.full;
739
+ return { ok: true, value: next.line };
740
+ },
741
+ options,
742
+ );
743
+ return written.ok ? { ok: true, value: full as T } : written;
744
+ }
745
+
746
+ /** Append one agent-level row (decision, ruling, verdict, handoff). */
747
+ export function appendRow<T extends LedgerRow>(
748
+ cwd: string,
749
+ row: NewRow<T>,
750
+ options: AppendOptions = {},
751
+ ): LedgerResult<T> {
752
+ if ((row as { observer?: unknown }).observer === "workit_cli")
753
+ return err("invalid_input", 'observer "workit_cli" is reserved for appendObserved');
754
+ return appendRaw<T>(cwd, row, options);
755
+ }
756
+
757
+ /** The bytes `appendObserved` would write for `row` (its chain fields included). */
758
+ export function observedRowBytes(row: Record<string, unknown>): number {
759
+ const sample = sealed({ ...row, observer: "workit_cli" }, newId(), new Date().toISOString(), "");
760
+ return Buffer.byteLength(sample.line);
761
+ }
762
+
763
+ /**
764
+ * Internal API for observing verbs only (S9b `check`, S10 `ci`, S11 `pr`/`git
765
+ * commit`): append a row the CLI itself observed, stamped
766
+ * `observer:"workit_cli"`. Never reachable from `workit ledger`.
767
+ */
768
+ export function appendObserved(
769
+ cwd: string,
770
+ row: Record<string, unknown> & { type: string; actor: LedgerActor },
771
+ options: AppendOptions = {},
772
+ ): LedgerResult<Record<string, unknown>> {
773
+ return appendRaw(cwd, { ...row, observer: "workit_cli" }, options);
774
+ }
775
+
776
+ /**
777
+ * A row a host hook observed (a raw shell `git commit`), stamped
778
+ * `observer:"host_hook"`: weaker than the CLI's own observation, but enough
779
+ * to name the session that authored a commit (D18 honest-agent model).
780
+ */
781
+ export function appendHookObserved(
782
+ cwd: string,
783
+ row: Record<string, unknown> & { type: string; actor: LedgerActor },
784
+ ): LedgerResult<Record<string, unknown>> {
785
+ return appendRaw(cwd, { ...row, observer: "host_hook" }, {});
786
+ }
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // read (D17: tolerant)
790
+
791
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
792
+ typeof value === "object" && value !== null && !Array.isArray(value);
793
+
794
+ const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
795
+
796
+ const normalizeActor = (value: unknown): LedgerActor => {
797
+ const actor = isRecord(value) ? value : {};
798
+ return {
799
+ host: str(actor.host) ?? "unknown",
800
+ session: str(actor.session),
801
+ agentId: str(actor.agentId),
802
+ };
803
+ };
804
+
805
+ type SupersedeRow = Pick<ReadRow, "type" | "actor"> & {
806
+ self?: unknown;
807
+ branch?: unknown;
808
+ kind?: unknown;
809
+ at?: unknown;
810
+ };
811
+
812
+ /**
813
+ * May `by` supersede `target`? Same type, same known session, same branch
814
+ * (`onBranch`: the target belongs to `by`'s branch, which follows renames),
815
+ * for a verdict the same kind, later in the file, and a self verdict never
816
+ * replaces an independent one.
817
+ */
818
+ export function supersedeAllowed(
819
+ by: SupersedeRow,
820
+ target: SupersedeRow,
821
+ onBranch: (target: SupersedeRow) => boolean = (row) =>
822
+ (by.branch ?? null) === (row.branch ?? null),
823
+ ): string | null {
824
+ if (by.type !== target.type) return `a ${by.type} row cannot supersede a ${target.type} row`;
825
+ if (!by.actor.session || by.actor.session !== target.actor.session)
826
+ return "only the session that wrote a row can supersede it";
827
+ if (!onBranch(target))
828
+ return `the row is on another branch (${String(target.branch ?? "none")}); a row supersedes only one on its own branch`;
829
+ if (by.type === "verdict" && (by.kind ?? "review") !== (target.kind ?? "review"))
830
+ return `a ${String(by.kind ?? "review")} verdict cannot supersede a ${String(target.kind ?? "review")} verdict; record it with --kind ${String(target.kind ?? "review")}`;
831
+ if (by.type === "verdict" && by.self === true && target.self !== true)
832
+ return "a self verdict cannot replace an independent verdict";
833
+ return null;
834
+ }
835
+
836
+ export type UnverifiedRow = {
837
+ seq: number;
838
+ id: string;
839
+ type: string;
840
+ branch: string | null;
841
+ reason: UnverifiedReason;
842
+ };
843
+ /** The hash chain over the file: rows the CLI wrote, rows from before the chain, and the rest. */
844
+ export type LedgerIntegrity = {
845
+ chained: number;
846
+ legacy: number;
847
+ unverified: UnverifiedRow[];
848
+ };
849
+ export type LedgerRead = {
850
+ path: string;
851
+ rows: ReadRow[];
852
+ skipped: number;
853
+ /** Only when read with `{ integrity: true }` (hashing every line has a cost). */
854
+ integrity: LedgerIntegrity | null;
855
+ };
856
+
857
+ /**
858
+ * Every valid row in append order. A missing file is empty. `integrity`
859
+ * verifies the hash chain too (`ledger check`, `verify-integrity`); other
860
+ * readers, such as the write gate, skip that cost.
861
+ */
862
+ export function readLedger(
863
+ cwd: string,
864
+ options: { integrity?: boolean } = {},
865
+ ): LedgerResult<LedgerRead> {
866
+ const file = ledgerPath(cwd);
867
+ return file.ok ? readLedgerAt(cwd, file.value, options) : file;
868
+ }
869
+
870
+ /** readLedger of the ledger file at `file` (branch renames read in `cwd`). */
871
+ function readLedgerAt(
872
+ cwd: string,
873
+ file: string,
874
+ options: { integrity?: boolean },
875
+ ): LedgerResult<LedgerRead> {
876
+ let text = "";
877
+ let stat: fs.Stats | null = null;
878
+ try {
879
+ stat = fs.statSync(file);
880
+ text = fs.readFileSync(file, "utf8");
881
+ } catch (error) {
882
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
883
+ return {
884
+ ok: true,
885
+ value: {
886
+ path: file,
887
+ rows: [],
888
+ skipped: 0,
889
+ integrity: options.integrity ? { chained: 0, legacy: 0, unverified: [] } : null,
890
+ },
891
+ };
892
+ return err("unavailable", `cannot read ${file}: ${(error as Error).message}`);
893
+ }
894
+ const rows: ReadRow[] = [];
895
+ let skipped = 0;
896
+ const integrity: LedgerIntegrity = { chained: 0, legacy: 0, unverified: [] };
897
+ // Hashes of every earlier line; null stands for the start of the file.
898
+ // Lock-free appenders racing on one file chain onto whatever line they saw
899
+ // last, which can be dozens of lines back, so any earlier line is a valid
900
+ // link. An edited row fails its own hash and breaks every link to it.
901
+ const earlier = new Set<string | null>([null]);
902
+ let chainStarted = false;
903
+ const ids = new Set<string>();
904
+ for (const raw of text.split("\n")) {
905
+ const trimmed = raw.trim();
906
+ if (!trimmed) continue;
907
+ // Checked after this line is added: a row cannot name its own line.
908
+ const linked = (prevHash: string | null): boolean => earlier.has(prevHash);
909
+ if (options.integrity) earlier.add(sha256(trimmed));
910
+ let parsed: unknown;
911
+ try {
912
+ parsed = JSON.parse(trimmed);
913
+ } catch {
914
+ skipped += 1;
915
+ continue;
916
+ }
917
+ if (!isRecord(parsed) || !str(parsed.id) || !str(parsed.type) || !str(parsed.at)) {
918
+ skipped += 1;
919
+ continue;
920
+ }
921
+ let rowIntegrity: RowIntegrity | undefined = options.integrity ? "verified" : undefined;
922
+ let reason: UnverifiedReason | null = null;
923
+ if (!options.integrity) {
924
+ // not verified on this read
925
+ } else if (typeof parsed.rowHash === "string") {
926
+ chainStarted = true;
927
+ const { rowHash, ...body } = parsed;
928
+ const prevHash = str(parsed.prevHash);
929
+ if (chainHash(prevHash, JSON.stringify(body)) !== rowHash) reason = "hash_mismatch";
930
+ else if (!linked(prevHash)) reason = "broken_link";
931
+ } else if (chainStarted) reason = "unsigned";
932
+ else rowIntegrity = "legacy";
933
+ // A replayed copy of an earlier line hashes and links like the original.
934
+ if (options.integrity && !reason && ids.has(parsed.id as string)) reason = "duplicate_id";
935
+ if (options.integrity) ids.add(parsed.id as string);
936
+ if (reason) rowIntegrity = "unverified";
937
+ const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
938
+ const row: ReadRow = {
939
+ ...parsed,
940
+ v: typeof parsed.v === "number" ? parsed.v : LEDGER_VERSION,
941
+ id: parsed.id as string,
942
+ at: parsed.at as string,
943
+ type: parsed.type as string,
944
+ actor: normalizeActor(parsed.actor),
945
+ branch: str(parsed.branch),
946
+ head: str(parsed.head),
947
+ base: str(parsed.base),
948
+ baseSha: str(parsed.baseSha),
949
+ patchId: str(parsed.patchId),
950
+ diffHash: str(parsed.diffHash),
951
+ tree: str(parsed.tree),
952
+ dirty: typeof parsed.dirty === "boolean" ? parsed.dirty : null,
953
+ ...(pr === undefined ? {} : { pr }),
954
+ ...(str(parsed.supersedes) ? { supersedes: parsed.supersedes as string } : {}),
955
+ seq: rows.length + 1,
956
+ superseded: false,
957
+ supersedeIgnored: false,
958
+ ...(rowIntegrity ? { integrity: rowIntegrity } : {}),
959
+ ...(reason ? { integrityReason: reason } : {}),
960
+ };
961
+ rows.push(row);
962
+ if (!options.integrity) continue;
963
+ if (rowIntegrity === "verified") integrity.chained += 1;
964
+ else if (rowIntegrity === "legacy") integrity.legacy += 1;
965
+ else if (reason)
966
+ integrity.unverified.push({
967
+ seq: row.seq,
968
+ id: row.id,
969
+ type: row.type,
970
+ branch: row.branch,
971
+ reason,
972
+ });
973
+ }
974
+ // Only a well-formed supersede link counts; anything else is ignored. A
975
+ // link across branch names is checked against the renames (reflog read
976
+ // only for such links, once per branch). An unverified row's link never
977
+ // counts: a hand-appended row cannot retire another one (N13).
978
+ const scopes = new Map<string, BranchScope>();
979
+ const scopeOf = (branch: string): BranchScope => {
980
+ let scope = scopes.get(branch);
981
+ if (!scope) scopes.set(branch, (scope = branchScope(cwd, branch)));
982
+ return scope;
983
+ };
984
+ const byId = new Map<string, ReadRow>();
985
+ for (const row of rows) {
986
+ const target = row.supersedes ? byId.get(row.supersedes) : undefined;
987
+ if (
988
+ target &&
989
+ row.integrity !== "unverified" &&
990
+ supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null
991
+ )
992
+ target.superseded = true;
993
+ else if (row.supersedes) row.supersedeIgnored = true;
994
+ if (!byId.has(row.id)) byId.set(row.id, row);
995
+ }
996
+ // A read that saw exactly the stat'ed file remembers its chain breaks.
997
+ if (options.integrity && stat && Buffer.byteLength(text) === stat.size)
998
+ tamperCache.set(file, {
999
+ size: stat.size,
1000
+ stamp: fileStamp(stat),
1001
+ breaks: integrity.unverified.filter((row) => TAMPER_REASONS.has(row.reason)),
1002
+ });
1003
+ return {
1004
+ ok: true,
1005
+ value: {
1006
+ path: file,
1007
+ rows,
1008
+ skipped,
1009
+ integrity: options.integrity ? integrity : null,
1010
+ },
1011
+ };
1012
+ }
1013
+
1014
+ /** Chain breaks honest appends never produce: a row edited, removed or moved. */
1015
+ const TAMPER_REASONS: ReadonlySet<UnverifiedReason> = new Set(["hash_mismatch", "broken_link"]);
1016
+ const tamperCache = new Map<string, { size: number; stamp: string; breaks: UnverifiedRow[] }>();
1017
+ /** Inode, mtime and ctime: `touch -r` can restore an mtime, never a ctime. */
1018
+ const fileStamp = (stat: fs.Stats): string => `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
1019
+
1020
+ /**
1021
+ * Rows anywhere in the ledger whose chain shows an edit, a removal or a move
1022
+ * (`hash_mismatch`, `broken_link`). Cached per file size and mtime; empty
1023
+ * when the ledger cannot be read.
1024
+ */
1025
+ export function ledgerTampering(cwd: string): UnverifiedRow[] {
1026
+ const file = ledgerPath(cwd);
1027
+ if (!file.ok) return [];
1028
+ try {
1029
+ const stat = fs.statSync(file.value);
1030
+ const cached = tamperCache.get(file.value);
1031
+ if (cached && cached.size === stat.size && cached.stamp === fileStamp(stat))
1032
+ return cached.breaks;
1033
+ } catch {
1034
+ return [];
1035
+ }
1036
+ const read = readLedger(cwd, { integrity: true });
1037
+ return read.ok
1038
+ ? (read.value.integrity?.unverified ?? []).filter((row) => TAMPER_REASONS.has(row.reason))
1039
+ : [];
1040
+ }
1041
+
1042
+ /** How to repair a tampered ledger: see what broke, then start a new one and re-record. */
1043
+ export function tamperedUnblock(cwd: string): string {
1044
+ const file = ledgerPath(cwd);
1045
+ const where = file.ok ? file.value : "the ledger file";
1046
+ return `workit ledger verify-integrity # lists the edited or missing rows; if the edit is understood, move ${where} aside (mv ${where} ${where}.tampered-<date>) and record the verdicts again`;
1047
+ }
1048
+
1049
+ const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string =>
1050
+ `the ledger was edited, or a row was removed or moved (${breaks
1051
+ .slice(0, 3)
1052
+ .map((row) => `${row.reason} at row ${row.seq}`)
1053
+ .join(
1054
+ ", ",
1055
+ )}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
1056
+
1057
+ export type RowFilter = {
1058
+ branch?: string;
1059
+ pr?: number;
1060
+ type?: string;
1061
+ last?: number;
1062
+ };
1063
+
1064
+ export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
1065
+ const matched = rows.filter(
1066
+ (row) =>
1067
+ (filter.branch === undefined || row.branch === filter.branch) &&
1068
+ (filter.pr === undefined || row.pr === filter.pr) &&
1069
+ (filter.type === undefined || row.type === filter.type),
1070
+ );
1071
+ return filter.last === undefined
1072
+ ? matched
1073
+ : matched.slice(Math.max(0, matched.length - filter.last));
1074
+ }
1075
+
1076
+ // ---------------------------------------------------------------------------
1077
+ // PR → branch (never from arbitrary rows)
1078
+
1079
+ export type PrBranch =
1080
+ | { ok: true; branch: string; source: "pr_row" | "forge_ref" }
1081
+ | { ok: false; reason: "unknown" | "ambiguous"; candidates: string[] };
1082
+
1083
+ /**
1084
+ * The branch a PR/MR number belongs to, from (1) rows the CLI's own PR verbs
1085
+ * observed (`PR_ROW_TYPES` with `observer:"workit_cli"`), else (2) a fetched
1086
+ * forge ref (`refs/pull/<n>/head`, `refs/merge-requests/<n>/head`, or the same
1087
+ * under `refs/remotes/<remote>/`) whose commit is the tip of exactly one local
1088
+ * branch. Forge refs that disagree, or a tip shared by several branches, are
1089
+ * `ambiguous`; nothing at all is `unknown`.
1090
+ */
1091
+ export function branchForPr(cwd: string, rows: readonly ReadRow[], pr: number): PrBranch {
1092
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
1093
+ const row = rows[index];
1094
+ if (row.pr === pr && row.branch && PR_ROW_TYPES.has(row.type) && row.observer === "workit_cli")
1095
+ return { ok: true, branch: row.branch, source: "pr_row" };
1096
+ }
1097
+ const tips = git(cwd, [
1098
+ "for-each-ref",
1099
+ "--format=%(objectname)",
1100
+ `refs/pull/${pr}/head`,
1101
+ `refs/merge-requests/${pr}/head`,
1102
+ `refs/remotes/*/pull/${pr}/head`,
1103
+ `refs/remotes/*/merge-requests/${pr}/head`,
1104
+ ]);
1105
+ const shas = [...new Set((tips ?? "").split("\n").filter(Boolean))];
1106
+ if (shas.length === 0) return { ok: false, reason: "unknown", candidates: [] };
1107
+ if (shas.length > 1) return { ok: false, reason: "ambiguous", candidates: shas };
1108
+ const branches = (
1109
+ git(cwd, ["for-each-ref", "--format=%(refname:short)", "--points-at", shas[0], "refs/heads"]) ??
1110
+ ""
1111
+ )
1112
+ .split("\n")
1113
+ .filter(Boolean);
1114
+ if (branches.length === 1) return { ok: true, branch: branches[0], source: "forge_ref" };
1115
+ return branches.length === 0
1116
+ ? { ok: false, reason: "unknown", candidates: [] }
1117
+ : { ok: false, reason: "ambiguous", candidates: branches };
1118
+ }
1119
+
1120
+ // ---------------------------------------------------------------------------
1121
+ // code keys and verdict validity (§2.2)
1122
+
1123
+ /**
1124
+ * The code state of `branch` (default: the checked-out branch) against
1125
+ * `base` (default: defaultBase). The worktree tree is included only for the
1126
+ * checked-out branch.
1127
+ */
1128
+ export function codeKey(
1129
+ cwd: string,
1130
+ options: { branch?: string | null; base?: string | null } = {},
1131
+ ): CodeKey {
1132
+ const checkedOut = currentBranch(cwd);
1133
+ const branch = options.branch ?? checkedOut;
1134
+ const head = branch ? branchHead(cwd, branch) : headSha(cwd);
1135
+ const base = options.base ?? defaultBase(cwd);
1136
+ const baseSha = base ? resolveCommit(cwd, base) : null;
1137
+ const onTree = branch !== null && branch === checkedOut;
1138
+ const tree = onTree ? worktreeTree(cwd) : null;
1139
+ return {
1140
+ branch,
1141
+ head,
1142
+ base,
1143
+ baseSha,
1144
+ ...(base && head ? rangeKeys(cwd, base, head) : { patchId: null, diffHash: null }),
1145
+ tree: tree?.key ?? null,
1146
+ dirty: tree ? tree.dirty : null,
1147
+ };
1148
+ }
1149
+
1150
+ export type VerdictBasis = "fresh" | "carried" | "stale" | "none";
1151
+
1152
+ type Keyed = {
1153
+ head: string | null;
1154
+ patchId: string | null;
1155
+ diffHash: string | null;
1156
+ };
1157
+
1158
+ /** fresh: same head · carried: same non-null patch-id AND exact diff · stale: otherwise. */
1159
+ export function verdictBasis(verdict: Keyed, current: Keyed): Exclude<VerdictBasis, "none"> {
1160
+ if (verdict.head && verdict.head === current.head) return "fresh";
1161
+ if (
1162
+ verdict.patchId &&
1163
+ verdict.patchId === current.patchId &&
1164
+ verdict.diffHash &&
1165
+ verdict.diffHash === current.diffHash
1166
+ )
1167
+ return "carried";
1168
+ return "stale";
1169
+ }
1170
+
1171
+ export type RejectReason =
1172
+ | "stale"
1173
+ | "not_passing"
1174
+ | "self"
1175
+ | "no_session"
1176
+ | "author_session"
1177
+ | "failing_verdict"
1178
+ /** The only passing current verdict is `type-check-only`, which proves no behavior. */
1179
+ | "type_check_only"
1180
+ | "no_verdict"
1181
+ /** The verdict row is outside the CLI's hash chain: appended by hand, or pre-chain. */
1182
+ | "unsigned"
1183
+ /** The verdict row was edited after the CLI wrote it. */
1184
+ | "hash_mismatch"
1185
+ /** A row before the verdict was edited or removed. */
1186
+ | "broken_link"
1187
+ /** The verdict row repeats an earlier row's id (a replayed line). */
1188
+ | "duplicate_id"
1189
+ /** A `--session` verdict whose id no SubagentStart hook issued, recorded on a host. */
1190
+ | "unissued_session"
1191
+ /** An issued id recorded without the token its hook bound it to (another session reusing it). */
1192
+ | "unbound_session"
1193
+ /** A row somewhere in the ledger was edited, removed or moved: no verdict is accepted until it is repaired. */
1194
+ | "ledger_tampered";
1195
+
1196
+ export type VerdictEntry = {
1197
+ kind: string;
1198
+ basis: Exclude<VerdictBasis, "none">;
1199
+ /** The verdict still applies to the code (fresh or carried). */
1200
+ current: boolean;
1201
+ /** Independent: not self, a known session, not an author of the branch. */
1202
+ independent: boolean;
1203
+ /** current AND passing AND independent. */
1204
+ accepted: boolean;
1205
+ reasons: RejectReason[];
1206
+ /** Why a verdict on an older head still applies after a follow-up change (core/policy/carry.ts). */
1207
+ carry: CarryNote | null;
1208
+ verdict: ReadRow;
1209
+ };
1210
+
1211
+ /** A verdict carried over a follow-up change: by which rule, from which judged head, over which files. */
1212
+ export type CarryNote = { rule: CarryRule; since: string; files: string[] };
1213
+
1214
+ /** A stale verdict whose follow-up change is small and off risk paths: re-verify only these files. */
1215
+ export type Reverify = { scope: "focused"; since: string; files: string[] };
1216
+
1217
+ /** `carried (lane none since <head>)`: how a carried verdict reads in `ledger check` and `pr status`. */
1218
+ export const carryLabel = (carry: CarryNote): string =>
1219
+ carry.rule === "rebase"
1220
+ ? "carried (rebase: patch unchanged)"
1221
+ : `carried (${carry.rule === "lane-none" ? "lane none" : "tests only"} since ${carry.since.slice(0, 12)})`;
1222
+
1223
+ export type VerdictCheck = {
1224
+ branch: string;
1225
+ head: string | null;
1226
+ base: string | null;
1227
+ patchId: string | null;
1228
+ /** Newest current verdict (else newest verdict): does it still apply? */
1229
+ current: { basis: VerdictBasis; verdict: ReadRow | null };
1230
+ /** The carry of the accepted verdict (else of the newest current one), when a follow-up change carried it. */
1231
+ carry: CarryNote | null;
1232
+ /** No verdict applies, but the newest passing one is stale only by a small change: re-verify that. */
1233
+ reverify: Reverify | null;
1234
+ /**
1235
+ * What merge gates read: an accepted strong (`verified`/`tests-verified`)
1236
+ * verdict and no current independent failure. `type-check-only` never counts.
1237
+ */
1238
+ accepted: {
1239
+ accepted: boolean;
1240
+ verdict: ReadRow | null;
1241
+ reasons: RejectReason[];
1242
+ };
1243
+ review: ReviewLabel;
1244
+ /** The newest current, strong, self verdict when `review` is self-reviewed. */
1245
+ selfVerdict: ReadRow | null;
1246
+ verdicts: VerdictEntry[];
1247
+ authors: string[];
1248
+ /**
1249
+ * Things a human should read before trusting the result; none of them
1250
+ * blocks (D18): unverified ledger rows on the branch, and an author's own
1251
+ * failure newer than the accepted pass.
1252
+ */
1253
+ warnings: string[];
1254
+ };
1255
+
1256
+ const isLiveVerdict = (row: ReadRow, retired: ReadonlySet<string>): boolean =>
1257
+ row.type === "verdict" && typeof row.result === "string" && !retired.has(row.id);
1258
+
1259
+ /** Sessions recorded in `Workit-Session:` trailers of `base...head` commits. */
1260
+ function trailerSessions(cwd: string, base: string | null, head: string | null): string[] {
1261
+ if (!base || !head || !safeRef(base) || !safeRef(head)) return [];
1262
+ const out = git(cwd, [
1263
+ "log",
1264
+ "--format=%(trailers:key=Workit-Session,valueonly,separator=%x00)",
1265
+ `${base}..${head}`,
1266
+ ]);
1267
+ return (out ?? "")
1268
+ .split(/[\0\n]/u)
1269
+ .map((value) => value.trim())
1270
+ .filter(Boolean);
1271
+ }
1272
+
1273
+ /** Does a row belong to a branch? Its own name, or an earlier name up to the rename. */
1274
+ export type BranchScope = (row: Pick<ReadRow, "branch" | "at">) => boolean;
1275
+
1276
+ const RENAMED = /^branch: renamed refs\/heads\/(.+) to refs\/heads\/(.+)$/iu;
1277
+
1278
+ /**
1279
+ * The rows of `branch`, following `git branch -m` through the branch's
1280
+ * reflog (git moves the reflog with the branch): a row keyed to an earlier
1281
+ * name counts until that rename, so a name reused later does not leak in.
1282
+ */
1283
+ export function branchScope(cwd: string, branch: string): BranchScope {
1284
+ const renamedAt = new Map<string, number>();
1285
+ // The branch's oldest reflog entry (its creation): rows of an older,
1286
+ // deleted branch that once had one of its earlier names predate it.
1287
+ let since = Number.NEGATIVE_INFINITY;
1288
+ if (safeRef(branch)) {
1289
+ const log = git(cwd, [
1290
+ "log",
1291
+ "-g",
1292
+ "--date=unix",
1293
+ "--format=%gd%x09%gs",
1294
+ `refs/heads/${branch}`,
1295
+ "--",
1296
+ ]);
1297
+ for (const line of (log ?? "").split("\n")) {
1298
+ const [selector = "", subject = ""] = line.split("\t");
1299
+ const seconds = Number(/@\{(\d+)\}$/u.exec(selector)?.[1]);
1300
+ if (!Number.isFinite(seconds)) continue;
1301
+ // Newest first: the last entry read is the oldest.
1302
+ since = seconds * 1000;
1303
+ const match = RENAMED.exec(subject);
1304
+ if (!match || match[1] === branch) continue;
1305
+ // Reflog times are whole seconds: the rename's second still counts.
1306
+ const until = (seconds + 1) * 1000;
1307
+ renamedAt.set(match[1], Math.max(renamedAt.get(match[1]) ?? 0, until));
1308
+ }
1309
+ }
1310
+ return (row) => {
1311
+ if (row.branch === branch) return true;
1312
+ const until = row.branch === null ? undefined : renamedAt.get(row.branch);
1313
+ if (until === undefined) return false;
1314
+ const at = Date.parse(row.at);
1315
+ return at >= since && at <= until;
1316
+ };
1317
+ }
1318
+
1319
+ /** `supersedeAllowed`'s branch test for rows on `branch`, following its renames. */
1320
+ const renameAwareBranch = (scopeOf: (branch: string) => BranchScope, branch: unknown) => {
1321
+ return (target: SupersedeRow): boolean => {
1322
+ const own = typeof branch === "string" ? branch : null;
1323
+ const theirs = typeof target.branch === "string" ? target.branch : null;
1324
+ if (own === theirs) return true;
1325
+ if (own === null || theirs === null || typeof target.at !== "string") return false;
1326
+ return scopeOf(own)({ branch: theirs, at: target.at });
1327
+ };
1328
+ };
1329
+
1330
+ /**
1331
+ * Sessions that authored `branch`: the union of `commit.recorded` /
1332
+ * `task.opened` ledger rows (under its current or an earlier name) and
1333
+ * `Workit-Session:` commit trailers on `base..head`. Git author name/email is
1334
+ * not used: every agent on a machine shares it, so it cannot tell sessions
1335
+ * apart.
1336
+ */
1337
+ export function authorSessions(
1338
+ cwd: string,
1339
+ rows: readonly ReadRow[],
1340
+ branch: string,
1341
+ range: { base: string | null; head: string | null } = {
1342
+ base: null,
1343
+ head: null,
1344
+ },
1345
+ scope: BranchScope = branchScope(cwd, branch),
1346
+ ): Set<string> {
1347
+ const sessions = new Set<string>();
1348
+ for (const row of rows) {
1349
+ if (!scope(row) || (row.type !== "commit.recorded" && row.type !== "task.opened")) continue;
1350
+ const session = str(row.session) ?? row.actor.session;
1351
+ if (session) sessions.add(session);
1352
+ }
1353
+ for (const session of trailerSessions(cwd, range.base, range.head)) sessions.add(session);
1354
+ return sessions;
1355
+ }
1356
+
1357
+ /**
1358
+ * #312: the way back when an issued id lost its token (a verifier resumed
1359
+ * after its lead crashed, or one whose context was compacted). A token is
1360
+ * issued once, so nothing rebinds the id: re-issuing one would let anyone
1361
+ * re-fire the hook and supersede that reviewer's failure.
1362
+ */
1363
+ export const unboundRecovery = (session: string | null, branch: string | null): string =>
1364
+ `a token is issued once, so no command rebinds ${session ?? "an issued id"}. If the verifier's SubagentStart context still shows the token, it reruns the same command with --token=<that token> (or WORKIT_SESSION_TOKEN). Otherwise (a verifier resumed after its lead crashed, or whose context was compacted) the lead spawns a fresh verifier for ${branch ?? "the branch"}: its SubagentStart context prints the whole workit ledger verdict command with a new --session and --token; it re-checks the head (pr status shows a focused reverify when that is enough) and records the verdict there`;
1365
+
1366
+ /** Where each host's verifier session comes from, for refusal hints. */
1367
+ const HOST_DERIVED_SESSIONS =
1368
+ "Claude Code, Codex and Cursor: `--session <lead>:<agent id>` from the SubagentStart hook; OpenCode child sessions and Pi workers: WORKIT_SESSION_ID=<lead>:<child> already set in their shell";
1369
+
1370
+ /** Whether `derived` is a host-derived `<lead>:<bare>` id for the bare host session `bare`. */
1371
+ const derivesFrom = (derived: string, bare: string): boolean =>
1372
+ !bare.includes(":") && derived.endsWith(`:${bare}`);
1373
+
1374
+ /**
1375
+ * Whether `session` is one of the authors: the same id, or one is a bare host
1376
+ * session and the other its derived `<lead>:<session>` form (an OpenCode
1377
+ * child that committed as `<lead>:<child>` and judges as `<child>` is still
1378
+ * the author). Two derived ids never match on a shared last segment.
1379
+ */
1380
+ export const authoredBy = (authors: Set<string>, session: string): boolean => {
1381
+ if (authors.has(session)) return true;
1382
+ for (const author of authors)
1383
+ if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
1384
+ return false;
1385
+ };
1386
+
1387
+ /** Reasons a gate cannot trust a verdict row at all, whoever it names. */
1388
+ const TRUST_REASONS: ReadonlySet<RejectReason> = new Set([
1389
+ "unsigned",
1390
+ "hash_mismatch",
1391
+ "broken_link",
1392
+ "duplicate_id",
1393
+ "unissued_session",
1394
+ "unbound_session",
1395
+ ]);
1396
+
1397
+ /**
1398
+ * Hosts whose shell itself names the session (OpenCode, Pi, Codex), and no
1399
+ * host at all (`cli`: CI or a terminal, where any id is a declaration): an
1400
+ * environment session from them was checked against that shell when recorded.
1401
+ */
1402
+ const SHELL_VOUCHED_HOSTS: ReadonlySet<string> = new Set([
1403
+ "cli",
1404
+ "opencode",
1405
+ "pi",
1406
+ "codex_cli",
1407
+ "codex_desktop",
1408
+ ]);
1409
+
1410
+ /**
1411
+ * A verdict acting as a session nothing vouches for (`ledger verdict` refuses
1412
+ * these when recording; rows by an older CLI or by hand are caught here):
1413
+ * - `unissued_session`: a `--session` id on a host, or a `<lead>:<x>`
1414
+ * environment id on a host that does not name it in its shell (Claude
1415
+ * Code, Cursor, any declared WORKIT_HOST), that no SubagentStart hook issued;
1416
+ * - `unbound_session`: such an id the hook bound to a token, recorded
1417
+ * without it (another session reusing an id it read in the ledger).
1418
+ */
1419
+ function sessionTrust(cwd: string, row: ReadRow): RejectReason | null {
1420
+ const session = row.actor.session;
1421
+ if (!session || row.self === true) return null;
1422
+ const source = typeof row.sessionSource === "string" ? row.sessionSource : "env";
1423
+ const host = row.actor.host;
1424
+ const issuedOnly =
1425
+ source === "flag"
1426
+ ? host !== "cli"
1427
+ : source === "env" && session.includes(":") && !SHELL_VOUCHED_HOSTS.has(host);
1428
+ if (!issuedOnly) return null;
1429
+ const binding = issuedBinding(cwd, session);
1430
+ if (binding === null) return "unissued_session";
1431
+ return binding === "token" && row.sessionBound !== true ? "unbound_session" : null;
1432
+ }
1433
+
1434
+ /**
1435
+ * Why a gate cannot trust `row` (D18, audit N1): outside the hash chain, or
1436
+ * acting as a session nothing vouches for. Rows read without integrity are unsigned.
1437
+ */
1438
+ function trustReasons(cwd: string, row: ReadRow): RejectReason[] {
1439
+ if (row.integrity === "unverified") return [row.integrityReason ?? "unsigned"];
1440
+ if (row.integrity !== "verified") return ["unsigned"];
1441
+ const session = sessionTrust(cwd, row);
1442
+ return session ? [session] : [];
1443
+ }
1444
+
1445
+ /**
1446
+ * `rows` with their place in the hash chain. Rows read without integrity are
1447
+ * re-read with it (and with integrity-aware supersede links): every caller of
1448
+ * `checkVerdicts` is a gate. A row the re-read cannot match stays unsigned.
1449
+ */
1450
+ function withIntegrity(cwd: string, rows: readonly ReadRow[]): readonly ReadRow[] {
1451
+ if (rows.every((row) => row.integrity !== undefined)) return rows;
1452
+ const read = readLedger(cwd, { integrity: true });
1453
+ const bySeq = new Map(read.ok ? read.value.rows.map((row) => [row.seq, row]) : []);
1454
+ return rows.map((row) => {
1455
+ if (row.integrity !== undefined) return row;
1456
+ const fresh = bySeq.get(row.seq);
1457
+ return fresh && fresh.id === row.id && fresh.at === row.at ? fresh : row;
1458
+ });
1459
+ }
1460
+
1461
+ const untrusted = (reasons: readonly RejectReason[]): boolean =>
1462
+ reasons.some((reason) => TRUST_REASONS.has(reason));
1463
+
1464
+ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
1465
+ const reasons: RejectReason[] = [];
1466
+ if (!row.actor.session) reasons.push("no_session");
1467
+ else if (authoredBy(authors, row.actor.session)) reasons.push("author_session");
1468
+ if (row.self === true && !reasons.includes("no_session")) reasons.push("self");
1469
+ return reasons;
1470
+ };
1471
+
1472
+ /**
1473
+ * The effective verdict of one kind, walking rows in order: a self verdict
1474
+ * never displaces a current independent one (a stale one it does, so the
1475
+ * author's pass on new code is not hidden behind it), and an independent failed/blocked
1476
+ * verdict sticks, for the code it judged, until an independent verdict from a
1477
+ * different session (or its own session's supersede) replaces it. A verdict
1478
+ * from the same session on different code (`sameCode` false: a real change,
1479
+ * not a carry or restack of the failed code) is a re-review of the fix and
1480
+ * replaces it.
1481
+ */
1482
+ function effectiveOf(
1483
+ rows: readonly ReadRow[],
1484
+ reasonsOf: (row: ReadRow) => RejectReason[],
1485
+ sameCode: (failed: ReadRow, row: ReadRow) => boolean,
1486
+ isCurrent: (row: ReadRow) => boolean,
1487
+ ): ReadRow | null {
1488
+ let effective: ReadRow | null = null;
1489
+ for (const row of rows) {
1490
+ if (!effective) {
1491
+ effective = row;
1492
+ continue;
1493
+ }
1494
+ // A row the gate cannot trust never displaces one it can.
1495
+ if (untrusted(reasonsOf(row)) && !untrusted(reasonsOf(effective))) continue;
1496
+ const rowIndependent = reasonsOf(row).length === 0;
1497
+ const effIndependent = reasonsOf(effective).length === 0;
1498
+ if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
1499
+ if (
1500
+ effIndependent &&
1501
+ effective.result === "failed" &&
1502
+ row.actor.session === effective.actor.session &&
1503
+ sameCode(effective, row)
1504
+ )
1505
+ continue;
1506
+ effective = row;
1507
+ }
1508
+ return effective;
1509
+ }
1510
+
1511
+ /**
1512
+ * Stacked branches (S12): a verdict keyed against the trunk cannot carry
1513
+ * through a restack after its parent was squash-merged, because the
1514
+ * trunk-relative diff loses the parent's change. `workit stack sync` observes
1515
+ * each restack itself and records `stack.restacked` with `patchEqual` when
1516
+ * the change relative to the parent is identical (same patch-id AND same
1517
+ * exact diff hash, D18). A verdict carries when such observed rows link its
1518
+ * head to the current head. With `exactIn` (the checkout), each link is
1519
+ * re-read with exact blobs (sameOwnChange, #334), so a row recorded before
1520
+ * that check carries a pass only when the branch's own files are identical
1521
+ * across the restack or the head is the judged head cleanly rebased (#468). Null keeps every observed link: a failure keeps
1522
+ * applying through them (failing closed the other way).
1523
+ */
1524
+ const restackExact = new WeakMap<ReadRow, boolean>();
1525
+ function restackCarries(
1526
+ exactIn: string | null,
1527
+ rows: readonly ReadRow[],
1528
+ branch: string,
1529
+ from: string | null,
1530
+ to: string | null,
1531
+ ): boolean {
1532
+ if (!from || !to) return false;
1533
+ const exact = (row: ReadRow, fromHead: string, head: string): boolean => {
1534
+ if (exactIn === null) return true;
1535
+ const cwd = exactIn;
1536
+ let known = restackExact.get(row);
1537
+ if (known === undefined) {
1538
+ // Identical own blobs, or the head is exactly the judged head cleanly
1539
+ // rebased onto the moved base (#468): a base edit elsewhere in a shared file.
1540
+ const range =
1541
+ typeof row.fromBase === "string" && typeof row.toBase === "string"
1542
+ ? {
1543
+ from: fromHead,
1544
+ to: head,
1545
+ fromBase: row.fromBase,
1546
+ toBase: row.toBase,
1547
+ }
1548
+ : null;
1549
+ known = range !== null && (sameOwnChange(cwd, range) || cleanRebase(cwd, range));
1550
+ restackExact.set(row, known);
1551
+ }
1552
+ return known;
1553
+ };
1554
+ const edges = new Map<string, string[]>();
1555
+ for (const row of rows)
1556
+ if (
1557
+ row.type === "stack.restacked" &&
1558
+ row.observer === "workit_cli" &&
1559
+ row.integrity === "verified" &&
1560
+ row.branch === branch &&
1561
+ row.patchEqual === true &&
1562
+ typeof row.fromHead === "string" &&
1563
+ row.head &&
1564
+ exact(row, row.fromHead, row.head)
1565
+ )
1566
+ edges.set(row.fromHead, [...(edges.get(row.fromHead) ?? []), row.head]);
1567
+ const seen = new Set<string>([from]);
1568
+ const queue = [from];
1569
+ while (queue.length) {
1570
+ const next = queue.shift() as string;
1571
+ if (next === to) return true;
1572
+ for (const target of edges.get(next) ?? [])
1573
+ if (!seen.has(target)) {
1574
+ seen.add(target);
1575
+ queue.push(target);
1576
+ }
1577
+ }
1578
+ return false;
1579
+ }
1580
+
1581
+ /** A row a gate imported from the verdict mirror (#311): it names the ledger it came from. */
1582
+ const isMirrored = (row: ReadRow): boolean => typeof row.mirroredFrom === "string";
1583
+
1584
+ /**
1585
+ * Where each imported row sits among the local rows: just after the last
1586
+ * local row (in ledger order) recorded at or before it, so an older pass
1587
+ * from a clone never outranks a newer local verdict. Local rows rank by seq.
1588
+ * Its `at` counts for no later than its import (its own place in this
1589
+ * ledger) or `now`, so a future-dated row never outranks local rows written
1590
+ * after it arrived. On an equal `at`, a failure ranks after a pass.
1591
+ */
1592
+ function mirrorRanks(rows: readonly ReadRow[], now: number = Date.now()): Map<ReadRow, number> {
1593
+ const ranks = new Map<ReadRow, number>();
1594
+ const local = rows.filter((row) => !isMirrored(row));
1595
+ const failing = (row: ReadRow) => FAILING.has(String(row.result));
1596
+ for (const row of rows) {
1597
+ if (!isMirrored(row)) continue;
1598
+ const at = Math.min(Date.parse(row.at), now);
1599
+ let after = 0;
1600
+ for (const other of local) {
1601
+ if (other.seq > row.seq) continue;
1602
+ const otherAt = Date.parse(other.at);
1603
+ const before = otherAt < at || (otherAt === at && (failing(row) || !failing(other)));
1604
+ if (before) after = Math.max(after, other.seq);
1605
+ }
1606
+ // Between that row and the next; ties among imports keep their own order.
1607
+ ranks.set(row, after + 0.5 + Math.min(row.seq, 1e9) / 1e10);
1608
+ }
1609
+ return ranks;
1610
+ }
1611
+
1612
+ /** Current and accepted verdicts for `branch`, after carry-over (D18). */
1613
+ export function checkVerdicts(
1614
+ cwd: string,
1615
+ branch: string,
1616
+ ledgerRows: readonly ReadRow[],
1617
+ ): VerdictCheck {
1618
+ return withinCall(() => checkVerdictsOnce(cwd, branch, ledgerRows));
1619
+ }
1620
+
1621
+ function checkVerdictsOnce(
1622
+ cwd: string,
1623
+ branch: string,
1624
+ ledgerRows: readonly ReadRow[],
1625
+ ): VerdictCheck {
1626
+ // Verdicts recorded in another clone of the same remote (#311).
1627
+ const imported = importMirroredVerdicts(cwd, branch);
1628
+ const rows = withIntegrity(cwd, imported.length ? [...ledgerRows, ...imported] : ledgerRows);
1629
+ const head = branchHead(cwd, branch);
1630
+ const fallbackBase = defaultBase(cwd);
1631
+ const scope = branchScope(cwd, branch);
1632
+ const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
1633
+ const keysAt = (base: string | null, at: string | null) => {
1634
+ if (!base || !at) return { patchId: null, diffHash: null };
1635
+ const cacheKey = `${base}\0${at}`;
1636
+ let key = keyCache.get(cacheKey);
1637
+ if (!key) {
1638
+ key = rangeKeys(cwd, base, at);
1639
+ keyCache.set(cacheKey, key);
1640
+ }
1641
+ return key;
1642
+ };
1643
+ const keyFor = (base: string | null) => keysAt(base, head);
1644
+ // The branch's own change since a trusted verdict's head, read once per
1645
+ // row (#334). A row the gates cannot trust never carries.
1646
+ const driftCache = new Map<ReadRow, Drift | null>();
1647
+ const driftOf = (row: ReadRow): Drift | null => {
1648
+ if (driftCache.has(row)) return driftCache.get(row) ?? null;
1649
+ let drift: Drift | null = null;
1650
+ const toBase = row.base ?? fallbackBase;
1651
+ const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1652
+ const fromBase = row.baseSha ?? toBaseSha;
1653
+ if (
1654
+ row.head &&
1655
+ head &&
1656
+ row.head !== head &&
1657
+ toBaseSha &&
1658
+ fromBase &&
1659
+ !untrusted(reasonsOf(row))
1660
+ )
1661
+ drift = verdictDrift(cwd, {
1662
+ from: row.head,
1663
+ to: head,
1664
+ fromBase,
1665
+ toBase: toBaseSha,
1666
+ kind: typeof row.kind === "string" ? row.kind : "review",
1667
+ });
1668
+ driftCache.set(row, drift);
1669
+ return drift;
1670
+ };
1671
+ // The same patch-id and diff hash are not enough to carry a pass (#334):
1672
+ // both drop hunk line numbers, so an edit moved within a file keeps them.
1673
+ // The branch's own files must be identical blobs too (sameOwnChange), or
1674
+ // the head must be exactly the judged head cleanly rebased onto the moved
1675
+ // base (cleanRebase): a base edit elsewhere in a file the branch touches.
1676
+ // A failure keeps the looser carry: it fails closed by applying more.
1677
+ // How a pass's own files match: identical blobs, a clean rebase, or neither.
1678
+ const exactCache = new Map<ReadRow, "same" | "rebase" | null>();
1679
+ const exactMatch = (row: ReadRow): "same" | "rebase" | null => {
1680
+ let exact = exactCache.get(row);
1681
+ if (exact === undefined) {
1682
+ const toBase = row.base ?? fallbackBase;
1683
+ const toBaseSha = toBase ? resolveCommit(cwd, toBase) : null;
1684
+ const fromBase = row.baseSha ?? toBaseSha;
1685
+ const range =
1686
+ row.head && head && toBaseSha && fromBase
1687
+ ? { from: row.head, to: head, fromBase, toBase: toBaseSha }
1688
+ : null;
1689
+ exact = !range
1690
+ ? null
1691
+ : sameOwnChange(cwd, range)
1692
+ ? "same"
1693
+ : cleanRebase(cwd, range)
1694
+ ? "rebase"
1695
+ : null;
1696
+ exactCache.set(row, exact);
1697
+ }
1698
+ return exact;
1699
+ };
1700
+ const exactCarry = (row: ReadRow): boolean =>
1701
+ FAILING.has(String(row.result)) || exactMatch(row) !== null;
1702
+ const keyBasis = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1703
+ const basis = verdictBasis(row, {
1704
+ head,
1705
+ ...keyFor(row.base ?? fallbackBase),
1706
+ });
1707
+ return basis === "carried" && !exactCarry(row) ? "stale" : basis;
1708
+ };
1709
+ /** A pass the exact keys carry only through a clean rebase (the base also edited a branch file). */
1710
+ const rebaseNote = (row: ReadRow): CarryNote | null =>
1711
+ row.head &&
1712
+ head &&
1713
+ row.head !== head &&
1714
+ !FAILING.has(String(row.result)) &&
1715
+ keyBasis(row) === "carried" &&
1716
+ exactMatch(row) === "rebase"
1717
+ ? { rule: "rebase", since: row.head, files: [] }
1718
+ : null;
1719
+ const restacked = (row: ReadRow): boolean =>
1720
+ restackCarries(FAILING.has(String(row.result)) ? null : cwd, rows, branch, row.head, head);
1721
+ /** The follow-up carry of a verdict the exact keys and restacks do not carry. */
1722
+ const carryOf = (row: ReadRow): CarryNote | null => {
1723
+ if (keyBasis(row) !== "stale") return null;
1724
+ if (restacked(row)) return null;
1725
+ const drift = driftOf(row);
1726
+ return drift?.carries ? { rule: drift.rule, since: drift.since, files: drift.files } : null;
1727
+ };
1728
+ const basisOf = (row: ReadRow): Exclude<VerdictBasis, "none"> => {
1729
+ const basis = keyBasis(row);
1730
+ if (basis !== "stale") return basis;
1731
+ return restacked(row) || carryOf(row) ? "carried" : basis;
1732
+ };
1733
+ // A newer row is judged under the failure's own base, so a row recorded
1734
+ // with a different --base cannot make unchanged code look changed. When
1735
+ // both rows were keyed against the same base commit, the newer row's own
1736
+ // stored keys are that comparison already: no git call per row.
1737
+ const sameCode = (failed: ReadRow, row: ReadRow): boolean => {
1738
+ if (failed.head && failed.head === row.head) return true;
1739
+ const base = failed.base ?? fallbackBase;
1740
+ const stored =
1741
+ (row.base ?? fallbackBase) === base && row.baseSha !== null && row.baseSha === failed.baseSha;
1742
+ const keys = stored ? { patchId: row.patchId, diffHash: row.diffHash } : keysAt(base, row.head);
1743
+ return (
1744
+ verdictBasis(failed, { head: row.head, ...keys }) !== "stale" ||
1745
+ restackCarries(null, rows, branch, failed.head, row.head)
1746
+ );
1747
+ };
1748
+ const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
1749
+ // A failure is an author's only when chained ledger rows written before it
1750
+ // made its session an author. `ledger verdict` refuses an author's
1751
+ // independent verdict, so authorship gained afterwards (a forged row, a
1752
+ // commit or an empty amend under the reviewer's id, a trailer) never
1753
+ // discounts a failure (audit N13, mv83zyg6 P2b).
1754
+ // "Before" is verdict precedence (byRank), not raw seq: a row
1755
+ // imported from the mirror sits where it was recorded, not where it landed.
1756
+ const rank = mirrorRanks(rows);
1757
+ const byRank = (a: ReadRow, b: ReadRow) => (rank.get(a) ?? a.seq) - (rank.get(b) ?? b.seq);
1758
+ const failureAuthorCache = new Map<ReadRow, Set<string>>();
1759
+ const failureAuthors = (row: ReadRow): Set<string> => {
1760
+ let set = failureAuthorCache.get(row);
1761
+ if (!set) {
1762
+ set = authorSessions(
1763
+ cwd,
1764
+ rows.filter((other) => other.integrity === "verified" && byRank(other, row) < 0),
1765
+ branch,
1766
+ { base: null, head: null },
1767
+ scope,
1768
+ );
1769
+ failureAuthorCache.set(row, set);
1770
+ }
1771
+ return set;
1772
+ };
1773
+ const reasonCache = new Map<ReadRow, RejectReason[]>();
1774
+ const reasonsOf = (row: ReadRow): RejectReason[] => {
1775
+ let reasons = reasonCache.get(row);
1776
+ if (!reasons) {
1777
+ const failing = FAILING.has(String(row.result));
1778
+ reasons = [
1779
+ ...trustReasons(cwd, row),
1780
+ ...independenceReasons(row, failing ? failureAuthors(row) : authors),
1781
+ ];
1782
+ reasonCache.set(row, reasons);
1783
+ }
1784
+ return reasons;
1785
+ };
1786
+ // Only a trusted verdict retires another: an unverified row or one acting
1787
+ // as an unissued or unbound session never supersedes (N13, mv83zyg6 P1).
1788
+ const retired = new Set<string>();
1789
+ for (const row of rows)
1790
+ if (
1791
+ row.type === "verdict" &&
1792
+ row.supersedes &&
1793
+ !row.supersedeIgnored &&
1794
+ trustReasons(cwd, row).length === 0
1795
+ )
1796
+ retired.add(row.supersedes);
1797
+ // Verdict precedence follows when each verdict was recorded. Local rows keep
1798
+ // their ledger order; a row imported from the mirror (#311) is appended
1799
+ // whenever a gate first sees it, so it is placed after the last local row
1800
+ // recorded at or before its own `at`, never ahead of a newer local verdict.
1801
+ const byKind = new Map<string, ReadRow[]>();
1802
+ for (const row of rows.toSorted(byRank))
1803
+ if (isLiveVerdict(row, retired) && scope(row)) {
1804
+ const kind = typeof row.kind === "string" ? row.kind : "review";
1805
+ byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
1806
+ }
1807
+ const verdicts: VerdictEntry[] = [];
1808
+ const warnings: string[] = [];
1809
+ for (const [kind, list] of byKind) {
1810
+ const row = effectiveOf(
1811
+ list,
1812
+ reasonsOf,
1813
+ sameCode,
1814
+ (candidate) => basisOf(candidate) !== "stale",
1815
+ );
1816
+ if (!row) continue;
1817
+ const basis = basisOf(row);
1818
+ const current = basis !== "stale";
1819
+ const independence = reasonsOf(row);
1820
+ const reasons: RejectReason[] = [
1821
+ ...(current ? [] : (["stale"] as const)),
1822
+ ...(PASSING.has(String(row.result)) ? [] : (["not_passing"] as const)),
1823
+ ...independence,
1824
+ ];
1825
+ verdicts.push({
1826
+ kind,
1827
+ basis,
1828
+ current,
1829
+ independent: independence.length === 0,
1830
+ accepted: reasons.length === 0,
1831
+ reasons,
1832
+ carry: basis === "carried" ? (rebaseNote(row) ?? carryOf(row)) : null,
1833
+ verdict: row,
1834
+ });
1835
+ // The author's own failure never displaces an independent pass, but it
1836
+ // must not vanish either.
1837
+ if (reasons.length === 0)
1838
+ for (const later of list)
1839
+ if (
1840
+ byRank(later, row) > 0 &&
1841
+ reasonsOf(later).length > 0 &&
1842
+ !untrusted(reasonsOf(later)) &&
1843
+ FAILING.has(String(later.result)) &&
1844
+ basisOf(later) !== "stale"
1845
+ )
1846
+ warnings.push(
1847
+ `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`,
1848
+ );
1849
+ }
1850
+ for (const row of rows)
1851
+ if (row.integrity === "unverified" && scope(row))
1852
+ warnings.push(
1853
+ `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)`,
1854
+ );
1855
+ // An edited, removed or moved row anywhere breaks the chain; honest appends
1856
+ // never do. Until it is repaired no verdict is accepted (mv83zyg6 P3/P4).
1857
+ const tampered = ledgerTampering(cwd);
1858
+ if (tampered.length) {
1859
+ warnings.push(tamperedWarning(cwd, tampered));
1860
+ for (const entry of verdicts) {
1861
+ entry.accepted = false;
1862
+ entry.reasons = [...entry.reasons, "ledger_tampered"];
1863
+ }
1864
+ }
1865
+ verdicts.sort((a, b) => byRank(a.verdict, b.verdict));
1866
+ const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
1867
+ const failing = verdicts.find(
1868
+ (entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
1869
+ );
1870
+ const newestAccepted =
1871
+ verdicts.findLast(
1872
+ (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
1873
+ ) ?? null;
1874
+ const accepted = tampered.length
1875
+ ? {
1876
+ accepted: false,
1877
+ verdict: null,
1878
+ reasons: ["ledger_tampered"] as RejectReason[],
1879
+ }
1880
+ : newestAccepted && !failing
1881
+ ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
1882
+ : {
1883
+ accepted: false,
1884
+ verdict: failing?.verdict ?? null,
1885
+ reasons: failing
1886
+ ? (["failing_verdict"] as RejectReason[])
1887
+ : !newestCurrent
1888
+ ? (["no_verdict"] as RejectReason[])
1889
+ : newestCurrent.reasons.length
1890
+ ? newestCurrent.reasons
1891
+ : (["type_check_only"] as RejectReason[]),
1892
+ };
1893
+ // The author's latest pass or failure on the current code wins, across
1894
+ // kinds: a newer failure cancels an older pass (review finding mvkcoxgb).
1895
+ const latestSelf = failing
1896
+ ? undefined
1897
+ : verdicts.findLast(
1898
+ (entry) =>
1899
+ entry.current &&
1900
+ !entry.independent &&
1901
+ !untrusted(entry.reasons) &&
1902
+ (STRONG_RESULTS.has(String(entry.verdict.result)) ||
1903
+ FAILING.has(String(entry.verdict.result))),
1904
+ );
1905
+ const selfEntry =
1906
+ latestSelf && STRONG_RESULTS.has(String(latestSelf.verdict.result)) ? latestSelf : undefined;
1907
+ const carryEntry =
1908
+ verdicts.find((entry) => entry.verdict === accepted.verdict && entry.accepted) ?? newestCurrent;
1909
+ // A focused re-verify: nothing applies now, and the newest trusted pass is
1910
+ // stale only by a small change off risk paths (the drift's lane is self).
1911
+ const lastPass =
1912
+ accepted.accepted || failing || tampered.length
1913
+ ? undefined
1914
+ : verdicts.findLast(
1915
+ (entry) =>
1916
+ !entry.current &&
1917
+ !untrusted(entry.reasons) &&
1918
+ STRONG_RESULTS.has(String(entry.verdict.result)),
1919
+ );
1920
+ const drift = lastPass ? driftOf(lastPass.verdict) : null;
1921
+ const reverify: Reverify | null =
1922
+ drift && !drift.carries && drift.focused && drift.files
1923
+ ? { scope: "focused", since: drift.since, files: drift.files }
1924
+ : null;
1925
+ return {
1926
+ branch,
1927
+ head,
1928
+ review: accepted.accepted ? "verified" : selfEntry ? "self-reviewed" : "unreviewed",
1929
+ selfVerdict: accepted.accepted ? null : (selfEntry?.verdict ?? null),
1930
+ base: newestCurrent?.verdict.base ?? fallbackBase,
1931
+ patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
1932
+ current: {
1933
+ basis: newestCurrent?.basis ?? "none",
1934
+ verdict: newestCurrent?.verdict ?? null,
1935
+ },
1936
+ carry: carryEntry?.carry ?? null,
1937
+ reverify,
1938
+ accepted,
1939
+ verdicts,
1940
+ authors: [...authors].toSorted(),
1941
+ warnings,
1942
+ };
1943
+ }
1944
+
1945
+ // ---------------------------------------------------------------------------
1946
+ // verdict mirror (#311): verdicts survive a throwaway clone
1947
+ //
1948
+ // A clone has its own `.git`, so its ledger dies with it. Every verdict is
1949
+ // also appended to a per-user mirror keyed by the clone's `origin` (one hop:
1950
+ // a clone of a local checkout is keyed by that checkout's origin), at
1951
+ // `<state dir>/verdicts/<key>/ledger.jsonl`, hash-chained like a ledger. The
1952
+ // gates of any checkout of the same remote import its rows for the branch
1953
+ // they check into their own ledger (same id, re-chained), so a verdict
1954
+ // recorded in a clone is found after the clone is deleted. Imported rows keep
1955
+ // every field, so session trust, independence and carry are judged as for a
1956
+ // row recorded here. Only rows that verify in the mirror's chain are taken.
1957
+
1958
+ /** The remote identity of `cwd`'s `origin`: host and path, lowercased; null without one. */
1959
+ function remoteIdentity(cwd: string, hop = 0): string | null {
1960
+ const raw = originUrl(cwd);
1961
+ if (!raw) return null;
1962
+ // A clone of a local checkout: that checkout's own origin names the remote.
1963
+ if (hop === 0 && !/^[\w+.-]+:\/\/|^[^/\\]+@[^/\\]+:/u.test(raw)) {
1964
+ const local = raw.replace(/^file:\/\//u, "");
1965
+ const resolved = path.isAbsolute(local) ? local : path.resolve(cwd, local);
1966
+ if (fs.existsSync(resolved)) return remoteIdentity(resolved, 1);
1967
+ }
1968
+ const parsed = parseRemoteUrl(raw);
1969
+ if (!parsed || parsed.protocol === "file") return null;
1970
+ return `${parsed.host}${parsed.port ? `:${parsed.port}` : ""}/${parsed.path}`.toLowerCase();
1971
+ }
1972
+
1973
+ const mirrorKeys = new Map<string, string | null>();
1974
+ /** The verdict mirror file for `cwd`'s remote, or null when it has no remote origin. */
1975
+ export function verdictMirrorPath(cwd: string): string | null {
1976
+ const top = gitDirs(cwd)?.commonDir ?? null;
1977
+ if (!top) return null;
1978
+ let identity = mirrorKeys.get(top);
1979
+ if (identity === undefined) {
1980
+ identity = remoteIdentity(cwd);
1981
+ mirrorKeys.set(top, identity);
1982
+ }
1983
+ if (!identity) return null;
1984
+ const key = createHash("sha256").update(identity).digest("hex").slice(0, 32);
1985
+ return path.join(resolveStateDir(), "verdicts", key, "ledger.jsonl");
1986
+ }
1987
+
1988
+ /** Append a recorded verdict to the mirror (best effort: the local row is the record). */
1989
+ function mirrorVerdict(cwd: string, row: Record<string, unknown>): void {
1990
+ const file = verdictMirrorPath(cwd);
1991
+ const local = ledgerPath(cwd);
1992
+ if (!file || !local.ok || file === local.value) return;
1993
+ const { prevHash: _prev, rowHash: _row, ...fields } = row;
1994
+ appendLine(
1995
+ file,
1996
+ (previous) => {
1997
+ const next = sealed(
1998
+ { ...fields, mirroredFrom: local.value },
1999
+ String(row.id),
2000
+ String(row.at),
2001
+ previous,
2002
+ );
2003
+ return Buffer.byteLength(next.line) > MAX_LINE_BYTES
2004
+ ? err("invalid_input", "verdict row too long to mirror")
2005
+ : { ok: true, value: next.line };
2006
+ },
2007
+ {},
2008
+ );
2009
+ }
2010
+
2011
+ const importedStamp = new Map<string, string>();
2012
+
2013
+ /**
2014
+ * Import the mirror's verdicts on `branch` that this checkout's ledger lacks,
2015
+ * and return them as read back (with integrity). Empty when there is no
2016
+ * mirror, nothing new, or the ledger is busy.
2017
+ */
2018
+ function importMirroredVerdicts(cwd: string, branch: string): ReadRow[] {
2019
+ const file = verdictMirrorPath(cwd);
2020
+ const local = ledgerPath(cwd);
2021
+ if (!file || !local.ok || file === local.value) return [];
2022
+ let stamp: string;
2023
+ try {
2024
+ stamp = `${branch}\0${fileStamp(fs.statSync(file))}`;
2025
+ } catch {
2026
+ return [];
2027
+ }
2028
+ const cacheKey = `${local.value}\0${file}`;
2029
+ if (importedStamp.get(cacheKey) === stamp) return [];
2030
+ const mirror = readLedgerAt(cwd, file, { integrity: true });
2031
+ if (!mirror.ok) return [];
2032
+ const candidates = mirror.value.rows.filter(
2033
+ (row) => row.type === "verdict" && row.branch === branch && row.integrity === "verified",
2034
+ );
2035
+ const added = new Set<string>();
2036
+ try {
2037
+ fs.mkdirSync(path.dirname(local.value), { recursive: true });
2038
+ } catch {
2039
+ return [];
2040
+ }
2041
+ const done = candidates.length
2042
+ ? withLedgerLock(local.value, () => {
2043
+ const have = readLedgerAt(cwd, local.value, {});
2044
+ if (!have.ok) return false;
2045
+ const ids = new Set(have.value.rows.map((row) => row.id));
2046
+ for (const row of candidates) {
2047
+ if (ids.has(row.id)) continue;
2048
+ const {
2049
+ v: _v,
2050
+ id,
2051
+ at,
2052
+ seq: _seq,
2053
+ superseded: _superseded,
2054
+ supersedeIgnored: _ignored,
2055
+ integrity: _integrity,
2056
+ integrityReason: _reason,
2057
+ ...fields
2058
+ } = row;
2059
+ const written = appendLine(
2060
+ local.value,
2061
+ (previous) => ({ ok: true, value: sealed(fields, id, at, previous).line }),
2062
+ { heldLock: true },
2063
+ );
2064
+ if (!written.ok) return false;
2065
+ ids.add(id);
2066
+ added.add(id);
2067
+ }
2068
+ return true;
2069
+ })
2070
+ : true;
2071
+ if (done) importedStamp.set(cacheKey, stamp);
2072
+ if (!added.size) return [];
2073
+ const read = readLedgerAt(cwd, local.value, { integrity: true });
2074
+ return read.ok ? read.value.rows.filter((row) => added.has(row.id)) : [];
2075
+ }
2076
+
2077
+ // ---------------------------------------------------------------------------
2078
+ // recording helpers
2079
+
2080
+ export type RecordContext = {
2081
+ cwd: string;
2082
+ actor: LedgerActor;
2083
+ branch?: string | null;
2084
+ base?: string | null;
2085
+ pr?: number;
2086
+ supersedes?: string;
2087
+ /** Where `actor.session` came from (verdicts record it). Default: the environment. */
2088
+ sessionSource?: SessionSource;
2089
+ /** The caller presented the token bound to its issued session (verdicts record it). */
2090
+ sessionBound?: boolean;
2091
+ now?: Date;
2092
+ };
2093
+
2094
+ const common = (context: RecordContext, key: CodeKey) => ({
2095
+ ...key,
2096
+ actor: context.actor,
2097
+ ...(context.pr === undefined ? {} : { pr: context.pr }),
2098
+ ...(context.supersedes ? { supersedes: context.supersedes } : {}),
2099
+ });
2100
+
2101
+ /** Cheap key for notes: branch and head only (no tree hashing, no patch-id). */
2102
+ const noteKey = (context: RecordContext): CodeKey => {
2103
+ const branch = context.branch ?? currentBranch(context.cwd);
2104
+ return {
2105
+ branch,
2106
+ head: branch ? branchHead(context.cwd, branch) : headSha(context.cwd),
2107
+ base: null,
2108
+ baseSha: null,
2109
+ patchId: null,
2110
+ diffHash: null,
2111
+ tree: null,
2112
+ dirty: null,
2113
+ };
2114
+ };
2115
+
2116
+ const required = (value: string | undefined | null, flag: string): LedgerResult<string> => {
2117
+ const text = value?.trim();
2118
+ return text ? { ok: true, value: text } : err("invalid_input", `${flag} is required`);
2119
+ };
2120
+
2121
+ /** The row a `--supersedes` value names: an id, or the row number `ledger list` prints (`#12` or `12`). */
2122
+ export function findSupersedeTarget<T extends { id: string; seq: number }>(
2123
+ rows: readonly T[],
2124
+ value: string,
2125
+ ): T | undefined {
2126
+ const byId = rows.find((row) => row.id === value);
2127
+ if (byId) return byId;
2128
+ const number = /^#?(\d+)$/.exec(value.trim());
2129
+ return number ? rows.find((row) => row.seq === Number(number[1])) : undefined;
2130
+ }
2131
+
2132
+ /** Refuse a supersede link that the reader would ignore. */
2133
+ function checkSupersede(
2134
+ context: RecordContext,
2135
+ type: string,
2136
+ row: { self?: boolean; kind?: string; branch?: string | null } = {},
2137
+ ): LedgerResult<string | undefined> {
2138
+ if (!context.supersedes) return { ok: true, value: undefined };
2139
+ const ledger = readLedger(context.cwd);
2140
+ if (!ledger.ok) return ledger;
2141
+ const target = findSupersedeTarget(ledger.value.rows, context.supersedes);
2142
+ if (!target) return err("not_found", `no ledger row ${context.supersedes} to supersede`);
2143
+ const branch =
2144
+ row.branch === undefined ? (context.branch ?? currentBranch(context.cwd)) : row.branch;
2145
+ const problem = supersedeAllowed(
2146
+ { type, actor: context.actor, ...row, branch },
2147
+ target,
2148
+ renameAwareBranch((name) => branchScope(context.cwd, name), branch),
2149
+ );
2150
+ return problem
2151
+ ? err("invalid_input", `--supersedes: ${problem}`)
2152
+ : { ok: true, value: target.id };
2153
+ }
2154
+
2155
+ export function recordDecision(
2156
+ context: RecordContext,
2157
+ input: {
2158
+ what?: string;
2159
+ why?: string;
2160
+ refs?: string[];
2161
+ by?: string;
2162
+ views?: string[];
2163
+ },
2164
+ ): LedgerResult<DecisionRow> {
2165
+ if (input.by !== undefined && input.by !== "decider")
2166
+ return err("invalid_input", `--by takes "decider", got "${input.by}"`);
2167
+ if (input.by === undefined && input.views?.length)
2168
+ return err("invalid_input", "--view needs --by decider");
2169
+ const what = required(input.what, "<what>");
2170
+ if (!what.ok) return what;
2171
+ const why = required(input.why, "--why");
2172
+ if (!why.ok) return why;
2173
+ const link = checkSupersede(context, "decision");
2174
+ if (!link.ok) return link;
2175
+ return appendRow<DecisionRow>(
2176
+ context.cwd,
2177
+ {
2178
+ ...common({ ...context, supersedes: link.value }, noteKey(context)),
2179
+ type: "decision",
2180
+ what: what.value,
2181
+ why: why.value,
2182
+ refs: input.refs ?? [],
2183
+ ...(input.by === "decider" ? { by: "decider" as const } : {}),
2184
+ ...(input.views?.length ? { views: input.views } : {}),
2185
+ },
2186
+ { now: context.now },
2187
+ );
2188
+ }
2189
+
2190
+ export function recordRuling(
2191
+ context: RecordContext,
2192
+ input: { what?: string; why?: string; costIfWrong?: string; refs?: string[] },
2193
+ ): LedgerResult<RulingRow> {
2194
+ const what = required(input.what, "<what>");
2195
+ if (!what.ok) return what;
2196
+ const why = required(input.why, "--why");
2197
+ if (!why.ok) return why;
2198
+ const cost = required(input.costIfWrong, "--cost-if-wrong");
2199
+ if (!cost.ok) return cost;
2200
+ const link = checkSupersede(context, "ruling");
2201
+ if (!link.ok) return link;
2202
+ return appendRow<RulingRow>(
2203
+ context.cwd,
2204
+ {
2205
+ ...common({ ...context, supersedes: link.value }, noteKey(context)),
2206
+ type: "ruling",
2207
+ what: what.value,
2208
+ why: why.value,
2209
+ costIfWrong: cost.value,
2210
+ refs: input.refs ?? [],
2211
+ },
2212
+ { now: context.now },
2213
+ );
2214
+ }
2215
+
2216
+ /**
2217
+ * May this row speak for the fanout's lead? With recorded leads (the current
2218
+ * one and any it took over from) only their rows count; a plan from before
2219
+ * leads (null) takes any session.
2220
+ */
2221
+ const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
2222
+ leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
2223
+
2224
+ /**
2225
+ * The standing orders in force for `fanout`, oldest first (D17: odd rows are
2226
+ * skipped). Given the plan's lead session, orders and clears recorded by any
2227
+ * other session are ignored, so a worker can neither add nor clear one.
2228
+ */
2229
+ export function activeStanding(
2230
+ rows: readonly ReadRow[],
2231
+ fanout: string,
2232
+ leads: readonly string[] | null = null,
2233
+ ): ReadRow[] {
2234
+ const active: ReadRow[] = [];
2235
+ for (const row of rows) {
2236
+ if (row.fanout !== fanout || !fromLead(row, leads)) continue;
2237
+ if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
2238
+ if (!row.superseded) active.push(row);
2239
+ } else if (row.type === "standing.cleared") {
2240
+ const target = str(row.target);
2241
+ if (target === null) active.length = 0;
2242
+ else {
2243
+ const index = active.findIndex((order) => order.id === target);
2244
+ if (index >= 0) active.splice(index, 1);
2245
+ }
2246
+ }
2247
+ }
2248
+ return active;
2249
+ }
2250
+
2251
+ /**
2252
+ * A worker or verifier session by the ids workit hands out: `fanout brief`'s
2253
+ * `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
2254
+ * hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
2255
+ * session ids carry no colon. Used where no recorded lead decides: plans that
2256
+ * predate `leadSession`, and `fanout plan --take-lead`.
2257
+ */
2258
+ export const isDelegateSession = (session: string | null): boolean =>
2259
+ session !== null &&
2260
+ (/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
2261
+ /-v\d+$/u.test(session) ||
2262
+ session.includes(":"));
2263
+
2264
+ /**
2265
+ * Standing orders come from the fanout's lead: the session that first made
2266
+ * the plan (`lead`). A plan without one falls back to refusing the session
2267
+ * ids handed to workers and verifiers.
2268
+ */
2269
+ function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
2270
+ const session = context.actor.session;
2271
+ if (lead !== null ? session === lead : !isDelegateSession(session))
2272
+ return { ok: true, value: undefined };
2273
+ return err(
2274
+ "blocked",
2275
+ lead !== null
2276
+ ? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
2277
+ : `session ${session} is a worker or verifier: standing orders come from the lead`,
2278
+ lead !== null
2279
+ ? `the lead in a new session? workit fanout plan <plan.json> --take-lead from this session; a worker or verifier reports the order to the lead instead`
2280
+ : "a worker or verifier reports the order to the lead instead",
2281
+ );
2282
+ }
2283
+
2284
+ /** Running workers see a new revision on their next hook (hooks/standing.ts). */
2285
+ function signalStanding<T extends { id: string }>(
2286
+ cwd: string,
2287
+ result: LedgerResult<T>,
2288
+ ): LedgerResult<T> {
2289
+ const root = result.ok ? storeRoot(cwd) : null;
2290
+ if (result.ok && root?.ok && root.value.shared) bumpStandingRev(root.value.root, result.value.id);
2291
+ return result;
2292
+ }
2293
+
2294
+ /** The stop line's text, as briefs and running workers read it. */
2295
+ export const stopOrderText = (note: string | null): string =>
2296
+ `STOP: start nothing new, stop every process you started (dev servers, browsers, watchers), commit only finished work, and report to the lead now${note ? ` (${note})` : ""}`;
2297
+
2298
+ export function recordStanding(
2299
+ context: RecordContext,
2300
+ input: { fanout: string; lead: string | null; what?: string; stop?: boolean },
2301
+ ): LedgerResult<StandingRow> {
2302
+ const what: LedgerResult<string> = input.stop
2303
+ ? { ok: true, value: stopOrderText(input.what?.trim() || null) }
2304
+ : required(input.what, "<order>");
2305
+ if (!what.ok) return what;
2306
+ // One order per line of every brief: a newline or control character would
2307
+ // let an order forge other brief fields.
2308
+ if (/\p{Cc}/u.test(what.value))
2309
+ return err(
2310
+ "invalid_input",
2311
+ "a standing order is one line: no newlines or control characters",
2312
+ 'add each order separately: workit ledger standing add "<order>"',
2313
+ );
2314
+ const lead = leadOnly(context, input.lead);
2315
+ if (!lead.ok) return lead;
2316
+ const link = checkSupersede(context, "standing");
2317
+ if (!link.ok) return link;
2318
+ const recorded = appendRow<StandingRow>(
2319
+ context.cwd,
2320
+ {
2321
+ ...common({ ...context, supersedes: link.value }, noteKey(context)),
2322
+ type: "standing",
2323
+ fanout: input.fanout,
2324
+ what: what.value,
2325
+ ...(input.stop ? { stop: true as const } : {}),
2326
+ },
2327
+ { now: context.now },
2328
+ );
2329
+ return signalStanding(context.cwd, recorded);
2330
+ }
2331
+
2332
+ /** Clear one standing order by id (it must be in force), or all of them; lead only. */
2333
+ export function clearStanding(
2334
+ context: RecordContext,
2335
+ input: {
2336
+ fanout: string;
2337
+ lead: string | null;
2338
+ leads?: readonly string[] | null;
2339
+ target: string | null;
2340
+ },
2341
+ ): LedgerResult<StandingClearedRow> {
2342
+ const lead = leadOnly(context, input.lead);
2343
+ if (!lead.ok) return lead;
2344
+ if (input.target !== null) {
2345
+ const ledger = readLedger(context.cwd);
2346
+ if (!ledger.ok) return ledger;
2347
+ if (
2348
+ !activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
2349
+ (row) => row.id === input.target,
2350
+ )
2351
+ )
2352
+ return err(
2353
+ "not_found",
2354
+ `no standing order ${input.target} in force for fanout ${input.fanout}`,
2355
+ `workit ledger standing list --fanout ${input.fanout}`,
2356
+ );
2357
+ }
2358
+ return signalStanding(
2359
+ context.cwd,
2360
+ appendRow<StandingClearedRow>(
2361
+ context.cwd,
2362
+ {
2363
+ ...common(context, noteKey(context)),
2364
+ type: "standing.cleared",
2365
+ fanout: input.fanout,
2366
+ target: input.target,
2367
+ },
2368
+ { now: context.now },
2369
+ ),
2370
+ );
2371
+ }
2372
+
2373
+ /** Lift the stop line: clear each stop order in force and keep the others; lead only. */
2374
+ export function liftStop(
2375
+ context: RecordContext,
2376
+ input: {
2377
+ fanout: string;
2378
+ lead: string | null;
2379
+ leads?: readonly string[] | null;
2380
+ },
2381
+ ): LedgerResult<StandingClearedRow[]> {
2382
+ const lead = leadOnly(context, input.lead);
2383
+ if (!lead.ok) return lead;
2384
+ const ledger = readLedger(context.cwd);
2385
+ if (!ledger.ok) return ledger;
2386
+ const stops = activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).filter(
2387
+ (row) => row.stop === true,
2388
+ );
2389
+ if (!stops.length)
2390
+ return err(
2391
+ "not_found",
2392
+ `no stop line in force for fanout ${input.fanout}`,
2393
+ `workit ledger standing list --fanout ${input.fanout}`,
2394
+ );
2395
+ const cleared: StandingClearedRow[] = [];
2396
+ for (const stop of stops) {
2397
+ const row = clearStanding(context, { ...input, target: stop.id });
2398
+ if (!row.ok) return row;
2399
+ cleared.push(row.value);
2400
+ }
2401
+ return { ok: true, value: cleared };
2402
+ }
2403
+
2404
+ /** Tracked files changed or staged in `cwd`'s worktree; null when git cannot say. */
2405
+ function trackedDirty(cwd: string): boolean | null {
2406
+ const status = gitRun(cwd, ["status", "--porcelain", "--untracked-files=no"]);
2407
+ return status.ok ? status.stdout.trim().length > 0 : null;
2408
+ }
2409
+
2410
+ const MAX_UNTRACKED_LISTED = 20;
2411
+
2412
+ /** Untracked, non-ignored files in `cwd`'s worktree: they never change the head a verdict judges. */
2413
+ function untrackedFiles(cwd: string): string[] {
2414
+ const listed = gitRun(cwd, ["ls-files", "--others", "--exclude-standard"]);
2415
+ return listed.ok ? listed.stdout.split("\n").filter(Boolean) : [];
2416
+ }
2417
+
2418
+ /**
2419
+ * Is `branch` checked out, with uncommitted changes, in another worktree of
2420
+ * this repository? (`codeKey` sees only the cwd's own worktree.)
2421
+ */
2422
+ function dirtyElsewhere(cwd: string, branch: string, head: string): boolean {
2423
+ // A checkout of exactly the branch head (a verifier's detached worktree)
2424
+ // judges what it holds: clean judges that head, whatever state another
2425
+ // checkout of the branch is in; dirty (or unreadable) does not.
2426
+ if (headSha(cwd) === head) return trackedDirty(cwd) !== false;
2427
+ const list = git(cwd, ["worktree", "list", "--porcelain"]);
2428
+ let worktree: string | null = null;
2429
+ for (const line of (list ?? "").split("\n")) {
2430
+ if (line.startsWith("worktree ")) worktree = line.slice("worktree ".length);
2431
+ else if (line === `branch refs/heads/${branch}` && worktree) {
2432
+ const status = gitRun(worktree, ["status", "--porcelain", "--untracked-files=no"]);
2433
+ return status.ok && status.stdout.trim().length > 0;
2434
+ }
2435
+ }
2436
+ return false;
2437
+ }
2438
+
2439
+ /**
2440
+ * Record an agent-asserted verdict on a branch's committed head. Refused on a
2441
+ * dirty worktree (the head would not be what was judged) and, for an author
2442
+ * session, unless `--self`. Without a session the verdict is recorded as
2443
+ * self: an unknown actor cannot be told apart from the author.
2444
+ */
2445
+ export function recordVerdict(
2446
+ context: RecordContext,
2447
+ input: {
2448
+ result: string;
2449
+ kind?: string;
2450
+ how?: string;
2451
+ surface?: string | null;
2452
+ self?: boolean;
2453
+ evidenceRefs?: string[];
2454
+ checkEvidence?: { name: string; status: string; via: string | null }[];
2455
+ /**
2456
+ * A role id derived from another session (`--as`): the session it was
2457
+ * derived from. `null` means it was derived from no session, which makes
2458
+ * the verdict self. An author's derived id is refused like the author.
2459
+ */
2460
+ derivedFrom?: string | null;
2461
+ },
2462
+ ): LedgerResult<VerdictRow> {
2463
+ if (!(VERDICT_RESULTS as readonly string[]).includes(input.result))
2464
+ return err(
2465
+ "invalid_input",
2466
+ `unknown verdict "${input.result}"; expected ${VERDICT_RESULTS.join("|")}`,
2467
+ );
2468
+ const kind = input.kind ?? "review";
2469
+ if (!(VERDICT_KINDS as readonly string[]).includes(kind))
2470
+ return err("invalid_input", `--kind must be ${VERDICT_KINDS.join("|")}`);
2471
+ const surface = input.surface ?? null;
2472
+ if (surface !== null && !(VERDICT_SURFACES as readonly string[]).includes(surface))
2473
+ return err("invalid_input", `--surface must be ${VERDICT_SURFACES.join("|")}`);
2474
+ const how = required(input.how, "--how");
2475
+ if (!how.ok) return how;
2476
+ if (gitDirs(context.cwd) === null)
2477
+ return err(
2478
+ "invalid_input",
2479
+ `${context.cwd} is not inside a git checkout, so there is no commit to judge`,
2480
+ "pass --cwd <the worktree or checkout the verdict is for>",
2481
+ );
2482
+ const branch = context.branch ?? currentBranch(context.cwd);
2483
+ if (!branch)
2484
+ return err(
2485
+ "invalid_input",
2486
+ "HEAD is detached; name the branch the verdict is for",
2487
+ "pass --branch <branch>",
2488
+ );
2489
+ const ledger = readLedger(context.cwd);
2490
+ if (!ledger.ok) return ledger;
2491
+ const key = codeKey(context.cwd, { branch, base: context.base });
2492
+ if (!key.head) return err("not_found", `branch "${branch}" has no commit to judge`);
2493
+ // Tracked changes only: an untracked probe file cannot change the head, and
2494
+ // is recorded on the row instead.
2495
+ const dirty =
2496
+ key.dirty === null
2497
+ ? dirtyElsewhere(context.cwd, branch, key.head)
2498
+ : trackedDirty(context.cwd) !== false;
2499
+ if (dirty)
2500
+ return err(
2501
+ "blocked",
2502
+ `dirty_worktree: ${branch} has uncommitted changes to tracked files, so its head is not what was judged`,
2503
+ "commit or stash the changes, re-check, then record the verdict",
2504
+ );
2505
+ const session = context.actor.session;
2506
+ const selfReason: SelfReason | null =
2507
+ input.self === true ? "flag" : session && input.derivedFrom !== null ? null : "no_session";
2508
+ const self = selfReason !== null;
2509
+ const authors = authorSessions(context.cwd, ledger.value.rows, branch, {
2510
+ base: key.base,
2511
+ head: key.head,
2512
+ });
2513
+ const authoring = [session, input.derivedFrom].find(
2514
+ (candidate): candidate is string =>
2515
+ typeof candidate === "string" && authoredBy(authors, candidate),
2516
+ );
2517
+ if (!self && authoring)
2518
+ return err(
2519
+ "blocked",
2520
+ `author_verdict: session ${authoring} authored ${branch}; a verdict must come from a different session`,
2521
+ input.derivedFrom === undefined
2522
+ ? `have a non-author session record the verdict: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS}); or, as the author, pass --self (self-reviewed, never accepted)`
2523
+ : `run the verifier as a separate session: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS})`,
2524
+ );
2525
+ const link = checkSupersede(context, "verdict", { self, kind, branch });
2526
+ if (!link.ok) return link;
2527
+ const recorded = appendRow<VerdictRow>(
2528
+ context.cwd,
2529
+ {
2530
+ ...common({ ...context, supersedes: link.value }, key),
2531
+ type: "verdict",
2532
+ kind: kind as VerdictKind,
2533
+ result: input.result as VerdictResult,
2534
+ how: how.value,
2535
+ surface: surface as VerdictSurface | null,
2536
+ observer: "agent_asserted",
2537
+ self,
2538
+ selfReason,
2539
+ sessionSource: context.sessionSource ?? "env",
2540
+ ...(context.sessionBound === true ? { sessionBound: true } : {}),
2541
+ evidenceRefs: input.evidenceRefs ?? [],
2542
+ ...(input.checkEvidence?.length ? { checkEvidence: input.checkEvidence } : {}),
2543
+ ...untrackedField(context.cwd),
2544
+ },
2545
+ { now: context.now },
2546
+ );
2547
+ if (recorded.ok) mirrorVerdict(context.cwd, recorded.value);
2548
+ return recorded;
2549
+ }
2550
+
2551
+ function untrackedField(cwd: string): {
2552
+ untracked?: string[];
2553
+ untrackedCount?: number;
2554
+ } {
2555
+ const files = untrackedFiles(cwd);
2556
+ return files.length
2557
+ ? {
2558
+ untracked: files.slice(0, MAX_UNTRACKED_LISTED),
2559
+ untrackedCount: files.length,
2560
+ }
2561
+ : {};
2562
+ }
2563
+
2564
+ export function recordHandoff(
2565
+ context: RecordContext,
2566
+ input: { note: string | null; next: string },
2567
+ ): LedgerResult<HandoffRow> {
2568
+ return appendRow<HandoffRow>(
2569
+ context.cwd,
2570
+ {
2571
+ ...common(context, noteKey(context)),
2572
+ type: "handoff",
2573
+ note: input.note,
2574
+ next: input.next,
2575
+ },
2576
+ { now: context.now },
2577
+ );
2578
+ }
2579
+
2580
+ // ---------------------------------------------------------------------------
2581
+ // handoff brief (replaces export/import: the ledger is shared across worktrees)
2582
+
2583
+ export type RowSummary = {
2584
+ seq: number;
2585
+ id: string;
2586
+ at: string;
2587
+ type: string;
2588
+ branch: string | null;
2589
+ summary: string;
2590
+ /** Why a reader should discount the row: superseded, ignored (by the trust rules), self. */
2591
+ labels: Array<"superseded" | "ignored" | "self" | "decider">;
2592
+ };
2593
+
2594
+ /** Row types that count only when an observing verb wrote them. */
2595
+ const OBSERVED_ONLY: ReadonlySet<string> = new Set([
2596
+ "check",
2597
+ "merge.unverified",
2598
+ "policy.judged",
2599
+ ...PR_ROW_TYPES,
2600
+ ]);
2601
+
2602
+ export function rowLabels(row: ReadRow): RowSummary["labels"] {
2603
+ const labels: RowSummary["labels"] = [];
2604
+ if (row.superseded) labels.push("superseded");
2605
+ if (row.supersedeIgnored || (OBSERVED_ONLY.has(row.type) && row.observer !== "workit_cli"))
2606
+ labels.push("ignored");
2607
+ if (row.type === "verdict" && row.self === true) labels.push("self");
2608
+ if (row.type === "decision" && row.by === "decider") labels.push("decider");
2609
+ return labels;
2610
+ }
2611
+
2612
+ export function summarizeRow(row: ReadRow): RowSummary {
2613
+ const text = (key: string): string => str(row[key]) ?? "";
2614
+ let summary: string;
2615
+ switch (row.type) {
2616
+ case "decision": {
2617
+ const views = Array.isArray(row.views) ? row.views.map(String) : [];
2618
+ summary = `${row.by === "decider" ? "[decider] " : ""}${text("what")} (why: ${text("why")}${views.length ? `; views: ${views.join(" | ")}` : ""})`;
2619
+ break;
2620
+ }
2621
+ case "ruling":
2622
+ summary = `${text("what")} (why: ${text("why")}; cost if wrong: ${text("costIfWrong")})`;
2623
+ break;
2624
+ case "verdict":
2625
+ 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")}`;
2626
+ break;
2627
+ case "policy.judged": {
2628
+ const lifted = Array.isArray(row.lifted) ? row.lifted.map(String) : [];
2629
+ const requirements = Array.isArray(row.requirements) ? row.requirements.map(String) : [];
2630
+ summary = `judged by ${row.actor.session ?? "no session"}: ${requirements.length ? requirements.join(", ") : "no requirements"}${lifted.length ? `; lifted ${lifted.join(", ")} (why: ${text("why") || "none given"})` : ""}`;
2631
+ break;
2632
+ }
2633
+ case "handoff":
2634
+ summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
2635
+ break;
2636
+ case "standing":
2637
+ summary = `${text("what")} (fanout ${text("fanout")})`;
2638
+ break;
2639
+ case "standing.cleared":
2640
+ summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
2641
+ break;
2642
+ case "skill.loaded":
2643
+ summary = `${text("skill")} (${text("via") || "tool"})${row.actor.session ? "" : " (no session)"}`;
2644
+ break;
2645
+ case "commit.recorded":
2646
+ summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
2647
+ break;
2648
+ case "push.verified":
2649
+ case "push.noop":
2650
+ summary = `${text("remote")} ${(str(row.previous) ?? "(new)").slice(0, 12)} -> ${(row.head ?? "?").slice(0, 12)}${row.forced === true ? " (force-with-lease)" : ""}`;
2651
+ break;
2652
+ case "pr.created":
2653
+ summary = `#${row.pr ?? "?"} -> ${text("base")} at ${(row.head ?? "?").slice(0, 12)}${row.created === false ? " (existing)" : ""}`;
2654
+ break;
2655
+ case "pr.status":
2656
+ summary = `#${row.pr ?? "?"} ${text("state") && text("state") !== "open" ? `${text("state")} ` : ""}checks ${text("checks") || "?"} at ${(row.head ?? "?").slice(0, 12)}`;
2657
+ break;
2658
+ case "pr.merged":
2659
+ summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
2660
+ break;
2661
+ case "pr.linked":
2662
+ summary = `#${row.pr ?? "?"} links ${text("tracker")} ${text("issueRepo") ? `${text("issueRepo")}` : ""}${text("tracker") === "youtrack" ? "" : "#"}${text("issue")} (${text("relation") || "closes"}, from ${text("source") || "?"})`;
2663
+ break;
2664
+ case "merge.unverified":
2665
+ summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
2666
+ break;
2667
+ case "check": {
2668
+ const argv = Array.isArray(row.argv) ? row.argv.map(String).join(" ") : "";
2669
+ const exit = typeof row.exitCode === "number" ? `, exit ${row.exitCode}` : "";
2670
+ summary = `${text("name") || argv || "check"} ${text("result") || "?"}${exit}${row.timedOut === true ? ", timed out" : ""}`;
2671
+ break;
2672
+ }
2673
+ case "pr.merge.intent":
2674
+ summary = `#${row.pr ?? "?"} merge requested (${text("method") || "?"}) at ${(row.head ?? "?").slice(0, 12)}`;
2675
+ break;
2676
+ case "pr.merge.verified":
2677
+ summary = `#${row.pr ?? "?"} merge confirmed${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
2678
+ break;
2679
+ case "fanout.planned":
2680
+ summary = `${text("fanout")}: ${typeof row.slices === "number" ? row.slices : "?"} slice(s)`;
2681
+ break;
2682
+ case "delivery.verified":
2683
+ summary = `${text("expect")} delivered at ${(row.head ?? "?").slice(0, 12)}`;
2684
+ break;
2685
+ default:
2686
+ summary = text("what") || text("name") || row.type;
2687
+ }
2688
+ return {
2689
+ seq: row.seq,
2690
+ id: row.id,
2691
+ at: row.at,
2692
+ type: row.type,
2693
+ branch: row.branch,
2694
+ summary,
2695
+ labels: rowLabels(row),
2696
+ };
2697
+ }
2698
+
2699
+ export type StackPosition = {
2700
+ name: string;
2701
+ index: number;
2702
+ size: number;
2703
+ parent: string | null;
2704
+ pr: number | null;
2705
+ };
2706
+
2707
+ /** The branch's place in a cached stack (`<store>/stacks/*.json`, S12), if any. */
2708
+ export function stackPosition(root: string, branch: string): StackPosition | null {
2709
+ const dir = path.join(root, "stacks");
2710
+ let names: string[] = [];
2711
+ try {
2712
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
2713
+ } catch {
2714
+ return null;
2715
+ }
2716
+ for (const name of names.toSorted()) {
2717
+ let parsed: unknown;
2718
+ try {
2719
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
2720
+ } catch {
2721
+ continue;
2722
+ }
2723
+ if (!isRecord(parsed) || !Array.isArray(parsed.branches)) continue;
2724
+ const branches = parsed.branches.filter(isRecord);
2725
+ const index = branches.findIndex((entry) => entry.branch === branch);
2726
+ if (index < 0) continue;
2727
+ const entry = branches[index];
2728
+ return {
2729
+ name: str(parsed.name) ?? name.replace(/\.json$/u, ""),
2730
+ index: index + 1,
2731
+ size: branches.length,
2732
+ parent: str(entry.parent),
2733
+ pr: typeof entry.pr === "number" ? entry.pr : null,
2734
+ };
2735
+ }
2736
+ return null;
2737
+ }
2738
+
2739
+ export type CheckFreshness = {
2740
+ name: string;
2741
+ result: string | null;
2742
+ fresh: boolean;
2743
+ at: string;
2744
+ };
2745
+
2746
+ type VerdictSummary = Omit<VerdictEntry, "verdict"> & { summary: string };
2747
+
2748
+ export type HandoffBrief = {
2749
+ store: { root: string; shared: boolean; ledger: string };
2750
+ branch: string | null;
2751
+ head: string | null;
2752
+ subject: string | null;
2753
+ base: string | null;
2754
+ ahead: number | null;
2755
+ behind: number | null;
2756
+ dirty: { dirty: boolean; files: number; paths: string[] };
2757
+ pr: number | null;
2758
+ stack: StackPosition | null;
2759
+ verdict: {
2760
+ current: VerdictCheck["current"]["basis"];
2761
+ accepted: boolean;
2762
+ reasons: RejectReason[];
2763
+ verdicts: VerdictSummary[];
2764
+ };
2765
+ checks: CheckFreshness[];
2766
+ rulings: RowSummary[];
2767
+ recent: RowSummary[];
2768
+ lastHandoff: { at: string; note: string | null; next: string } | null;
2769
+ note: string | null;
2770
+ next: string;
2771
+ nextCommand: string;
2772
+ skippedRows: number;
2773
+ };
2774
+
2775
+ const MAX_DIRTY_PATHS = 10;
2776
+
2777
+ const dirtyState = (cwd: string): HandoffBrief["dirty"] => {
2778
+ const run = gitRun(
2779
+ cwd,
2780
+ ["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
2781
+ GIT_TIMEOUTS.worktree,
2782
+ );
2783
+ if (!run.ok) return { dirty: false, files: 0, paths: [] };
2784
+ const paths: string[] = [];
2785
+ const entries = run.stdout.split("\0");
2786
+ for (let index = 0; index < entries.length; index += 1) {
2787
+ const entry = entries[index];
2788
+ if (entry.length < 4) continue;
2789
+ paths.push(entry.slice(3));
2790
+ // Renames and copies carry the source path as the next entry.
2791
+ if (entry[0] === "R" || entry[0] === "C") index += 1;
2792
+ }
2793
+ return {
2794
+ dirty: paths.length > 0,
2795
+ files: paths.length,
2796
+ paths: paths.slice(0, MAX_DIRTY_PATHS),
2797
+ };
2798
+ };
2799
+
2800
+ const nextCommandFor = (brief: Omit<HandoffBrief, "nextCommand" | "next">): string => {
2801
+ const branchFlag = brief.branch ? ` --branch ${brief.branch}` : "";
2802
+ const verify = `workit ledger verdict verified${branchFlag} --how "<method and evidence>" (from a session that did not author the branch, with WORKIT_SESSION_ID set)`;
2803
+ if (!brief.head) return "make the first commit on this branch, then: workit handoff";
2804
+ if (brief.dirty.dirty)
2805
+ return `commit or stash the ${brief.dirty.files} changed file(s), then: workit handoff`;
2806
+ const stale = brief.checks.find((check) => !check.fresh);
2807
+ if (stale) return `workit check ${stale.name}`;
2808
+ const { verdict } = brief;
2809
+ if (verdict.reasons.includes("failing_verdict"))
2810
+ return `fix what the failing verdict found, then get a new independent verdict: ${verify}`;
2811
+ if (!verdict.accepted) return verify;
2812
+ return brief.pr === null ? "workit pr create" : `workit pr status --pr ${brief.pr}`;
2813
+ };
2814
+
2815
+ /**
2816
+ * A compact resume brief for the checked-out branch: code state, dirty files,
2817
+ * stack position, verdict acceptance, check freshness, rulings, the newest
2818
+ * ledger rows, and one next command.
2819
+ */
2820
+ export function buildHandoff(
2821
+ cwd: string,
2822
+ options: { last?: number; note?: string | null; next?: string | null } = {},
2823
+ ): LedgerResult<HandoffBrief> {
2824
+ const store = storeRoot(cwd);
2825
+ if (!store.ok) return store;
2826
+ const read = readLedger(cwd);
2827
+ if (!read.ok) return read;
2828
+ const ledger = read.value;
2829
+ const branch = currentBranch(cwd);
2830
+ const head = headSha(cwd);
2831
+ const base = defaultBase(cwd);
2832
+ let ahead: number | null = null;
2833
+ let behind: number | null = null;
2834
+ if (base && head) {
2835
+ const counts = git(cwd, ["rev-list", "--left-right", "--count", `${base}...HEAD`]);
2836
+ const [left, right] = (counts ?? "").split(/\s+/u).map(Number);
2837
+ if (Number.isFinite(left) && Number.isFinite(right)) {
2838
+ behind = left;
2839
+ ahead = right;
2840
+ }
2841
+ }
2842
+ const mine = branch ? filterRows(ledger.rows, { branch }) : [];
2843
+ const stack = branch ? stackPosition(store.value.root, branch) : null;
2844
+ const pr =
2845
+ stack?.pr ??
2846
+ mine.findLast(
2847
+ (row) => row.pr !== undefined && PR_ROW_TYPES.has(row.type) && row.observer === "workit_cli",
2848
+ )?.pr ??
2849
+ null;
2850
+ const check = branch ? checkVerdicts(cwd, branch, ledger.rows) : null;
2851
+ const checkRows = mine.filter(
2852
+ (row) => row.type === "check" && row.observer === "workit_cli" && str(row.name),
2853
+ );
2854
+ const latestChecks = new Map<string, ReadRow>();
2855
+ for (const row of checkRows) latestChecks.set(str(row.name) as string, row);
2856
+ const tree = latestChecks.size ? (worktreeTree(cwd)?.key ?? null) : null;
2857
+ const checks: CheckFreshness[] = [...latestChecks.entries()].map(([name, row]) => ({
2858
+ name,
2859
+ result: str(row.result),
2860
+ fresh: tree !== null && row.tree === tree,
2861
+ at: row.at,
2862
+ }));
2863
+ const lastHandoffRow = mine.findLast((row) => row.type === "handoff");
2864
+ const partial: Omit<HandoffBrief, "nextCommand" | "next"> = {
2865
+ store: {
2866
+ root: store.value.root,
2867
+ shared: store.value.shared,
2868
+ ledger: ledger.path,
2869
+ },
2870
+ branch,
2871
+ head,
2872
+ subject: head ? git(cwd, ["log", "-1", "--format=%s", head]) : null,
2873
+ base,
2874
+ ahead,
2875
+ behind,
2876
+ dirty: dirtyState(cwd),
2877
+ pr,
2878
+ stack,
2879
+ verdict: {
2880
+ current: check?.current.basis ?? "none",
2881
+ accepted: check?.accepted.accepted ?? false,
2882
+ reasons: check?.accepted.reasons ?? ["no_verdict"],
2883
+ verdicts: (check?.verdicts ?? []).map(({ verdict: row, ...rest }) => ({
2884
+ ...rest,
2885
+ summary: summarizeRow(row).summary,
2886
+ })),
2887
+ },
2888
+ checks,
2889
+ rulings: mine
2890
+ .filter((row) => row.type === "ruling" && !row.superseded)
2891
+ .slice(-5)
2892
+ .map(summarizeRow),
2893
+ recent: ledger.rows.slice(-(options.last ?? 10)).map(summarizeRow),
2894
+ lastHandoff: lastHandoffRow
2895
+ ? {
2896
+ at: lastHandoffRow.at,
2897
+ note: str(lastHandoffRow.note),
2898
+ next: str(lastHandoffRow.next) ?? "",
2899
+ }
2900
+ : null,
2901
+ note: options.note?.trim() || null,
2902
+ skippedRows: ledger.skipped,
2903
+ };
2904
+ const nextCommand = nextCommandFor(partial);
2905
+ return {
2906
+ ok: true,
2907
+ value: {
2908
+ ...partial,
2909
+ nextCommand,
2910
+ next: options.next?.trim() || nextCommand,
2911
+ },
2912
+ };
2913
+ }