@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/fanout.ts CHANGED
@@ -18,12 +18,33 @@
18
18
  // deterministic suggestion: an owner for known shared files (lockfiles,
19
19
  // manifests, barrels, CI config), else a dependency that serializes them.
20
20
  //
21
+ // Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
22
+ // scope, owns, branch, base, dependencies), not how it is briefed (tier,
23
+ // timebox, context) or where its worktree goes. A re-plan that changes a
24
+ // slice starts a new run for that slice alone: its `hashSince` moves to now,
25
+ // and only ledger rows recorded under its hash (worktree rows carry
26
+ // `sliceHash`) or since then link a gone branch's merged PR to it or count as
27
+ // its worktree. Liveness still reads every row. Its siblings keep their runs.
28
+ //
29
+ // `fanIn: "integration"` is the one-PR mode: the trunk is an integration
30
+ // branch (never origin's default branch), workers merge its tip into their
31
+ // branch before reporting, and the lead fast-forwards it.
32
+ //
21
33
  // The CLI never spawns, waits or wakes anything: it only records and checks.
22
34
  import { spawnSync } from "node:child_process";
35
+ import { createHash } from "node:crypto";
23
36
  import fs from "node:fs";
24
37
  import path from "node:path";
25
38
  import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
26
- import { appendObserved, defaultBase, storeRoot, type LedgerActor } from "./ledger";
39
+ import {
40
+ activeStanding,
41
+ appendObserved,
42
+ isDelegateSession,
43
+ defaultBase,
44
+ readLedger,
45
+ storeRoot,
46
+ type LedgerActor,
47
+ } from "./ledger";
27
48
  import { stackFileName } from "./stack";
28
49
 
29
50
  export const FANOUT_VERSION = 1;
@@ -48,15 +69,28 @@ export type Slice = {
48
69
  forbidden: string[];
49
70
  context: string | null;
50
71
  timebox: string | null;
72
+ /** sliceHash of this definition: which run of the slice a ledger row belongs to. */
73
+ hash: string;
74
+ /** When a slice with this hash was first planned: older rows are not its run. */
75
+ hashSince: string;
51
76
  };
52
77
 
78
+ export const FAN_IN_MODES = ["prs", "integration"] as const;
79
+ export type FanInMode = (typeof FAN_IN_MODES)[number];
80
+
53
81
  export type FanoutFile = {
54
82
  v: number;
55
83
  name: string;
56
84
  trunk: string;
85
+ /** prs: one PR per slice (default); integration: slices fast-forward one integration branch. */
86
+ fanIn: FanInMode;
57
87
  /** Extra shared-file globs on top of DEFAULT_SHARED. */
58
88
  shared: string[];
59
89
  slices: Slice[];
90
+ /** The lead: the session that made the plan or took it over (`--take-lead`). */
91
+ leadSession: string | null;
92
+ /** Leads it took over from: their standing orders stay in force until cleared. */
93
+ formerLeads: string[];
60
94
  createdAt: string;
61
95
  updatedAt: string;
62
96
  };
@@ -308,6 +342,9 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
308
342
  if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
309
343
  else shared.push(normalized.pattern);
310
344
  }
345
+ const fanIn = raw.fanIn ?? "prs";
346
+ if (!(FAN_IN_MODES as readonly unknown[]).includes(fanIn))
347
+ problems.push(`fanIn must be one of ${FAN_IN_MODES.join(", ")}`);
311
348
  if (!Array.isArray(raw.slices) || raw.slices.length === 0)
312
349
  return { ok: false, problems: [...problems, "slices: list at least one slice"] };
313
350
 
@@ -388,6 +425,8 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
388
425
  forbidden,
389
426
  context: optional("context"),
390
427
  timebox: optional("timebox"),
428
+ hash: "",
429
+ hashSince: "",
391
430
  });
392
431
  });
393
432
 
@@ -419,8 +458,11 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
419
458
  v: FANOUT_VERSION,
420
459
  name: planName,
421
460
  trunk,
461
+ fanIn: fanIn as FanInMode,
422
462
  shared,
423
463
  slices,
464
+ leadSession: null,
465
+ formerLeads: [],
424
466
  createdAt: "",
425
467
  updatedAt: "",
426
468
  },
@@ -728,6 +770,29 @@ export function worktreeRoot(cwd: string): string {
728
770
  return path.join(path.dirname(main), `${path.basename(main)}-wt`);
729
771
  }
