@brainervirus/workit-core 7.5.2 → 7.7.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
  }
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> & {
@@ -1076,6 +1090,142 @@ export function recordRuling(
1076
1090
  );
1077
1091
  }
1078
1092
 
1093
+ /**
1094
+ * May this row speak for the fanout's lead? With recorded leads (the current
1095
+ * one and any it took over from) only their rows count; a plan from before
1096
+ * leads (null) takes any session.
1097
+ */
1098
+ const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
1099
+ leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
1100
+
1101
+ /**
1102
+ * The standing orders in force for `fanout`, oldest first (D17: odd rows are
1103
+ * skipped). Given the plan's lead session, orders and clears recorded by any
1104
+ * other session are ignored, so a worker can neither add nor clear one.
1105
+ */
1106
+ export function activeStanding(
1107
+ rows: readonly ReadRow[],
1108
+ fanout: string,
1109
+ leads: readonly string[] | null = null,
1110
+ ): ReadRow[] {
1111
+ const active: ReadRow[] = [];
1112
+ for (const row of rows) {
1113
+ if (row.fanout !== fanout || !fromLead(row, leads)) continue;
1114
+ if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
1115
+ if (!row.superseded) active.push(row);
1116
+ } else if (row.type === "standing.cleared") {
1117
+ const target = str(row.target);
1118
+ if (target === null) active.length = 0;
1119
+ else {
1120
+ const index = active.findIndex((order) => order.id === target);
1121
+ if (index >= 0) active.splice(index, 1);
1122
+ }
1123
+ }
1124
+ }
1125
+ return active;
1126
+ }
1127
+
1128
+ /**
1129
+ * A worker or verifier session by the ids workit hands out: `fanout brief`'s
1130
+ * `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
1131
+ * hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
1132
+ * session ids carry no colon. Used where no recorded lead decides: plans that
1133
+ * predate `leadSession`, and `fanout plan --take-lead`.
1134
+ */
1135
+ export const isDelegateSession = (session: string | null): boolean =>
1136
+ session !== null &&
1137
+ (/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
1138
+ /-v\d+$/u.test(session) ||
1139
+ session.includes(":"));
1140
+
1141
+ /**
1142
+ * Standing orders come from the fanout's lead: the session that first made
1143
+ * the plan (`lead`). A plan without one falls back to refusing the session
1144
+ * ids handed to workers and verifiers.
1145
+ */
1146
+ function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
1147
+ const session = context.actor.session;
1148
+ if (lead !== null ? session === lead : !isDelegateSession(session))
1149
+ return { ok: true, value: undefined };
1150
+ return err(
1151
+ "blocked",
1152
+ lead !== null
1153
+ ? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
1154
+ : `session ${session} is a worker or verifier: standing orders come from the lead`,
1155
+ lead !== null
1156
+ ? `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`
1157
+ : "a worker or verifier reports the order to the lead instead",
1158
+ );
1159
+ }
1160
+
1161
+ export function recordStanding(
1162
+ context: RecordContext,
1163
+ input: { fanout: string; lead: string | null; what?: string },
1164
+ ): LedgerResult<StandingRow> {
1165
+ const what = required(input.what, "<order>");
1166
+ if (!what.ok) return what;
1167
+ // One order per line of every brief: a newline or control character would
1168
+ // let an order forge other brief fields.
1169
+ if (/\p{Cc}/u.test(what.value))
1170
+ return err(
1171
+ "invalid_input",
1172
+ "a standing order is one line: no newlines or control characters",
1173
+ 'add each order separately: workit ledger standing add "<order>"',
1174
+ );
1175
+ const lead = leadOnly(context, input.lead);
1176
+ if (!lead.ok) return lead;
1177
+ const link = checkSupersede(context, "standing");
1178
+ if (!link.ok) return link;
1179
+ return appendRow<StandingRow>(
1180
+ context.cwd,
1181
+ {
1182
+ ...common(context, noteKey(context)),
1183
+ type: "standing",
1184
+ fanout: input.fanout,
1185
+ what: what.value,
1186
+ },
1187
+ { now: context.now },
1188
+ );
1189
+ }
1190
+
1191
+ /** Clear one standing order by id (it must be in force), or all of them; lead only. */
1192
+ export function clearStanding(
1193
+ context: RecordContext,
1194
+ input: {
1195
+ fanout: string;
1196
+ lead: string | null;
1197
+ leads?: readonly string[] | null;
1198
+ target: string | null;
1199
+ },
1200
+ ): LedgerResult<StandingClearedRow> {
1201
+ const lead = leadOnly(context, input.lead);
1202
+ if (!lead.ok) return lead;
1203
+ if (input.target !== null) {
1204
+ const ledger = readLedger(context.cwd);
1205
+ if (!ledger.ok) return ledger;
1206
+ if (
1207
+ !activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
1208
+ (row) => row.id === input.target,
1209
+ )
1210
+ )
1211
+ return err(
1212
+ "not_found",
1213
+ `no standing order ${input.target} in force for fanout ${input.fanout}`,
1214
+ `workit ledger standing list --fanout ${input.fanout}`,
1215
+ );
1216
+ }
1217
+ return appendRow<StandingClearedRow>(
1218
+ context.cwd,
1219
+ {
1220
+ ...common(context, noteKey(context)),
1221
+ type: "standing.cleared",
1222
+ fanout: input.fanout,
1223
+ target: input.target,
1224
+ },
1225
+ { now: context.now },
1226
+ );
1227
+ }
1228
+
1079
1229
  /**
1080
1230
  * Record an agent-asserted verdict on a branch's committed head. Refused on a
1081
1231
  * dirty worktree (the head would not be what was judged) and, for an author
@@ -1221,6 +1371,12 @@ export function summarizeRow(row: ReadRow): RowSummary {
1221
1371
  case "handoff":
1222
1372
  summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
1223
1373
  break;
1374
+ case "standing":
1375
+ summary = `${text("what")} (fanout ${text("fanout")})`;
1376
+ break;
1377
+ case "standing.cleared":
1378
+ summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
1379
+ break;
1224
1380
  case "commit.recorded":
1225
1381
  summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
1226
1382
  break;