@brainervirus/workit-core 8.23.0 → 8.24.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/src/hooks/stop.ts CHANGED
@@ -11,11 +11,15 @@
11
11
  // or merged;
12
12
  // (c) verdict: the session opened the PR (or verified delivery) but the
13
13
  // head lacks the verdict the policy asks for, while the endpoint is
14
- // green or merged (a PR opens without one): the author's own `--self`
15
- // verdict at normal risk under `verification: self`, an accepted
16
- // non-author verdict at high risk or under `verification: independent`.
17
- // Not raised when the task's latest judgment records no behavior
18
- // change at normal risk (policy/derive.ts asks for none either), nor while a verifier or reviewer this lead started still runs.
14
+ // green or merged (a PR opens without one). Under `verification:
15
+ // proportional` (the default) the review lane of the head decides
16
+ // (core/policy/lane.ts): none owes nothing, self the author's own
17
+ // `--self` verdict, focused and full an accepted non-author verdict.
18
+ // Under `self`: the author's own at normal risk; at high risk or under
19
+ // `independent`: a non-author one, and not raised when the task's
20
+ // latest judgment records no behavior change at normal risk
21
+ // (policy/derive.ts asks for none either). Never raised while a
22
+ // verifier or reviewer this lead started still runs.
19
23
  // Each obligation blocks once per head and session (a marker keyed by kind
20
24
  // and head), not once per turn.
21
25
  // A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
@@ -29,10 +33,9 @@
29
33
  // the ledger and local git only (no forge call), and every failure allows
30
34
  // the stop.
31
35
  import { spawnSync } from "node:child_process";
32
- import { resolveAutonomy } from "../autonomy";
33
- import { latestJudgment } from "../core/policy/derive";
34
- import type { Judgment } from "../core/task-contract";
35
- import { TaskStore } from "../core/task-store";
36
+ import { resolveAutonomy, type VerificationMode } from "../autonomy";
37
+ import { currentJudgment } from "../core/policy/current-judgment";
38
+ import { branchLane, laneNeed } from "../core/policy/lane";
36
39
  import { currentBranch, pushRemoteName } from "../git/rev";
37
40
  import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
38
41
  import type { HostDescriptor } from "./descriptor";
@@ -237,43 +240,57 @@ const redChecks = (
237
240
  };
238
241
  };
239
242
 