730
772
 
773
+ /** Whose standing orders count: the lead and those it took over from; null before leads. */
774
+ export const planLeads = (
775
+ plan: Pick<FanoutFile, "leadSession" | "formerLeads">,
776
+ ): string[] | null => (plan.leadSession ? [plan.leadSession, ...plan.formerLeads] : null);
777
+
778
+ /**
779
+ * sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
780
+ * timebox, context) and the worktree path are left out, so fixing a typo in
781
+ * them never re-dispatches landed work.
782
+ */
783
+ export function sliceHash(slice: Slice): string {
784
+ const {
785
+ worktree: _worktree,
786
+ hash: _hash,
787
+ hashSince: _since,
788
+ tier: _tier,
789
+ timebox: _timebox,
790
+ context: _context,
791
+ ...definition
792
+ } = slice;
793
+ return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
794
+ }
795
+
731
796
  // ---------------------------------------------------------------------------
732
797
  // store
733
798
 
@@ -777,8 +842,12 @@ function parseStored(raw: unknown): FanoutFile | null {
777
842
  forbidden: list(item.forbidden),
778
843
  context: typeof item.context === "string" ? item.context : null,
779
844
  timebox: typeof item.timebox === "string" ? item.timebox : null,
845
+ hash: text(item.hash),
846
+ // A plan written before slice hashes: its run began at createdAt.
847
+ hashSince: text(item.hashSince) || text(raw.createdAt),
780
848
  });
781
849
  }
850
+ for (const slice of slices) slice.hash ||= sliceHash(slice);
782
851
  const shared = globs(raw.shared);
783
852
  const ids = new Set(slices.map((slice) => slice.id));
784
853
  if (
@@ -788,13 +857,18 @@ function parseStored(raw: unknown): FanoutFile | null {
788
857
  findCycle(slices)
789
858
  )
790
859
  return null;
860
+ const fanIn = raw.fanIn === "integration" ? "integration" : "prs";
861
+ const createdAt = text(raw.createdAt);
791
862
  return {
792
863
  v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
793
864
  name: raw.name,
794
865
  trunk: raw.trunk,
866
+ fanIn,
795
867
  shared,
796
868
  slices,
797
- createdAt: text(raw.createdAt),
869
+ leadSession: typeof raw.leadSession === "string" && raw.leadSession ? raw.leadSession : null,
870
+ formerLeads: list(raw.formerLeads),
871
+ createdAt,
798
872
  updatedAt: text(raw.updatedAt),
799
873
  };
800
874
  }
@@ -890,6 +964,7 @@ function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
890
964
  export type PlanOutcome = {
891
965
  name: string;
892
966
  trunk: string;
967
+ fanIn: FanInMode;
893
968
  file: string;
894
969
  created: boolean;
895
970
  slices: Array<
@@ -899,6 +974,8 @@ export type PlanOutcome = {
899
974
  waves: string[][];
900
975
  landingOrder: string[];
901
976
  notes: string[];
977
+ /** A new plan under a name that still has standing orders in force (an earlier run's?). */
978
+ warnings: string[];
902
979
  };
903
980
 
904
981
  /** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
@@ -906,11 +983,31 @@ export const IN_FLIGHT_CAP = 6;
906
983
 
907
984
  export function planFanout(
908
985
  cwd: string,
909
- input: { raw: unknown; name: string | null; trunk: string | null; actor: LedgerActor },
986
+ input: {
987
+ raw: unknown;
988
+ name: string | null;
989
+ trunk: string | null;
990
+ actor: LedgerActor;
991
+ /** Record this session as the lead (a lead resuming in a new session). */
992
+ takeLead?: boolean;
993
+ },
910
994
  now: Date = new Date(),
