@brainervirus/workit-core 8.23.0 → 8.25.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/skills/workit-fanout/SKILL.md +1 -1
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-implement/SKILL.md +5 -5
- package/skills/workit-review/SKILL.md +5 -0
- package/skills/workit-ship/SKILL.md +1 -2
- package/src/autonomy.ts +22 -11
- package/src/check-config.ts +9 -2
- package/src/core/methods.ts +5 -5
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +57 -9
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/workspaces.ts +1 -1
- package/src/fanout-brief.ts +8 -6
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +65 -14
- package/src/forge/report.ts +4 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +244 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/stop.ts +50 -33
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +155 -52
- package/src/stack.ts +68 -16
- package/src/verdict-cover.ts +151 -0
package/src/stack.ts
CHANGED
|
@@ -62,10 +62,10 @@ import {
|
|
|
62
62
|
pushRemoteName,
|
|
63
63
|
remoteRefTip,
|
|
64
64
|
resolveRef,
|
|
65
|
+
sameOwnChange,
|
|
65
66
|
} from "./git/rev";
|
|
66
67
|
import {
|
|
67
68
|
appendObserved,
|
|
68
|
-
checkVerdicts,
|
|
69
69
|
diffHash,
|
|
70
70
|
readLedger,
|
|
71
71
|
storeRoot,
|
|
@@ -73,6 +73,9 @@ import {
|
|
|
73
73
|
type ReadRow,
|
|
74
74
|
type VerdictBasis,
|
|
75
75
|
} from "./ledger";
|
|
76
|
+
import type { LaneNeed, ReviewLane } from "./core/policy/lane";
|
|
77
|
+
import { prLaneGate } from "./core/policy/pr-lane";
|
|
78
|
+
import { checkVerdictsCovered, type StackCover } from "./verdict-cover";
|
|
76
79
|
|
|
77
80
|
export const STACK_VERSION = 1;
|
|
78
81
|
|
|
@@ -1016,7 +1019,22 @@ export type BranchStatus = {
|
|
|
1016
1019
|
next: NextAction | null;
|
|
1017
1020
|
checks: ChecksState | null;
|
|
1018
1021
|
blockers: string[];
|
|
1019
|
-
|
|
1022
|
+
/**
|
|
1023
|
+
* `accepted`: an accepted independent verdict; `satisfied`: what the land
|
|
1024
|
+
* gate reads, the verdict the PR's review lane asks for (an accepted
|
|
1025
|
+
* independent one outside `verification: proportional`).
|
|
1026
|
+
*/
|
|
1027
|
+
verdict: {
|
|
1028
|
+
accepted: boolean;
|
|
1029
|
+
satisfied: boolean;
|
|
1030
|
+
basis: VerdictBasis;
|
|
1031
|
+
reasons: string[];
|
|
1032
|
+
verdictId: string | null;
|
|
1033
|
+
lane: ReviewLane | null;
|
|
1034
|
+
need: LaneNeed;
|
|
1035
|
+
/** The verdict higher in the stack that covers this branch, when it has none of its own. */
|
|
1036
|
+
cover: StackCover | null;
|
|
1037
|
+
};
|
|
1020
1038
|
};
|
|
1021
1039
|
|
|
1022
1040
|
export type Qualification =
|
|
@@ -1081,16 +1099,29 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
|
|
|
1081
1099
|
return null;
|
|
1082
1100
|
}
|
|
1083
1101
|
|
|
1084
|
-
const verdictOf = (
|
|
1085
|
-
|
|
1102
|
+
const verdictOf = (
|
|
1103
|
+
cwd: string,
|
|
1104
|
+
branch: string,
|
|
1105
|
+
rows: readonly ReadRow[],
|
|
1106
|
+
doc: PrStatusDoc | null,
|
|
1107
|
+
): BranchStatus["verdict"] => {
|
|
1108
|
+
// A lower branch may be covered by the verdict at the top of the stack (#334).
|
|
1109
|
+
const check = checkVerdictsCovered(cwd, branch, rows);
|
|
1110
|
+
// Without a PR there is no base to diff against: the gate stays independent.
|
|
1111
|
+
const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
|
|
1086
1112
|
return {
|
|
1087
1113
|
accepted: check.accepted.accepted,
|
|
1114
|
+
satisfied: gate ? gate.satisfied : check.accepted.accepted,
|
|
1088
1115
|
basis: check.current.basis,
|
|
1089
|
-
reasons: check.accepted.reasons
|
|
1090
|
-
verdictId:
|
|
1091
|
-
|
|
1116
|
+
reasons: check.accepted.reasons,
|
|
1117
|
+
verdictId: gate
|
|
1118
|
+
? gate.verdictId
|
|
1119
|
+
: typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
|
|
1092
1120
|
? check.accepted.verdict.id
|
|
1093
1121
|
: null,
|
|
1122
|
+
lane: gate?.lane.lane ?? null,
|
|
1123
|
+
need: gate?.need ?? "non-author",
|
|
1124
|
+
cover: check.cover,
|
|
1094
1125
|
};
|
|
1095
1126
|
};
|
|
1096
1127
|
|
|
@@ -1161,12 +1192,20 @@ export function qualify(
|
|
|
1161
1192
|
`${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
|
|
1162
1193
|
"a new independent verdict on the head supersedes it",
|
|
1163
1194
|
);
|
|
1164
|
-
if (!(bypass && allowUnverified) && !verdict.
|
|
1195
|
+
if (!(bypass && allowUnverified) && !verdict.satisfied) {
|
|
1196
|
+
const bypassHint = allowUnverified
|
|
1197
|
+
? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"`
|
|
1198
|
+
: "";
|
|
1165
1199
|
return stop(
|
|
1166
1200
|
verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
|
|
1167
|
-
|
|
1168
|
-
|
|
1201
|
+
verdict.need === "self"
|
|
1202
|
+
? `${label} has no verdict for ${doc.head.sha.slice(0, 12)} (lane self: the author's own --self verdict is enough; ${verdict.reasons.join(", ") || "none"})`
|
|
1203
|
+
: `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)}${verdict.lane && verdict.lane !== "none" ? ` (lane ${verdict.lane})` : ""} (${verdict.reasons.join(", ") || "none"})`,
|
|
1204
|
+
verdict.need === "self"
|
|
1205
|
+
? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${entry.branch}${bypassHint}`
|
|
1206
|
+
: `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${bypassHint}`,
|
|
1169
1207
|
);
|
|
1208
|
+
}
|
|
1170
1209
|
if (!grant.allowed)
|
|
1171
1210
|
return stop(
|
|
1172
1211
|
"grant_required",
|
|
@@ -1215,8 +1254,17 @@ export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutco
|
|
|
1215
1254
|
checks: doc?.checks.state ?? null,
|
|
1216
1255
|
blockers: doc?.blockers ?? [],
|
|
1217
1256
|
verdict: merged
|
|
1218
|
-
? {
|
|
1219
|
-
|
|
1257
|
+
? {
|
|
1258
|
+
accepted: true,
|
|
1259
|
+
satisfied: true,
|
|
1260
|
+
basis: "none",
|
|
1261
|
+
reasons: [],
|
|
1262
|
+
verdictId: null,
|
|
1263
|
+
lane: null,
|
|
1264
|
+
need: "non-author",
|
|
1265
|
+
cover: null,
|
|
1266
|
+
}
|
|
1267
|
+
: verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
|
|
1220
1268
|
};
|
|
1221
1269
|
branches.push(row);
|
|
1222
1270
|
if (merged) {
|
|
@@ -1494,7 +1542,7 @@ export function syncStack(
|
|
|
1494
1542
|
return stackFail(
|
|
1495
1543
|
"blocked",
|
|
1496
1544
|
`a rebase is in progress in ${owner}`,
|
|
1497
|
-
`git -C ${owner} rebase --continue # or --abort; then workit --cwd ${owner}
|
|
1545
|
+
`git -C ${owner} rebase --continue # or --abort; then workit stack --cwd ${owner} sync`,
|
|
1498
1546
|
{ conflict: entry.branch, worktree: owner, progress: outcome },
|
|
1499
1547
|
);
|
|
1500
1548
|
if (owner && trackedDirt(owner))
|
|
@@ -1624,7 +1672,11 @@ export function syncStack(
|
|
|
1624
1672
|
};
|
|
1625
1673
|
const after = { p: patchId(cwd, parentTip, newTip), d: diffHash(cwd, parentTip, newTip) };
|
|
1626
1674
|
const equal =
|
|
1627
|
-
before.d !== null &&
|
|
1675
|
+
before.d !== null &&
|
|
1676
|
+
before.d === after.d &&
|
|
1677
|
+
before.p !== null &&
|
|
1678
|
+
before.p === after.p &&
|
|
1679
|
+
sameOwnChange(cwd, { from: fromHead, to: newTip, fromBase, toBase: parentTip });
|
|
1628
1680
|
appendObserved(cwd, {
|
|
1629
1681
|
type: "stack.restacked",
|
|
1630
1682
|
actor,
|
|
@@ -1866,7 +1918,7 @@ export async function landStack(
|
|
|
1866
1918
|
}
|
|
1867
1919
|
}
|
|
1868
1920
|
const grant = requireGrant(ctx.cwd, "merge");
|
|
1869
|
-
const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows());
|
|
1921
|
+
const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows(), doc);
|
|
1870
1922
|
// A dry run predicts the retarget the real run would do first.
|
|
1871
1923
|
const effective =
|
|
1872
1924
|
options.dryRun && simulated.size > 0 && doc ? { ...doc, base: stack.trunk } : doc;
|
|
@@ -1906,7 +1958,7 @@ export async function landStack(
|
|
|
1906
1958
|
deleteBranch: false,
|
|
1907
1959
|
actor: ctx.actor,
|
|
1908
1960
|
expect: { base: stack.trunk, branch: entry.branch },
|
|
1909
|
-
...(bypass && !verdict.
|
|
1961
|
+
...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
|
|
1910
1962
|
},
|
|
1911
1963
|
ctx.sleep,
|
|
1912
1964
|
);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Stack-wide verdicts (#334, decision mvk5q3io): one verifier at the top of
|
|
2
|
+
// a stack, after its last push, covers the lower PRs it judged. A branch with
|
|
3
|
+
// no accepted verdict of its own is covered by a branch above it in a cached
|
|
4
|
+
// stack (`<store>/stacks/*.json`, S12) when:
|
|
5
|
+
//
|
|
6
|
+
// - that branch has an accepted independent verdict now (fresh or carried);
|
|
7
|
+
// - the verdict's session is not an author of the lower branch either;
|
|
8
|
+
// - every commit of the lower branch since its fork from the trunk has a
|
|
9
|
+
// verbatim patch-id (whitespace counts) among the commits of the judged
|
|
10
|
+
// head (the verdict's own head, not the upper branch's current tip): the
|
|
11
|
+
// lower change was judged unchanged. No merge commits, at least one
|
|
12
|
+
// non-empty commit;
|
|
13
|
+
// - the judged head holds exactly the lower branch's resulting files (no
|
|
14
|
+
// `<lower>..<judged>` difference in any file the lower branch changed):
|
|
15
|
+
// an upper commit that reverts or rewrites a lower file means the lower
|
|
16
|
+
// PR's own code was never judged;
|
|
17
|
+
// - the lower branch's own review lane is below full (decision B3): a risk
|
|
18
|
+
// path or a large change needs its own verdict.
|
|
19
|
+
//
|
|
20
|
+
// A lower branch that changed after the verdict (a new commit, an amend, a
|
|
21
|
+
// conflict resolved differently) holds a patch-id the judged head lacks and
|
|
22
|
+
// is not covered. When git cannot read any of this, nothing is covered. A current independent failure or a tampered ledger on the
|
|
23
|
+
// lower branch is never covered over. `pr merge`, `stack land`, `stack
|
|
24
|
+
// status`, `pr status` and `ledger check` read this; the Stop hook does not.
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { branchLane } from "./core/policy/lane";
|
|
28
|
+
import { commitPatchIds, diffRaw, mergeBase } from "./git/rev";
|
|
29
|
+
import {
|
|
30
|
+
authorSessions,
|
|
31
|
+
authoredBy,
|
|
32
|
+
checkVerdicts,
|
|
33
|
+
defaultBase,
|
|
34
|
+
resolveCommit,
|
|
35
|
+
storeRoot,
|
|
36
|
+
type ReadRow,
|
|
37
|
+
type VerdictCheck,
|
|
38
|
+
} from "./ledger";
|
|
39
|
+
|
|
40
|
+
/** The verdict above `branch` in a stack that covers it. */
|
|
41
|
+
export type StackCover = {
|
|
42
|
+
stack: string;
|
|
43
|
+
/** The upper branch the verdict was recorded on. */
|
|
44
|
+
branch: string;
|
|
45
|
+
verdictId: string;
|
|
46
|
+
/** The head that verdict judged. */
|
|
47
|
+
head: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type CoveredCheck = VerdictCheck & { cover: StackCover | null };
|
|
51
|
+
|
|
52
|
+
type Chain = { name: string; trunk: string; above: string[] };
|
|
53
|
+
|
|
54
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
55
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
/** The open branches above `branch` in each cached stack holding it, top first. */
|
|
58
|
+
function chainsAbove(cwd: string, branch: string): Chain[] {
|
|
59
|
+
const root = storeRoot(cwd);
|
|
60
|
+
if (!root.ok) return [];
|
|
61
|
+
const dir = path.join(root.value.root, "stacks");
|
|
62
|
+
let names: string[] = [];
|
|
63
|
+
try {
|
|
64
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const chains: Chain[] = [];
|
|
69
|
+
for (const name of names.toSorted()) {
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.branches) || typeof parsed.trunk !== "string")
|
|
77
|
+
continue;
|
|
78
|
+
const entries = parsed.branches.filter(isRecord);
|
|
79
|
+
const index = entries.findIndex((entry) => entry.branch === branch && !entry.merged);
|
|
80
|
+
if (index < 0) continue;
|
|
81
|
+
chains.push({
|
|
82
|
+
name: typeof parsed.name === "string" ? parsed.name : name,
|
|
83
|
+
trunk: parsed.trunk,
|
|
84
|
+
above: entries
|
|
85
|
+
.slice(index + 1)
|
|
86
|
+
.filter((entry) => typeof entry.branch === "string" && !entry.merged)
|
|
87
|
+
.map((entry) => entry.branch as string)
|
|
88
|
+
.toReversed(),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return chains;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the verdict on `judged` covers the lower branch at `head` (see the
|
|
96
|
+
* header). Anything git cannot read is not covered.
|
|
97
|
+
*/
|
|
98
|
+
function covers(cwd: string, trunk: string, head: string, judged: string): boolean {
|
|
99
|
+
const trunkSha = [`origin/${trunk}`, trunk].map((ref) => resolveCommit(cwd, ref)).find(Boolean);
|
|
100
|
+
const ownFork = trunkSha ? mergeBase(cwd, trunkSha, head) : null;
|
|
101
|
+
const judgedFork = trunkSha ? mergeBase(cwd, trunkSha, judged) : null;
|
|
102
|
+
if (!ownFork || !judgedFork) return false;
|
|
103
|
+
const own = commitPatchIds(cwd, ownFork, head);
|
|
104
|
+
const top = commitPatchIds(cwd, judgedFork, judged);
|
|
105
|
+
if (!own || !top || own.merges > 0 || own.patchIds.length === 0) return false;
|
|
106
|
+
const judgedIds = new Set(top.patchIds);
|
|
107
|
+
if (!own.patchIds.every((id) => judgedIds.has(id))) return false;
|
|
108
|
+
// The judged tree holds exactly the lower branch's resulting files.
|
|
109
|
+
const changed = diffRaw(cwd, ownFork, head);
|
|
110
|
+
const between = changed ? diffRaw(cwd, head, judged) : null;
|
|
111
|
+
if (!changed || !between) return false;
|
|
112
|
+
const files = new Set(changed.map((entry) => entry.path));
|
|
113
|
+
if (between.some((entry) => files.has(entry.path))) return false;
|
|
114
|
+
return branchLane(cwd, { base: trunk, head }).lane !== "full";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `checkVerdicts` for `branch`, and when it has no accepted verdict of its
|
|
119
|
+
* own, the stack verdict that covers it (see the header): then `accepted`
|
|
120
|
+
* names that verdict and `cover` says where it came from.
|
|
121
|
+
*/
|
|
122
|
+
export function checkVerdictsCovered(
|
|
123
|
+
cwd: string,
|
|
124
|
+
branch: string,
|
|
125
|
+
rows: readonly ReadRow[],
|
|
126
|
+
): CoveredCheck {
|
|
127
|
+
const check = checkVerdicts(cwd, branch, rows);
|
|
128
|
+
const blocked = check.accepted.reasons.some(
|
|
129
|
+
(reason) => reason === "failing_verdict" || reason === "ledger_tampered",
|
|
130
|
+
);
|
|
131
|
+
if (check.accepted.accepted || blocked || !check.head) return { ...check, cover: null };
|
|
132
|
+
let authors: Set<string> | null = null;
|
|
133
|
+
for (const chain of chainsAbove(cwd, branch))
|
|
134
|
+
for (const upper of chain.above) {
|
|
135
|
+
const verdict = checkVerdicts(cwd, upper, rows).accepted;
|
|
136
|
+
const row = verdict.accepted ? verdict.verdict : null;
|
|
137
|
+
const session = row?.actor.session;
|
|
138
|
+
if (!row?.head || !session || typeof row.id !== "string") continue;
|
|
139
|
+
authors ??= authorSessions(cwd, rows, branch, { base: defaultBase(cwd), head: check.head });
|
|
140
|
+
if (authoredBy(authors, session)) continue;
|
|
141
|
+
if (!covers(cwd, chain.trunk, check.head, row.head)) continue;
|
|
142
|
+
return {
|
|
143
|
+
...check,
|
|
144
|
+
accepted: { accepted: true, verdict: row, reasons: [] },
|
|
145
|
+
review: "verified",
|
|
146
|
+
selfVerdict: null,
|
|
147
|
+
cover: { stack: chain.name, branch: upper, verdictId: row.id, head: row.head },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ...check, cover: null };
|
|
151
|
+
}
|