@brainervirus/workit-core 8.12.3 → 8.13.1
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/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/references/orchestration.md +4 -3
- package/skills/workit-review/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +20 -20
- package/skills/workit-ship/references/release.md +26 -0
- package/src/core/branch-policy.ts +15 -0
- package/src/core/hygiene.ts +37 -1
- package/src/core/methods.ts +12 -11
- package/src/core/store-lock.ts +9 -1
- package/src/forge/github.ts +30 -0
- package/src/forge/gitlab.ts +33 -0
- package/src/forge/pr-ops.ts +84 -18
- package/src/forge/redact.ts +6 -2
- package/src/forge/resolve.ts +15 -0
- package/src/forge/types.ts +10 -0
- package/src/git/ops.ts +181 -46
- package/src/git/release.ts +719 -0
- package/src/hooks/handle.ts +6 -3
- package/src/hooks/raw-git.ts +7 -5
- package/src/hooks/stop.ts +4 -2
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/src/ledger.ts
CHANGED
|
@@ -38,8 +38,12 @@
|
|
|
38
38
|
// the previous line) and `rowHash` (sha256 over `prevHash` and the row), a
|
|
39
39
|
// running chain. A row without it after the chain began, or whose hashes do
|
|
40
40
|
// not match, is reported as unverified (`workit ledger verify-integrity`, a
|
|
41
|
-
// warning in `ledger check`).
|
|
42
|
-
//
|
|
41
|
+
// warning in `ledger check`). Appends are never refused, but verdict gates
|
|
42
|
+
// (`checkVerdicts`) never accept an unverified or pre-chain verdict, nor a
|
|
43
|
+
// `--session` verdict whose id no SubagentStart hook issued, and ignore
|
|
44
|
+
// an unverified row's `supersedes`. The chain is keyless: it stops a row
|
|
45
|
+
// appended or edited by hand, not a forger who recomputes the hashes.
|
|
46
|
+
// The file is created 0600 (POSIX).
|
|
43
47
|
// - Branch renames: rows keyed to a branch's earlier names (from its reflog)
|
|
44
48
|
// count for it, up to the rename.
|
|
45
49
|
//
|
|
@@ -49,9 +53,10 @@ import { spawnSync } from "node:child_process";
|
|
|
49
53
|
import { createHash, randomBytes } from "node:crypto";
|
|
50
54
|
import fs from "node:fs";
|
|
51
55
|
import path from "node:path";
|
|
56
|
+
import { hostAwareLockTimeout } from "./core/store-lock";
|
|
52
57
|
import { vcsConfig } from "./core/vcs-config";
|
|
53
58
|
import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
|
|
54
|
-
import { hostSessionFromEnv } from "./host-session";
|
|
59
|
+
import { hostSessionFromEnv, issuedBinding } from "./host-session";
|
|
55
60
|
import { resolveStore } from "./store/paths";
|
|
56
61
|
|
|
57
62
|
export const LEDGER_VERSION = 1;
|
|
@@ -146,6 +151,8 @@ export type VerdictRow = RowCommon & {
|
|
|
146
151
|
self: boolean;
|
|
147
152
|
selfReason: SelfReason | null;
|
|
148
153
|
sessionSource: SessionSource;
|
|
154
|
+
/** The issued session's own subagent presented the token its hook bound the id to. */
|
|
155
|
+
sessionBound?: boolean;
|
|
149
156
|
evidenceRefs: string[];
|
|
150
157
|
};
|
|
151
158
|
export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
|
|
@@ -171,8 +178,12 @@ export type LedgerRow =
|
|
|
171
178
|
* the ledger had a chain).
|
|
172
179
|
*/
|
|
173
180
|
export type RowIntegrity = "verified" | "unverified" | "legacy";
|
|
174
|
-
/**
|
|
175
|
-
|
|
181
|
+
/**
|
|
182
|
+
* unsigned: no chain fields (hand-appended) · hash_mismatch: edited ·
|
|
183
|
+
* broken_link: a row before it was edited or removed · duplicate_id: a copy
|
|
184
|
+
* of an earlier row (a replayed line).
|
|
185
|
+
*/
|
|
186
|
+
export type UnverifiedReason = "unsigned" | "hash_mismatch" | "broken_link" | "duplicate_id";
|
|
176
187
|
/** A row as read back: any known or future type, plus its position. */
|
|
177
188
|
export type ReadRow = RowCommon &
|
|
178
189
|
Record<string, unknown> & {
|
|
@@ -182,6 +193,8 @@ export type ReadRow = RowCommon &
|
|
|
182
193
|
supersedeIgnored: boolean;
|
|
183
194
|
/** Set only when read with `{ integrity: true }`. */
|
|
184
195
|
integrity?: RowIntegrity;
|
|
196
|
+
/** Why an `unverified` row is unverified. */
|
|
197
|
+
integrityReason?: UnverifiedReason;
|
|
185
198
|
};
|
|
186
199
|
|
|
187
200
|
export type LedgerError = {
|
|
@@ -497,11 +510,15 @@ export const ledgerLock = {
|
|
|
497
510
|
},
|
|
498
511
|
};
|
|
499
512
|
|
|
500
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* Run `fn` holding the ledger's advisory lock; null when it stays held for
|
|
515
|
+
* `waitMs`. The wait blocks the thread, so in-process hosts (OpenCode, Pi,
|
|
516
|
+
* MCP) wait at most 250 ms by default; the CLI keeps LOCK_WAIT_MS.
|
|
517
|
+
*/
|
|
501
518
|
export function withLedgerLock<T>(
|
|
502
519
|
file: string,
|
|
503
520
|
fn: () => T,
|
|
504
|
-
waitMs: number = LOCK_WAIT_MS,
|
|
521
|
+
waitMs: number = hostAwareLockTimeout(LOCK_WAIT_MS),
|
|
505
522
|
): T | null {
|
|
506
523
|
const lock = `${file}.lock`;
|
|
507
524
|
const deadline = Date.now() + waitMs;
|
|
@@ -569,6 +586,15 @@ const lastLine = (fd: number, size: number): string | null => {
|
|
|
569
586
|
return null;
|
|
570
587
|
};
|
|
571
588
|
|
|
589
|
+
/** A ledger created before 0600 loses its group/other bits on the next append. */
|
|
590
|
+
const tighten = (fd: number): void => {
|
|
591
|
+
try {
|
|
592
|
+
if ((fs.fstatSync(fd).mode & 0o077) !== 0) fs.fchmodSync(fd, 0o600);
|
|
593
|
+
} catch {
|
|
594
|
+
// Not the owner (a shared store): the append still goes through.
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
|
|
572
598
|
/** The line to write, given the file's current last line (null: empty file). */
|
|
573
599
|
type LineBuilder = (previous: string | null) => LedgerResult<string>;
|
|
574
600
|
|
|
@@ -576,8 +602,10 @@ function appendLine(file: string, build: LineBuilder, options: AppendOptions): L
|
|
|
576
602
|
const write = (): LedgerResult<void> => {
|
|
577
603
|
// a+ = O_APPEND|O_RDWR: every write lands at the end, and we can still
|
|
578
604
|
// peek at the tail to chain onto the last row and fence off a torn one.
|
|
579
|
-
|
|
605
|
+
// 0600: rows name sessions, branches and verdict notes (audit N14).
|
|
606
|
+
const fd = fs.openSync(file, "a+", 0o600);
|
|
580
607
|
try {
|
|
608
|
+
if (process.platform !== "win32") tighten(fd);
|
|
581
609
|
const size = fs.fstatSync(fd).size;
|
|
582
610
|
let fence = "";
|
|
583
611
|
if (size > 0) {
|
|
@@ -774,7 +802,9 @@ export function readLedger(
|
|
|
774
802
|
const file = ledgerPath(cwd);
|
|
775
803
|
if (!file.ok) return file;
|
|
776
804
|
let text = "";
|
|
805
|
+
let stat: fs.Stats | null = null;
|
|
777
806
|
try {
|
|
807
|
+
stat = fs.statSync(file.value);
|
|
778
808
|
text = fs.readFileSync(file.value, "utf8");
|
|
779
809
|
} catch (error) {
|
|
780
810
|
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
@@ -798,6 +828,7 @@ export function readLedger(
|
|
|
798
828
|
// link. An edited row fails its own hash and breaks every link to it.
|
|
799
829
|
const earlier = new Set<string | null>([null]);
|
|
800
830
|
let chainStarted = false;
|
|
831
|
+
const ids = new Set<string>();
|
|
801
832
|
for (const raw of text.split("\n")) {
|
|
802
833
|
const trimmed = raw.trim();
|
|
803
834
|
if (!trimmed) continue;
|
|
@@ -827,6 +858,9 @@ export function readLedger(
|
|
|
827
858
|
else if (!linked(prevHash)) reason = "broken_link";
|
|
828
859
|
} else if (chainStarted) reason = "unsigned";
|
|
829
860
|
else rowIntegrity = "legacy";
|
|
861
|
+
// A replayed copy of an earlier line hashes and links like the original.
|
|
862
|
+
if (options.integrity && !reason && ids.has(parsed.id as string)) reason = "duplicate_id";
|
|
863
|
+
if (options.integrity) ids.add(parsed.id as string);
|
|
830
864
|
if (reason) rowIntegrity = "unverified";
|
|
831
865
|
const pr = typeof parsed.pr === "number" && Number.isInteger(parsed.pr) ? parsed.pr : undefined;
|
|
832
866
|
const row: ReadRow = {
|
|
@@ -850,6 +884,7 @@ export function readLedger(
|
|
|
850
884
|
superseded: false,
|
|
851
885
|
supersedeIgnored: false,
|
|
852
886
|
...(rowIntegrity ? { integrity: rowIntegrity } : {}),
|
|
887
|
+
...(reason ? { integrityReason: reason } : {}),
|
|
853
888
|
};
|
|
854
889
|
rows.push(row);
|
|
855
890
|
if (!options.integrity) continue;
|
|
@@ -866,7 +901,8 @@ export function readLedger(
|
|
|
866
901
|
}
|
|
867
902
|
// Only a well-formed supersede link counts; anything else is ignored. A
|
|
868
903
|
// link across branch names is checked against the renames (reflog read
|
|
869
|
-
// only for such links, once per branch).
|
|
904
|
+
// only for such links, once per branch). An unverified row's link never
|
|
905
|
+
// counts: a hand-appended row cannot retire another one (N13).
|
|
870
906
|
const scopes = new Map<string, BranchScope>();
|
|
871
907
|
const scopeOf = (branch: string): BranchScope => {
|
|
872
908
|
let scope = scopes.get(branch);
|
|
@@ -876,17 +912,71 @@ export function readLedger(
|
|
|
876
912
|
const byId = new Map<string, ReadRow>();
|
|
877
913
|
for (const row of rows) {
|
|
878
914
|
const target = row.supersedes ? byId.get(row.supersedes) : undefined;
|
|
879
|
-
if (
|
|
915
|
+
if (
|
|
916
|
+
target &&
|
|
917
|
+
row.integrity !== "unverified" &&
|
|
918
|
+
supersedeAllowed(row, target, renameAwareBranch(scopeOf, row.branch)) === null
|
|
919
|
+
)
|
|
880
920
|
target.superseded = true;
|
|
881
921
|
else if (row.supersedes) row.supersedeIgnored = true;
|
|
882
922
|
if (!byId.has(row.id)) byId.set(row.id, row);
|
|
883
923
|
}
|
|
924
|
+
// A read that saw exactly the stat'ed file remembers its chain breaks.
|
|
925
|
+
if (options.integrity && stat && Buffer.byteLength(text) === stat.size)
|
|
926
|
+
tamperCache.set(file.value, {
|
|
927
|
+
size: stat.size,
|
|
928
|
+
stamp: fileStamp(stat),
|
|
929
|
+
breaks: integrity.unverified.filter((row) => TAMPER_REASONS.has(row.reason)),
|
|
930
|
+
});
|
|
884
931
|
return {
|
|
885
932
|
ok: true,
|
|
886
933
|
value: { path: file.value, rows, skipped, integrity: options.integrity ? integrity : null },
|
|
887
934
|
};
|
|
888
935
|
}
|
|
889
936
|
|
|
937
|
+
/** Chain breaks honest appends never produce: a row edited, removed or moved. */
|
|
938
|
+
const TAMPER_REASONS: ReadonlySet<UnverifiedReason> = new Set(["hash_mismatch", "broken_link"]);
|
|
939
|
+
const tamperCache = new Map<string, { size: number; stamp: string; breaks: UnverifiedRow[] }>();
|
|
940
|
+
/** Inode, mtime and ctime: `touch -r` can restore an mtime, never a ctime. */
|
|
941
|
+
const fileStamp = (stat: fs.Stats): string => `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Rows anywhere in the ledger whose chain shows an edit, a removal or a move
|
|
945
|
+
* (`hash_mismatch`, `broken_link`). Cached per file size and mtime; empty
|
|
946
|
+
* when the ledger cannot be read.
|
|
947
|
+
*/
|
|
948
|
+
export function ledgerTampering(cwd: string): UnverifiedRow[] {
|
|
949
|
+
const file = ledgerPath(cwd);
|
|
950
|
+
if (!file.ok) return [];
|
|
951
|
+
try {
|
|
952
|
+
const stat = fs.statSync(file.value);
|
|
953
|
+
const cached = tamperCache.get(file.value);
|
|
954
|
+
if (cached && cached.size === stat.size && cached.stamp === fileStamp(stat))
|
|
955
|
+
return cached.breaks;
|
|
956
|
+
} catch {
|
|
957
|
+
return [];
|
|
958
|
+
}
|
|
959
|
+
const read = readLedger(cwd, { integrity: true });
|
|
960
|
+
return read.ok
|
|
961
|
+
? (read.value.integrity?.unverified ?? []).filter((row) => TAMPER_REASONS.has(row.reason))
|
|
962
|
+
: [];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** How to repair a tampered ledger: see what broke, then start a new one and re-record. */
|
|
966
|
+
export function tamperedUnblock(cwd: string): string {
|
|
967
|
+
const file = ledgerPath(cwd);
|
|
968
|
+
const where = file.ok ? file.value : "the ledger file";
|
|
969
|
+
return `workit ledger verify-integrity # lists the edited or missing rows; if the edit is understood, move ${where} aside (mv ${where} ${where}.tampered-<date>) and record the verdicts again`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const tamperedWarning = (cwd: string, breaks: readonly UnverifiedRow[]): string =>
|
|
973
|
+
`the ledger was edited, or a row was removed or moved (${breaks
|
|
974
|
+
.slice(0, 3)
|
|
975
|
+
.map((row) => `${row.reason} at row ${row.seq}`)
|
|
976
|
+
.join(
|
|
977
|
+
", ",
|
|
978
|
+
)}${breaks.length > 3 ? ", …" : ""}): no verdict is accepted until it is repaired. ${tamperedUnblock(cwd)}`;
|
|
979
|
+
|
|
890
980
|
export type RowFilter = { branch?: string; pr?: number; type?: string; last?: number };
|
|
891
981
|
|
|
892
982
|
export function filterRows(rows: readonly ReadRow[], filter: RowFilter): ReadRow[] {
|
|
@@ -1002,7 +1092,21 @@ export type RejectReason =
|
|
|
1002
1092
|
| "failing_verdict"
|
|
1003
1093
|
/** The only passing current verdict is `type-check-only`, which proves no behavior. */
|
|
1004
1094
|
| "type_check_only"
|
|
1005
|
-
| "no_verdict"
|
|
1095
|
+
| "no_verdict"
|
|
1096
|
+
/** The verdict row is outside the CLI's hash chain: appended by hand, or pre-chain. */
|
|
1097
|
+
| "unsigned"
|
|
1098
|
+
/** The verdict row was edited after the CLI wrote it. */
|
|
1099
|
+
| "hash_mismatch"
|
|
1100
|
+
/** A row before the verdict was edited or removed. */
|
|
1101
|
+
| "broken_link"
|
|
1102
|
+
/** The verdict row repeats an earlier row's id (a replayed line). */
|
|
1103
|
+
| "duplicate_id"
|
|
1104
|
+
/** A `--session` verdict whose id no SubagentStart hook issued, recorded on a host. */
|
|
1105
|
+
| "unissued_session"
|
|
1106
|
+
/** An issued id recorded without the token its hook bound it to (another session reusing it). */
|
|
1107
|
+
| "unbound_session"
|
|
1108
|
+
/** A row somewhere in the ledger was edited, removed or moved: no verdict is accepted until it is repaired. */
|
|
1109
|
+
| "ledger_tampered";
|
|
1006
1110
|
|
|
1007
1111
|
export type VerdictEntry = {
|
|
1008
1112
|
kind: string;
|
|
@@ -1042,8 +1146,8 @@ export type VerdictCheck = {
|
|
|
1042
1146
|
warnings: string[];
|
|
1043
1147
|
};
|
|
1044
1148
|
|
|
1045
|
-
const isLiveVerdict = (row: ReadRow): boolean =>
|
|
1046
|
-
row.type === "verdict" && typeof row.result === "string" && !row.
|
|
1149
|
+
const isLiveVerdict = (row: ReadRow, retired: ReadonlySet<string>): boolean =>
|
|
1150
|
+
row.type === "verdict" && typeof row.result === "string" && !retired.has(row.id);
|
|
1047
1151
|
|
|
1048
1152
|
/** Sessions recorded in `Workit-Session:` trailers of `base...head` commits. */
|
|
1049
1153
|
function trailerSessions(cwd: string, base: string | null, head: string | null): string[] {
|
|
@@ -1161,6 +1265,83 @@ const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
|
1161
1265
|
return false;
|
|
1162
1266
|
};
|
|
1163
1267
|
|
|
1268
|
+
/** Reasons a gate cannot trust a verdict row at all, whoever it names. */
|
|
1269
|
+
const TRUST_REASONS: ReadonlySet<RejectReason> = new Set([
|
|
1270
|
+
"unsigned",
|
|
1271
|
+
"hash_mismatch",
|
|
1272
|
+
"broken_link",
|
|
1273
|
+
"duplicate_id",
|
|
1274
|
+
"unissued_session",
|
|
1275
|
+
"unbound_session",
|
|
1276
|
+
]);
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Hosts whose shell itself names the session (OpenCode, Pi, Codex), and no
|
|
1280
|
+
* host at all (`cli`: CI or a terminal, where any id is a declaration): an
|
|
1281
|
+
* environment session from them was checked against that shell when recorded.
|
|
1282
|
+
*/
|
|
1283
|
+
const SHELL_VOUCHED_HOSTS: ReadonlySet<string> = new Set([
|
|
1284
|
+
"cli",
|
|
1285
|
+
"opencode",
|
|
1286
|
+
"pi",
|
|
1287
|
+
"codex_cli",
|
|
1288
|
+
"codex_desktop",
|
|
1289
|
+
]);
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* A verdict acting as a session nothing vouches for (`ledger verdict` refuses
|
|
1293
|
+
* these when recording; rows by an older CLI or by hand are caught here):
|
|
1294
|
+
* - `unissued_session`: a `--session` id on a host, or a `<lead>:<x>`
|
|
1295
|
+
* environment id on a host that does not name it in its shell (Claude
|
|
1296
|
+
* Code, Cursor, any declared WORKIT_HOST), that no SubagentStart hook issued;
|
|
1297
|
+
* - `unbound_session`: such an id the hook bound to a token, recorded
|
|
1298
|
+
* without it (another session reusing an id it read in the ledger).
|
|
1299
|
+
*/
|
|
1300
|
+
function sessionTrust(cwd: string, row: ReadRow): RejectReason | null {
|
|
1301
|
+
const session = row.actor.session;
|
|
1302
|
+
if (!session || row.self === true) return null;
|
|
1303
|
+
const source = typeof row.sessionSource === "string" ? row.sessionSource : "env";
|
|
1304
|
+
const host = row.actor.host;
|
|
1305
|
+
const issuedOnly =
|
|
1306
|
+
source === "flag"
|
|
1307
|
+
? host !== "cli"
|
|
1308
|
+
: source === "env" && session.includes(":") && !SHELL_VOUCHED_HOSTS.has(host);
|
|
1309
|
+
if (!issuedOnly) return null;
|
|
1310
|
+
const binding = issuedBinding(cwd, session);
|
|
1311
|
+
if (binding === null) return "unissued_session";
|
|
1312
|
+
return binding === "token" && row.sessionBound !== true ? "unbound_session" : null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Why a gate cannot trust `row` (D18, audit N1): outside the hash chain, or
|
|
1317
|
+
* acting as a session nothing vouches for. Rows read without integrity are unsigned.
|
|
1318
|
+
*/
|
|
1319
|
+
function trustReasons(cwd: string, row: ReadRow): RejectReason[] {
|
|
1320
|
+
if (row.integrity === "unverified") return [row.integrityReason ?? "unsigned"];
|
|
1321
|
+
if (row.integrity !== "verified") return ["unsigned"];
|
|
1322
|
+
const session = sessionTrust(cwd, row);
|
|
1323
|
+
return session ? [session] : [];
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* `rows` with their place in the hash chain. Rows read without integrity are
|
|
1328
|
+
* re-read with it (and with integrity-aware supersede links): every caller of
|
|
1329
|
+
* `checkVerdicts` is a gate. A row the re-read cannot match stays unsigned.
|
|
1330
|
+
*/
|
|
1331
|
+
function withIntegrity(cwd: string, rows: readonly ReadRow[]): readonly ReadRow[] {
|
|
1332
|
+
if (rows.every((row) => row.integrity !== undefined)) return rows;
|
|
1333
|
+
const read = readLedger(cwd, { integrity: true });
|
|
1334
|
+
const bySeq = new Map(read.ok ? read.value.rows.map((row) => [row.seq, row]) : []);
|
|
1335
|
+
return rows.map((row) => {
|
|
1336
|
+
if (row.integrity !== undefined) return row;
|
|
1337
|
+
const fresh = bySeq.get(row.seq);
|
|
1338
|
+
return fresh && fresh.id === row.id && fresh.at === row.at ? fresh : row;
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const untrusted = (reasons: readonly RejectReason[]): boolean =>
|
|
1343
|
+
reasons.some((reason) => TRUST_REASONS.has(reason));
|
|
1344
|
+
|
|
1164
1345
|
const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
|
|
1165
1346
|
const reasons: RejectReason[] = [];
|
|
1166
1347
|
if (!row.actor.session) reasons.push("no_session");
|
|
@@ -1181,7 +1362,7 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
|
|
|
1181
1362
|
*/
|
|
1182
1363
|
function effectiveOf(
|
|
1183
1364
|
rows: readonly ReadRow[],
|
|
1184
|
-
|
|
1365
|
+
reasonsOf: (row: ReadRow) => RejectReason[],
|
|
1185
1366
|
sameCode: (failed: ReadRow, row: ReadRow) => boolean,
|
|
1186
1367
|
isCurrent: (row: ReadRow) => boolean,
|
|
1187
1368
|
): ReadRow | null {
|
|
@@ -1191,8 +1372,10 @@ function effectiveOf(
|
|
|
1191
1372
|
effective = row;
|
|
1192
1373
|
continue;
|
|
1193
1374
|
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1375
|
+
// A row the gate cannot trust never displaces one it can.
|
|
1376
|
+
if (untrusted(reasonsOf(row)) && !untrusted(reasonsOf(effective))) continue;
|
|
1377
|
+
const rowIndependent = reasonsOf(row).length === 0;
|
|
1378
|
+
const effIndependent = reasonsOf(effective).length === 0;
|
|
1196
1379
|
if (effIndependent && !rowIndependent && isCurrent(effective)) continue;
|
|
1197
1380
|
if (
|
|
1198
1381
|
effIndependent &&
|
|
@@ -1227,6 +1410,7 @@ function restackCarries(
|
|
|
1227
1410
|
if (
|
|
1228
1411
|
row.type === "stack.restacked" &&
|
|
1229
1412
|
row.observer === "workit_cli" &&
|
|
1413
|
+
row.integrity === "verified" &&
|
|
1230
1414
|
row.branch === branch &&
|
|
1231
1415
|
row.patchEqual === true &&
|
|
1232
1416
|
typeof row.fromHead === "string" &&
|
|
@@ -1248,7 +1432,12 @@ function restackCarries(
|
|
|
1248
1432
|
}
|
|
1249
1433
|
|
|
1250
1434
|
/** Current and accepted verdicts for `branch`, after carry-over (D18). */
|
|
1251
|
-
export function checkVerdicts(
|
|
1435
|
+
export function checkVerdicts(
|
|
1436
|
+
cwd: string,
|
|
1437
|
+
branch: string,
|
|
1438
|
+
ledgerRows: readonly ReadRow[],
|
|
1439
|
+
): VerdictCheck {
|
|
1440
|
+
const rows = withIntegrity(cwd, ledgerRows);
|
|
1252
1441
|
const head = branchHead(cwd, branch);
|
|
1253
1442
|
const fallbackBase = defaultBase(cwd);
|
|
1254
1443
|
const scope = branchScope(cwd, branch);
|
|
@@ -1284,20 +1473,69 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1284
1473
|
);
|
|
1285
1474
|
};
|
|
1286
1475
|
const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head }, scope);
|
|
1476
|
+
// A failure is an author's only when chained ledger rows written before it
|
|
1477
|
+
// made its session an author. `ledger verdict` refuses an author's
|
|
1478
|
+
// independent verdict, so authorship gained afterwards (a forged row, a
|
|
1479
|
+
// commit or an empty amend under the reviewer's id, a trailer) never
|
|
1480
|
+
// discounts a failure (audit N13, mv83zyg6 P2b).
|
|
1481
|
+
const failureAuthorCache = new Map<number, Set<string>>();
|
|
1482
|
+
const failureAuthors = (row: ReadRow): Set<string> => {
|
|
1483
|
+
let set = failureAuthorCache.get(row.seq);
|
|
1484
|
+
if (!set) {
|
|
1485
|
+
set = authorSessions(
|
|
1486
|
+
cwd,
|
|
1487
|
+
rows.filter((other) => other.integrity === "verified" && other.seq < row.seq),
|
|
1488
|
+
branch,
|
|
1489
|
+
{ base: null, head: null },
|
|
1490
|
+
scope,
|
|
1491
|
+
);
|
|
1492
|
+
failureAuthorCache.set(row.seq, set);
|
|
1493
|
+
}
|
|
1494
|
+
return set;
|
|
1495
|
+
};
|
|
1496
|
+
const reasonCache = new Map<ReadRow, RejectReason[]>();
|
|
1497
|
+
const reasonsOf = (row: ReadRow): RejectReason[] => {
|
|
1498
|
+
let reasons = reasonCache.get(row);
|
|
1499
|
+
if (!reasons) {
|
|
1500
|
+
const failing = FAILING.has(String(row.result));
|
|
1501
|
+
reasons = [
|
|
1502
|
+
...trustReasons(cwd, row),
|
|
1503
|
+
...independenceReasons(row, failing ? failureAuthors(row) : authors),
|
|
1504
|
+
];
|
|
1505
|
+
reasonCache.set(row, reasons);
|
|
1506
|
+
}
|
|
1507
|
+
return reasons;
|
|
1508
|
+
};
|
|
1509
|
+
// Only a trusted verdict retires another: an unverified row or one acting
|
|
1510
|
+
// as an unissued or unbound session never supersedes (N13, mv83zyg6 P1).
|
|
1511
|
+
const retired = new Set<string>();
|
|
1512
|
+
for (const row of rows)
|
|
1513
|
+
if (
|
|
1514
|
+
row.type === "verdict" &&
|
|
1515
|
+
row.supersedes &&
|
|
1516
|
+
!row.supersedeIgnored &&
|
|
1517
|
+
trustReasons(cwd, row).length === 0
|
|
1518
|
+
)
|
|
1519
|
+
retired.add(row.supersedes);
|
|
1287
1520
|
const byKind = new Map<string, ReadRow[]>();
|
|
1288
1521
|
for (const row of rows)
|
|
1289
|
-
if (isLiveVerdict(row) && scope(row)) {
|
|
1522
|
+
if (isLiveVerdict(row, retired) && scope(row)) {
|
|
1290
1523
|
const kind = typeof row.kind === "string" ? row.kind : "review";
|
|
1291
1524
|
byKind.set(kind, [...(byKind.get(kind) ?? []), row]);
|
|
1292
1525
|
}
|
|
1293
1526
|
const verdicts: VerdictEntry[] = [];
|
|
1294
1527
|
const warnings: string[] = [];
|
|
1295
1528
|
for (const [kind, list] of byKind) {
|
|
1296
|
-
const row = effectiveOf(
|
|
1529
|
+
const row = effectiveOf(
|
|
1530
|
+
list,
|
|
1531
|
+
reasonsOf,
|
|
1532
|
+
sameCode,
|
|
1533
|
+
(candidate) => basisOf(candidate) !== "stale",
|
|
1534
|
+
);
|
|
1297
1535
|
if (!row) continue;
|
|
1298
1536
|
const basis = basisOf(row);
|
|
1299
1537
|
const current = basis !== "stale";
|
|
1300
|
-
const independence =
|
|
1538
|
+
const independence = reasonsOf(row);
|
|
1301
1539
|
const reasons: RejectReason[] = [
|
|
1302
1540
|
...(current ? [] : (["stale"] as const)),
|
|
1303
1541
|
...(PASSING.has(String(row.result)) ? [] : (["not_passing"] as const)),
|
|
@@ -1318,7 +1556,8 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1318
1556
|
for (const later of list)
|
|
1319
1557
|
if (
|
|
1320
1558
|
later.seq > row.seq &&
|
|
1321
|
-
|
|
1559
|
+
reasonsOf(later).length > 0 &&
|
|
1560
|
+
!untrusted(reasonsOf(later)) &&
|
|
1322
1561
|
FAILING.has(String(later.result)) &&
|
|
1323
1562
|
basisOf(later) !== "stale"
|
|
1324
1563
|
)
|
|
@@ -1331,6 +1570,16 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1331
1570
|
warnings.push(
|
|
1332
1571
|
`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)`,
|
|
1333
1572
|
);
|
|
1573
|
+
// An edited, removed or moved row anywhere breaks the chain; honest appends
|
|
1574
|
+
// never do. Until it is repaired no verdict is accepted (mv83zyg6 P3/P4).
|
|
1575
|
+
const tampered = ledgerTampering(cwd);
|
|
1576
|
+
if (tampered.length) {
|
|
1577
|
+
warnings.push(tamperedWarning(cwd, tampered));
|
|
1578
|
+
for (const entry of verdicts) {
|
|
1579
|
+
entry.accepted = false;
|
|
1580
|
+
entry.reasons = [...entry.reasons, "ledger_tampered"];
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1334
1583
|
verdicts.sort((a, b) => a.verdict.seq - b.verdict.seq);
|
|
1335
1584
|
const newestCurrent = verdicts.findLast((entry) => entry.current) ?? verdicts.at(-1) ?? null;
|
|
1336
1585
|
const failing = verdicts.find(
|
|
@@ -1340,8 +1589,9 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1340
1589
|
verdicts.findLast(
|
|
1341
1590
|
(entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1342
1591
|
) ?? null;
|
|
1343
|
-
const accepted =
|
|
1344
|
-
|
|
1592
|
+
const accepted = tampered.length
|
|
1593
|
+
? { accepted: false, verdict: null, reasons: ["ledger_tampered"] as RejectReason[] }
|
|
1594
|
+
: newestAccepted && !failing
|
|
1345
1595
|
? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
|
|
1346
1596
|
: {
|
|
1347
1597
|
accepted: false,
|
|
@@ -1358,7 +1608,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
|
|
|
1358
1608
|
? undefined
|
|
1359
1609
|
: verdicts.findLast(
|
|
1360
1610
|
(entry) =>
|
|
1361
|
-
entry.current &&
|
|
1611
|
+
entry.current &&
|
|
1612
|
+
!entry.independent &&
|
|
1613
|
+
!untrusted(entry.reasons) &&
|
|
1614
|
+
STRONG_RESULTS.has(String(entry.verdict.result)),
|
|
1362
1615
|
);
|
|
1363
1616
|
return {
|
|
1364
1617
|
branch,
|
|
@@ -1387,6 +1640,8 @@ export type RecordContext = {
|
|
|
1387
1640
|
supersedes?: string;
|
|
1388
1641
|
/** Where `actor.session` came from (verdicts record it). Default: the environment. */
|
|
1389
1642
|
sessionSource?: SessionSource;
|
|
1643
|
+
/** The caller presented the token bound to its issued session (verdicts record it). */
|
|
1644
|
+
sessionBound?: boolean;
|
|
1390
1645
|
now?: Date;
|
|
1391
1646
|
};
|
|
1392
1647
|
|
|
@@ -1734,6 +1989,7 @@ export function recordVerdict(
|
|
|
1734
1989
|
self,
|
|
1735
1990
|
selfReason,
|
|
1736
1991
|
sessionSource: context.sessionSource ?? "env",
|
|
1992
|
+
...(context.sessionBound === true ? { sessionBound: true } : {}),
|
|
1737
1993
|
evidenceRefs: input.evidenceRefs ?? [],
|
|
1738
1994
|
},
|
|
1739
1995
|
{ now: context.now },
|
package/src/stack.ts
CHANGED
|
@@ -1180,7 +1180,7 @@ export function qualify(
|
|
|
1180
1180
|
export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutcome> {
|
|
1181
1181
|
const mismatch = repoMismatch(ctx, stack);
|
|
1182
1182
|
if (mismatch) return mismatch;
|
|
1183
|
-
const ledger = readLedger(ctx.cwd);
|
|
1183
|
+
const ledger = readLedger(ctx.cwd, { integrity: true });
|
|
1184
1184
|
if (!ledger.ok) return stackFail(ledger.code, ledger.error, ledger.unblock);
|
|
1185
1185
|
const remote = remoteFor(ctx.cwd, ctx.resolved, stack.trunk);
|
|
1186
1186
|
const trunkSha = trunkTip(ctx.cwd, remote, stack.trunk);
|
|
@@ -1823,7 +1823,7 @@ export async function landStack(
|
|
|
1823
1823
|
absorb(synced.data);
|
|
1824
1824
|
}
|
|
1825
1825
|
const ledgerRows = () => {
|
|
1826
|
-
const ledger = readLedger(ctx.cwd);
|
|
1826
|
+
const ledger = readLedger(ctx.cwd, { integrity: true });
|
|
1827
1827
|
return ledger.ok ? ledger.value.rows : [];
|
|
1828
1828
|
};
|
|
1829
1829
|
const simulated = new Set<string>();
|