@brainervirus/workit-core 2.2.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ledger.ts +1370 -0
package/package.json
CHANGED
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,1370 @@
|
|
|
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 is one `write(2)` of at most MAX_LINE_BYTES on an
|
|
10
|
+
// O_APPEND descriptor. POSIX promises atomicity only for pipes (PIPE_BUF),
|
|
11
|
+
// not regular files; what we rely on is that local filesystems (ext4, xfs,
|
|
12
|
+
// btrfs, apfs, NTFS via FILE_APPEND_DATA) position every O_APPEND write at
|
|
13
|
+
// the end under the inode lock, so small single writes never interleave. That
|
|
14
|
+
// does not hold on network filesystems (NFS, SMB, FUSE), so when statfs
|
|
15
|
+
// reports one the append runs under an advisory lock directory instead. A
|
|
16
|
+
// short write is reported as `unavailable`, and a torn tail left by a crashed
|
|
17
|
+
// writer is fenced off with a newline so it never swallows the next row.
|
|
18
|
+
// - Reader tolerance (D17): unknown keys are kept and ignored, unparsable
|
|
19
|
+
// lines and rows without the required keys are skipped and counted, and the
|
|
20
|
+
// file is never rewritten. `seq` is the row's position among valid rows (a
|
|
21
|
+
// lock-free appender cannot allocate one); a row supersedes an older one by
|
|
22
|
+
// its `id`, and only a row of the same type from the same session may.
|
|
23
|
+
// - Trust (D18): the threat model is honest mistakes and accidental
|
|
24
|
+
// self-certification, not adversarial agents. `ledger verdict` rows are
|
|
25
|
+
// `observer:"agent_asserted"`; `observer:"workit_cli"` is reserved for rows
|
|
26
|
+
// written by observing verbs through `appendObserved`. `actor.attested` is
|
|
27
|
+
// never trusted. A verdict is `current` when it still applies to the code and
|
|
28
|
+
// `accepted` only when it is current, passing, independent (not self, a
|
|
29
|
+
// known session, not an author) and no current independent verdict fails.
|
|
30
|
+
// Merge gates read only `accepted`.
|
|
31
|
+
// - Verdict keying (§2.2): a verdict stores the HEAD SHA it judged plus
|
|
32
|
+
// `base`, the stable patch-id of `base...head` and an exact diff hash. It is
|
|
33
|
+
// `fresh` (same head; verdicts on a dirty worktree are refused, so the head
|
|
34
|
+
// is the judged tree), `carried` (new head, same patch-id AND same exact
|
|
35
|
+
// diff: a rebase that only moved the base) or `stale`.
|
|
36
|
+
//
|
|
37
|
+
// Plain TS over the git binary: no zod, no task store, no config reads.
|
|
38
|
+
import { spawnSync } from "node:child_process";
|
|
39
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
|
|
43
|
+
|
|
44
|
+
export const LEDGER_VERSION = 1;
|
|
45
|
+
/** The largest row (including its newline) that one append may carry. */
|
|
46
|
+
export const MAX_LINE_BYTES = 4096;
|
|
47
|
+
|
|
48
|
+
export const VERDICT_RESULTS = [
|
|
49
|
+
"verified",
|
|
50
|
+
"tests-verified",
|
|
51
|
+
"type-check-only",
|
|
52
|
+
"blocked",
|
|
53
|
+
"failed",
|
|
54
|
+
] as const;
|
|
55
|
+
export type VerdictResult = (typeof VERDICT_RESULTS)[number];
|
|
56
|
+
const PASSING: ReadonlySet<string> = new Set(["verified", "tests-verified", "type-check-only"]);
|
|
57
|
+
const FAILING: ReadonlySet<string> = new Set(["failed", "blocked"]);
|
|
58
|
+
export const VERDICT_KINDS = ["unit", "live", "perf", "review"] as const;
|
|
59
|
+
export type VerdictKind = (typeof VERDICT_KINDS)[number];
|
|
60
|
+
export const VERDICT_SURFACES = ["ui", "cli", "api"] as const;
|
|
61
|
+
export type VerdictSurface = (typeof VERDICT_SURFACES)[number];
|
|
62
|
+
export type Observer = "agent_asserted" | "workit_cli";
|
|
63
|
+
|
|
64
|
+
/** Row types written by the CLI's own PR verbs (S10/S11); the only PR→branch source. */
|
|
65
|
+
export const PR_ROW_TYPES: ReadonlySet<string> = new Set(["pr.created", "pr.status"]);
|
|
66
|
+
|
|
67
|
+
/** Who wrote a row. Unverified: the environment says so (D18). */
|
|
68
|
+
export type LedgerActor = { host: string; session: string | null; agentId: string | null };
|
|
69
|
+
|
|
70
|
+
/** The code state a row was recorded against (§2.2). */
|
|
71
|
+
export type CodeKey = {
|
|
72
|
+
branch: string | null;
|
|
73
|
+
head: string | null;
|
|
74
|
+
/** The base ref the patch-id was computed against (e.g. `origin/main`). */
|
|
75
|
+
base: string | null;
|
|
76
|
+
baseSha: string | null;
|
|
77
|
+
patchId: string | null;
|
|
78
|
+
/** sha256 of the exact `base...head` diff (whitespace-sensitive), minus line positions. */
|
|
79
|
+
diffHash: string | null;
|
|
80
|
+
/** Worktree tree key, only when recorded on the checked-out branch. */
|
|
81
|
+
tree: string | null;
|
|
82
|
+
dirty: boolean | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type RowCommon = CodeKey & {
|
|
86
|
+
v: number;
|
|
87
|
+
id: string;
|
|
88
|
+
at: string;
|
|
89
|
+
type: string;
|
|
90
|
+
actor: LedgerActor;
|
|
91
|
+
pr?: number;
|
|
92
|
+
/** The `id` of an older row of the same type and session that this one replaces. */
|
|
93
|
+
supersedes?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type DecisionRow = RowCommon & {
|
|
97
|
+
type: "decision";
|
|
98
|
+
what: string;
|
|
99
|
+
why: string;
|
|
100
|
+
refs: string[];
|
|
101
|
+
};
|
|
102
|
+
export type RulingRow = RowCommon & {
|
|
103
|
+
type: "ruling";
|
|
104
|
+
what: string;
|
|
105
|
+
why: string;
|
|
106
|
+
costIfWrong: string;
|
|
107
|
+
refs: string[];
|
|
108
|
+
};
|
|
109
|
+
export type SelfReason = "flag" | "no_session";
|
|
110
|
+
export type VerdictRow = RowCommon & {
|
|
111
|
+
type: "verdict";
|
|
112
|
+
kind: VerdictKind;
|
|
113
|
+
result: VerdictResult;
|
|
114
|
+
how: string;
|
|
115
|
+
surface: VerdictSurface | null;
|
|
116
|
+
observer: Observer;
|
|
117
|
+
/** Not an independent verdict: `--self`, or no session to tell it from the author. */
|
|
118
|
+
self: boolean;
|
|
119
|
+
selfReason: SelfReason | null;
|
|
120
|
+
evidenceRefs: string[];
|
|
121
|
+
};
|
|
122
|
+
export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
|
|
123
|
+
|
|
124
|
+
export type LedgerRow = DecisionRow | RulingRow | VerdictRow | HandoffRow;
|
|
125
|
+
/** A row as read back: any known or future type, plus its position. */
|
|
126
|
+
export type ReadRow = RowCommon &
|
|
127
|
+
Record<string, unknown> & {
|
|
128
|
+
seq: number;
|
|
129
|
+
superseded: boolean;
|
|
130
|
+
/** The row names a `supersedes` target the reader refused (D18 supersede rules). */
|
|
131
|
+
supersedeIgnored: boolean;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type LedgerError = {
|
|
135
|
+
ok: false;
|
|
136
|
+
code: "invalid_input" | "blocked" | "busy" | "unavailable" | "not_found";
|
|
137
|
+
error: string;
|
|
138
|
+
unblock?: string;
|
|
139
|
+
};
|
|
140
|
+
export type LedgerResult<T> = { ok: true; value: T } | LedgerError;
|
|
141
|
+
|
|
142
|
+
const err = (code: LedgerError["code"], error: string, unblock?: string): LedgerError => ({
|
|
143
|
+
ok: false,
|
|
144
|
+
code,
|
|
145
|
+
error,
|
|
146
|
+
...(unblock ? { unblock } : {}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// git plumbing (bounded, never prompts, C locale so messages are matchable)
|
|
151
|
+
|
|
152
|
+
type GitRun = { ok: boolean; stdout: string; stderr: string };
|
|
153
|
+
|
|
154
|
+
const gitRun = (cwd: string, args: string[], timeoutMs: number = GIT_TIMEOUTS.local): GitRun => {
|
|
155
|
+
const run = spawnSync("git", args, {
|
|
156
|
+
cwd,
|
|
157
|
+
encoding: "utf8",
|
|
158
|
+
timeout: timeoutMs,
|
|
159
|
+
killSignal: "SIGKILL",
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
162
|
+
env: {
|
|
163
|
+
...process.env,
|
|
164
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
165
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
166
|
+
LC_ALL: "C",
|
|
167
|
+
LANGUAGE: "C",
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
ok: run.status === 0,
|
|
172
|
+
stdout: run.stdout ?? "",
|
|
173
|
+
stderr: run.error ? String(run.error.message) : (run.stderr ?? ""),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
178
|
+
const run = gitRun(cwd, args);
|
|
179
|
+
const value = run.ok ? run.stdout.trim() : "";
|
|
180
|
+
return value ? value : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
184
|
+
|
|
185
|
+
/** The commit a ref resolves to, or null. */
|
|
186
|
+
export function resolveCommit(cwd: string, ref: string): string | null {
|
|
187
|
+
if (!safeRef(ref)) return null;
|
|
188
|
+
return git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A local branch's tip, falling back to its remote-tracking ref on origin. */
|
|
192
|
+
export function branchHead(cwd: string, branch: string): string | null {
|
|
193
|
+
if (!safeRef(branch)) return null;
|
|
194
|
+
return (
|
|
195
|
+
resolveCommit(cwd, `refs/heads/${branch}`) ??
|
|
196
|
+
resolveCommit(cwd, `refs/remotes/origin/${branch}`)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The default base for patch-ids: origin's HEAD branch when known, else the
|
|
202
|
+
* first of origin/main, origin/master, main, master that exists.
|
|
203
|
+
*/
|
|
204
|
+
export function defaultBase(cwd: string): string | null {
|
|
205
|
+
const originHead = git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
|
|
206
|
+
if (originHead && resolveCommit(cwd, originHead)) return originHead;
|
|
207
|
+
for (const candidate of ["origin/main", "origin/master", "main", "master"])
|
|
208
|
+
if (resolveCommit(cwd, candidate)) return candidate;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* sha256 of the exact `base...head` diff with the pinned options rev.ts uses
|
|
214
|
+
* for patch-ids, plus `--binary`. Unlike `git patch-id --stable` it keeps
|
|
215
|
+
* whitespace, so a re-indent (meaningful in Python, YAML, Makefiles) changes
|
|
216
|
+
* it. Hunk headers lose their line numbers and `index` lines are dropped, so a
|
|
217
|
+
* base change elsewhere in the same file does not. Deterministic across git
|
|
218
|
+
* versions (no `patch-id --verbatim` dependency).
|
|
219
|
+
*/
|
|
220
|
+
export function diffHash(cwd: string, base: string, head: string): string | null {
|
|
221
|
+
if (!safeRef(base) || !safeRef(head)) return null;
|
|
222
|
+
const diff = gitRun(
|
|
223
|
+
cwd,
|
|
224
|
+
[
|
|
225
|
+
"diff",
|
|
226
|
+
"-U3",
|
|
227
|
+
"--diff-algorithm=myers",
|
|
228
|
+
"--indent-heuristic",
|
|
229
|
+
"--no-color",
|
|
230
|
+
"--no-ext-diff",
|
|
231
|
+
"--no-textconv",
|
|
232
|
+
"--no-renames",
|
|
233
|
+
"--full-index",
|
|
234
|
+
"--binary",
|
|
235
|
+
"--src-prefix=a/",
|
|
236
|
+
"--dst-prefix=b/",
|
|
237
|
+
`${base}...${head}`,
|
|
238
|
+
"--",
|
|
239
|
+
],
|
|
240
|
+
GIT_TIMEOUTS.worktree,
|
|
241
|
+
);
|
|
242
|
+
if (!diff.ok || !diff.stdout) return null;
|
|
243
|
+
const hash = createHash("sha256");
|
|
244
|
+
for (const line of diff.stdout.split("\n")) {
|
|
245
|
+
if (line.startsWith("index ")) continue;
|
|
246
|
+
hash.update(line.startsWith("@@ ") ? "@@" : line);
|
|
247
|
+
hash.update("\n");
|
|
248
|
+
}
|
|
249
|
+
return `sha256:${hash.digest("hex")}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// store root (D13)
|
|
254
|
+
|
|
255
|
+
export type StoreRoot = { root: string; shared: boolean };
|
|
256
|
+
|
|
257
|
+
const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Where workit state lives for `cwd`: `<git common dir>/workit` (shared by all
|
|
261
|
+
* worktrees of the repo), or `<cwd>/.workit` only when git reports that `cwd`
|
|
262
|
+
* is not inside a repository. A broken repository, a missing git binary or a
|
|
263
|
+
* refused (dubious-ownership) repository is `unavailable`.
|
|
264
|
+
*/
|
|
265
|
+
export function storeRoot(cwd: string): LedgerResult<StoreRoot> {
|
|
266
|
+
const run = gitRun(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
267
|
+
const common = run.stdout.trim();
|
|
268
|
+
if (run.ok && common)
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
value: { root: path.join(path.resolve(cwd, common), "workit"), shared: true },
|
|
272
|
+
};
|
|
273
|
+
if (!run.ok && NOT_A_REPO.test(run.stderr))
|
|
274
|
+
return { ok: true, value: { root: path.join(path.resolve(cwd), ".workit"), shared: false } };
|
|
275
|
+
return err(
|
|
276
|
+
"unavailable",
|
|
277
|
+
`cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"})`,
|
|
278
|
+
"fix the repository (git status should work here) or run from a non-git directory",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function ledgerPath(cwd: string): LedgerResult<string> {
|
|
283
|
+
const root = storeRoot(cwd);
|
|
284
|
+
return root.ok ? { ok: true, value: path.join(root.value.root, "ledger", "ledger.jsonl") } : root;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// append
|
|
289
|
+
|
|
290
|
+
/** The acting identity from the environment. */
|
|
291
|
+
export function actorFromEnv(env: NodeJS.ProcessEnv): LedgerActor {
|
|
292
|
+
const value = (key: string): string | null => {
|
|
293
|
+
const raw = env[key]?.trim();
|
|
294
|
+
return raw ? raw : null;
|
|
295
|
+
};
|
|
296
|
+
return {
|
|
297
|
+
host: value("WORKIT_HOST") ?? "cli",
|
|
298
|
+
session: value("WORKIT_SESSION_ID"),
|
|
299
|
+
agentId: value("WORKIT_AGENT_ID"),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const newId = (): string => `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
304
|
+
|
|
305
|
+
// statfs magic numbers of network/userspace filesystems (linux/magic.h).
|
|
306
|
+
const NETWORK_FS_TYPES: ReadonlySet<number> = new Set([
|
|
307
|
+
0x6969, // NFS
|
|
308
|
+
0x517b, // SMB
|
|
309
|
+
0xfe534d42, // SMB2
|
|
310
|
+
0xff534d42, // CIFS
|
|
311
|
+
0x65735546, // FUSE (sshfs, rclone, …)
|
|
312
|
+
0x5346414f, // AFS
|
|
313
|
+
0x00c36400, // Ceph
|
|
314
|
+
0x01021997, // 9P
|
|
315
|
+
0x47504653, // GPFS
|
|
316
|
+
0x0bd00bd0, // Lustre
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
/** True when `type` (statfs f_type on Linux) names a network or FUSE filesystem. */
|
|
320
|
+
export const isNetworkFsType = (type: number): boolean => NETWORK_FS_TYPES.has(type >>> 0);
|
|
321
|
+
|
|
322
|
+
const onNetworkFs = (dir: string): boolean => {
|
|
323
|
+
if (process.platform !== "linux") return false;
|
|
324
|
+
try {
|
|
325
|
+
return isNetworkFsType(fs.statfsSync(dir).type);
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const LOCK_WAIT_MS = 5_000;
|
|
332
|
+
const LOCK_STALE_MS = 30_000;
|
|
333
|
+
|
|
334
|
+
const LOCK_OWNER = "owner";
|
|
335
|
+
|
|
336
|
+
const lockToken = (lock: string): string | null => {
|
|
337
|
+
try {
|
|
338
|
+
return fs.readFileSync(path.join(lock, LOCK_OWNER), "utf8");
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** Last sign of life: the owner file's mtime, else the directory's. */
|
|
345
|
+
const lockAgeMs = (lock: string): number | null => {
|
|
346
|
+
for (const target of [path.join(lock, LOCK_OWNER), lock]) {
|
|
347
|
+
try {
|
|
348
|
+
return Date.now() - fs.statSync(target).mtimeMs;
|
|
349
|
+
} catch {
|
|
350
|
+
// try the next
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Advisory lock directory for network filesystems (`mkdir` and `rename` are
|
|
358
|
+
* atomic on NFS too). The holder writes a random ownership token into it and
|
|
359
|
+
* releases only a lock that still carries its own token. A lock idle for
|
|
360
|
+
* LOCK_STALE_MS is taken over by `reap`, which renames it aside, checks that
|
|
361
|
+
* the renamed lock still holds the token the taker judged stale, and puts it
|
|
362
|
+
* back otherwise. So of two takers racing for one stale lock, at most one
|
|
363
|
+
* removes it, and neither removes a lock the other has since acquired.
|
|
364
|
+
*/
|
|
365
|
+
export const ledgerLock = {
|
|
366
|
+
/** mkdir + token; null when the lock is held. */
|
|
367
|
+
acquire(lock: string): string | null {
|
|
368
|
+
try {
|
|
369
|
+
fs.mkdirSync(lock);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return null;
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
const token = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
375
|
+
fs.writeFileSync(path.join(lock, LOCK_OWNER), token);
|
|
376
|
+
return token;
|
|
377
|
+
},
|
|
378
|
+
/** Remove the lock only if it still carries `token`. */
|
|
379
|
+
release(lock: string, token: string): boolean {
|
|
380
|
+
if (lockToken(lock) !== token) return false;
|
|
381
|
+
const tomb = `${lock}.release-${randomBytes(6).toString("hex")}`;
|
|
382
|
+
try {
|
|
383
|
+
fs.renameSync(lock, tomb);
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
if (lockToken(tomb) !== token) {
|
|
388
|
+
// Lost a race with a reaper and a new holder: give the lock back.
|
|
389
|
+
try {
|
|
390
|
+
fs.renameSync(tomb, lock);
|
|
391
|
+
} catch {
|
|
392
|
+
// A third holder exists; the renamed lock is not ours to keep.
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
397
|
+
return true;
|
|
398
|
+
},
|
|
399
|
+
/** The lock's current token (null when absent or not yet written). */
|
|
400
|
+
token: lockToken,
|
|
401
|
+
/** Take over a lock judged stale while it carried `observed`. */
|
|
402
|
+
reap(lock: string, observed: string | null): boolean {
|
|
403
|
+
const tomb = `${lock}.reap-${randomBytes(6).toString("hex")}`;
|
|
404
|
+
try {
|
|
405
|
+
fs.renameSync(lock, tomb);
|
|
406
|
+
} catch {
|
|
407
|
+
return false; // another taker got there first, or the holder released
|
|
408
|
+
}
|
|
409
|
+
if (lockToken(tomb) === observed) {
|
|
410
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
fs.renameSync(tomb, lock);
|
|
415
|
+
} catch {
|
|
416
|
+
// Someone acquired a fresh lock meanwhile; leave theirs alone.
|
|
417
|
+
fs.rmSync(tomb, { recursive: true, force: true });
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/** Run `fn` holding the ledger's advisory lock; null when it stays held for `waitMs`. */
|
|
424
|
+
export function withLedgerLock<T>(
|
|
425
|
+
file: string,
|
|
426
|
+
fn: () => T,
|
|
427
|
+
waitMs: number = LOCK_WAIT_MS,
|
|
428
|
+
): T | null {
|
|
429
|
+
const lock = `${file}.lock`;
|
|
430
|
+
const deadline = Date.now() + waitMs;
|
|
431
|
+
let token: string | null = null;
|
|
432
|
+
for (;;) {
|
|
433
|
+
token = ledgerLock.acquire(lock);
|
|
434
|
+
if (token) break;
|
|
435
|
+
const observed = lockToken(lock);
|
|
436
|
+
const age = lockAgeMs(lock);
|
|
437
|
+
if (age !== null && age > LOCK_STALE_MS && ledgerLock.reap(lock, observed)) continue;
|
|
438
|
+
if (Date.now() >= deadline) return null;
|
|
439
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
return fn();
|
|
443
|
+
} finally {
|
|
444
|
+
ledgerLock.release(lock, token);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
type AppendOptions = { now?: Date; forceLock?: boolean };
|
|
449
|
+
|
|
450
|
+
function appendLine(file: string, line: string, options: AppendOptions): LedgerResult<void> {
|
|
451
|
+
const write = (): LedgerResult<void> => {
|
|
452
|
+
// a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
|
|
453
|
+
// peek at the last byte to fence off a torn tail.
|
|
454
|
+
const fd = fs.openSync(file, "a+");
|
|
455
|
+
try {
|
|
456
|
+
const size = fs.fstatSync(fd).size;
|
|
457
|
+
let fence = "";
|
|
458
|
+
if (size > 0) {
|
|
459
|
+
const last = Buffer.alloc(1);
|
|
460
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
461
|
+
if (last[0] !== 0x0a) fence = "\n";
|
|
462
|
+
}
|
|
463
|
+
const bytes = Buffer.from(fence + line);
|
|
464
|
+
const written = fs.writeSync(fd, bytes);
|
|
465
|
+
if (written !== bytes.length)
|
|
466
|
+
return err(
|
|
467
|
+
"unavailable",
|
|
468
|
+
`short write to ${file}: ${written} of ${bytes.length} bytes; the row is torn and will be skipped`,
|
|
469
|
+
);
|
|
470
|
+
return { ok: true, value: undefined };
|
|
471
|
+
} finally {
|
|
472
|
+
fs.closeSync(fd);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
try {
|
|
476
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
477
|
+
if (!options.forceLock && !onNetworkFs(path.dirname(file))) return write();
|
|
478
|
+
const locked = withLedgerLock(file, write);
|
|
479
|
+
return (
|
|
480
|
+
locked ??
|
|
481
|
+
err(
|
|
482
|
+
"busy",
|
|
483
|
+
`ledger lock ${file}.lock is held`,
|
|
484
|
+
"retry; remove the .lock directory if no workit process is running",
|
|
485
|
+
)
|
|
486
|
+
);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
return err("unavailable", `cannot append to ${file}: ${(error as Error).message}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type NewRow<T extends LedgerRow> = Omit<T, "v" | "id" | "at">;
|
|
493
|
+
|
|
494
|
+
const serialize = (row: object, now: Date) => {
|
|
495
|
+
const full = { v: LEDGER_VERSION, id: newId(), at: now.toISOString(), ...row };
|
|
496
|
+
return { full, line: `${JSON.stringify(full)}\n` };
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
function appendRaw<T>(cwd: string, row: object, options: AppendOptions): LedgerResult<T> {
|
|
500
|
+
const { full, line } = serialize(row, options.now ?? new Date());
|
|
501
|
+
const bytes = Buffer.byteLength(line);
|
|
502
|
+
if (bytes > MAX_LINE_BYTES)
|
|
503
|
+
return err(
|
|
504
|
+
"invalid_input",
|
|
505
|
+
`ledger row is ${bytes} bytes; the limit is ${MAX_LINE_BYTES}. Shorten the text and pass details by --ref <path|url>`,
|
|
506
|
+
);
|
|
507
|
+
const file = ledgerPath(cwd);
|
|
508
|
+
if (!file.ok) return file;
|
|
509
|
+
const written = appendLine(file.value, line, options);
|
|
510
|
+
return written.ok ? { ok: true, value: full as T } : written;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Append one agent-level row (decision, ruling, verdict, handoff). */
|
|
514
|
+
export function appendRow<T extends LedgerRow>(
|
|
515
|
+
cwd: string,
|
|
516
|
+
row: NewRow<T>,
|
|
517
|
+
options: AppendOptions = {},
|
|
518
|
+
): LedgerResult<T> {
|
|
519
|
+
if ((row as { observer?: unknown }).observer === "workit_cli")
|
|
520
|
+
return err("invalid_input", 'observer "workit_cli" is reserved for appendObserved');
|
|
521
|
+
return appendRaw<T>(cwd, row, options);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Internal API for observing verbs only (S9b `check`, S10 `ci`, S11 `pr`/`git
|
|
526
|
+
* commit`): append a row the CLI itself observed, stamped
|
|
527
|
+
* `observer:"workit_cli"`. Never reachable from `workit ledger`.
|
|
528
|
+
*/
|
|
529
|
+
export function appendObserved(
|
|
530
|
+
cwd: string,
|
|
531
|
+
row: Record<string, unknown> & { type: string; actor: LedgerActor },
|
|
532
|
+
options: AppendOptions = {},
|
|
533
|
+
): LedgerResult<Record<string, unknown>> {
|
|
534
|
+
return appendRaw(cwd, { ...row, observer: "workit_cli" }, options);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// read (D17: tolerant)
|
|
539
|
+
|
|
540
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
541
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
542
|
+
|
|
543
|
+
const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
|
|
544
|
+
|
|
545
|
+
const normalizeActor = (value: unknown): LedgerActor => {
|
|
546
|
+
const actor = isRecord(value) ? value : {};
|
|
547
|
+
return {
|
|
548
|
+
host: str(actor.host) ?? "unknown",
|
|
549
|
+
session: str(actor.session),
|
|
550
|
+
agentId: str(actor.agentId),
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* May `by` supersede `target`? Same type, same known session, later in the
|
|
556
|
+
* file, and a self verdict never replaces an independent one.
|
|
557
|
+
*/
|
|
558
|
+
export function supersedeAllowed(
|
|
559
|
+
by: Pick<ReadRow, "type" | "actor"> & { self?: unknown },
|
|
560
|
+
target: Pick<ReadRow, "type" | "actor"> & { self?: unknown },
|
|
561
|
+
): string | null {
|
|
562
|
+
if (by.type !== target.type) return `a ${by.type} row cannot supersede a ${target.type} row`;
|
|
563
|
+
if (!by.actor.session || by.actor.session !== target.actor.session)
|
|
564
|
+
return "only the session that wrote a row can supersede it";
|
|
565
|
+
if (by.type === "verdict" && by.self === true && target.self !== true)
|
|
566
|
+
return "a self verdict cannot replace an independent verdict";
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export type LedgerRead = { path: string; rows: ReadRow[]; skipped: number };
|
|
571
|
+
|
|
572
|
+
/** Every valid row in append order. A missing file is empty. */
|
|
573
|
+
export function readLedger(cwd: string): LedgerResult<LedgerRead> {
|
|
574
|
+
const file = ledgerPath(cwd);
|
|
575
|
+
if (!file.ok) return file;
|
|
576
|
+
let text = "";
|
|
577
|
+
try {
|
|
578
|
+
text = fs.readFileSync(file.value, "utf8");
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
581
|
+
return { ok: true, value: { path: file.value, rows: [], skipped: 0 } };
|
|
582
|
+
return err("unavailable", `cannot read ${file.value}: ${(error as Error).message}`);
|
|
583
|
+
}
|
|
584
|
+
const rows: ReadRow[] = [];
|
|
585
|
+
let skipped = 0;
|
|
586
|
+
for (const raw of text.split("\n")) {
|
|
587
|
+
const trimmed = raw.trim();
|
|
588
|
+
if (!trimmed) continue;
|
|
589
|
+
let parsed: unknown;
|
|
590
|
+
try {
|
|
591
|
+
parsed = JSON.parse(trimmed);
|
|
592
|
+
} catch {
|
|
593
|
+
skipped += 1;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (!isRecord(parsed) || !str(parsed.id) || !str(parsed.type) || !str(parsed.at)) {
|
|
597
|
+
skipped += 1;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
|
|
601
|
+
rows.push({
|
|
602
|
+
...parsed,
|
|
603
|
+
v: typeof parsed.v === "number" ? parsed.v : LEDGER_VERSION,
|
|
604
|
+
id: parsed.id as string,
|
|
605
|
+
at: parsed.at as string,
|
|
606
|
+
type: parsed.type as string,
|
|
607
|
+
actor: normalizeActor(parsed.actor),
|
|
608
|
+
branch: str(parsed.branch),
|
|
609
|
+
head: str(parsed.head),
|
|
610
|
+
base: str(parsed.base),
|
|
611
|
+
baseSha: str(parsed.baseSha),
|
|
612
|
+
patchId: str(parsed.patchId),
|
|
613
|
+
diffHash: str(parsed.diffHash),
|
|
614
|
+
tree: str(parsed.tree),
|
|
615
|
+
dirty: typeof parsed.dirty === "boolean" ? parsed.dirty : null,
|
|
616
|
+
...(pr === undefined ? {} : { pr }),
|
|
617
|
+
...(str(parsed.supersedes) ? { supersedes: parsed.supersedes as string } : {}),
|
|
618
|
+
seq: rows.length + 1,
|
|
619
|
+
superseded: false,
|
|
620
|
+
supersedeIgnored: false,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// Only a well-formed supersede link counts; anything else is ignored.
|
|
624
|
+
const byId = new Map<string, ReadRow>();
|
|
625
|
+
for (const row of rows) {
|
|
626
|
+
const target = row.supersedes ? byId.get(row.supersedes) : undefined;
|
|
627
|
+
if (target && supersedeAllowed(row, target) === null) target.superseded = true;
|
|
628
|
+
else if (row.supersedes) row.supersedeIgnored = true;
|
|
629
|
+
if (!byId.has(row.id)) byId.set(row.id, row);
|
|
630
|
+
}
|
|
631
|
+
return { ok: true, value: { path: file.value, rows, skipped } };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
|
|
635
|
+
|
|
636
|
+
export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
|
|
637
|
+
const matched = rows.filter(
|
|
638
|
+
(row) =>
|
|
639
|
+
(filter.branch === undefined || row.branch === filter.branch) &&
|
|
640
|
+
(filter.pr === undefined || row.pr === filter.pr) &&
|
|
641
|
+
(filter.type === undefined || row.type === filter.type),
|
|
642
|
+
);
|
|
643
|
+
return filter.last === undefined
|
|
644
|
+
? matched
|
|
645
|
+
: matched.slice(Math.max(0, matched.length - filter.last));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
// PR → branch (never from arbitrary rows)
|
|
650
|
+
|
|
651
|
+
export type PrBranch =
|
|
652
|
+
| { ok: true; branch: string; source: "pr_row" | "forge_ref" }
|
|
653
|
+
| { ok: false; reason: "unknown" | "ambiguous"; candidates: string[] };
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The branch a PR/MR number belongs to, from (1) rows the CLI's own PR verbs
|
|
657
|
+
* observed (`PR_ROW_TYPES` with `observer:"workit_cli"`), else (2) a fetched
|
|
658
|
+
* forge ref (`refs/pull/<n>/head`, `refs/merge-requests/<n>/head`, or the same
|
|
659
|
+
* under `refs/remotes/<remote>/`) whose commit is the tip of exactly one local
|
|
660
|
+
* branch. Forge refs that disagree, or a tip shared by several branches, are
|
|
661
|
+
* `ambiguous`; nothing at all is `unknown`.
|
|
662
|
+
*/
|
|
663
|
+
export function branchForPr(cwd: string, rows: readonly ReadRow[], pr: number): PrBranch {
|
|
664
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
665
|
+
const row = rows[index];
|
|
666
|
+
if (row.pr === pr && row.branch && PR_ROW_TYPES.has(row.type) && row.observer === "workit_cli")
|
|
667
|
+
return { ok: true, branch: row.branch, source: "pr_row" };
|
|
668
|
+
}
|
|
669
|
+
const tips = git(cwd, [
|
|
670
|
+
"for-each-ref",
|
|
671
|
+
"--format=%(objectname)",
|
|
672
|
+
`refs/pull/${pr}/head`,
|
|
673
|
+
`refs/merge-requests/${pr}/head`,
|
|
674
|
+
`refs/remotes/*/pull/${pr}/head`,
|
|
675
|
+
`refs/remotes/*/merge-requests/${pr}/head`,
|
|
676
|
+
]);
|
|
677
|
+
const shas = [...new Set((tips ?? "").split("\n").filter(Boolean))];
|
|
678
|
+
if (shas.length === 0) return { ok: false, reason: "unknown", candidates: [] };
|
|
679
|
+
if (shas.length > 1) return { ok: false, reason: "ambiguous", candidates: shas };
|
|
680
|
+
const branches = (
|
|
681
|
+
git(cwd, ["for-each-ref", "--format=%(refname:short)", "--points-at", shas[0], "refs/heads"]) ??
|
|
682
|
+
""
|
|
683
|
+
)
|
|
684
|
+
.split("\n")
|
|
685
|
+
.filter(Boolean);
|
|
686
|
+
if (branches.length === 1) return { ok: true, branch: branches[0], source: "forge_ref" };
|
|
687
|
+
return branches.length === 0
|
|
688
|
+
? { ok: false, reason: "unknown", candidates: [] }
|
|
689
|
+
: { ok: false, reason: "ambiguous", candidates: branches };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// code keys and verdict validity (§2.2)
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The code state of `branch` (default: the checked-out branch) against
|
|
697
|
+
* `base` (default: defaultBase). The worktree tree is included only for the
|
|
698
|
+
* checked-out branch.
|
|
699
|
+
*/
|
|
700
|
+
export function codeKey(
|
|
701
|
+
cwd: string,
|
|
702
|
+
options: { branch?: string | null; base?: string | null } = {},
|
|
703
|
+
): CodeKey {
|
|
704
|
+
const checkedOut = currentBranch(cwd);
|
|
705
|
+
const branch = options.branch ?? checkedOut;
|
|
706
|
+
const head = branch ? branchHead(cwd, branch) : headSha(cwd);
|
|
707
|
+
const base = options.base ?? defaultBase(cwd);
|
|
708
|
+
const baseSha = base ? resolveCommit(cwd, base) : null;
|
|
709
|
+
const onTree = branch !== null && branch === checkedOut;
|
|
710
|
+
const tree = onTree ? worktreeTree(cwd) : null;
|
|
711
|
+
return {
|
|
712
|
+
branch,
|
|
713
|
+
head,
|
|
714
|
+
base,
|
|
715
|
+
baseSha,
|
|
716
|
+
patchId: base && head ? patchId(cwd, base, head) : null,
|
|
717
|
+
diffHash: base && head ? diffHash(cwd, base, head) : null,
|
|
718
|
+
tree: tree?.key ?? null,
|
|
719
|
+
dirty: tree ? tree.dirty : null,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export type VerdictBasis = "fresh" | "carried" | "stale" | "none";
|
|
724
|
+
|
|
725
|
+
type Keyed = { head: string | null; patchId: string | null; diffHash: string | null };
|
|
726
|
+
|
|
727
|
+
/** fresh: same head · carried: same non-null patch-id AND exact diff · stale: otherwise. */
|
|
728
|
+
export function verdictBasis(verdict: Keyed, current: Keyed): Exclude<VerdictBasis, "none"> {
|
|
729
|
+
if (verdict.head && verdict.head === current.head) return "fresh";
|
|
730
|
+
if (
|
|
731
|
+
verdict.patchId &&
|
|
732
|
+
verdict.patchId === current.patchId &&
|
|
733
|
+
verdict.diffHash &&
|
|
734
|
+
verdict.diffHash === current.diffHash
|
|
735
|
+
)
|
|
736
|
+
return "carried";
|
|
737
|
+
return "stale";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export type RejectReason =
|
|
741
|
+
| "stale"
|
|
742
|
+
| "not_passing"
|
|
743
|
+
| "self"
|
|
744
|
+
| "no_session"
|
|
745
|
+
| "author_session"
|
|
746
|
+
| "failing_verdict"
|
|
747
|
+
| "no_verdict";
|
|
748
|
+
|
|
749
|
+
export type VerdictEntry = {
|
|
750
|
+
kind: string;
|
|
751
|
+
basis: Exclude<VerdictBasis, "none">;
|
|
752
|
+
/** The verdict still applies to the code (fresh or carried). */
|
|
753
|
+
current: boolean;
|
|
754
|
+
/** Independent: not self, a known session, not an author of the branch. */
|
|
755
|
+
independent: boolean;
|
|
756
|
+
/** current AND passing AND independent. */
|
|
757
|
+
accepted: boolean;
|
|
758
|
+
reasons: RejectReason[];
|
|
759
|
+
verdict: ReadRow;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
export type VerdictCheck = {
|
|
763
|
+
branch: string;
|
|
764
|
+
head: string | null;
|
|
765
|
+
base: string | null;
|
|
766
|
+
patchId: string | null;
|
|
767
|
+
/** Newest current verdict (else newest verdict): does it still apply? */
|
|
768
|
+
current: { basis: VerdictBasis; verdict: ReadRow | null };
|
|
769
|
+
/** What merge gates read: an accepted verdict and no current independent failure. */
|
|
770
|
+
accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
|
|
771
|
+
verdicts: VerdictEntry[];
|
|
772
|
+
authors: string[];
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const isLiveVerdict = (row: ReadRow): boolean =>
|
|
776
|
+
row.type === "verdict" && typeof row.result === "string" && !row.superseded;
|
|
777
|
+
|
|
778
|
+
/** Sessions recorded in `Workit-Session:` trailers of `base...head` commits. */
|
|
779
|
+
function trailerSessions(cwd: string, base: string | null, head: string | null): string[] {
|
|
780
|
+
if (!base || !head || !safeRef(base) || !safeRef(head)) return [];
|
|
781
|
+
const out = git(cwd, [
|
|
782
|
+
"log",
|
|
783
|
+
"--format=%(trailers:key=Workit-Session,valueonly,separator=%x00)",
|
|
784
|
+
`${base}..${head}`,
|
|
785
|
+
]);
|
|
786
|
+
return (out ?? "")
|
|
787
|
+
.split(/[\0\n]/u)
|
|
788
|
+
.map((value) => value.trim())
|
|
789
|
+
.filter(Boolean);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Sessions that authored `branch`: the union of `commit.recorded` /
|
|
794
|
+
* `task.opened` ledger rows and `Workit-Session:` commit trailers on
|
|
795
|
+
* `base..head`. Git author name/email is not used: every agent on
|
|
796
|
+
* a machine shares it, so it cannot tell sessions apart.
|
|
797
|
+
*/
|
|
798
|
+
export function authorSessions(
|
|
799
|
+
cwd: string,
|
|
800
|
+
rows: readonly ReadRow[],
|
|
801
|
+
branch: string,
|
|
802
|
+
range: { base: string | null; head: string | null } = { base: null, head: null },
|
|
803
|
+
): Set<string> {
|
|
804
|
+
const sessions = new Set<string>();
|
|
805
|
+
for (const row of rows) {
|
|
806
|
+
if (row.branch !== branch || (row.type !== "commit.recorded" && row.type !== "task.opened"))
|
|
807
|
+
continue;
|
|
808
|
+
const session = str(row.session) ?? row.actor.session;
|
|
809
|
+
if (session) sessions.add(session);
|
|
810
|
+
}
|
|
811
|
+
for (const session of trailerSessions(cwd, range.base, range.head)) sessions.add(session);
|
|
812
|
+
return sessions;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
|
|
816
|
+
const reasons: RejectReason[] = [];
|
|
817
|
+
if (!row.actor.session) reasons.push("no_session");
|
|
818
|
+
else if (authors.has(row.actor.session)) reasons.push("author_session");
|
|
819
|
+
if (row.self === true && !reasons.includes("no_session")) reasons.push("self");
|
|
820
|
+
return reasons;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* The effective verdict of one kind, walking rows in order: a self verdict
|
|
825
|
+
* never displaces an independent one, and an independent failed/blocked
|
|
826
|
+
* verdict sticks until an independent verdict from a different session (or
|
|
827
|
+
* its own session's supersede) replaces it.
|
|
828
|
+
*/
|
|
829
|
+
function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow | null {
|
|
830
|
+
let effective: ReadRow | null = null;
|
|
831
|
+
for (const row of rows) {
|
|
832
|
+
if (!effective) {
|
|
833
|
+
effective = row;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const rowIndependent = independenceReasons(row, authors).length === 0;
|
|
837
|
+
const effIndependent = independenceReasons(effective, authors).length === 0;
|
|
838
|
+
if (effIndependent && !rowIndependent) continue;
|
|
839
|
+
if (
|
|
840
|
+
effIndependent &&
|
|
841
|
+
FAILING.has(String(effective.result)) &&
|
|
842
|
+
row.actor.session === effective.actor.session
|
|
843
|
+
)
|
|
844
|
+
continue;
|
|
845
|
+
effective = row;
|
|
846
|
+
}
|
|
847
|
+
return effective;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Current and accepted verdicts for `branch`, after carry-over (D18). */
|
|
851
|
+
export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRow[]): VerdictCheck {
|
|
852
|
+
const head = branchHead(cwd, branch);
|
|
853
|
+
const fallbackBase = defaultBase(cwd);
|
|
854
|
+
const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
|
|
855
|
+
const keyFor = (base: string | null) => {
|
|
856
|
+
if (!base || !head) return { patchId: null, diffHash: null };
|
|
857
|
+
let key = keyCache.get(base);
|
|
858
|
+
if (!key) {
|
|
859
|
+
key = { patchId: patchId(cwd, base, head), diffHash: diffHash(cwd, base, head) };
|
|
860
|
+
keyCache.set(base, key);
|
|
861
|
+
}
|
|
862
|
+
return key;
|
|
863
|
+
};
|
|
864
|
+
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
|
|
865
|
+
const byKind = new Map<string, ReadRow[]>();
|
|
866
|
+
for (const row of rows)
|
|
867
|
+
if (row.branch === branch && isLiveVerdict(row)) {
|
|
868
|
+
const kind = typeof row.kind === "string" ? row.kind : "review";
|
|
869
|
+
byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
|
|
870
|
+
}
|
|
871
|
+
const verdicts: VerdictEntry[] = [];
|
|
872
|
+
for (const [kind, list] of byKind) {
|
|
873
|
+
const row = effectiveOf(list, authors);
|
|
874
|
+
if (!row) continue;
|
|
875
|
+
const basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
|
|
876
|
+
const current = basis !== "stale";
|
|
877
|
+
const independence = independenceReasons(row, authors);
|
|
878
|
+
const reasons: RejectReason[] = [
|
|
879
|
+
...(current ? [] : (["stale"] as const)),
|
|
880
|
+
...(PASSING.has(String(row.result)) ? [] : (["not_passing"] as const)),
|
|
881
|
+
...independence,
|
|
882
|
+
];
|
|
883
|
+
verdicts.push({
|
|
884
|
+
kind,
|
|
885
|
+
basis,
|
|
886
|
+
current,
|
|
887
|
+
independent: independence.length === 0,
|
|
888
|
+
accepted: reasons.length === 0,
|
|
889
|
+
reasons,
|
|
890
|
+
verdict: row,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
|
|
894
|
+
const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
|
|
895
|
+
const failing = verdicts.find(
|
|
896
|
+
(entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
|
|
897
|
+
);
|
|
898
|
+
const newestAccepted = verdicts.findLast((entry) => entry.accepted) ?? null;
|
|
899
|
+
const accepted =
|
|
900
|
+
newestAccepted && !failing
|
|
901
|
+
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
902
|
+
: {
|
|
903
|
+
accepted: false,
|
|
904
|
+
verdict: failing?.verdict ?? null,
|
|
905
|
+
reasons: failing
|
|
906
|
+
? (["failing_verdict"] as RejectReason[])
|
|
907
|
+
: (newestCurrent?.reasons ?? (["no_verdict"] as RejectReason[])),
|
|
908
|
+
};
|
|
909
|
+
return {
|
|
910
|
+
branch,
|
|
911
|
+
head,
|
|
912
|
+
base: newestCurrent?.verdict.base ?? fallbackBase,
|
|
913
|
+
patchId: keyFor(newestCurrent?.verdict.base ?? fallbackBase).patchId,
|
|
914
|
+
current: { basis: newestCurrent?.basis ?? "none", verdict: newestCurrent?.verdict ?? null },
|
|
915
|
+
accepted,
|
|
916
|
+
verdicts,
|
|
917
|
+
authors: [...authors].toSorted(),
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// recording helpers
|
|
923
|
+
|
|
924
|
+
export type RecordContext = {
|
|
925
|
+
cwd: string;
|
|
926
|
+
actor: LedgerActor;
|
|
927
|
+
branch?: string | null;
|
|
928
|
+
base?: string | null;
|
|
929
|
+
pr?: number;
|
|
930
|
+
supersedes?: string;
|
|
931
|
+
now?: Date;
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
const common = (context: RecordContext, key: CodeKey) => ({
|
|
935
|
+
...key,
|
|
936
|
+
actor: context.actor,
|
|
937
|
+
...(context.pr === undefined ? {} : { pr: context.pr }),
|
|
938
|
+
...(context.supersedes ? { supersedes: context.supersedes } : {}),
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
/** Cheap key for notes: branch and head only (no tree hashing, no patch-id). */
|
|
942
|
+
const noteKey = (context: RecordContext): CodeKey => {
|
|
943
|
+
const branch = context.branch ?? currentBranch(context.cwd);
|
|
944
|
+
return {
|
|
945
|
+
branch,
|
|
946
|
+
head: branch ? branchHead(context.cwd, branch) : headSha(context.cwd),
|
|
947
|
+
base: null,
|
|
948
|
+
baseSha: null,
|
|
949
|
+
patchId: null,
|
|
950
|
+
diffHash: null,
|
|
951
|
+
tree: null,
|
|
952
|
+
dirty: null,
|
|
953
|
+
};
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
const required = (value: string | undefined | null, flag: string): LedgerResult<string> => {
|
|
957
|
+
const text = value?.trim();
|
|
958
|
+
return text ? { ok: true, value: text } : err("invalid_input", `${flag} is required`);
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/** Refuse a supersede link that the reader would ignore. */
|
|
962
|
+
function checkSupersede(context: RecordContext, type: string, self?: boolean): LedgerResult<void> {
|
|
963
|
+
if (!context.supersedes) return { ok: true, value: undefined };
|
|
964
|
+
const ledger = readLedger(context.cwd);
|
|
965
|
+
if (!ledger.ok) return ledger;
|
|
966
|
+
const target = ledger.value.rows.find((row) => row.id === context.supersedes);
|
|
967
|
+
if (!target) return err("not_found", `no ledger row ${context.supersedes} to supersede`);
|
|
968
|
+
const problem = supersedeAllowed({ type, actor: context.actor, self }, target);
|
|
969
|
+
return problem
|
|
970
|
+
? err("invalid_input", `--supersedes: ${problem}`)
|
|
971
|
+
: { ok: true, value: undefined };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export function recordDecision(
|
|
975
|
+
context: RecordContext,
|
|
976
|
+
input: { what?: string; why?: string; refs?: string[] },
|
|
977
|
+
): LedgerResult<DecisionRow> {
|
|
978
|
+
const what = required(input.what, "<what>");
|
|
979
|
+
if (!what.ok) return what;
|
|
980
|
+
const why = required(input.why, "--why");
|
|
981
|
+
if (!why.ok) return why;
|
|
982
|
+
const link = checkSupersede(context, "decision");
|
|
983
|
+
if (!link.ok) return link;
|
|
984
|
+
return appendRow<DecisionRow>(
|
|
985
|
+
context.cwd,
|
|
986
|
+
{
|
|
987
|
+
...common(context, noteKey(context)),
|
|
988
|
+
type: "decision",
|
|
989
|
+
what: what.value,
|
|
990
|
+
why: why.value,
|
|
991
|
+
refs: input.refs ?? [],
|
|
992
|
+
},
|
|
993
|
+
{ now: context.now },
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export function recordRuling(
|
|
998
|
+
context: RecordContext,
|
|
999
|
+
input: { what?: string; why?: string; costIfWrong?: string; refs?: string[] },
|
|
1000
|
+
): LedgerResult<RulingRow> {
|
|
1001
|
+
const what = required(input.what, "<what>");
|
|
1002
|
+
if (!what.ok) return what;
|
|
1003
|
+
const why = required(input.why, "--why");
|
|
1004
|
+
if (!why.ok) return why;
|
|
1005
|
+
const cost = required(input.costIfWrong, "--cost-if-wrong");
|
|
1006
|
+
if (!cost.ok) return cost;
|
|
1007
|
+
const link = checkSupersede(context, "ruling");
|
|
1008
|
+
if (!link.ok) return link;
|
|
1009
|
+
return appendRow<RulingRow>(
|
|
1010
|
+
context.cwd,
|
|
1011
|
+
{
|
|
1012
|
+
...common(context, noteKey(context)),
|
|
1013
|
+
type: "ruling",
|
|
1014
|
+
what: what.value,
|
|
1015
|
+
why: why.value,
|
|
1016
|
+
costIfWrong: cost.value,
|
|
1017
|
+
refs: input.refs ?? [],
|
|
1018
|
+
},
|
|
1019
|
+
{ now: context.now },
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Record an agent-asserted verdict on a branch's committed head. Refused on a
|
|
1025
|
+
* dirty worktree (the head would not be what was judged) and, for an author
|
|
1026
|
+
* session, unless `--self`. Without a session the verdict is recorded as
|
|
1027
|
+
* self: an unknown actor cannot be told apart from the author.
|
|
1028
|
+
*/
|
|
1029
|
+
export function recordVerdict(
|
|
1030
|
+
context: RecordContext,
|
|
1031
|
+
input: {
|
|
1032
|
+
result: string;
|
|
1033
|
+
kind?: string;
|
|
1034
|
+
how?: string;
|
|
1035
|
+
surface?: string | null;
|
|
1036
|
+
self?: boolean;
|
|
1037
|
+
evidenceRefs?: string[];
|
|
1038
|
+
},
|
|
1039
|
+
): LedgerResult<VerdictRow> {
|
|
1040
|
+
if (!(VERDICT_RESULTS as readonly string[]).includes(input.result))
|
|
1041
|
+
return err(
|
|
1042
|
+
"invalid_input",
|
|
1043
|
+
`unknown verdict "${input.result}"; expected ${VERDICT_RESULTS.join("|")}`,
|
|
1044
|
+
);
|
|
1045
|
+
const kind = input.kind ?? "review";
|
|
1046
|
+
if (!(VERDICT_KINDS as readonly string[]).includes(kind))
|
|
1047
|
+
return err("invalid_input", `--kind must be ${VERDICT_KINDS.join("|")}`);
|
|
1048
|
+
const surface = input.surface ?? null;
|
|
1049
|
+
if (surface !== null && !(VERDICT_SURFACES as readonly string[]).includes(surface))
|
|
1050
|
+
return err("invalid_input", `--surface must be ${VERDICT_SURFACES.join("|")}`);
|
|
1051
|
+
const how = required(input.how, "--how");
|
|
1052
|
+
if (!how.ok) return how;
|
|
1053
|
+
const branch = context.branch ?? currentBranch(context.cwd);
|
|
1054
|
+
if (!branch)
|
|
1055
|
+
return err(
|
|
1056
|
+
"invalid_input",
|
|
1057
|
+
"HEAD is detached; name the branch the verdict is for",
|
|
1058
|
+
"pass --branch <branch>",
|
|
1059
|
+
);
|
|
1060
|
+
const ledger = readLedger(context.cwd);
|
|
1061
|
+
if (!ledger.ok) return ledger;
|
|
1062
|
+
const key = codeKey(context.cwd, { branch, base: context.base });
|
|
1063
|
+
if (!key.head) return err("not_found", `branch "${branch}" has no commit to judge`);
|
|
1064
|
+
if (key.dirty === true)
|
|
1065
|
+
return err(
|
|
1066
|
+
"blocked",
|
|
1067
|
+
`dirty_worktree: ${branch} has uncommitted changes, so its head is not what was judged`,
|
|
1068
|
+
"commit or stash the changes, re-check, then record the verdict",
|
|
1069
|
+
);
|
|
1070
|
+
const session = context.actor.session;
|
|
1071
|
+
const selfReason: SelfReason | null =
|
|
1072
|
+
input.self === true ? "flag" : session ? null : "no_session";
|
|
1073
|
+
const self = selfReason !== null;
|
|
1074
|
+
if (
|
|
1075
|
+
!self &&
|
|
1076
|
+
session &&
|
|
1077
|
+
authorSessions(context.cwd, ledger.value.rows, branch, { base: key.base, head: key.head }).has(
|
|
1078
|
+
session,
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
return err(
|
|
1082
|
+
"blocked",
|
|
1083
|
+
`author_verdict: session ${session} authored ${branch}; a verdict must come from a different session`,
|
|
1084
|
+
'have a non-author session record the verdict, or pass --self (a self verdict is never accepted by merge:"verified")',
|
|
1085
|
+
);
|
|
1086
|
+
const link = checkSupersede(context, "verdict", self);
|
|
1087
|
+
if (!link.ok) return link;
|
|
1088
|
+
return appendRow<VerdictRow>(
|
|
1089
|
+
context.cwd,
|
|
1090
|
+
{
|
|
1091
|
+
...common(context, key),
|
|
1092
|
+
type: "verdict",
|
|
1093
|
+
kind: kind as VerdictKind,
|
|
1094
|
+
result: input.result as VerdictResult,
|
|
1095
|
+
how: how.value,
|
|
1096
|
+
surface: surface as VerdictSurface | null,
|
|
1097
|
+
observer: "agent_asserted",
|
|
1098
|
+
self,
|
|
1099
|
+
selfReason,
|
|
1100
|
+
evidenceRefs: input.evidenceRefs ?? [],
|
|
1101
|
+
},
|
|
1102
|
+
{ now: context.now },
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
export function recordHandoff(
|
|
1107
|
+
context: RecordContext,
|
|
1108
|
+
input: { note: string | null; next: string },
|
|
1109
|
+
): LedgerResult<HandoffRow> {
|
|
1110
|
+
return appendRow<HandoffRow>(
|
|
1111
|
+
context.cwd,
|
|
1112
|
+
{ ...common(context, noteKey(context)), type: "handoff", note: input.note, next: input.next },
|
|
1113
|
+
{ now: context.now },
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
// handoff brief (replaces export/import: the ledger is shared across worktrees)
|
|
1119
|
+
|
|
1120
|
+
export type RowSummary = {
|
|
1121
|
+
seq: number;
|
|
1122
|
+
id: string;
|
|
1123
|
+
at: string;
|
|
1124
|
+
type: string;
|
|
1125
|
+
branch: string | null;
|
|
1126
|
+
summary: string;
|
|
1127
|
+
/** Why a reader should discount the row: superseded, ignored (by the trust rules), self. */
|
|
1128
|
+
labels: Array<"superseded" | "ignored" | "self">;
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
/** Row types that count only when an observing verb wrote them. */
|
|
1132
|
+
const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", ...PR_ROW_TYPES]);
|
|
1133
|
+
|
|
1134
|
+
export function rowLabels(row: ReadRow): RowSummary["labels"] {
|
|
1135
|
+
const labels: RowSummary["labels"] = [];
|
|
1136
|
+
if (row.superseded) labels.push("superseded");
|
|
1137
|
+
if (row.supersedeIgnored || (OBSERVED_ONLY.has(row.type) && row.observer !== "workit_cli"))
|
|
1138
|
+
labels.push("ignored");
|
|
1139
|
+
if (row.type === "verdict" && row.self === true) labels.push("self");
|
|
1140
|
+
return labels;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function summarizeRow(row: ReadRow): RowSummary {
|
|
1144
|
+
const text = (key: string): string => str(row[key]) ?? "";
|
|
1145
|
+
let summary: string;
|
|
1146
|
+
switch (row.type) {
|
|
1147
|
+
case "decision":
|
|
1148
|
+
summary = `${text("what")} (why: ${text("why")})`;
|
|
1149
|
+
break;
|
|
1150
|
+
case "ruling":
|
|
1151
|
+
summary = `${text("what")} (why: ${text("why")}; cost if wrong: ${text("costIfWrong")})`;
|
|
1152
|
+
break;
|
|
1153
|
+
case "verdict":
|
|
1154
|
+
summary = `${text("result")} [${text("kind") || "review"}] at ${(row.head ?? "?").slice(0, 12)}${row.self === true ? ` (self${text("selfReason") === "no_session" ? ": no session" : ""})` : ""}: ${text("how")}`;
|
|
1155
|
+
break;
|
|
1156
|
+
case "handoff":
|
|
1157
|
+
summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
|
|
1158
|
+
break;
|
|
1159
|
+
default:
|
|
1160
|
+
summary = text("what") || text("name") || row.type;
|
|
1161
|
+
}
|
|
1162
|
+
return {
|
|
1163
|
+
seq: row.seq,
|
|
1164
|
+
id: row.id,
|
|
1165
|
+
at: row.at,
|
|
1166
|
+
type: row.type,
|
|
1167
|
+
branch: row.branch,
|
|
1168
|
+
summary,
|
|
1169
|
+
labels: rowLabels(row),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export type StackPosition = {
|
|
1174
|
+
name: string;
|
|
1175
|
+
index: number;
|
|
1176
|
+
size: number;
|
|
1177
|
+
parent: string | null;
|
|
1178
|
+
pr: number | null;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
/** The branch's place in a cached stack (`<store>/stacks/*.json`, S12), if any. */
|
|
1182
|
+
export function stackPosition(root: string, branch: string): StackPosition | null {
|
|
1183
|
+
const dir = path.join(root, "stacks");
|
|
1184
|
+
let names: string[] = [];
|
|
1185
|
+
try {
|
|
1186
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
1187
|
+
} catch {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
for (const name of names.toSorted()) {
|
|
1191
|
+
let parsed: unknown;
|
|
1192
|
+
try {
|
|
1193
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
1194
|
+
} catch {
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.branches)) continue;
|
|
1198
|
+
const branches = parsed.branches.filter(isRecord);
|
|
1199
|
+
const index = branches.findIndex((entry) => entry.branch === branch);
|
|
1200
|
+
if (index < 0) continue;
|
|
1201
|
+
const entry = branches[index];
|
|
1202
|
+
return {
|
|
1203
|
+
name: str(parsed.name) ?? name.replace(/\.json$/u, ""),
|
|
1204
|
+
index: index + 1,
|
|
1205
|
+
size: branches.length,
|
|
1206
|
+
parent: str(entry.parent),
|
|
1207
|
+
pr: typeof entry.pr === "number" ? entry.pr : null,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export type CheckFreshness = { name: string; result: string | null; fresh: boolean; at: string };
|
|
1214
|
+
|
|
1215
|
+
type VerdictSummary = Omit<VerdictEntry, "verdict"> & { summary: string };
|
|
1216
|
+
|
|
1217
|
+
export type HandoffBrief = {
|
|
1218
|
+
store: { root: string; shared: boolean; ledger: string };
|
|
1219
|
+
branch: string | null;
|
|
1220
|
+
head: string | null;
|
|
1221
|
+
subject: string | null;
|
|
1222
|
+
base: string | null;
|
|
1223
|
+
ahead: number | null;
|
|
1224
|
+
behind: number | null;
|
|
1225
|
+
dirty: { dirty: boolean; files: number; paths: string[] };
|
|
1226
|
+
pr: number | null;
|
|
1227
|
+
stack: StackPosition | null;
|
|
1228
|
+
verdict: {
|
|
1229
|
+
current: VerdictCheck["current"]["basis"];
|
|
1230
|
+
accepted: boolean;
|
|
1231
|
+
reasons: RejectReason[];
|
|
1232
|
+
verdicts: VerdictSummary[];
|
|
1233
|
+
};
|
|
1234
|
+
checks: CheckFreshness[];
|
|
1235
|
+
rulings: RowSummary[];
|
|
1236
|
+
recent: RowSummary[];
|
|
1237
|
+
lastHandoff: { at: string; note: string | null; next: string } | null;
|
|
1238
|
+
note: string | null;
|
|
1239
|
+
next: string;
|
|
1240
|
+
nextCommand: string;
|
|
1241
|
+
skippedRows: number;
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
const MAX_DIRTY_PATHS = 10;
|
|
1245
|
+
|
|
1246
|
+
const dirtyState = (cwd: string): HandoffBrief["dirty"] => {
|
|
1247
|
+
const run = gitRun(
|
|
1248
|
+
cwd,
|
|
1249
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=normal"],
|
|
1250
|
+
GIT_TIMEOUTS.worktree,
|
|
1251
|
+
);
|
|
1252
|
+
if (!run.ok) return { dirty: false, files: 0, paths: [] };
|
|
1253
|
+
const paths: string[] = [];
|
|
1254
|
+
const entries = run.stdout.split("\0");
|
|
1255
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
1256
|
+
const entry = entries[index];
|
|
1257
|
+
if (entry.length < 4) continue;
|
|
1258
|
+
paths.push(entry.slice(3));
|
|
1259
|
+
// Renames and copies carry the source path as the next entry.
|
|
1260
|
+
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
1261
|
+
}
|
|
1262
|
+
return { dirty: paths.length > 0, files: paths.length, paths: paths.slice(0, MAX_DIRTY_PATHS) };
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const nextCommandFor = (brief: Omit<HandoffBrief, "nextCommand" | "next">): string => {
|
|
1266
|
+
const branchFlag = brief.branch ? ` --branch ${brief.branch}` : "";
|
|
1267
|
+
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)`;
|
|
1268
|
+
if (!brief.head) return "make the first commit on this branch, then: workit handoff";
|
|
1269
|
+
if (brief.dirty.dirty)
|
|
1270
|
+
return `commit or stash the ${brief.dirty.files} changed file(s), then: workit handoff`;
|
|
1271
|
+
const stale = brief.checks.find((check) => !check.fresh);
|
|
1272
|
+
if (stale) return `workit check ${stale.name}`;
|
|
1273
|
+
const { verdict } = brief;
|
|
1274
|
+
if (verdict.reasons.includes("failing_verdict"))
|
|
1275
|
+
return `fix what the failing verdict found, then get a new independent verdict: ${verify}`;
|
|
1276
|
+
if (!verdict.accepted) return verify;
|
|
1277
|
+
return brief.pr === null ? "workit pr create" : `workit pr status --pr ${brief.pr}`;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* A compact resume brief for the checked-out branch: code state, dirty files,
|
|
1282
|
+
* stack position, verdict acceptance, check freshness, rulings, the newest
|
|
1283
|
+
* ledger rows, and one next command.
|
|
1284
|
+
*/
|
|
1285
|
+
export function buildHandoff(
|
|
1286
|
+
cwd: string,
|
|
1287
|
+
options: { last?: number; note?: string | null; next?: string | null } = {},
|
|
1288
|
+
): LedgerResult<HandoffBrief> {
|
|
1289
|
+
const store = storeRoot(cwd);
|
|
1290
|
+
if (!store.ok) return store;
|
|
1291
|
+
const read = readLedger(cwd);
|
|
1292
|
+
if (!read.ok) return read;
|
|
1293
|
+
const ledger = read.value;
|
|
1294
|
+
const branch = currentBranch(cwd);
|
|
1295
|
+
const head = headSha(cwd);
|
|
1296
|
+
const base = defaultBase(cwd);
|
|
1297
|
+
let ahead: number | null = null;
|
|
1298
|
+
let behind: number | null = null;
|
|
1299
|
+
if (base && head) {
|
|
1300
|
+
const counts = git(cwd, ["rev-list", "--left-right", "--count", `${base}...HEAD`]);
|
|
1301
|
+
const [left, right] = (counts ?? "").split(/\s+/u).map(Number);
|
|
1302
|
+
if (Number.isFinite(left) && Number.isFinite(right)) {
|
|
1303
|
+
behind = left;
|
|
1304
|
+
ahead = right;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const mine = branch ? filterRows(ledger.rows, { branch }) : [];
|
|
1308
|
+
const stack = branch ? stackPosition(store.value.root, branch) : null;
|
|
1309
|
+
const pr =
|
|
1310
|
+
stack?.pr ??
|
|
1311
|
+
mine.findLast(
|
|
1312
|
+
(row) => row.pr !== undefined && PR_ROW_TYPES.has(row.type) && row.observer === "workit_cli",
|
|
1313
|
+
)?.pr ??
|
|
1314
|
+
null;
|
|
1315
|
+
const check = branch ? checkVerdicts(cwd, branch, ledger.rows) : null;
|
|
1316
|
+
const checkRows = mine.filter(
|
|
1317
|
+
(row) => row.type === "check" && row.observer === "workit_cli" && str(row.name),
|
|
1318
|
+
);
|
|
1319
|
+
const latestChecks = new Map<string, ReadRow>();
|
|
1320
|
+
for (const row of checkRows) latestChecks.set(str(row.name) as string, row);
|
|
1321
|
+
const tree = latestChecks.size ? (worktreeTree(cwd)?.key ?? null) : null;
|
|
1322
|
+
const checks: CheckFreshness[] = [...latestChecks.entries()].map(([name, row]) => ({
|
|
1323
|
+
name,
|
|
1324
|
+
result: str(row.result),
|
|
1325
|
+
fresh: tree !== null && row.tree === tree,
|
|
1326
|
+
at: row.at,
|
|
1327
|
+
}));
|
|
1328
|
+
const lastHandoffRow = mine.findLast((row) => row.type === "handoff");
|
|
1329
|
+
const partial: Omit<HandoffBrief, "nextCommand" | "next"> = {
|
|
1330
|
+
store: { root: store.value.root, shared: store.value.shared, ledger: ledger.path },
|
|
1331
|
+
branch,
|
|
1332
|
+
head,
|
|
1333
|
+
subject: head ? git(cwd, ["log", "-1", "--format=%s", head]) : null,
|
|
1334
|
+
base,
|
|
1335
|
+
ahead,
|
|
1336
|
+
behind,
|
|
1337
|
+
dirty: dirtyState(cwd),
|
|
1338
|
+
pr,
|
|
1339
|
+
stack,
|
|
1340
|
+
verdict: {
|
|
1341
|
+
current: check?.current.basis ?? "none",
|
|
1342
|
+
accepted: check?.accepted.accepted ?? false,
|
|
1343
|
+
reasons: check?.accepted.reasons ?? ["no_verdict"],
|
|
1344
|
+
verdicts: (check?.verdicts ?? []).map(({ verdict: row, ...rest }) => ({
|
|
1345
|
+
...rest,
|
|
1346
|
+
summary: summarizeRow(row).summary,
|
|
1347
|
+
})),
|
|
1348
|
+
},
|
|
1349
|
+
checks,
|
|
1350
|
+
rulings: mine
|
|
1351
|
+
.filter((row) => row.type === "ruling" && !row.superseded)
|
|
1352
|
+
.slice(-5)
|
|
1353
|
+
.map(summarizeRow),
|
|
1354
|
+
recent: ledger.rows.slice(-(options.last ?? 10)).map(summarizeRow),
|
|
1355
|
+
lastHandoff: lastHandoffRow
|
|
1356
|
+
? {
|
|
1357
|
+
at: lastHandoffRow.at,
|
|
1358
|
+
note: str(lastHandoffRow.note),
|
|
1359
|
+
next: str(lastHandoffRow.next) ?? "",
|
|
1360
|
+
}
|
|
1361
|
+
: null,
|
|
1362
|
+
note: options.note?.trim() || null,
|
|
1363
|
+
skippedRows: ledger.skipped,
|
|
1364
|
+
};
|
|
1365
|
+
const nextCommand = nextCommandFor(partial);
|
|
1366
|
+
return {
|
|
1367
|
+
ok: true,
|
|
1368
|
+
value: { ...partial, nextCommand, next: options.next?.trim() || nextCommand },
|
|
1369
|
+
};
|
|
1370
|
+
}
|