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