911
995
  ): FanoutResult<PlanOutcome> {
912
996
  const dir = fanoutsDir(cwd);
913
997
  if (!dir.ok) return dir;
998
+ const session = input.actor.session;
999
+ if (input.takeLead && !session)
1000
+ return fanoutFail(
1001
+ "blocked",
1002
+ "--take-lead needs a session: WORKIT_SESSION_ID is not set",
1003
+ "export WORKIT_SESSION_ID=<your session id>, then plan again with --take-lead",
1004
+ );
1005
+ if (input.takeLead && isDelegateSession(session))
1006
+ return fanoutFail(
1007
+ "blocked",
1008
+ `session ${session} is a worker or verifier id; only a lead takes the lead`,
1009
+ "run --take-lead from the lead's own session",
1010
+ );
914
1011
  const parsed = parsePlan(
915
1012
  input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
916
1013
  {
@@ -928,6 +1025,12 @@ export function planFanout(
928
1025
  { problems: parsed.problems },
929
1026
  );
930
1027
  const plan = parsed.plan;
1028
+ if (plan.fanIn === "integration" && plan.trunk === defaultTrunk(cwd))
1029
+ return fanoutFail(
1030
+ "invalid_input",
1031
+ `fanIn "integration" needs an integration branch as the trunk, not ${plan.trunk}`,
1032
+ `workit git branch <integration-branch> --base ${plan.trunk}, then plan with --trunk <integration-branch>`,
1033
+ );
931
1034
  const badBranches = plan.slices
932
1035
  .filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
933
1036
  .map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
@@ -975,20 +1078,60 @@ export function planFanout(
975
1078
  const existing = readFanout(cwd, plan.name);
976
1079
  const previous = existing.ok ? existing.data : null;
977
1080
  plan.createdAt = previous?.createdAt || now.toISOString();
1081
+ // A re-plan keeps the lead (and a plan from before leads stays without one)
1082
+ // unless this session takes it over.
1083
+ plan.leadSession = input.takeLead ? session : previous ? previous.leadSession : session;
1084
+ plan.formerLeads = [
1085
+ ...new Set([
1086
+ ...(previous?.formerLeads ?? []),
1087
+ ...(previous?.leadSession && previous.leadSession !== plan.leadSession
1088
+ ? [previous.leadSession]
1089
+ : []),
1090
+ ]),
1091
+ ].filter((former) => former !== plan.leadSession);
978
1092
  plan.updatedAt = now.toISOString();
1093
+ // Per slice: the same definition continues its run; a changed one starts anew.
1094
+ for (const slice of plan.slices) {
1095
+ slice.hash = sliceHash(slice);
1096
+ const before = previous?.slices.find((old) => old.id === slice.id);
1097
+ slice.hashSince =
1098
+ before && before.hash === slice.hash ? before.hashSince || plan.updatedAt : plan.updatedAt;
1099
+ }
979
1100
  const written = writeFanout(cwd, plan);
980
1101
  if (!written.ok) return written;
1102
+ if (previous && plan.leadSession !== previous.leadSession)
1103
+ appendObserved(cwd, {
1104
+ type: "fanout.lead.changed",
1105
+ actor: input.actor,
1106
+ fanout: plan.name,
1107
+ from: previous.leadSession,
1108
+ to: plan.leadSession,
1109
+ });
981
1110
  appendObserved(cwd, {
982
1111
  type: "fanout.planned",
983
1112
  actor: input.actor,
984
1113
  fanout: plan.name,
985
1114
  trunk: plan.trunk,
1115
+ fanIn: plan.fanIn,
986
1116
  slices: plan.slices.length,
987
1117
  ids: plan.slices.slice(0, 20).map((slice) => slice.id),
988
1118
  });
989
1119
 
990
1120
  const grouped = waves(plan.slices);
991
1121
  const notes: string[] = [];
1122
+ const warnings: string[] = [];
1123
+ if (!input.takeLead && previous?.leadSession && session && session !== previous.leadSession)
1124
+ notes.push(
1125
+ `the lead stays ${previous.leadSession}; this session is ${session}. Leading from this session now? workit fanout plan <plan.json> --take-lead`,
1126
+ );
1127
+ if (previous === null) {
1128
+ const ledger = readLedger(cwd);
1129
+ const inForce = ledger.ok ? activeStanding(ledger.value.rows, plan.name, planLeads(plan)) : [];
1130
+ if (inForce.length)
1131
+ warnings.push(
1132
+ `${inForce.length} standing order${inForce.length === 1 ? " is" : "s are"} already in force for fanout ${plan.name}, and every brief will carry ${inForce.length === 1 ? "it" : "them"}: ${inForce.map((row) => `"${String(row.what)}"`).join("; ")}. From an earlier run? workit ledger standing clear --fanout ${plan.name}`,
1133
+ );
1134
+ }
992
1135
  if (!ref)
993
1136
  notes.push(
994
1137
  `trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
@@ -1009,6 +1152,7 @@ export function planFanout(
1009
1152
  data: {
1010
1153
  name: plan.name,
1011
1154
  trunk: plan.trunk,
1155
+ fanIn: plan.fanIn,
1012
1156
  file: written.data,
1013
1157
  created: previous === null,
1014
1158
  slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
@@ -1025,6 +1169,7 @@ export function planFanout(
1025
1169
  waves: grouped,
1026
1170
  landingOrder: landingOrder(plan.slices),
1027
1171
  notes,
1172
+ warnings,
1028
1173
  },
1029
1174
  };
1030
1175
  }
@@ -9,13 +9,19 @@
9
9
  // merge: three independent gates, all required:
10
10
  // 1. the S10 status document reads READY (required checks green, no
11
11
  // conflicts, threads, review or draft blockers);
12
- // 2. an accepted S13 verdict for that exact head (fresh or carried), unless
13
- // the workspace grants `merge: true`;
12
+ // 2. an accepted S13 verdict for that exact head (fresh or carried). Under
13
+ // `merge: true` only, `--unverified --reason` bypasses it, and the bypass
14
+ // is recorded (`merge.unverified`) before the merge call;
14
15
  // 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
15
16
  // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
16
17
  // head that moves after the gates refuses instead of merging unverified code.
17
18
  // --delete-branch deletes with a lease on that same SHA.
18
- import { requireGrant, type AutonomySource } from "../autonomy";
19
+ import {
20
+ requireGrant,
21
+ resolveAutonomy,
22
+ type AutonomySource,
23
+ type DefaultEndpoint,
24
+ } from "../autonomy";
19
25
  import { isProtectedTarget } from "../core/branch";
20
26
  import { vcsConfig } from "../core/vcs-config";
21
27
  import { workspaceReleaseTracks } from "../core/release-tracks";
@@ -64,6 +70,20 @@ export type CreateOutcome = {
64
70
  created: boolean;
65
71
  draft: boolean;
66
72
  recorded: { id: string } | { error: string };
73
+ /** The review step and what the effective endpoint does after it. */
74
+ next: string;
75
+ };
76
+
77
+ const AFTER_REVIEW: Record<DefaultEndpoint, string> = {
78
+ commit: "stop here; review comes when the user asks",
79
+ pr: "stop here; review comes when the user asks",
80
+ green: "babysit it to merge-ready, never merging",
81
+ merged: "babysit it, then land it after verification",
82
+ };
83
+
84
+ const createNext = (cwd: string): string => {
85
+ const endpoint = resolveAutonomy(cwd).effectiveEndpoint;
86
+ return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
67
87
  };
68
88
 
69
89
  /**
@@ -231,6 +251,7 @@ export async function createPullRequest(
231
251
  created,
232
252
  draft: input.draft,
233
253
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
254
+ next: createNext(cwd),
234
255
  });
235
256
  }
236
257
 
@@ -247,6 +268,8 @@ export type MergeInput = {
247
268
  * comes from this branch, re-checked right before the merge call.
248
269
  */
249
270
  expect?: { base: string; branch: string };
271
+ /** Merge without a verdict (`merge: true` only); the reason is recorded. */
272
+ unverified?: { reason: string };
250
273
  };
251
274
 
252
275
  export type MergeOutcome = {
@@ -258,6 +281,8 @@ export type MergeOutcome = {
258
281
  method: MergeMethod;
259
282
  mergeSha: string | null;
260
283
  verdict: { required: boolean; accepted: boolean; verdictId: string | null };
284
+ /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
285
+ unverified: { reason: string; recorded: string } | null;
261
286
  grant: { source: AutonomySource };
262
287
  deletedBranch: boolean | { error: string };
263
288
  recorded: { id: string } | { error: string };
@@ -294,6 +319,8 @@ const mergeBackFor = (cwd: string, base: string): { track: string | null; branch
294
319
  export type MergeRefusal = {
295
320
  reason:
296
321
  | "grant_required"
322
+ | "unverified_refused"
323
+ | "failed_verdict"
297
324
  | "not_ready"
298
325
  | "needs_verdict"
299
326
  | "head_mismatch"
@@ -346,6 +373,15 @@ export async function mergePullRequest(
346
373
  ): Promise<MergeResult> {
347
374
  const grant = requireGrant(cwd, "merge");
348
375
  if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
376
+ const bypass = input.unverified ?? null;
377
+ if (bypass && !bypass.reason.trim())
378
+ return failure("invalid_input", "--unverified needs --reason <why the user asked for it>");
379
+ if (bypass && !grant.allowUnverified)
380
+ return refuse(
381
+ `unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never merges without an accepted independent verdict`,
382
+ "a non-author session verifies the head (workit-review), then merge without --unverified",
383
+ { reason: "unverified_refused" },
384
+ );
349
385
 
350
386
  const number = selectPr(cwd, resolved, { pr: input.pr });
351
387
  if (!number.ok) return number;
@@ -404,34 +440,57 @@ export async function mergePullRequest(
404
440
  { reason: "protected_branch" },
405
441
  );
406
442
  }
443
+ const ledger = readLedger(cwd);
444
+ if (!ledger.ok) return ledger;
445
+ const check = checkVerdicts(cwd, branch, ledger.value.rows);
446
+ const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
447
+ const covered = check.head === head && check.accepted.accepted;
407
448
  let verdict: MergeOutcome["verdict"] = {
408
- required: grant.requireVerdict,
409
- accepted: false,
410
- verdictId: null,
449
+ required: true,
450
+ accepted: covered,
451
+ verdictId:
452
+ covered && typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
411
453
  };
412
- if (grant.requireVerdict) {
413
- const ledger = readLedger(cwd);
414
- if (!ledger.ok) return ledger;
415
- const check = checkVerdicts(cwd, branch, ledger.value.rows);
416
- const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
417
- if (check.head !== head)
418
- return refuse(
419
- `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
420
- `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
421
- { reason: "head_mismatch", verdict: summary },
422
- );
423
- if (!check.accepted.accepted)
424
- return refuse(
425
- `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
426
- `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}`,
427
- { reason: "needs_verdict", verdict: summary },
454
+ let unverified: MergeOutcome["unverified"] = null;
455
+ // The bypass covers a missing verdict, never a rejection.
456
+ if (!covered && bypass && check.accepted.reasons.includes("failing_verdict"))
457
+ return refuse(
458
+ `failed_verdict: ${label} has a current independent failed verdict${typeof check.accepted.verdict?.id === "string" ? ` (${check.accepted.verdict.id})` : ""}; --unverified never merges over a rejection`,
459
+ "a new independent verdict on the head supersedes it",
460
+ { reason: "failed_verdict", verdict: summary },
461
+ );
462
+ if (!covered && bypass) {
463
+ // Recorded before the merge call: a bypass that cannot be audited never merges.
464
+ const reason = bypass.reason.trim();
465
+ const recorded = appendObserved(cwd, {
466
+ type: "merge.unverified",
467
+ actor: input.actor,
468
+ branch,
469
+ head,
470
+ pr: doc.number,
471
+ base: doc.base,
472
+ reason,
473
+ grant: grant.source,
474
+ });
475
+ if (!recorded.ok)
476
+ return failure(
477
+ "failed",
478
+ `unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
428
479
  );
429
- verdict = {
430
- required: true,
431
- accepted: true,
432
- verdictId: typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
433
- };
434
- }
480
+ verdict = { required: false, accepted: false, verdictId: null };
481
+ unverified = { reason, recorded: String(recorded.value.id) };
482
+ } else if (check.head !== head)
483
+ return refuse(
484
+ `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
485
+ `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
486
+ { reason: "head_mismatch", verdict: summary },
487
+ );
488
+ else if (!covered)
489
+ return refuse(
490
+ `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
491
+ `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${grant.allowUnverified ? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"` : ""}`,
492
+ { reason: "needs_verdict", verdict: summary },
493
+ );
435
494
 
436
495
  const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
437
496
  if (!merged.ok) return merged;
@@ -470,6 +529,7 @@ export async function mergePullRequest(
470
529
  mergeSha: merged.data.mergeSha,
471
530
  method: input.method,
472
531
  verdictId: verdict.verdictId,
532
+ ...(unverified ? { unverified: unverified.recorded } : {}),
473
533
  grant: grant.source,
474
534
  });
475
535
  return {
@@ -483,6 +543,7 @@ export async function mergePullRequest(
483
543
  method: input.method,
484
544
  mergeSha: merged.data.mergeSha,
485
545
  verdict,
546
+ unverified,
486
547
  grant: { source: grant.source },
487
548
  deletedBranch,
488
549
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },