@brainervirus/workit-core 7.6.0 → 8.0.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/ledger.ts CHANGED
@@ -130,8 +130,22 @@ export type VerdictRow = RowCommon & {
130
130
  evidenceRefs: string[];
131
131
  };
132
132
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
133
+ /** A lead's standing order for every worker of a fanout ("no new dependencies"). */
134
+ export type StandingRow = RowCommon & { type: "standing"; fanout: string; what: string };
135
+ /** Ends one standing order (`target`), or every one recorded before it (null). */
136
+ export type StandingClearedRow = RowCommon & {
137
+ type: "standing.cleared";
138
+ fanout: string;
139
+ target: string | null;
140
+ };
133
141
 
134
- export type LedgerRow = DecisionRow | RulingRow | VerdictRow | HandoffRow;
142
+ export type LedgerRow =
143
+ | DecisionRow
144
+ | RulingRow
145
+ | VerdictRow
146
+ | HandoffRow
147
+ | StandingRow
148
+ | StandingClearedRow;
135
149
  /** A row as read back: any known or future type, plus its position. */
136
150
  export type ReadRow = RowCommon &
137
151
  Record<string, unknown> & {
@@ -747,6 +761,8 @@ export type RejectReason =
747
761
  | "no_session"
748
762
  | "author_session"
749
763
  | "failing_verdict"
764
+ /** The only passing current verdict is `type-check-only`, which proves no behavior. */
765
+ | "type_check_only"
750
766
  | "no_verdict";
751
767
 
752
768
  export type VerdictEntry = {
@@ -769,7 +785,10 @@ export type VerdictCheck = {
769
785
  patchId: string | null;
770
786
  /** Newest current verdict (else newest verdict): does it still apply? */
771
787
  current: { basis: VerdictBasis; verdict: ReadRow | null };
772
- /** What merge gates read: an accepted verdict and no current independent failure. */
788
+ /**
789
+ * What merge gates read: an accepted strong (`verified`/`tests-verified`)
790
+ * verdict and no current independent failure. `type-check-only` never counts.
791
+ */
773
792
  accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
774
793
  review: ReviewLabel;
775
794
  /** The newest current, strong, self verdict when `review` is self-reviewed. */
@@ -829,10 +848,17 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
829
848
  /**
830
849
  * The effective verdict of one kind, walking rows in order: a self verdict
831
850
  * never displaces an independent one, and an independent failed/blocked
832
- * verdict sticks until an independent verdict from a different session (or
833
- * its own session's supersede) replaces it.
851
+ * verdict sticks, for the code it judged, until an independent verdict from a
852
+ * different session (or its own session's supersede) replaces it. A verdict
853
+ * from the same session on different code (`sameCode` false: a real change,
854
+ * not a carry or restack of the failed code) is a re-review of the fix and
855
+ * replaces it.
834
856
  */
835
- function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow | null {
857
+ function effectiveOf(
858
+ rows: readonly ReadRow[],
859
+ authors: Set<string>,
860
+ sameCode: (failed: ReadRow, row: ReadRow) => boolean,
861
+ ): ReadRow | null {
836
862
  let effective: ReadRow | null = null;
837
863
  for (const row of rows) {
838
864
  if (!effective) {
@@ -845,7 +871,8 @@ function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow |
845
871
  if (
846
872
  effIndependent &&
847
873
  FAILING.has(String(effective.result)) &&
848
- row.actor.session === effective.actor.session
874
+ row.actor.session === effective.actor.session &&
875
+ sameCode(effective, row)
849
876
  )
850
877
  continue;
851
878
  effective = row;
@@ -899,15 +926,24 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
899
926
  const head = branchHead(cwd, branch);
900
927
  const fallbackBase = defaultBase(cwd);
901
928
  const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
902
- const keyFor = (base: string | null) => {
903
- if (!base || !head) return { patchId: null, diffHash: null };
904
- let key = keyCache.get(base);
929
+ const keysAt = (base: string | null, at: string | null) => {
930
+ if (!base || !at) return { patchId: null, diffHash: null };
931
+ const cacheKey = `${base}\0${at}`;
932
+ let key = keyCache.get(cacheKey);
905
933
  if (!key) {
906
- key = { patchId: patchId(cwd, base, head), diffHash: diffHash(cwd, base, head) };
907
- keyCache.set(base, key);
934
+ key = { patchId: patchId(cwd, base, at), diffHash: diffHash(cwd, base, at) };
935
+ keyCache.set(cacheKey, key);
908
936
  }
909
937
  return key;
910
938
  };
939
+ const keyFor = (base: string | null) => keysAt(base, head);
940
+ // 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
+ const sameCode = (failed: ReadRow, row: ReadRow): boolean =>
943
+ verdictBasis(failed, {
944
+ head: row.head,
945
+ ...keysAt(failed.base ?? fallbackBase, row.head),
946
+ }) !== "stale" || restackCarries(rows, branch, failed.head, row.head);
911
947
  const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
912
948
  const byKind = new Map<string, ReadRow[]>();
913
949
  for (const row of rows)
@@ -917,7 +953,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
917
953
  }
918
954
  const verdicts: VerdictEntry[] = [];
919
955
  for (const [kind, list] of byKind) {
920
- const row = effectiveOf(list, authors);
956
+ const row = effectiveOf(list, authors, sameCode);
921
957
  if (!row) continue;
922
958
  let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
923
959
  if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
@@ -943,7 +979,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
943
979
  const failing = verdicts.find(
944
980
  (entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
945
981
  );
946
- const newestAccepted = verdicts.findLast((entry) => entry.accepted) ?? null;
982
+ const newestAccepted =
983
+ verdicts.findLast(
984
+ (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
985
+ ) ?? null;
947
986
  const accepted =
948
987
  newestAccepted && !failing
949
988
  ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
@@ -952,7 +991,11 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
952
991
  verdict: failing?.verdict ?? null,
953
992
  reasons: failing
954
993
  ? (["failing_verdict"] as RejectReason[])
955
- : (newestCurrent?.reasons ?? (["no_verdict"] as RejectReason[])),
994
+ : !newestCurrent
995
+ ? (["no_verdict"] as RejectReason[])
996
+ : newestCurrent.reasons.length
997
+ ? newestCurrent.reasons
998
+ : (["type_check_only"] as RejectReason[]),
956
999
  };
957
1000
  const selfEntry = failing
958
1001
  ? undefined
@@ -1076,6 +1119,142 @@ export function recordRuling(
1076
1119
  );
1077
1120
  }
1078
1121
 
1122
+ /**
1123
+ * May this row speak for the fanout's lead? With recorded leads (the current
1124
+ * one and any it took over from) only their rows count; a plan from before
1125
+ * leads (null) takes any session.
1126
+ */
1127
+ const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
1128
+ leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
1129
+
1130
+ /**
1131
+ * The standing orders in force for `fanout`, oldest first (D17: odd rows are
1132
+ * skipped). Given the plan's lead session, orders and clears recorded by any
1133
+ * other session are ignored, so a worker can neither add nor clear one.
1134
+ */
1135
+ export function activeStanding(
1136
+ rows: readonly ReadRow[],
1137
+ fanout: string,
1138
+ leads: readonly string[] | null = null,
1139
+ ): ReadRow[] {
1140
+ const active: ReadRow[] = [];
1141
+ for (const row of rows) {
1142
+ if (row.fanout !== fanout || !fromLead(row, leads)) continue;
1143
+ if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
1144
+ if (!row.superseded) active.push(row);
1145
+ } else if (row.type === "standing.cleared") {
1146
+ const target = str(row.target);
1147
+ if (target === null) active.length = 0;
1148
+ else {
1149
+ const index = active.findIndex((order) => order.id === target);
1150
+ if (index >= 0) active.splice(index, 1);
1151
+ }
1152
+ }
1153
+ }
1154
+ return active;
1155
+ }
1156
+
1157
+ /**
1158
+ * A worker or verifier session by the ids workit hands out: `fanout brief`'s
1159
+ * `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
1160
+ * hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
1161
+ * session ids carry no colon. Used where no recorded lead decides: plans that
1162
+ * predate `leadSession`, and `fanout plan --take-lead`.
1163
+ */
1164
+ export const isDelegateSession = (session: string | null): boolean =>
1165
+ session !== null &&
1166
+ (/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
1167
+ /-v\d+$/u.test(session) ||
1168
+ session.includes(":"));
1169
+
1170
+ /**
1171
+ * Standing orders come from the fanout's lead: the session that first made
1172
+ * the plan (`lead`). A plan without one falls back to refusing the session
1173
+ * ids handed to workers and verifiers.
1174
+ */
1175
+ function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
1176
+ const session = context.actor.session;
1177
+ if (lead !== null ? session === lead : !isDelegateSession(session))
1178
+ return { ok: true, value: undefined };
1179
+ return err(
1180
+ "blocked",
1181
+ lead !== null
1182
+ ? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
1183
+ : `session ${session} is a worker or verifier: standing orders come from the lead`,
1184
+ lead !== null
1185
+ ? `the lead in a new session? workit fanout plan <plan.json> --take-lead from this session; a worker or verifier reports the order to the lead instead`
1186
+ : "a worker or verifier reports the order to the lead instead",
1187
+ );
1188
+ }
1189
+
1190
+ export function recordStanding(
1191
+ context: RecordContext,
1192
+ input: { fanout: string; lead: string | null; what?: string },
1193
+ ): LedgerResult<StandingRow> {
1194
+ const what = required(input.what, "<order>");
1195
+ if (!what.ok) return what;
1196
+ // One order per line of every brief: a newline or control character would
1197
+ // let an order forge other brief fields.
1198
+ if (/\p{Cc}/u.test(what.value))
1199
+ return err(
1200
+ "invalid_input",
1201
+ "a standing order is one line: no newlines or control characters",
1202
+ 'add each order separately: workit ledger standing add "<order>"',
1203
+ );
1204
+ const lead = leadOnly(context, input.lead);
1205
+ if (!lead.ok) return lead;
1206
+ const link = checkSupersede(context, "standing");
1207
+ if (!link.ok) return link;
1208
+ return appendRow<StandingRow>(
1209
+ context.cwd,
1210
+ {
1211
+ ...common(context, noteKey(context)),
1212
+ type: "standing",
1213
+ fanout: input.fanout,
1214
+ what: what.value,
1215
+ },
1216
+ { now: context.now },
1217
+ );
1218
+ }
1219
+
1220
+ /** Clear one standing order by id (it must be in force), or all of them; lead only. */
1221
+ export function clearStanding(
1222
+ context: RecordContext,
1223
+ input: {
1224
+ fanout: string;
1225
+ lead: string | null;
1226
+ leads?: readonly string[] | null;
1227
+ target: string | null;
1228
+ },
1229
+ ): LedgerResult<StandingClearedRow> {
1230
+ const lead = leadOnly(context, input.lead);
1231
+ if (!lead.ok) return lead;
1232
+ if (input.target !== null) {
1233
+ const ledger = readLedger(context.cwd);
1234
+ if (!ledger.ok) return ledger;
1235
+ if (
1236
+ !activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
1237
+ (row) => row.id === input.target,
1238
+ )
1239
+ )
1240
+ return err(
1241
+ "not_found",
1242
+ `no standing order ${input.target} in force for fanout ${input.fanout}`,
1243
+ `workit ledger standing list --fanout ${input.fanout}`,
1244
+ );
1245
+ }
1246
+ return appendRow<StandingClearedRow>(
1247
+ context.cwd,
1248
+ {
1249
+ ...common(context, noteKey(context)),
1250
+ type: "standing.cleared",
1251
+ fanout: input.fanout,
1252
+ target: input.target,
1253
+ },
1254
+ { now: context.now },
1255
+ );
1256
+ }
1257
+
1079
1258
  /**
1080
1259
  * Record an agent-asserted verdict on a branch's committed head. Refused on a
1081
1260
  * dirty worktree (the head would not be what was judged) and, for an author
@@ -1194,7 +1373,7 @@ export type RowSummary = {
1194
1373
  };
1195
1374
 
1196
1375
  /** Row types that count only when an observing verb wrote them. */
1197
- const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", ...PR_ROW_TYPES]);
1376
+ const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", "merge.unverified", ...PR_ROW_TYPES]);
1198
1377
 
1199
1378
  export function rowLabels(row: ReadRow): RowSummary["labels"] {
1200
1379
  const labels: RowSummary["labels"] = [];
@@ -1221,6 +1400,12 @@ export function summarizeRow(row: ReadRow): RowSummary {
1221
1400
  case "handoff":
1222
1401
  summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
1223
1402
  break;
1403
+ case "standing":
1404
+ summary = `${text("what")} (fanout ${text("fanout")})`;
1405
+ break;
1406
+ case "standing.cleared":
1407
+ summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
1408
+ break;
1224
1409
  case "commit.recorded":
1225
1410
  summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
1226
1411
  break;
@@ -1234,6 +1419,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
1234
1419
  case "pr.merged":
1235
1420
  summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
1236
1421
  break;
1422
+ case "merge.unverified":
1423
+ summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
1424
+ break;
1237
1425
  case "delivery.verified":
1238
1426
  summary = `${text("expect")} delivered at ${(row.head ?? "?").slice(0, 12)}`;
1239
1427
  break;
package/src/stack.ts CHANGED
@@ -996,6 +996,7 @@ export type StopReason =
996
996
  | "not_ready"
997
997
  | "no_verdict"
998
998
  | "needs_verdict"
999
+ | "failed_verdict"
999
1000
  | "grant_required"
1000
1001
  | "max_reached"
1001
1002
  | "merge_refused"
@@ -1096,8 +1097,9 @@ const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
1096
1097
  /**
1097
1098
  * Does the root-most open PR qualify to land? Order: a PR exists and is
1098
1099
  * open, it targets the trunk, the forge says READY, an accepted verdict
1099
- * covers the head (unless the grant is `merge: true`), and the grant allows
1100
- * merging. `ready` says everything but the grant holds ("verified, ready").
1100
+ * covers the head (unless `bypass`: `--unverified` under `merge: true`), and
1101
+ * the grant allows merging. `ready` says everything but the grant holds
1102
+ * ("verified, ready").
1101
1103
  */
1102
1104
  export function qualify(
1103
1105
  ctx: Pick<Ctx, "cwd" | "resolved">,
@@ -1108,6 +1110,8 @@ export function qualify(
1108
1110
  grant: GrantDecision,
1109
1111
  /** Why the PR number no longer belongs to this branch (null: it does). */
1110
1112
  binding: string | null = null,
1113
+ /** `stack land --unverified`: skip the verdict where the grant allows it. */
1114
+ bypass = false,
1111
1115
  ): Qualification {
1112
1116
  const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
1113
1117
  const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
@@ -1149,12 +1153,19 @@ export function qualify(
1149
1153
  ? `workit ci wait --pr ${entry.pr}`
1150
1154
  : `workit pr status --pr ${entry.pr}`,
1151
1155
  );
1152
- const requireVerdict = grant.allowed ? grant.requireVerdict : true;
1153
- if (requireVerdict && !verdict.accepted)
1156
+ const allowUnverified = grant.allowed && grant.allowUnverified;
1157
+ // The bypass covers a missing verdict, never a rejection.
1158
+ if (bypass && allowUnverified && verdict.reasons.includes("failing_verdict"))
1159
+ return stop(
1160
+ "failed_verdict",
1161
+ `${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
1162
+ "a new independent verdict on the head supersedes it",
1163
+ );
1164
+ if (!(bypass && allowUnverified) && !verdict.accepted)
1154
1165
  return stop(
1155
1166
  verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
1156
1167
  `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
1157
- `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}`,
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>"` : ""}`,
1158
1169
  );
1159
1170
  if (!grant.allowed)
1160
1171
  return stop(
@@ -1701,6 +1712,8 @@ export type LandOptions = {
1701
1712
  dryRun: boolean;
1702
1713
  max: number | null;
1703
1714
  method: MergeMethod;
1715
+ /** Land without verdicts (`merge: true` only); each bypass is recorded. */
1716
+ unverified?: { reason: string } | null;
1704
1717
  /** How long to wait for CI on a PR that was just restacked. */
1705
1718
  timeoutMs: number;
1706
1719
  intervalMs: number;
@@ -1709,7 +1722,8 @@ export type LandOptions = {
1709
1722
  export type LandOutcome = {
1710
1723
  name: string;
1711
1724
  dryRun: boolean;
1712
- landed: Array<{ pr: number; branch: string; mergeSha: string | null }>;
1725
+ /** `unverified`: the ledger row of an `--unverified` bypass, else null. */
1726
+ landed: Array<{ pr: number; branch: string; mergeSha: string | null; unverified: string | null }>;
1713
1727
  /** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
1714
1728
  wouldLand: Array<{ pr: number; branch: string }>;
1715
1729
  stoppedAt: {
@@ -1778,6 +1792,23 @@ export async function landStack(
1778
1792
  });
1779
1793
  const mismatch = repoMismatch(ctx, stack);
1780
1794
  if (mismatch) return failWith(mismatch);
1795
+ const bypass = options.unverified ?? null;
1796
+ if (bypass) {
1797
+ const grant = requireGrant(ctx.cwd, "merge");
1798
+ if (!grant.allowed)
1799
+ return failWith(
1800
+ stackFail("blocked", grant.error, grant.unblock, { reason: "grant_required" }),
1801
+ );
1802
+ if (!grant.allowUnverified)
1803
+ return failWith(
1804
+ stackFail(
1805
+ "blocked",
1806
+ `unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never lands without an accepted independent verdict`,
1807
+ "a non-author session verifies each head (workit-review), then land without --unverified",
1808
+ { reason: "unverified_refused" },
1809
+ ),
1810
+ );
1811
+ }
1781
1812
 
1782
1813
  // Catch up first: a parent merged elsewhere leaves the rest to restack.
1783
1814
  if (!options.dryRun) {
@@ -1847,6 +1878,7 @@ export async function landStack(
1847
1878
  verdict,
1848
1879
  grant,
1849
1880
  doc ? prBinding(ctx, entry, doc) : null,
1881
+ bypass !== null,
1850
1882
  );
1851
1883
  if (!q.ok) {
1852
1884
  outcome.stoppedAt = {
@@ -1874,6 +1906,7 @@ export async function landStack(
1874
1906
  deleteBranch: false,
1875
1907
  actor: ctx.actor,
1876
1908
  expect: { base: stack.trunk, branch: entry.branch },
1909
+ ...(bypass && !verdict.accepted ? { unverified: bypass } : {}),
1877
1910
  },
1878
1911
  ctx.sleep,
1879
1912
  );
@@ -1891,7 +1924,12 @@ export async function landStack(
1891
1924
  }
1892
1925
  return failWith(stackFail(merged.code, merged.error, merged.unblock));
1893
1926
  }
1894
- outcome.landed.push({ pr, branch: entry.branch, mergeSha: merged.data.mergeSha });
1927
+ outcome.landed.push({
1928
+ pr,
1929
+ branch: entry.branch,
1930
+ mergeSha: merged.data.mergeSha,
1931
+ unverified: merged.data.unverified?.recorded ?? null,
1932
+ });
1895
1933
  entry.merged = { pr, mergeSha: merged.data.mergeSha, at: new Date().toISOString() };
1896
1934
  const written = writeStack(ctx.cwd, stack);
1897
1935
  if (!written.ok) return failWith(written);