240
- /** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. */
241
- const taskJudgment = (cwd: string): Judgment | null => {
242
- try {
243
- const store = new TaskStore(cwd);
244
- const listed = store.listTaskIndex();
245
- const key = store.currentKey();
246
- if (!listed.ok || !key.ok) return null;
247
- const entry = listed.data
248
- .filter((item) => item.key === key.data.key && item.status !== "closed")
249
- .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
250
- const task = entry ? store.readTask(entry.id) : null;
251
- return task?.ok === true ? latestJudgment(task.data) : null;
252
- } catch {
253
- return null;
254
- }
243
+ /**
244
+ * The base the branch's PR targets: the newest the forge reported (a
245
+ * `pr.status` row, so a PR retargeted on the forge is read against its
246
+ * current base), else the one it was opened with (`pr.created`), else null
247
+ * (the default base). Read from the ledger: the hook never calls the forge.
248
+ */
249
+ const prBase = (rows: readonly ReadRow[]): string | null => {
250
+ const seen = rows.findLast(
251
+ (row) =>
252
+ (row.type === "pr.status" || row.type === "pr.created") &&
253
+ row.observer === "workit_cli" &&
254
+ typeof row.base === "string",
255
+ );
256
+ return typeof seen?.base === "string" ? seen.base : null;
255
257
  };
256
258
 
257
259
  /**
258
260
  * (c) Delivered by this session, yet the head lacks the verdict the policy
259
- * asks for: the author's own at normal risk when the workspace's
260
- * verification is `self`, else an accepted non-author one.
261
+ * asks for. Under `verification: proportional` the review lane of the head
262
+ * decides (core/policy/lane.ts): none owes nothing, self the author's own,
263
+ * focused and full a non-author one. Under `self`, the author's own at
264
+ * normal risk; under `independent` or at high risk, a non-author one.
261
265
  */
262
266
  const unverified = (
263
267
  cwd: string,
264
268
  branch: string,
265
269
  all: readonly ReadRow[],
266
270
  descriptor: HostDescriptor,
267
- verification: "self" | "independent",
271
+ verification: VerificationMode,
272
+ head: string | null,
268
273
  ): StopObligation | null => {
269
274
  const check = checkVerdicts(cwd, branch, all);
270
275
  if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
271
- const judgment = taskJudgment(cwd);
276
+ const judgment = currentJudgment(cwd);
272
277
  const high = judgment?.riskTier === "high";
273
- // Like policy/derive.ts: a verdict is asked for a behavior change or high
274
- // risk. A recorded judgment with neither (docs only) owes none.
275
- if (judgment && !judgment.behaviorChange && !high) return null;
276
- const independent = verification === "independent" || high;
278
+ let independent: boolean;
279
+ if (verification === "proportional") {
280
+ const lane = branchLane(cwd, {
281
+ base: prBase(all.filter((row) => row.branch === branch)),
282
+ head,
283
+ judgment,
284
+ });
285
+ const need = laneNeed(lane.lane);
286
+ if (need === "none") return null;
287
+ independent = need === "non-author";
288
+ } else {
289
+ // Like policy/derive.ts: a verdict is asked for a behavior change or high
290
+ // risk. A recorded judgment with neither (docs only) owes none.
291
+ if (judgment && !judgment.behaviorChange && !high) return null;
292
+ independent = verification === "independent" || high;
293
+ }
277
294
  if (!independent && check.review === "self-reviewed") return null;
278
295
  const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
279
296
  const verifier = `${descriptor.subagents.agentPrefix}verifier`;
@@ -332,7 +349,7 @@ export function stopObligation(
332
349
  }
333
350
  // A PR opens without a verdict: only green and merged need one.
334
351
  if (!delivered || (endpoint !== "green" && endpoint !== "merged")) return null;
335
- const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification);
352
+ const left = unverified(input.cwd, branch, all, descriptor, autonomy.verification, head);
336
353
  return left ? { ...left, head } : null;
337
354
  } catch {
338
355
  return null;
package/src/ledger.ts CHANGED
@@ -1622,15 +1622,20 @@ export function checkVerdicts(
1622
1622
  ? newestCurrent.reasons
1623
1623
  : (["type_check_only"] as RejectReason[]),
1624
1624
  };
1625
- const selfEntry = failing
1625
+ // The author's latest pass or failure on the current code wins, across
1626
+ // kinds: a newer failure cancels an older pass (review finding mvkcoxgb).
1627
+ const latestSelf = failing
1626
1628
  ? undefined
1627
1629
  : verdicts.findLast(
1628
1630
  (entry) =>
1629
1631
  entry.current &&
1630
1632
  !entry.independent &&
1631
1633
  !untrusted(entry.reasons) &&
1632
- STRONG_RESULTS.has(String(entry.verdict.result)),
1634
+ (STRONG_RESULTS.has(String(entry.verdict.result)) ||
1635
+ FAILING.has(String(entry.verdict.result))),
1633
1636
  );
1637
+ const selfEntry =
1638
+ latestSelf && STRONG_RESULTS.has(String(latestSelf.verdict.result)) ? latestSelf : undefined;
1634
1639
  return {
1635
1640
  branch,
1636
1641
  head,
package/src/stack.ts CHANGED
@@ -73,6 +73,8 @@ 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";
76
78
 
77
79
  export const STACK_VERSION = 1;
78
80
 
@@ -1016,7 +1018,20 @@ export type BranchStatus = {
1016
1018
  next: NextAction | null;
1017
1019
  checks: ChecksState | null;
1018
1020
  blockers: string[];
1019
- verdict: { accepted: boolean; basis: VerdictBasis; reasons: string[]; verdictId: string | null };
1021
+ /**
1022
+ * `accepted`: an accepted independent verdict; `satisfied`: what the land
1023
+ * gate reads, the verdict the PR's review lane asks for (an accepted
1024
+ * independent one outside `verification: proportional`).
1025
+ */
1026
+ verdict: {
1027
+ accepted: boolean;
1028
+ satisfied: boolean;
1029
+ basis: VerdictBasis;
1030
+ reasons: string[];
1031
+ verdictId: string | null;
1032
+ lane: ReviewLane | null;
1033
+ need: LaneNeed;
1034
+ };
1020
1035
  };
1021
1036
 
1022
1037
  export type Qualification =
@@ -1081,16 +1096,27 @@ export function repoMismatch(ctx: Pick<Ctx, "resolved">, stack: StackFile): Stac
1081
1096
  return null;
1082
1097
  }
1083
1098
 
1084
- const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
1099
+ const verdictOf = (
1100
+ cwd: string,
1101
+ branch: string,
1102
+ rows: readonly ReadRow[],
1103
+ doc: PrStatusDoc | null,
1104
+ ): BranchStatus["verdict"] => {
1085
1105
  const check = checkVerdicts(cwd, branch, rows);
1106
+ // Without a PR there is no base to diff against: the gate stays independent.
1107
+ const gate = doc ? prLaneGate(cwd, check, { branch, base: doc.base, head: doc.head.sha }) : null;
1086
1108
  return {
1087
1109
  accepted: check.accepted.accepted,
1110
+ satisfied: gate ? gate.satisfied : check.accepted.accepted,
1088
1111
  basis: check.current.basis,
1089
- reasons: check.accepted.reasons as string[],
1090
- verdictId:
1091
- typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
1112
+ reasons: check.accepted.reasons,
1113
+ verdictId: gate
1114
+ ? gate.verdictId
1115
+ : typeof check.accepted.verdict?.id === "string" && check.accepted.accepted
1092
1116
  ? check.accepted.verdict.id
1093
1117
  : null,
1118
+ lane: gate?.lane.lane ?? null,
1119
+ need: gate?.need ?? "non-author",
1094
1120
  };
1095
1121
  };
1096
1122
 
@@ -1161,12 +1187,20 @@ export function qualify(
1161
1187
  `${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
1162
1188
  "a new independent verdict on the head supersedes it",
1163
1189
  );
1164
- if (!(bypass && allowUnverified) && !verdict.accepted)
1190
+ if (!(bypass && allowUnverified) && !verdict.satisfied) {
1191
+ const bypassHint = allowUnverified
1192
+ ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"`
1193
+ : "";
1165
1194
  return stop(
1166
1195
  verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
1167
- `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
1168
- `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${allowUnverified ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"` : ""}`,
1196
+ verdict.need === "self"
1197
+ ? `${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"})`
1198
+ : `${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"})`,
1199
+ verdict.need === "self"
1200
+ ? `after an observed passing \`workit check test\`, the author runs: workit ledger verdict tests-verified --self --how "<what you ran>" --branch ${entry.branch}${bypassHint}`
1201
+ : `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${bypassHint}`,
1169
1202
  );
1203
+ }
1170
1204
  if (!grant.allowed)
1171
1205
  return stop(
1172
1206
  "grant_required",
@@ -1215,8 +1249,16 @@ export function stackStatus(ctx: Ctx, stack: StackFile): StackResult<StatusOutco
1215
1249
  checks: doc?.checks.state ?? null,
1216
1250
  blockers: doc?.blockers ?? [],
1217
1251
  verdict: merged
1218
- ? { accepted: true, basis: "none", reasons: [], verdictId: null }
1219
- : verdictOf(ctx.cwd, entry.branch, ledger.value.rows),
1252
+ ? {
1253
+ accepted: true,
1254
+ satisfied: true,
1255
+ basis: "none",
1256
+ reasons: [],
1257
+ verdictId: null,
1258
+ lane: null,
1259
+ need: "non-author",
1260
+ }
1261
+ : verdictOf(ctx.cwd, entry.branch, ledger.value.rows, doc),
1220
1262
  };
1221
1263
  branches.push(row);
1222
1264
  if (merged) {
@@ -1866,7 +1908,7 @@ export async function landStack(
1866
1908
  }
1867
1909
  }
1868
1910
  const grant = requireGrant(ctx.cwd, "merge");
1869
- const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows());
1911
+ const verdict = verdictOf(ctx.cwd, entry.branch, ledgerRows(), doc);
1870
1912
  // A dry run predicts the retarget the real run would do first.
1871
1913
  const effective =
1872
1914
  options.dryRun && simulated.size > 0 && doc ? { ...doc, base: stack.trunk } : doc;
@@ -1906,7 +1948,7 @@ export async function landStack(
1906
1948
  deleteBranch: false,
1907
1949
  actor: ctx.actor,
1908
1950
  expect: { base: stack.trunk, branch: entry.branch },
1909
- ...(bypass && !verdict.accepted ? { unverified: bypass } : {}),
1951
+ ...(bypass && !verdict.satisfied ? { unverified: bypass } : {}),
1910
1952
  },
1911
1953
  ctx.sleep,
1912
1954
  );