@brainervirus/workit-cli 8.38.4 → 8.39.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.
Files changed (2) hide show
  1. package/dist/index.js +695 -329
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -9317,7 +9317,7 @@ var package_default;
9317
9317
  var init_package = __esm(() => {
9318
9318
  package_default = {
9319
9319
  name: "@brainervirus/workit-cli",
9320
- version: "8.38.4",
9320
+ version: "8.39.0",
9321
9321
  private: false,
9322
9322
  description: "Workit CLI — setup wizard, doctor, and task control for agentic coding workflows",
9323
9323
  keywords: [
@@ -9355,13 +9355,13 @@ var init_package = __esm(() => {
9355
9355
  },
9356
9356
  dependencies: {
9357
9357
  "@babel/parser": "7.29.9",
9358
- "@brainervirus/workit-cursor": "^8.38.4",
9358
+ "@brainervirus/workit-cursor": "^8.39.0",
9359
9359
  "@inkjs/ui": "2.0.0",
9360
9360
  ink: "7.1.1",
9361
9361
  react: "19.2.8"
9362
9362
  },
9363
9363
  devDependencies: {
9364
- "@brainervirus/workit-core": "^8.38.4"
9364
+ "@brainervirus/workit-core": "^8.39.0"
9365
9365
  },
9366
9366
  engines: {
9367
9367
  node: ">=24"
@@ -77869,6 +77869,7 @@ function resolveResourceSettings(sources, host = hostShape()) {
77869
77869
  const settings = {
77870
77870
  slots: DEFAULT_SLOTS,
77871
77871
  heavy: [...DEFAULT_HEAVY_CHECKS],
77872
+ light: [...DEFAULT_LIGHT_CHECKS],
77872
77873
  memoryMax: "",
77873
77874
  cpuQuota: "",
77874
77875
  waitMs: DEFAULT_WAIT_S * 1000,
@@ -77910,8 +77911,21 @@ function resolveResourceSettings(sources, host = hostShape()) {
77910
77911
  const heavy = isRecord9(value.heavy) ? Object.keys(value.heavy) : value.heavy;
77911
77912
  if (!Array.isArray(heavy) || !heavy.every((item) => typeof item === "string"))
77912
77913
  warn("heavy", "must be a list of check names");
77913
- else
77914
+ else {
77914
77915
  settings.heavy = repo ? [...new Set([...settings.heavy, ...heavy])] : heavy;
77916
+ settings.light = settings.light.filter((name) => !heavy.includes(name));
77917
+ }
77918
+ }
77919
+ if (value.light !== undefined) {
77920
+ const light = value.light;
77921
+ if (repo)
77922
+ machineOnly("light");
77923
+ else if (!Array.isArray(light) || !light.every((item) => typeof item === "string"))
77924
+ warn("light", "must be a list of check names");
77925
+ else {
77926
+ settings.light = light;
77927
+ settings.heavy = settings.heavy.filter((name) => !light.includes(name));
77928
+ }
77915
77929
  }
77916
77930
  if (value.memoryMax !== undefined) {
77917
77931
  const next = memoryValue(value.memoryMax, host);
@@ -77932,10 +77946,11 @@ function resolveResourceSettings(sources, host = hostShape()) {
77932
77946
  cpu = next;
77933
77947
  }
77934
77948
  if (value.wait !== undefined) {
77935
- if (typeof value.wait === "number" && value.wait >= 0)
77936
- settings.waitMs = value.wait * 1000;
77949
+ const wait = typeof value.wait === "number" ? value.wait >= 0 ? value.wait * 1000 : null : typeof value.wait === "string" ? parseWait(value.wait) : null;
77950
+ if (wait !== null)
77951
+ settings.waitMs = wait;
77937
77952
  else
77938
- warn("wait", "must be a number of seconds");
77953
+ warn("wait", 'must be a number of seconds or a duration ("30m")');
77939
77954
  }
77940
77955
  }
77941
77956
  settings.memoryMax = String(memory);
@@ -78053,7 +78068,65 @@ function removeOwn(file, id) {
78053
78068
  if (parseLease(readText5(file))?.id === id)
78054
78069
  fs24.rmSync(file, { force: true });
78055
78070
  }
78071
+ function leaseQueue(dir, probe = {}) {
78072
+ let names;
78073
+ try {
78074
+ names = fs24.readdirSync(queueDir(dir));
78075
+ } catch {
78076
+ return [];
78077
+ }
78078
+ const entries = [];
78079
+ for (const name of names.filter((item) => item.endsWith(".json")).toSorted()) {
78080
+ const file = path52.join(queueDir(dir), name);
78081
+ const record = parseLease(readText5(file));
78082
+ if (!record)
78083
+ continue;
78084
+ entries.push({
78085
+ file,
78086
+ holder: holderOf(record),
78087
+ id: record.id,
78088
+ stale: staleReason(record, probe)
78089
+ });
78090
+ }
78091
+ return entries;
78092
+ }
78093
+ function waitersAhead(options, own) {
78094
+ let ahead = 0;
78095
+ for (const entry of leaseQueue(options.dir, options.probe)) {
78096
+ if (path52.basename(entry.file) >= own)
78097
+ break;
78098
+ if (!TICKET.test(path52.basename(entry.file)))
78099
+ continue;
78100
+ if (entry.stale && reclaim(entry.file, (current) => !current || staleReason(current, options.probe) !== null))
78101
+ continue;
78102
+ ahead += 1;
78103
+ }
78104
+ return ahead;
78105
+ }
78106
+ function liveHolders(options) {
78107
+ return leaseSlots(options.dir, options.slots, options.probe).filter((slot) => slot.holder && !slot.stale).map((slot) => slot.holder);
78108
+ }
78109
+ function writeTicket(file, record) {
78110
+ fs24.mkdirSync(path52.dirname(file), { recursive: true, mode: 448 });
78111
+ fs24.writeFileSync(file, `${JSON.stringify(record)}
78112
+ `, { mode: 384 });
78113
+ }
78114
+ function parseWait(text) {
78115
+ const match = /^(\d+(?:\.\d+)?)([smh]?)$/u.exec(text.trim());
78116
+ if (!match)
78117
+ return null;
78118
+ const unit = { "": 1000, s: 1000, m: 60000, h: 3600000 }[match[2]] ?? 1000;
78119
+ return Math.round(Number(match[1]) * unit);
78120
+ }
78121
+ function formatWait(ms) {
78122
+ const seconds = Math.max(0, Math.round(ms / 1000));
78123
+ if (seconds < 60)
78124
+ return `${seconds}s`;
78125
+ const minutes = Math.floor(seconds / 60);
78126
+ return seconds % 60 ? `${minutes}m ${seconds % 60}s` : `${minutes}m`;
78127
+ }
78056
78128
  async function acquireLease(options) {
78129
+ const clock = options.clock ?? realClock;
78057
78130
  const pid = options.pid ?? process.pid;
78058
78131
  const boot = bootId();
78059
78132
  const record = {
@@ -78067,7 +78140,7 @@ async function acquireLease(options) {
78067
78140
  check: options.check,
78068
78141
  cwd: options.cwd,
78069
78142
  session: options.session,
78070
- startedAt: new Date().toISOString(),
78143
+ startedAt: new Date(clock.now()).toISOString(),
78071
78144
  scope: null
78072
78145
  };
78073
78146
  try {
@@ -78075,48 +78148,104 @@ async function acquireLease(options) {
78075
78148
  } catch (error) {
78076
78149
  return { ok: false, reason: "unavailable", error: error.message };
78077
78150
  }
78078
- const started = Date.now();
78151
+ const started = clock.now();
78079
78152
  const reclaimed = [];
78080
78153
  let waitedOn = [];
78081
- for (;; ) {
78082
- let outcome;
78083
- try {
78084
- outcome = tryOnce(options, record, reclaimed);
78085
- } catch (error) {
78086
- return { ok: false, reason: "unavailable", error: error.message };
78087
- }
78088
- if ("slot" in outcome) {
78089
- const { file, slot } = outcome;
78090
- let released = false;
78091
- const release = () => {
78092
- if (released)
78093
- return;
78094
- released = true;
78095
- process.off("exit", release);
78096
- removeOwn(file, record.id);
78097
- };
78098
- process.on("exit", release);
78099
- return {
78100
- ok: true,
78101
- lease: {
78102
- record,
78103
- slot,
78104
- file,
78105
- waitedOn,
78106
- waitedMs: waitedOn.length ? Date.now() - started : 0,
78107
- reclaimed,
78108
- release
78154
+ const name = ticketName(started, record.id);
78155
+ const ticket = path52.join(queueDir(options.dir), name);
78156
+ const ticketRecord = {
78157
+ ...record,
78158
+ maxAgeMs: options.waitMs + 60000
78159
+ };
78160
+ let queued = false;
78161
+ const onSignal = (signal) => {
78162
+ leave();
78163
+ process.exit(signal === "SIGINT" ? 130 : 143);
78164
+ };
78165
+ const leave = () => {
78166
+ process.off("exit", leave);
78167
+ process.off("SIGINT", onSignal);
78168
+ process.off("SIGTERM", onSignal);
78169
+ if (queued)
78170
+ removeOwn(ticket, record.id);
78171
+ queued = false;
78172
+ };
78173
+ let nextStatus = 0;
78174
+ try {
78175
+ for (;; ) {
78176
+ const waited = clock.now() - started;
78177
+ const ahead = queued ? waitersAhead(options, name) : waitersAhead(options, "~");
78178
+ const free = options.slots - liveHolders(options).length;
78179
+ if (ahead < free) {
78180
+ const outcome = tryOnce(options, record, reclaimed);
78181
+ if ("slot" in outcome) {
78182
+ const waitedMs = queued ? waited : 0;
78183
+ leave();
78184
+ const { file, slot } = outcome;
78185
+ let released = false;
78186
+ const release = () => {
78187
+ if (released)
78188
+ return;
78189
+ released = true;
78190
+ process.off("exit", release);
78191
+ removeOwn(file, record.id);
78192
+ };
78193
+ process.on("exit", release);
78194
+ return {
78195
+ ok: true,
78196
+ lease: {
78197
+ record,
78198
+ slot,
78199
+ file,
78200
+ waitedOn,
78201
+ waitedMs,
78202
+ reclaimed,
78203
+ release
78204
+ }
78205
+ };
78109
78206
  }
78110
- };
78111
- }
78112
- const waited = Date.now() - started;
78113
- if (!waitedOn.length) {
78114
- waitedOn = outcome.holders;
78115
- options.onWait?.(outcome.holders);
78207
+ }
78208
+ const holders = liveHolders(options);
78209
+ if (!waitedOn.length) {
78210
+ waitedOn = holders;
78211
+ options.onWait?.(holders);
78212
+ }
78213
+ if (waited >= options.waitMs)
78214
+ return {
78215
+ ok: false,
78216
+ reason: "timeout",
78217
+ holders,
78218
+ waitedMs: waited,
78219
+ position: ahead + 1
78220
+ };
78221
+ if (!queued) {
78222
+ writeTicket(ticket, ticketRecord);
78223
+ queued = true;
78224
+ process.on("exit", leave);
78225
+ process.on("SIGINT", onSignal);
78226
+ process.on("SIGTERM", onSignal);
78227
+ } else if (!fs24.existsSync(ticket)) {
78228
+ writeTicket(ticket, ticketRecord);
78229
+ }
78230
+ if (waited >= nextStatus) {
78231
+ options.onStatus?.({
78232
+ position: ahead + 1,
78233
+ ahead,
78234
+ holders,
78235
+ waitedMs: waited
78236
+ });
78237
+ nextStatus = waited + (options.statusEveryMs ?? 30000);
78238
+ }
78239
+ await clock.sleep(Math.min(options.pollMs ?? 2000, Math.max(1, options.waitMs - waited)));
78116
78240
  }
78117
- if (waited >= options.waitMs)
78118
- return { ok: false, reason: "timeout", holders: outcome.holders, waitedMs: waited };
78119
- await sleep(Math.min(options.pollMs ?? 2000, Math.max(1, options.waitMs - waited)));
78241
+ } catch (error) {
78242
+ return {
78243
+ ok: false,
78244
+ reason: "unavailable",
78245
+ error: error.message
78246
+ };
78247
+ } finally {
78248
+ leave();
78120
78249
  }
78121
78250
  }
78122
78251
  function updateLease(lease, patch) {
@@ -78229,7 +78358,7 @@ function leaseSlots(dir, slots, probe = {}) {
78229
78358
  }
78230
78359
  return states;
78231
78360
  }
78232
- var DEFAULT_HEAVY_CHECKS, DEFAULT_SLOTS = 1, DEFAULT_WAIT_S = 600, DEFAULT_MEMORY_SHARE = 0.4, DEFAULT_CPU_SHARE = 0.7, MAX_SLOTS = 64, UNREADABLE_TTL_MS = 30000, GUARD_TTL_MS = 30000, hostShape = () => ({
78361
+ var DEFAULT_HEAVY_CHECKS, DEFAULT_LIGHT_CHECKS, DEFAULT_SLOTS = 1, DEFAULT_WAIT_S = 1800, DEFAULT_MEMORY_SHARE = 0.4, DEFAULT_CPU_SHARE = 0.7, MAX_SLOTS = 64, UNREADABLE_TTL_MS = 30000, GUARD_TTL_MS = 30000, hostShape = () => ({
78233
78362
  totalMemBytes: os20.totalmem(),
78234
78363
  cpus: Math.max(1, os20.availableParallelism?.() ?? os20.cpus().length)
78235
78364
  }), isRecord9 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), SIZE, PERCENT, UNITS, leaseDir = (env) => path52.join(stateDir2(env), "leases"), readText5 = (file) => {
@@ -78263,16 +78392,22 @@ var DEFAULT_HEAVY_CHECKS, DEFAULT_SLOTS = 1, DEFAULT_WAIT_S = 600, DEFAULT_MEMOR
78263
78392
  cwd: lease.cwd,
78264
78393
  session: lease.session,
78265
78394
  startedAt: lease.startedAt
78266
- }), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), SCOPE_START_FAILURE, NOTICE_TTL_MS;
78395
+ }), realClock, queueDir = (dir) => path52.join(dir, "queue"), TICKET, ticketName = (now, id) => `${String(now).padStart(15, "0")}-${String(process.hrtime.bigint() % 10n ** 16n).padStart(16, "0")}-${id}.json`, SCOPE_START_FAILURE, NOTICE_TTL_MS;
78267
78396
  var init_resources = __esm(() => {
78268
78397
  init_checks3();
78269
78398
  init_heavy();
78270
78399
  init_heavy_scope();
78271
- DEFAULT_HEAVY_CHECKS = ["test", "build", "typecheck"];
78400
+ DEFAULT_HEAVY_CHECKS = ["test", "build"];
78401
+ DEFAULT_LIGHT_CHECKS = ["lint", "typecheck", "format"];
78272
78402
  SIZE = /^(\d+(?:\.\d+)?)([KMGT]?)$/iu;
78273
78403
  PERCENT = /^(\d+(?:\.\d+)?)%$/u;
78274
78404
  UNITS = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
78275
78405
  SCOPE_UNIT = /^[\w.@-]+\.scope$/u;
78406
+ realClock = {
78407
+ now: () => Date.now(),
78408
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
78409
+ };
78410
+ TICKET = /^\d{15}-\d{16}-[\w-]+\.json$/u;
78276
78411
  SCOPE_START_FAILURE = /^Failed to (?:connect to bus|create bus connection|start transient scope unit|parse \S+?=)/u;
78277
78412
  NOTICE_TTL_MS = 7 * 86400000;
78278
78413
  });
@@ -78283,7 +78418,6 @@ __export(exports_check, {
78283
78418
  OUTPUT_LINES: () => OUTPUT_LINES,
78284
78419
  boundedOutput: () => boundedOutput,
78285
78420
  countWarnings: () => countWarnings,
78286
- hostToolWaitCapMs: () => hostToolWaitCapMs,
78287
78421
  run: () => run12,
78288
78422
  workspaceResources: () => workspaceResources
78289
78423
  });
@@ -78306,6 +78440,7 @@ function parse6(argv) {
78306
78440
  positionals: [],
78307
78441
  command: split >= 0 ? argv.slice(split + 1) : null
78308
78442
  };
78443
+ let noWait = false;
78309
78444
  for (let index = 0;index < head.length; index += 1) {
78310
78445
  const arg = head[index];
78311
78446
  const [flag, inline] = arg.startsWith("--") && arg.includes("=") ? arg.split(/=(.*)/su) : [arg];
@@ -78329,6 +78464,13 @@ function parse6(argv) {
78329
78464
  options.noLease = true;
78330
78465
  continue;
78331
78466
  }
78467
+ if (flag === "--no-wait") {
78468
+ if (options.waitMs !== null && !noWait)
78469
+ return new Error("--no-wait goes without --wait");
78470
+ noWait = true;
78471
+ options.waitMs = 0;
78472
+ continue;
78473
+ }
78332
78474
  if (flag === "--name" || flag === "--task" || flag === "--base" || flag === "--timeout" || flag === "--wait" || flag === "--reason") {
78333
78475
  const got = value();
78334
78476
  if (got instanceof Error)
@@ -78338,10 +78480,12 @@ function parse6(argv) {
78338
78480
  else if (flag === "--reason")
78339
78481
  options.reason = got.trim() || null;
78340
78482
  else if (flag === "--wait") {
78341
- const seconds = Number(got);
78342
- if (!Number.isFinite(seconds) || seconds < 0)
78343
- return new Error("--wait must be a number of seconds");
78344
- options.waitMs = Math.round(seconds * 1000);
78483
+ if (noWait)
78484
+ return new Error("--no-wait goes without --wait");
78485
+ const waitMs = parseWait(got);
78486
+ if (waitMs === null)
78487
+ return new Error('--wait must be seconds or a duration ("90", "90s", "30m", "1h")');
78488
+ options.waitMs = waitMs;
78345
78489
  } else if (flag === "--task")
78346
78490
  options.task = got;
78347
78491
  else if (flag === "--base")
@@ -78471,22 +78615,30 @@ function countWarnings(log) {
78471
78615
  return redactLog(log).replace(ANSI_COLOR, "").split(`
78472
78616
  `).filter((line) => WARNING_LINE.test(line) && !NO_WARNINGS.test(line)).length;
78473
78617
  }
78474
- function boundedOutput(log, lines) {
78618
+ function boundedOutput(log, lines, stderr = "") {
78475
78619
  const all = tailLines(log, Number.MAX_SAFE_INTEGER);
78476
78620
  if (all.length === 0)
78477
78621
  return "";
78478
- const shown = all.slice(-lines);
78622
+ let shown = all.slice(-lines);
78623
+ const errors = tailLines(redactLog(stderr), STDERR_TAIL_LINES);
78624
+ let missing = [];
78625
+ if (errors.length) {
78626
+ const tail = new Set(shown);
78627
+ if (errors.some((line) => !tail.has(line))) {
78628
+ shown = all.slice(-Math.max(1, lines - errors.length - 2));
78629
+ const kept = new Set(shown);
78630
+ missing = errors.filter((line) => !kept.has(line));
78631
+ }
78632
+ }
78479
78633
  const omitted = all.length - shown.length;
78634
+ const footer = missing.length ? `… the last stderr lines, which the merged log holds earlier:
78635
+ ${missing.join(`
78636
+ `)}
78637
+ ` : "";
78480
78638
  return `${omitted ? `… ${omitted} earlier line${omitted === 1 ? "" : "s"} omitted (the full log is named below; --full streams it)
78481
78639
  ` : ""}${shown.join(`
78482
78640
  `)}
78483
- `;
78484
- }
78485
- function hostToolWaitCapMs(env) {
78486
- if (!env.CLAUDECODE && !env.OPENCODE && !env.OPENCODE_SESSION_ID)
78487
- return null;
78488
- const override = Number(env.WORKIT_HOST_WAIT_CAP_MS);
78489
- return Number.isFinite(override) && override >= 0 && env.WORKIT_HOST_WAIT_CAP_MS ? override : HOST_TOOL_WAIT_CAP_MS;
78641
+ ${footer}`;
78490
78642
  }
78491
78643
  async function workspaceResources(io) {
78492
78644
  const env = io.env;
@@ -78520,14 +78672,26 @@ async function prepareHeavy(io, settings, label, runCwd, timeoutMs) {
78520
78672
  waitMs: settings.waitMs,
78521
78673
  pollMs: 1000,
78522
78674
  maxAgeMs: (timeoutMs || MAX_UNVERIFIED_RUN_MS) + 60000,
78523
- onWait: (holders) => io.stderr(`workit: waiting for the heavy-check lease (${settings.slots} slot${settings.slots === 1 ? "" : "s"}): held by ${holders.map((holder) => describeHolder(holder)).join("; ")}
78524
- `)
78675
+ onStatus: (status) => {
78676
+ const slots = `${settings.slots} slot${settings.slots === 1 ? "" : "s"}`;
78677
+ const holders = status.holders.map((holder) => describeHolder(holder)).join("; ");
78678
+ io.stderr(status.waitedMs === 0 ? `workit: waiting for the heavy-check lease (${slots}): held by ${holders || "nobody; waiters ahead"}; queued at position ${status.position}, waiting up to ${formatWait(settings.waitMs)} (--wait <dur> sets it)
78679
+ ` : `workit: still queued for the heavy-check lease: position ${status.position}, ${formatWait(status.waitedMs)} of ${formatWait(settings.waitMs)} waited; held by ${holders || "nobody; waiters ahead"}
78680
+ `);
78681
+ }
78525
78682
  });
78526
78683
  if (!acquired.ok && acquired.reason === "timeout") {
78527
78684
  const holders = acquired.holders.map((holder) => describeHolder(holder)).join("; ");
78528
- emit(io, fail2("busy", `the heavy-check lease stayed busy for ${Math.round(acquired.waitedMs / 1000)} s`, {
78529
- data: { lease: { waitedMs: acquired.waitedMs, holders: acquired.holders } },
78530
- unblock: `wait for ${holders || "the holders"} to finish, then run it again`
78685
+ const waited = formatWait(acquired.waitedMs);
78686
+ emit(io, fail2("busy", settings.waitMs === 0 ? `the heavy-check lease is busy (--no-wait): held by ${holders || "nobody; waiters ahead"}` : `the heavy-check lease stayed busy for ${waited} (position ${acquired.position} in the queue)`, {
78687
+ data: {
78688
+ lease: {
78689
+ waitedMs: acquired.waitedMs,
78690
+ holders: acquired.holders,
78691
+ position: acquired.position
78692
+ }
78693
+ },
78694
+ unblock: `wait for ${holders || "the waiters ahead"} to finish (each holder's age is shown; a full test suite takes minutes), then run it again, or queue longer with --wait 30m`
78531
78695
  }));
78532
78696
  return 4;
78533
78697
  }
@@ -78627,11 +78791,8 @@ async function run12(argv, io) {
78627
78791
  { label: "workspaces.json", trust: "machine", value: workspace.value },
78628
78792
  { label: CHECKS_FILE, trust: "repo", value: config.resources }
78629
78793
  ]);
78630
- const cap = hostToolWaitCapMs(io.env);
78631
78794
  if (options.waitMs !== null)
78632
78795
  settings.waitMs = options.waitMs;
78633
- else if (cap !== null)
78634
- settings.waitMs = Math.min(settings.waitMs, cap);
78635
78796
  for (const warning of [workspace.warning, ...warnings])
78636
78797
  if (warning)
78637
78798
  io.stderr(`workit: resources: ${warning}
@@ -78648,6 +78809,7 @@ async function run12(argv, io) {
78648
78809
  `);
78649
78810
  const lease = heavy?.lease ?? null;
78650
78811
  let result;
78812
+ let stderrTail = "";
78651
78813
  try {
78652
78814
  const scoped = await runScoped((wrapper, sinks) => runCheckCommand(command, {
78653
78815
  cwd: runCwd,
@@ -78657,7 +78819,12 @@ async function run12(argv, io) {
78657
78819
  ...sinks,
78658
78820
  ...wrapper ? { wrapper } : {},
78659
78821
  ...lease ? { onSpawn: (pid) => updateLease(lease, { pgid: pid }) } : {}
78660
- }), heavy?.wrapper ?? null, options.full ? { onStdout: io.json ? io.stderr : io.stdout, onStderr: io.stderr } : { onStdout: () => {}, onStderr: () => {} });
78822
+ }), heavy?.wrapper ?? null, options.full ? { onStdout: io.json ? io.stderr : io.stdout, onStderr: io.stderr } : {
78823
+ onStdout: () => {},
78824
+ onStderr: (text) => {
78825
+ stderrTail = (stderrTail + text).slice(-STDERR_KEEP_CHARS);
78826
+ }
78827
+ });
78661
78828
  result = scoped.result;
78662
78829
  if (heavy && scoped.fallback) {
78663
78830
  io.stderr(`workit: the memory/CPU scope could not start (${scoped.fallback}); ran without it
@@ -78769,7 +78936,7 @@ async function run12(argv, io) {
78769
78936
  `);
78770
78937
  else {
78771
78938
  if (!options.full)
78772
- io.stdout(boundedOutput(result.log, OUTPUT_LINES));
78939
+ io.stdout(boundedOutput(result.log, OUTPUT_LINES, stderrTail));
78773
78940
  io.stderr(`${summary}
78774
78941
  `);
78775
78942
  if (recordError && passed)
@@ -78786,7 +78953,7 @@ async function run12(argv, io) {
78786
78953
  return recordError.code === "busy" ? 4 : recordError.code === "invalid_input" ? 2 : 5;
78787
78954
  return 0;
78788
78955
  }
78789
- var USAGE6 = 'workit check <name> [--full] [--no-lease --reason "<why>"] | workit check [--name <n>] [--shell] [--heavy] [--full] [--wait <s>] [--timeout <s>] [--task <id>] [--json] -- <cmd…>', OUTPUT_LINES = 60, MAX_UNVERIFIED_RUN_MS, RECORD_TAIL_LINES = 20, RECORD_TAIL_CHARS = 200, posix = (value) => value.split(path53.sep).join("/") || ".", ENGINE_CODES2, FINGERPRINT_VARS, ANSI_COLOR, WARNING_LINE, NO_WARNINGS, HOST_TOOL_WAIT_CAP_MS = 1e5;
78956
+ var USAGE6 = 'workit check <name> [--full] [--wait <dur> | --no-wait] [--no-lease --reason "<why>"] | workit check [--name <n>] [--shell] [--heavy] [--full] [--wait <dur> | --no-wait] [--timeout <s>] [--task <id>] [--json] -- <cmd…>', OUTPUT_LINES = 60, MAX_UNVERIFIED_RUN_MS, RECORD_TAIL_LINES = 20, RECORD_TAIL_CHARS = 200, posix = (value) => value.split(path53.sep).join("/") || ".", ENGINE_CODES2, FINGERPRINT_VARS, ANSI_COLOR, WARNING_LINE, NO_WARNINGS, STDERR_TAIL_LINES = 10, STDERR_KEEP_CHARS = 65536;
78790
78957
  var init_check = __esm(() => {
78791
78958
  init_check_config();
78792
78959
  init_checks3();
@@ -78824,25 +78991,35 @@ async function settingsFor(io) {
78824
78991
  { label: CHECKS_FILE, trust: "repo", value: config.resources }
78825
78992
  ]).settings;
78826
78993
  }
78827
- function slotLines(slots) {
78994
+ function slotLines(slots, queue = []) {
78828
78995
  const busy = held(slots).length;
78996
+ const waiting = queue.filter((entry) => !entry.stale);
78829
78997
  return [
78830
78998
  `heavy-check lease: ${busy} of ${slots.length} slot${slots.length === 1 ? "" : "s"} held`,
78831
- ...slots.map((slot) => slot.holder ? ` slot ${slot.slot}: ${describeHolder(slot.holder)}${slot.stale ? ` (stale: ${slot.stale}; the next heavy check reclaims it)` : ""}` : ` slot ${slot.slot}: free`)
78999
+ ...slots.map((slot) => slot.holder ? ` slot ${slot.slot}: ${describeHolder(slot.holder)}${slot.stale ? ` (stale: ${slot.stale}; the next heavy check reclaims it)` : ""}` : ` slot ${slot.slot}: free`),
79000
+ `queue: ${waiting.length ? `${waiting.length} waiting, first come, first served` : "empty"}`,
79001
+ ...waiting.map((entry, index) => ` ${index + 1}. ${describeHolder(entry.holder)}`)
78832
79002
  ];
78833
79003
  }
78834
79004
  async function status(io) {
78835
79005
  const settings = await settingsFor(io);
78836
79006
  const slots = leaseSlots(leaseDir(io.env), settings.slots);
78837
- return emit(io, ok2({ dir: leaseDir(io.env), slots, waitMs: settings.waitMs }), (data) => slotLines(data.slots));
79007
+ const queue = leaseQueue(leaseDir(io.env));
79008
+ const waiting = queue.filter((entry) => !entry.stale).map((entry) => entry.holder);
79009
+ return emit(io, ok2({
79010
+ dir: leaseDir(io.env),
79011
+ slots,
79012
+ queue: waiting,
79013
+ waitMs: settings.waitMs,
79014
+ light: settings.light
79015
+ }), () => slotLines(slots, queue));
78838
79016
  }
78839
79017
  function timeoutOf(args) {
78840
79018
  const at = args.findIndex((arg) => arg === "--timeout" || arg.startsWith("--timeout="));
78841
79019
  if (at < 0)
78842
79020
  return null;
78843
79021
  const raw = args[at].includes("=") ? args[at].split("=")[1] : args[at + 1];
78844
- const seconds = Number(raw);
78845
- return Number.isFinite(seconds) && seconds >= 0 ? Math.round(seconds * 1000) : new Error("--timeout must be a number of seconds");
79022
+ return parseWait(raw ?? "") ?? new Error('--timeout must be seconds or a duration ("90", "90s", "30m", "1h")');
78846
79023
  }
78847
79024
  async function wait(args, io) {
78848
79025
  const timeout = timeoutOf(args);
@@ -78859,10 +79036,10 @@ async function wait(args, io) {
78859
79036
  const waitedMs = Date.now() - started;
78860
79037
  if (busy.length < slots.length)
78861
79038
  return emit(io, ok2({ free: true, waitedMs, slots }), () => [
78862
- `workit: the heavy-check lease has a free slot${waitedMs ? ` after ${Math.round(waitedMs / 1000)} s` : ""}`
79039
+ `workit: the heavy-check lease has a free slot${waitedMs ? ` after ${formatWait(waitedMs)}` : ""}`
78863
79040
  ]);
78864
79041
  if (waitedMs >= limit)
78865
- return emit(io, fail2("busy", `the heavy-check lease stayed busy for ${Math.round(waitedMs / 1000)} s`, {
79042
+ return emit(io, fail2("busy", `the heavy-check lease stayed busy for ${formatWait(waitedMs)}`, {
78866
79043
  data: { free: false, waitedMs, slots },
78867
79044
  unblock: "workit lock status"
78868
79045
  }));
@@ -78871,16 +79048,16 @@ async function wait(args, io) {
78871
79048
  io.stderr(`workit: waiting for the heavy-check lease: held by ${busy.flatMap((slot) => slot.holder ? [describeHolder(slot.holder)] : []).join("; ")}
78872
79049
  `);
78873
79050
  }
78874
- await sleep2(Math.min(1000, Math.max(1, limit - waitedMs)));
79051
+ await sleep(Math.min(1000, Math.max(1, limit - waitedMs)));
78875
79052
  }
78876
79053
  }
78877
79054
  async function runHeavy(args, io) {
78878
79055
  const split = args.indexOf("--");
78879
79056
  const head = split < 0 ? args : args.slice(0, split);
78880
- const unknown = head.find((arg, index) => arg !== "--shell" && arg !== "--json" && !arg.startsWith("--timeout") && !arg.startsWith("--wait") && !((head[index - 1] === "--timeout" || head[index - 1] === "--wait") && !arg.startsWith("-")));
79057
+ const unknown = head.find((arg, index) => arg !== "--shell" && arg !== "--json" && arg !== "--no-wait" && !arg.startsWith("--timeout") && !arg.startsWith("--wait") && !((head[index - 1] === "--timeout" || head[index - 1] === "--wait") && !arg.startsWith("-")));
78881
79058
  if (split < 0 || unknown)
78882
79059
  return emit(io, fail2("invalid_input", unknown ? `unexpected ${unknown}` : "no command after --", {
78883
- unblock: "workit lock run [--shell] [--wait <s>] [--timeout <s>] -- <cmd…>"
79060
+ unblock: "workit lock run [--shell] [--wait <dur> | --no-wait] [--timeout <s>] -- <cmd…>"
78884
79061
  }));
78885
79062
  const check = await Promise.resolve().then(() => (init_check(), {}));
78886
79063
  return run12([`--heavy`, ...head, ...args.slice(split)], io);
@@ -78966,7 +79143,7 @@ async function run13(argv, io) {
78966
79143
  return heavy(rest, io);
78967
79144
  return emit(io, fail2("invalid_input", `unknown lock subcommand ${sub ?? "(none)"}`, { unblock: USAGE7 }));
78968
79145
  }
78969
- var USAGE7 = "workit lock status | wait [--timeout <s>] | run [--shell] [--wait <s>] [--timeout <s>] -- <cmd…> | heavy list|add|remove|test", held = (slots) => slots.filter((slot) => slot.holder && !slot.stale), sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), commandText = (args) => {
79146
+ var USAGE7 = "workit lock status | wait [--timeout <dur>] | run [--shell] [--wait <dur> | --no-wait] [--timeout <s>] -- <cmd…> | heavy list|add|remove|test", held = (slots) => slots.filter((slot) => slot.holder && !slot.stale), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), commandText = (args) => {
78970
79147
  const split = args.indexOf("--");
78971
79148
  return (split < 0 ? args : args.slice(split + 1)).join(" ").trim();
78972
79149
  };
@@ -82301,6 +82478,74 @@ function syncStack(cwd, ctx, stack, options, actor) {
82301
82478
  }
82302
82479
  return { ok: true, data: outcome };
82303
82480
  }
82481
+ function pruneMergedBranches(cwd, ctx, stack, options) {
82482
+ const outcome = { deleted: [], kept: [] };
82483
+ const remote = remoteFor(cwd, ctx?.resolved ?? null, stack.trunk);
82484
+ const trunkSha = trunkTip(cwd, remote, stack.trunk);
82485
+ const keep = (branch, reason) => outcome.kept.push({ branch, reason });
82486
+ for (const entry of stack.branches) {
82487
+ if (!entry.merged || !safeRef3(entry.branch) || entry.branch === stack.trunk)
82488
+ continue;
82489
+ const tip = branchTip(cwd, entry.branch);
82490
+ if (!tip)
82491
+ continue;
82492
+ if (isProtectedTarget(cwd, entry.branch)) {
82493
+ keep(entry.branch, "a protected branch under the workspace branch policy; never deleted");
82494
+ continue;
82495
+ }
82496
+ if (entry.lastHead === null || tip !== entry.lastHead) {
82497
+ keep(entry.branch, `its tip ${tip.slice(0, 12)} is not recorded by the stack (last ${entry.lastHead?.slice(0, 12) ?? "none"}); commits made after it merged stay`);
82498
+ continue;
82499
+ }
82500
+ const owner = worktreeOf(cwd, entry.branch);
82501
+ if (owner) {
82502
+ keep(entry.branch, `checked out in ${owner}; switch it away, then git branch -D ${entry.branch}`);
82503
+ continue;
82504
+ }
82505
+ const safe = safeToDelete(cwd, ctx, remote, trunkSha, entry, tip);
82506
+ if (safe === null) {
82507
+ keep(entry.branch, `${tip.slice(0, 12)} is not on ${stack.trunk}, not the head its PR merged at and not on ${remote}/${entry.branch}: deleting it could lose commits (push them, or delete it yourself)`);
82508
+ continue;
82509
+ }
82510
+ if (!options.dryRun) {
82511
+ const removed = gitRun3(cwd, ["branch", "-D", "--", entry.branch]);
82512
+ if (!removed.ok) {
82513
+ keep(entry.branch, `git branch -D failed: ${removed.stderr.trim().split(`
82514
+ `)[0] ?? ""}`);
82515
+ continue;
82516
+ }
82517
+ appendObserved(cwd, {
82518
+ type: "stack.branch.deleted",
82519
+ actor: options.actor,
82520
+ stack: stack.name,
82521
+ branch: entry.branch,
82522
+ head: tip,
82523
+ pr: entry.pr,
82524
+ because: safe
82525
+ });
82526
+ }
82527
+ outcome.deleted.push({ branch: entry.branch, sha: tip });
82528
+ }
82529
+ return outcome;
82530
+ }
82531
+ function safeToDelete(cwd, ctx, remote, trunkSha, entry, tip) {
82532
+ if (trunkSha && isAncestor2(cwd, tip, trunkSha))
82533
+ return "on_trunk";
82534
+ if (ctx && entry.pr) {
82535
+ const read = statusDoc(ctx, entry.pr);
82536
+ if (read.ok && read.data.state === "merged" && read.data.head.sha === tip)
82537
+ return "merged_head";
82538
+ }
82539
+ const own = pushRemoteName(cwd, entry.branch) ?? remote;
82540
+ const remoteTip = remoteRefTip(cwd, own, `refs/heads/${entry.branch}`);
82541
+ if (remoteTip.ok && remoteTip.sha) {
82542
+ if (!hasCommit(cwd, remoteTip.sha))
82543
+ fetchRefs(cwd, own, [remoteTip.sha]);
82544
+ if (hasCommit(cwd, remoteTip.sha) && isAncestor2(cwd, tip, remoteTip.sha))
82545
+ return "on_remote";
82546
+ }
82547
+ return null;
82548
+ }
82304
82549
  async function awaitChecks(ctx, pr, head, options) {
82305
82550
  let waited = 0;
82306
82551
  for (let poll = 0;; poll += 1) {
@@ -82541,6 +82786,7 @@ ${run.stdout}`.split(`
82541
82786
  }, commitCount = (cwd, base, head) => Number(gitRun3(cwd, ["rev-list", "--count", "--no-merges", `${base}..${head}`]).stdout.trim()) || 0, shortStat = (cwd, base, head) => gitRun3(cwd, ["diff", "--shortstat", `${base}...${head}`]).stdout.trim();
82542
82787
  var init_stack = __esm(() => {
82543
82788
  init_autonomy();
82789
+ init_branch();
82544
82790
  init_store_lock();
82545
82791
  init_pr_ops();
82546
82792
  init_report2();
@@ -84991,8 +85237,213 @@ var init_pr_edit = __esm(() => {
84991
85237
  init_report2();
84992
85238
  });
84993
85239
 
84994
- // packages/workit-core/src/core/live-session.ts
85240
+ // packages/workit-core/src/fanout-landed.ts
84995
85241
  import { spawnSync as spawnSync26 } from "node:child_process";
85242
+ function forgeBreaker(resolved) {
85243
+ if (!resolved)
85244
+ return { forge: null, down: () => null };
85245
+ let tripped = null;
85246
+ const guard = (call) => (...args) => {
85247
+ if (tripped)
85248
+ return { ok: false, code: "unavailable", error: tripped };
85249
+ const result = call(...args);
85250
+ if (!result.ok && result.code === "unavailable")
85251
+ tripped = result.error;
85252
+ return result;
85253
+ };
85254
+ const inner = resolved.forge;
85255
+ const forge = Object.create(inner);
85256
+ forge.findPr = guard(inner.findPr.bind(inner));
85257
+ forge.prStatus = guard(inner.prStatus.bind(inner));
85258
+ return { forge: { ...resolved, forge }, down: () => tripped };
85259
+ }
85260
+ function branchCreatedAt(cwd, branch) {
85261
+ if (!branch || branch.startsWith("-"))
85262
+ return null;
85263
+ const log = gitRun4(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
85264
+ if (!log.ok)
85265
+ return null;
85266
+ return log.stdout.trim().split(`
85267
+ `).findLast(Boolean) ?? null;
85268
+ }
85269
+ function hasOwnCommits2(cwd, branch, start, head) {
85270
+ if (!branch || branch.startsWith("-"))
85271
+ return false;
85272
+ const log = gitRun4(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
85273
+ if (!log.ok)
85274
+ return false;
85275
+ for (const line of log.stdout.split(`
85276
+ `)) {
85277
+ const [sha, subject = ""] = line.split("\x00");
85278
+ if (!sha)
85279
+ continue;
85280
+ const made = /^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
85281
+ const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
85282
+ if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha)))
85283
+ continue;
85284
+ if (sha !== head && !isAncestor3(cwd, sha, head))
85285
+ continue;
85286
+ if (start && (sha === start || isAncestor3(cwd, sha, start)))
85287
+ continue;
85288
+ return true;
85289
+ }
85290
+ return false;
85291
+ }
85292
+ function reverted(cwd, trunk, landedHead, start) {
85293
+ const fork = mergeBase(cwd, landedHead, trunk);
85294
+ if (fork && fork !== landedHead)
85295
+ return unchangedSince(cwd, fork, landedHead, trunk);
85296
+ const before = start && start !== landedHead && isAncestor3(cwd, start, landedHead) ? start : gitRun4(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
85297
+ return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
85298
+ }
85299
+ function unchangedSince(cwd, before, after, trunk) {
85300
+ const paths = gitRun4(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
85301
+ const files = paths.ok ? paths.stdout.split("\x00").filter(Boolean) : [];
85302
+ if (files.length === 0)
85303
+ return false;
85304
+ return gitRun4(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
85305
+ }
85306
+ function trunkPatchIds(ctx, fork) {
85307
+ const cache = ctx.cache ??= new Map;
85308
+ const known = cache.get(fork);
85309
+ if (known)
85310
+ return known;
85311
+ const ids = new Set;
85312
+ const log = gitRun4(ctx.cwd, [
85313
+ "log",
85314
+ "--no-merges",
85315
+ `--max-count=${TRUNK_SCAN}`,
85316
+ "-p",
85317
+ "--format=commit %H",
85318
+ "-U3",
85319
+ "--diff-algorithm=myers",
85320
+ "--indent-heuristic",
85321
+ "--no-color",
85322
+ "--no-ext-diff",
85323
+ "--no-textconv",
85324
+ "--no-renames",
85325
+ "--full-index",
85326
+ "--src-prefix=a/",
85327
+ "--dst-prefix=b/",
85328
+ `${fork}..${ctx.trunk}`,
85329
+ "--"
85330
+ ]);
85331
+ if (log.ok && log.stdout.trim()) {
85332
+ const run = spawnSync26("git", ["patch-id", "--stable"], {
85333
+ cwd: ctx.cwd,
85334
+ input: log.stdout,
85335
+ encoding: "utf8",
85336
+ timeout: GIT_TIMEOUTS.local,
85337
+ windowsHide: true,
85338
+ maxBuffer: 64 * 1024 * 1024
85339
+ });
85340
+ for (const line of (run.stdout ?? "").split(`
85341
+ `)) {
85342
+ const id = line.trim().split(/\s+/u)[0];
85343
+ if (id)
85344
+ ids.add(id);
85345
+ }
85346
+ }
85347
+ cache.set(fork, ids);
85348
+ return ids;
85349
+ }
85350
+ function inSliceRun(row, slice) {
85351
+ if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
85352
+ return row.sliceHash === slice.hash;
85353
+ const since = Date.parse(slice.hashSince);
85354
+ return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
85355
+ }
85356
+ function linked(rows, input, slice, head) {
85357
+ const since = input.since ? Date.parse(input.since) : Number.NaN;
85358
+ const recent = (row) => Number.isNaN(since) || Date.parse(String(row.at)) >= since;
85359
+ return rows.some((row) => {
85360
+ if (row.type === "fanout.worktree.created" && row.fanout === input.fanout && row.slice === slice.id)
85361
+ return typeof row.sliceHash === "string" && input.sliceHash ? row.sliceHash === input.sliceHash : recent(row);
85362
+ return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
85363
+ });
85364
+ }
85365
+ function detectLanding(ctx, slice, input) {
85366
+ const out = {
85367
+ landed: false,
85368
+ how: null,
85369
+ pr: null,
85370
+ landedHead: null,
85371
+ forgeError: null,
85372
+ note: null
85373
+ };
85374
+ const landed = (how, landedHead) => {
85375
+ if (!hasCommit(ctx.cwd, landedHead) || !reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom))
85376
+ return { ...out, landed: true, how, landedHead };
85377
+ return {
85378
+ ...out,
85379
+ note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`
85380
+ };
85381
+ };
85382
+ const { head } = input;
85383
+ if (ctx.forge) {
85384
+ const found = ctx.forge.forge.findPr(slice.branch, {
85385
+ owner: ctx.forge.headRepo.split("/")[0] ?? null,
85386
+ projectId: ctx.forge.headProjectId,
85387
+ sha: head
85388
+ });
85389
+ if (!found.ok)
85390
+ out.forgeError = found.error;
85391
+ else if (found.data) {
85392
+ out.pr = found.data;
85393
+ if (found.data.state === "merged") {
85394
+ const merged = found.data.headSha;
85395
+ const label = `#${found.data.number}`;
85396
+ if (!head) {
85397
+ if (!linked(input.rows, input, slice, merged)) {
85398
+ out.pr = null;
85399
+ out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
85400
+ return out;
85401
+ }
85402
+ return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
85403
+ }
85404
+ if (!merged || head === merged || isAncestor3(ctx.cwd, head, merged))
85405
+ return landed("pr_merged", merged ?? head);
85406
+ out.note = isAncestor3(ctx.cwd, merged, head) ? `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} is ahead of it with newer commits` : `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} has diverged from it (rewritten after the merge)`;
85407
+ return out;
85408
+ }
85409
+ }
85410
+ }
85411
+ if (!head)
85412
+ return out;
85413
+ const fork = mergeBase(ctx.cwd, head, ctx.trunk);
85414
+ if (fork === head) {
85415
+ return hasOwnCommits2(ctx.cwd, slice.branch, input.createdFrom, head) ? landed("on_trunk", head) : out;
85416
+ }
85417
+ if (!fork)
85418
+ return out;
85419
+ const trunkIds = trunkPatchIds(ctx, fork);
85420
+ if (trunkIds.size === 0)
85421
+ return out;
85422
+ const froms = [fork];
85423
+ if (input.parentHead && input.parentHead !== head && isAncestor3(ctx.cwd, input.parentHead, head))
85424
+ froms.push(input.parentHead);
85425
+ for (const from of froms) {
85426
+ const id = patchId(ctx.cwd, from, head);
85427
+ if (id && trunkIds.has(id))
85428
+ return landed("squash_patch", head);
85429
+ }
85430
+ return out;
85431
+ }
85432
+ function sliceStart(cwd, rows, fanout, slice) {
85433
+ const reflog = branchCreatedAt(cwd, slice.branch);
85434
+ if (reflog)
85435
+ return reflog;
85436
+ const row = rows.findLast((candidate) => candidate.type === "fanout.worktree.created" && candidate.fanout === fanout && candidate.slice === slice.id && typeof candidate.baseSha === "string" && inSliceRun(candidate, slice));
85437
+ return row?.baseSha ?? null;
85438
+ }
85439
+ var isAncestor3 = (cwd, a, b) => gitRun4(cwd, ["merge-base", "--is-ancestor", a, b]).ok, TRUNK_SCAN = 500;
85440
+ var init_fanout_landed = __esm(() => {
85441
+ init_rev();
85442
+ init_fanout();
85443
+ });
85444
+
85445
+ // packages/workit-core/src/core/live-session.ts
85446
+ import { spawnSync as spawnSync27 } from "node:child_process";
84996
85447
  var LIVE_SESSION_TTL_MS, liveProbe, PIDS;
84997
85448
  var init_live_session = __esm(() => {
84998
85449
  init_paths();
@@ -85001,7 +85452,7 @@ var init_live_session = __esm(() => {
85001
85452
  LIVE_SESSION_TTL_MS = 15 * 60000;
85002
85453
  liveProbe = {
85003
85454
  platform: process.platform,
85004
- run: (args) => spawnSync26("ps", args, { encoding: "utf8", timeout: 2000 }).stdout ?? "",
85455
+ run: (args) => spawnSync27("ps", args, { encoding: "utf8", timeout: 2000 }).stdout ?? "",
85005
85456
  start: (pid) => processStartOf(pid)
85006
85457
  };
85007
85458
  PIDS = new Map;
@@ -85294,8 +85745,65 @@ function worktreeDependencies(main, worktree) {
85294
85745
  ]
85295
85746
  };
85296
85747
  }
85748
+ const stale = staleBunInstall(main);
85749
+ if (stale && !fs32.existsSync(path64.join(worktree, "node_modules"))) {
85750
+ const command = `bun install --frozen-lockfile --cwd ${worktree}`;
85751
+ return {
85752
+ linked: null,
85753
+ install: command,
85754
+ notes: [
85755
+ `the main checkout's node_modules (${main}) do not match its bun.lock (${stale}), so they were not linked; install dependencies first: ${command}`
85756
+ ]
85757
+ };
85758
+ }
85297
85759
  return { ...linkWorktreeDependencies(main, worktree), install: null };
85298
85760
  }
85761
+ function staleBunInstall(main) {
85762
+ if (!fs32.existsSync(path64.join(main, "node_modules")))
85763
+ return null;
85764
+ const text = readBytes(path64.join(main, "bun.lock"))?.toString("utf8");
85765
+ if (!text)
85766
+ return null;
85767
+ let packages;
85768
+ try {
85769
+ const parsed = JSON.parse(text.replace(/,(\s*[}\]])/gu, "$1"));
85770
+ if (!parsed.packages || typeof parsed.packages !== "object")
85771
+ return null;
85772
+ packages = parsed.packages;
85773
+ } catch {
85774
+ return null;
85775
+ }
85776
+ const problems = [];
85777
+ let scanned = 0;
85778
+ for (const [key, entry] of Object.entries(packages)) {
85779
+ if (scanned++ >= STALE_SCAN || problems.length >= 3)
85780
+ break;
85781
+ const spec = Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : null;
85782
+ if (!spec)
85783
+ continue;
85784
+ const at = spec.lastIndexOf("@");
85785
+ if (at <= 0)
85786
+ continue;
85787
+ const name = spec.slice(0, at);
85788
+ const version = spec.slice(at + 1);
85789
+ if (name !== key || version.includes(":"))
85790
+ continue;
85791
+ const manifest = readBytes(path64.join(main, "node_modules", key, "package.json"));
85792
+ if (!manifest) {
85793
+ problems.push(`${key} missing`);
85794
+ continue;
85795
+ }
85796
+ let installed;
85797
+ try {
85798
+ installed = JSON.parse(manifest.toString("utf8")).version;
85799
+ } catch {
85800
+ installed = null;
85801
+ }
85802
+ if (installed !== version)
85803
+ problems.push(`${key} ${String(installed ?? "?")} installed, bun.lock has ${version}`);
85804
+ }
85805
+ return problems.length ? problems.join("; ") : null;
85806
+ }
85299
85807
  function ensureScratch(worktree) {
85300
85808
  const scratch = path64.join(worktree, SCRATCH_DIR);
85301
85809
  fs32.mkdirSync(scratch, { recursive: true });
@@ -85372,14 +85880,19 @@ function createSliceWorktree(cwd, plan, input) {
85372
85880
  notes.push(`resumed ${slice.branch} from ${remote}/${slice.branch}`);
85373
85881
  } else {
85374
85882
  mode = "new";
85375
- const parent = plan.slices.find((other) => other.branch === slice.base);
85376
- const from = slice.base === plan.trunk ? trunkRef(cwd, plan.trunk) : parent ? branchRef(cwd, parent.branch) : branchRef(cwd, slice.base);
85883
+ const landedOn = dependenciesLanded(cwd, plan, slice, input.forge);
85884
+ if (landedOn) {
85885
+ base.base = plan.trunk;
85886
+ notes.push(landedOn);
85887
+ }
85888
+ const parent = landedOn ? undefined : plan.slices.find((other) => other.branch === slice.base);
85889
+ const from = base.base === plan.trunk ? trunkRef(cwd, plan.trunk) : parent ? branchRef(cwd, parent.branch) : branchRef(cwd, slice.base);
85377
85890
  if (!from)
85378
85891
  return fanoutFail("not_found", `base ${slice.base} of slice ${slice.id} does not resolve here`, parent ? `start slice ${parent.id} first (its branch ${parent.branch} is the base)` : `git fetch origin ${slice.base}`);
85379
85892
  const added = gitRun4(cwd, ["worktree", "add", "--detach", target, from], SLOW);
85380
85893
  if (!added.ok)
85381
85894
  return worktreeFailed(added.stderr);
85382
- const branched = gitBranch(target, { name: slice.branch, base: slice.base });
85895
+ const branched = gitBranch(target, { name: slice.branch, base: base.base });
85383
85896
  if (!branched.ok) {
85384
85897
  gitRun4(cwd, ["worktree", "remove", target], SLOW);
85385
85898
  return fanoutFail(branched.code, `workit git branch ${slice.branch} refused: ${branched.error}`, branched.unblock ?? `fix the branch name in the plan, then workit fanout worktree create ${slice.id}`);
@@ -85409,6 +85922,7 @@ function createSliceWorktree(cwd, plan, input) {
85409
85922
  sliceHash: slice.hash,
85410
85923
  ...admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {},
85411
85924
  ...baseSha ? { baseSha } : {},
85925
+ ...base.base === slice.base ? {} : { startBase: base.base },
85412
85926
  ...deps.install ? { install: deps.install } : {}
85413
85927
  });
85414
85928
  return {
@@ -85425,6 +85939,35 @@ function createSliceWorktree(cwd, plan, input) {
85425
85939
  }
85426
85940
  };
85427
85941
  }
85942
+ function dependenciesLanded(cwd, plan, slice, forge) {
85943
+ if (slice.base === plan.trunk || slice.dependsOn.length === 0)
85944
+ return null;
85945
+ const deps = slice.dependsOn.map((id) => plan.slices.find((other) => other.id === id));
85946
+ if (deps.some((dep) => !dep))
85947
+ return null;
85948
+ const trunk = trunkRef(cwd, plan.trunk);
85949
+ if (!trunk)
85950
+ return null;
85951
+ const ledger = readLedger(cwd);
85952
+ const rows = ledger.ok ? ledger.value.rows : [];
85953
+ const ctx = { cwd, trunk, forge: forgeBreaker(forge?.() ?? null).forge };
85954
+ const landed = [];
85955
+ for (const dep of deps) {
85956
+ const landing = detectLanding(ctx, dep, {
85957
+ head: branchRef(cwd, dep.branch),
85958
+ createdFrom: sliceStart(cwd, rows, plan.name, dep),
85959
+ parentHead: null,
85960
+ fanout: plan.name,
85961
+ since: dep.hashSince || plan.createdAt || null,
85962
+ sliceHash: dep.hash || null,
85963
+ rows
85964
+ });
85965
+ if (!landing.landed)
85966
+ return null;
85967
+ landed.push(`${dep.branch} landed (${landing.how ?? "pr_merged"}${landing.pr ? ` #${landing.pr.number}` : ""})`);
85968
+ }
85969
+ return `${landed.join(", ")}: started from ${plan.trunk}, not from ${slice.base}'s pre-merge tip`;
85970
+ }
85428
85971
  function releaseSliceWorktree(cwd, plan, input) {
85429
85972
  const found = findSlice(plan, input.slice);
85430
85973
  if (!found.ok)
@@ -85567,12 +86110,13 @@ var SLOW, SCRATCH_DIR = ".workit-scratch", EXCLUDE_LINE, DIRTY_ROW_BYTES = 2000,
85567
86110
  } catch {
85568
86111
  return null;
85569
86112
  }
85570
- }, worktreeFailed = (stderr) => fanoutFail("failed", `git worktree add failed: ${stderr.trim().split(`
86113
+ }, STALE_SCAN = 5000, worktreeFailed = (stderr) => fanoutFail("failed", `git worktree add failed: ${stderr.trim().split(`
85571
86114
  `)[0] || "unknown error"}`);
85572
86115
  var init_fanout_worktree = __esm(() => {
85573
86116
  init_ops();
85574
86117
  init_rev();
85575
86118
  init_ledger();
86119
+ init_fanout_landed();
85576
86120
  init_worktree_confine();
85577
86121
  init_worktree_deps();
85578
86122
  init_fanout();
@@ -87205,7 +87749,7 @@ var init_git2 = __esm(() => {
87205
87749
  });
87206
87750
 
87207
87751
  // packages/workit-core/src/forge/verify.ts
87208
- import { spawnSync as spawnSync27 } from "node:child_process";
87752
+ import { spawnSync as spawnSync28 } from "node:child_process";
87209
87753
  function finish(cwd, report, actor) {
87210
87754
  const delivered = report.observations.length > 0 && report.observations.every((entry) => entry.ok);
87211
87755
  const out = { ...report, delivered };
@@ -87418,7 +87962,7 @@ var NPM_NAME, SEMVER2, SAFE_ARG, systemNpm = (args) => {
87418
87962
  };
87419
87963
  const found = process.platform === "win32" ? findExecutable("npm.cmd", process.env) : null;
87420
87964
  const npmCmd = found && !/["%!^]/u.test(found) ? `"${found}"` : "npm.cmd";
87421
- const result = process.platform === "win32" ? spawnSync27(`${npmCmd} ${args.join(" ")}`, { ...options, shell: true }) : spawnSync27("npm", [...args], options);
87965
+ const result = process.platform === "win32" ? spawnSync28(`${npmCmd} ${args.join(" ")}`, { ...options, shell: true }) : spawnSync28("npm", [...args], options);
87422
87966
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
87423
87967
  }, short3 = (sha) => sha ? sha.slice(0, 12) : "(absent)", npmView = (stdout) => {
87424
87968
  try {
@@ -87643,7 +88187,7 @@ function planReleaseFinish(cwd, input) {
87643
88187
  return failure3("not_found", `${name} exists neither locally nor on ${remote ?? "a remote"}`);
87644
88188
  if (!tracked || name === openRelease)
87645
88189
  continue;
87646
- if (!isAncestor3(cwd, tracked, local))
88190
+ if (!isAncestor4(cwd, tracked, local))
87647
88191
  return failure3("blocked", `branch_behind: local ${name} is behind or diverged from ${remote}/${name}; git-flow finishes only onto up-to-date branches`, `git switch ${name} && git merge --ff-only ${remote}/${name} # then: workit release finish ${version}`);
87648
88192
  const productionTracked = remote ? resolveRef(cwd, `refs/remotes/${remote}/${line.production}`) : null;
87649
88193
  const foreign = git5(cwd, [
@@ -87653,16 +88197,16 @@ function planReleaseFinish(cwd, input) {
87653
88197
  `^${releaseTip}`,
87654
88198
  ...productionTracked ? [`^${productionTracked}`] : []
87655
88199
  ]).stdout.split(`
87656
- `).filter(Boolean).filter((sha) => !(git5(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok && isAncestor3(cwd, releaseTip, sha)));
88200
+ `).filter(Boolean).filter((sha) => !(git5(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok && isAncestor4(cwd, releaseTip, sha)));
87657
88201
  if (foreign.length)
87658
88202
  return failure3("blocked", `branch_ahead: local ${name} has ${foreign.length} commit(s) ${remote}/${name} lacks that are not part of ${branch}; the finish push would publish them`, `land them through review first, or: git switch ${name} && git reset --keep ${remote}/${name} # then: workit release finish ${version}`);
87659
88203
  }
87660
- if (branchTip && remoteBranch && !isAncestor3(cwd, remoteBranch, branchTip))
88204
+ if (branchTip && remoteBranch && !isAncestor4(cwd, remoteBranch, branchTip))
87661
88205
  return failure3("blocked", `release_behind: ${remote}/${branch} has commits local ${branch} lacks`, `git switch ${branch} && git merge --ff-only ${remote}/${branch}`);
87662
88206
  const production = resolveRef(cwd, `refs/heads/${line.production}`);
87663
- const merged = isAncestor3(cwd, releaseTip, production);
88207
+ const merged = isAncestor4(cwd, releaseTip, production);
87664
88208
  const localTag = resolveRef(cwd, `refs/tags/${tag}`);
87665
- if (localTag && !(isAncestor3(cwd, localTag, production) && isAncestor3(cwd, releaseTip, localTag)))
88209
+ if (localTag && !(isAncestor4(cwd, localTag, production) && isAncestor4(cwd, releaseTip, localTag)))
87666
88210
  return failure3("blocked", `tag_exists: ${tag} (${localTag.slice(0, 12)}) is not this release's merge on ${line.production}`, "a release tag never moves; finish under the next version");
87667
88211
  if (remote) {
87668
88212
  const remoteTag = remoteRefTip(cwd, remote, `refs/tags/${tag}`);
@@ -87676,7 +88220,7 @@ function planReleaseFinish(cwd, input) {
87676
88220
  tagged: localTag !== null,
87677
88221
  mergedBack: Object.fromEntries(mergeBack.map((name) => [
87678
88222
  name,
87679
- localTag !== null && isAncestor3(cwd, localTag, resolveRef(cwd, `refs/heads/${name}`))
88223
+ localTag !== null && isAncestor4(cwd, localTag, resolveRef(cwd, `refs/heads/${name}`))
87680
88224
  ])),
87681
88225
  branchDeleted: branchTip === null
87682
88226
  };
@@ -87838,7 +88382,7 @@ var VERSION, PLACEHOLDER4, validVersion = (version) => VERSION.test(version) &&
87838
88382
  return ledger.value.rows.filter((row) => row.type.startsWith("release."));
87839
88383
  }, record2 = (cwd, actor, row) => {
87840
88384
  appendObserved(cwd, { ...row, actor });
87841
- }, KINDS, isAncestor3 = (cwd, ancestor, descendant) => git5(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok, switchTo = (cwd, branch) => {
88385
+ }, KINDS, isAncestor4 = (cwd, ancestor, descendant) => git5(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]).ok, switchTo = (cwd, branch) => {
87842
88386
  if (currentBranch(cwd) === branch)
87843
88387
  return success2(undefined);
87844
88388
  const switched = git5(cwd, ["switch", "-q", branch]);
@@ -88304,24 +88848,25 @@ async function status3(argv, io) {
88304
88848
  ...data.next ? [`next: ${data.next}`] : []
88305
88849
  ]);
88306
88850
  }
88307
- async function sync(argv, io) {
88851
+ async function sync(argv, io, restack = false) {
88852
+ const usageText = restack ? RESTACK_USAGE : SYNC_USAGE;
88308
88853
  const flags = parseFlags(argv, {
88309
88854
  name: "value",
88310
- local: "boolean",
88855
+ ...restack ? {} : { local: "boolean" },
88311
88856
  "dry-run": "boolean",
88312
88857
  force: "list"
88313
88858
  });
88314
88859
  if (typeof flags === "string")
88315
- return usage4(io, flags, SYNC_USAGE);
88860
+ return usage4(io, flags, usageText);
88316
88861
  if (flags.positionals.length)
88317
- return usage4(io, `unexpected argument ${flags.positionals[0]}`, SYNC_USAGE);
88862
+ return usage4(io, `unexpected argument ${flags.positionals[0]}`, usageText);
88318
88863
  const selected = selectStack(io.cwd, flags.values.name ?? null);
88319
88864
  if (!selected.ok)
88320
88865
  return stackFailed(io, selected);
88321
88866
  const force = new Set(flags.lists.force ?? []);
88322
88867
  const unknown = [...force].filter((branch) => !selected.data.branches.some((entry) => entry.branch === branch));
88323
88868
  if (unknown.length)
88324
- return usage4(io, `--force ${unknown[0]}: not a branch of stack ${selected.data.name}`, SYNC_USAGE);
88869
+ return usage4(io, `--force ${unknown[0]}: not a branch of stack ${selected.data.name}`, usageText);
88325
88870
  const local = flags.booleans.has("local");
88326
88871
  let ctx = null;
88327
88872
  if (!local) {
@@ -88340,14 +88885,23 @@ async function sync(argv, io) {
88340
88885
  const fresh = readStack(io.cwd, name);
88341
88886
  if (!fresh.ok)
88342
88887
  return fresh;
88343
- return syncStack(io.cwd, ctx, fresh.data ?? selected.data, {
88344
- publish: !local,
88345
- dryRun: flags.booleans.has("dry-run"),
88346
- force
88347
- }, actorFromEnv(io.env));
88888
+ const stack = fresh.data ?? selected.data;
88889
+ const dryRun = flags.booleans.has("dry-run");
88890
+ const synced = syncStack(io.cwd, ctx, stack, { publish: !local, dryRun, force }, actorFromEnv(io.env));
88891
+ if (!synced.ok || !restack)
88892
+ return synced;
88893
+ return {
88894
+ ok: true,
88895
+ data: {
88896
+ ...synced.data,
88897
+ pruned: pruneMergedBranches(io.cwd, ctx, stack, { dryRun, actor: actorFromEnv(io.env) })
88898
+ }
88899
+ };
88348
88900
  });
88349
88901
  if (!result.ok)
88350
88902
  return stackFailed(io, result);
88903
+ if (restack)
88904
+ return emit(io, ok2(result.data), renderRestack);
88351
88905
  return emit(io, ok2(result.data), renderSync);
88352
88906
  }
88353
88907
  async function land(argv, io) {
@@ -88434,11 +88988,13 @@ async function run20(argv, io) {
88434
88988
  return status3(rest, io);
88435
88989
  if (sub === "sync")
88436
88990
  return sync(rest, io);
88991
+ if (sub === "restack")
88992
+ return sync(rest, io, true);
88437
88993
  if (sub === "land")
88438
88994
  return land(rest, io);
88439
88995
  return usage4(io, sub && !sub.startsWith("-") ? `unknown stack subcommand "${sub}"` : "missing stack subcommand", USAGE13);
88440
88996
  }
88441
- var PLAN_USAGE = "workit stack plan [--name <n>] [--trunk <b> | --track <t>] [<bottom> … <top>] [--json]", STATUS_USAGE2 = "workit stack status [--name <n>] [--json]", SYNC_USAGE = "workit stack sync [--name <n>] [--local] [--dry-run] [--force <branch>]… [--json]", LAND_USAGE = "workit stack land [--name <n>] [--dry-run] [--max <n>] [--method squash|merge|rebase] [--unverified --reason <why>] [--base-red-ok --reason <why>] [--timeout 20m] [--interval 30s] [--json]", USAGE13 = "workit stack plan|status|sync|land ... (workit help stack)", short5 = (sha) => sha ? sha.slice(0, 12) : "?", stackFailed = (io, result) => emit(io, fail2(result.code, result.error, {
88997
+ var PLAN_USAGE = "workit stack plan [--name <n>] [--trunk <b> | --track <t>] [<bottom> … <top>] [--json]", STATUS_USAGE2 = "workit stack status [--name <n>] [--json]", SYNC_USAGE = "workit stack sync [--name <n>] [--local] [--dry-run] [--force <branch>]… [--json]", RESTACK_USAGE = "workit stack restack [--name <n>] [--dry-run] [--force <branch>]… [--json]", LAND_USAGE = "workit stack land [--name <n>] [--dry-run] [--max <n>] [--method squash|merge|rebase] [--unverified --reason <why>] [--base-red-ok --reason <why>] [--timeout 20m] [--interval 30s] [--json]", USAGE13 = "workit stack plan|status|sync|restack|land ... (workit help stack)", short5 = (sha) => sha ? sha.slice(0, 12) : "?", stackFailed = (io, result) => emit(io, fail2(result.code, result.error, {
88442
88998
  unblock: result.unblock,
88443
88999
  data: result.data ?? {}
88444
89000
  })), label3 = (forge, pr) => pr === null ? "no PR" : `${forge === "gitlab" ? "MR !" : "PR #"}${pr}`, renderSync = (data) => [
@@ -88449,6 +89005,10 @@ var PLAN_USAGE = "workit stack plan [--name <n>] [--trunk <b> | --track <t>] [<b
88449
89005
  step.push ? step.push.pushed ? `pushed${step.push.forced ? " (lease)" : ""}` : "remote current" : "",
88450
89006
  step.retarget ? `retargeted ${step.retarget.from} -> ${step.retarget.to}` : ""
88451
89007
  ].filter(Boolean).join(" "))
89008
+ ], renderRestack = (data) => [
89009
+ ...renderSync(data),
89010
+ ...data.pruned.deleted.map((item) => `${data.dryRun ? "would delete" : "deleted"} merged branch ${item.branch} at ${item.sha} (local; restore: git branch ${item.branch} ${item.sha})`),
89011
+ ...data.pruned.kept.map((item) => `kept merged branch ${item.branch}: ${item.reason}`)
88452
89012
  ], METHODS3;
88453
89013
  var init_stack2 = __esm(() => {
88454
89014
  init_ledger();
@@ -88488,6 +89048,7 @@ function renderBrief(cwd, plan, input) {
88488
89048
  const planned = `${slicePath(cwd, slice)}${input.attempt === null ? "" : `+try${input.attempt}`}`;
88489
89049
  const made = ledger.value.rows.findLast((row) => row.type === "fanout.worktree.created" && row.fanout === plan.name && row.slice === slice.id && typeof row.path === "string" && samePath(row.path, planned));
88490
89050
  const fresh = Boolean(head) && made?.baseSha === head;
89051
+ const sliceBase = typeof made?.startBase === "string" ? made.startBase : slice.base;
88491
89052
  const mode = input.mode ?? (head && !fresh ? "resume" : "new");
88492
89053
  if (mode === "resume" && !head)
88493
89054
  return fanoutFail("not_found", `MODE resume needs the branch ${branch}, which does not exist here`, `workit fanout brief ${slice.id} --mode new`);
@@ -88516,13 +89077,13 @@ function renderBrief(cwd, plan, input) {
88516
89077
  `GOAL: ${slice.goal}`,
88517
89078
  `SCOPE: ${slice.scope.join(", ")}`,
88518
89079
  ...slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : [],
88519
- ` branch: ${branch} base: ${slice.base}`,
89080
+ ` branch: ${branch} base: ${sliceBase}`,
88520
89081
  `CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
88521
89082
  ...slice.issues.length ? [
88522
89083
  `ISSUE: ${slice.issues.join(", ")} (\`workit pr create\` on ${branch} links ${slice.issues.length === 1 ? "it" : "them"} by itself; no --issue needed)`
88523
89084
  ] : [],
88524
89085
  ...mode === "resume" && head ? [
88525
- ` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${slice.base}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`
89086
+ ` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${sliceBase}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`
88526
89087
  ] : [],
88527
89088
  "ACCEPTANCE:",
88528
89089
  ...slice.acceptance.map((line) => ` - ${line}`),
@@ -88545,7 +89106,7 @@ function renderBrief(cwd, plan, input) {
88545
89106
  exists ? `WORKTREE: ${planned} (made by \`workit fanout worktree create ${slice.id}\`; yours alone)` : `WORKTREE: ${planned} (not made yet: the lead runs \`workit fanout worktree create ${slice.id}\` before spawning, then renders this brief again)`,
88546
89107
  ...exists && typeof made?.install === "string" && !fs35.existsSync(path68.join(planned, "node_modules")) ? [`INSTALL: \`${made.install}\` first: the main checkout's node_modules was not linked here`] : [],
88547
89108
  ...identityLines(identity, minted),
88548
- ...rules(mode, branch, slice.base, exists ? planned : null)
89109
+ ...rules(mode, branch, sliceBase, exists ? planned : null)
88549
89110
  ];
88550
89111
  return {
88551
89112
  ok: true,
@@ -88555,7 +89116,7 @@ function renderBrief(cwd, plan, input) {
88555
89116
  mode,
88556
89117
  attempt: input.attempt,
88557
89118
  branch,
88558
- base: slice.base,
89119
+ base: sliceBase,
88559
89120
  tier: slice.tier,
88560
89121
  issues: slice.issues,
88561
89122
  model: model?.model ?? null,
@@ -88620,211 +89181,6 @@ var init_fanout_brief = __esm(() => {
88620
89181
  ];
88621
89182
  });
88622
89183
 
88623
- // packages/workit-core/src/fanout-landed.ts
88624
- import { spawnSync as spawnSync28 } from "node:child_process";
88625
- function forgeBreaker(resolved) {
88626
- if (!resolved)
88627
- return { forge: null, down: () => null };
88628
- let tripped = null;
88629
- const guard = (call) => (...args) => {
88630
- if (tripped)
88631
- return { ok: false, code: "unavailable", error: tripped };
88632
- const result = call(...args);
88633
- if (!result.ok && result.code === "unavailable")
88634
- tripped = result.error;
88635
- return result;
88636
- };
88637
- const inner = resolved.forge;
88638
- const forge = Object.create(inner);
88639
- forge.findPr = guard(inner.findPr.bind(inner));
88640
- forge.prStatus = guard(inner.prStatus.bind(inner));
88641
- return { forge: { ...resolved, forge }, down: () => tripped };
88642
- }
88643
- function branchCreatedAt(cwd, branch) {
88644
- if (!branch || branch.startsWith("-"))
88645
- return null;
88646
- const log = gitRun4(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
88647
- if (!log.ok)
88648
- return null;
88649
- return log.stdout.trim().split(`
88650
- `).findLast(Boolean) ?? null;
88651
- }
88652
- function hasOwnCommits2(cwd, branch, start, head) {
88653
- if (!branch || branch.startsWith("-"))
88654
- return false;
88655
- const log = gitRun4(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
88656
- if (!log.ok)
88657
- return false;
88658
- for (const line of log.stdout.split(`
88659
- `)) {
88660
- const [sha, subject = ""] = line.split("\x00");
88661
- if (!sha)
88662
- continue;
88663
- const made = /^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
88664
- const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
88665
- if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha)))
88666
- continue;
88667
- if (sha !== head && !isAncestor4(cwd, sha, head))
88668
- continue;
88669
- if (start && (sha === start || isAncestor4(cwd, sha, start)))
88670
- continue;
88671
- return true;
88672
- }
88673
- return false;
88674
- }
88675
- function reverted(cwd, trunk, landedHead, start) {
88676
- const fork = mergeBase(cwd, landedHead, trunk);
88677
- if (fork && fork !== landedHead)
88678
- return unchangedSince(cwd, fork, landedHead, trunk);
88679
- const before = start && start !== landedHead && isAncestor4(cwd, start, landedHead) ? start : gitRun4(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
88680
- return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
88681
- }
88682
- function unchangedSince(cwd, before, after, trunk) {
88683
- const paths = gitRun4(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
88684
- const files = paths.ok ? paths.stdout.split("\x00").filter(Boolean) : [];
88685
- if (files.length === 0)
88686
- return false;
88687
- return gitRun4(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
88688
- }
88689
- function trunkPatchIds(ctx, fork) {
88690
- const cache = ctx.cache ??= new Map;
88691
- const known = cache.get(fork);
88692
- if (known)
88693
- return known;
88694
- const ids = new Set;
88695
- const log = gitRun4(ctx.cwd, [
88696
- "log",
88697
- "--no-merges",
88698
- `--max-count=${TRUNK_SCAN}`,
88699
- "-p",
88700
- "--format=commit %H",
88701
- "-U3",
88702
- "--diff-algorithm=myers",
88703
- "--indent-heuristic",
88704
- "--no-color",
88705
- "--no-ext-diff",
88706
- "--no-textconv",
88707
- "--no-renames",
88708
- "--full-index",
88709
- "--src-prefix=a/",
88710
- "--dst-prefix=b/",
88711
- `${fork}..${ctx.trunk}`,
88712
- "--"
88713
- ]);
88714
- if (log.ok && log.stdout.trim()) {
88715
- const run = spawnSync28("git", ["patch-id", "--stable"], {
88716
- cwd: ctx.cwd,
88717
- input: log.stdout,
88718
- encoding: "utf8",
88719
- timeout: GIT_TIMEOUTS.local,
88720
- windowsHide: true,
88721
- maxBuffer: 64 * 1024 * 1024
88722
- });
88723
- for (const line of (run.stdout ?? "").split(`
88724
- `)) {
88725
- const id = line.trim().split(/\s+/u)[0];
88726
- if (id)
88727
- ids.add(id);
88728
- }
88729
- }
88730
- cache.set(fork, ids);
88731
- return ids;
88732
- }
88733
- function inSliceRun(row, slice) {
88734
- if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
88735
- return row.sliceHash === slice.hash;
88736
- const since = Date.parse(slice.hashSince);
88737
- return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
88738
- }
88739
- function linked(rows, input, slice, head) {
88740
- const since = input.since ? Date.parse(input.since) : Number.NaN;
88741
- const recent = (row) => Number.isNaN(since) || Date.parse(String(row.at)) >= since;
88742
- return rows.some((row) => {
88743
- if (row.type === "fanout.worktree.created" && row.fanout === input.fanout && row.slice === slice.id)
88744
- return typeof row.sliceHash === "string" && input.sliceHash ? row.sliceHash === input.sliceHash : recent(row);
88745
- return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
88746
- });
88747
- }
88748
- function detectLanding(ctx, slice, input) {
88749
- const out = {
88750
- landed: false,
88751
- how: null,
88752
- pr: null,
88753
- landedHead: null,
88754
- forgeError: null,
88755
- note: null
88756
- };
88757
- const landed = (how, landedHead) => {
88758
- if (!hasCommit(ctx.cwd, landedHead) || !reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom))
88759
- return { ...out, landed: true, how, landedHead };
88760
- return {
88761
- ...out,
88762
- note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`
88763
- };
88764
- };
88765
- const { head } = input;
88766
- if (ctx.forge) {
88767
- const found = ctx.forge.forge.findPr(slice.branch, {
88768
- owner: ctx.forge.headRepo.split("/")[0] ?? null,
88769
- projectId: ctx.forge.headProjectId,
88770
- sha: head
88771
- });
88772
- if (!found.ok)
88773
- out.forgeError = found.error;
88774
- else if (found.data) {
88775
- out.pr = found.data;
88776
- if (found.data.state === "merged") {
88777
- const merged = found.data.headSha;
88778
- const label = `#${found.data.number}`;
88779
- if (!head) {
88780
- if (!linked(input.rows, input, slice, merged)) {
88781
- out.pr = null;
88782
- out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
88783
- return out;
88784
- }
88785
- return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
88786
- }
88787
- if (!merged || head === merged || isAncestor4(ctx.cwd, head, merged))
88788
- return landed("pr_merged", merged ?? head);
88789
- out.note = isAncestor4(ctx.cwd, merged, head) ? `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} is ahead of it with newer commits` : `${label} merged at ${merged.slice(0, 12)}; ${slice.branch} has diverged from it (rewritten after the merge)`;
88790
- return out;
88791
- }
88792
- }
88793
- }
88794
- if (!head)
88795
- return out;
88796
- const fork = mergeBase(ctx.cwd, head, ctx.trunk);
88797
- if (fork === head) {
88798
- return hasOwnCommits2(ctx.cwd, slice.branch, input.createdFrom, head) ? landed("on_trunk", head) : out;
88799
- }
88800
- if (!fork)
88801
- return out;
88802
- const trunkIds = trunkPatchIds(ctx, fork);
88803
- if (trunkIds.size === 0)
88804
- return out;
88805
- const froms = [fork];
88806
- if (input.parentHead && input.parentHead !== head && isAncestor4(ctx.cwd, input.parentHead, head))
88807
- froms.push(input.parentHead);
88808
- for (const from of froms) {
88809
- const id = patchId(ctx.cwd, from, head);
88810
- if (id && trunkIds.has(id))
88811
- return landed("squash_patch", head);
88812
- }
88813
- return out;
88814
- }
88815
- function sliceStart(cwd, rows, fanout, slice) {
88816
- const reflog = branchCreatedAt(cwd, slice.branch);
88817
- if (reflog)
88818
- return reflog;
88819
- const row = rows.findLast((candidate) => candidate.type === "fanout.worktree.created" && candidate.fanout === fanout && candidate.slice === slice.id && typeof candidate.baseSha === "string" && inSliceRun(candidate, slice));
88820
- return row?.baseSha ?? null;
88821
- }
88822
- var isAncestor4 = (cwd, a, b) => gitRun4(cwd, ["merge-base", "--is-ancestor", a, b]).ok, TRUNK_SCAN = 500;
88823
- var init_fanout_landed = __esm(() => {
88824
- init_rev();
88825
- init_fanout();
88826
- });
88827
-
88828
89184
  // packages/workit-core/src/fanout-check.ts
88829
89185
  function mergeConflicts(cwd, a, b) {
88830
89186
  const run = gitRun4(cwd, [
@@ -89007,7 +89363,9 @@ function inspect(cwd, plan, slice, ctx, changedBy) {
89007
89363
  return check;
89008
89364
  }
89009
89365
  const parent = plan.slices.find((other) => other.branch === slice.base);
89010
- const baseRef = slice.base === plan.trunk ? trunk : parent ? tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null : branchRef(cwd, slice.base);
89366
+ const parentLanded = parent ? ctx.landings.get(parent.id)?.landed === true : false;
89367
+ const parentRef = parent ? tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null : null;
89368
+ const baseRef = slice.base === plan.trunk ? trunk : parent ? parentLanded && !(parentRef && isAncestor5(cwd, parentRef, head)) ? trunk : parentRef : branchRef(cwd, slice.base);
89011
89369
  if (!SAFE(baseRef)) {
89012
89370
  check.reasons.push(`base ${slice.base} not found`);
89013
89371
  return check;
@@ -89475,7 +89833,8 @@ function worktree(argv, io) {
89475
89833
  if (action === "create") {
89476
89834
  const result = createSliceWorktree(io.cwd, selected.data, {
89477
89835
  slice: flags.positionals[0],
89478
- actor
89836
+ actor,
89837
+ forge: () => forgeFor(io, false).forge
89479
89838
  });
89480
89839
  if (!result.ok)
89481
89840
  return failed(io, result);
@@ -107041,9 +107400,10 @@ function withSubcommands(entry, options = {}) {
107041
107400
  };
107042
107401
  }
107043
107402
  var LEASE_NOTES = [
107044
- "The heavy-check lease: heavy work (tests, builds, lint, typecheck, installs, image builds) shares one machine-wide lease, so parallel agents and worktrees never exhaust this machine's memory and CPU. `slots` (default 1) heavy checks run at once; on Linux each also runs in a systemd scope with MemoryMax and CPUQuota.",
107045
- "Waiting: a heavy check that finds every slot held prints one `waiting for the heavy-check lease` line naming the holder (check, pid, session, age, directory), polls once a second, and exits 4 (busy) after `--wait <s>`, else `resources.wait` seconds (default 600, capped at 100 inside a Claude Code or OpenCode shell). `workit lock status` shows the holders; `workit lock wait` waits for a free slot.",
107046
- "--heavy: an ad-hoc `workit check -- <cmd>` takes the lease only with --heavy; the named checks test, build and typecheck always do. The shell hook runs a raw heavy command (`npx jest`, `npm run test:ci`, `cargo test`, `docker build`) as `workit check --heavy -- <cmd>` on Claude Code, OpenCode and Pi, and denies it with that exact command on Codex and Cursor.",
107403
+ "The heavy-check lease: heavy work (tests, builds, installs, image builds) shares one machine-wide lease, so parallel agents and worktrees never exhaust this machine's memory and CPU. `slots` (default 1) heavy checks run at once; on Linux each also runs in a systemd scope with MemoryMax and CPUQuota.",
107404
+ "Queueing: a heavy check that finds every slot held joins a first-come, first-served queue and prints `waiting for the heavy-check lease` naming the holder (check, pid, session, age, directory) and its position, then a status line every 30 s. It waits `--wait <dur>` (90, 90s, 30m, 1h), else `resources.wait` (default 30 min), and exits 4 (busy) only past that wait or at once with --no-wait. A waiter that is killed gives up its place. A wait longer than your shell tool's timeout belongs in a background call. `workit lock status` shows the holders and the queue.",
107405
+ "Light checks: the named checks lint, typecheck and format (`resources.light`, set in workspaces.json) never take the lease, so they run at once beside a long suite.",
107406
+ "--heavy: an ad-hoc `workit check -- <cmd>` takes the lease only with --heavy; the named checks test and build always do. The shell hook runs a raw heavy command (`npx jest`, `npm run test:ci`, `cargo test`, `docker build`) as `workit check --heavy -- <cmd>` on Claude Code, OpenCode and Pi, and denies it with that exact command on Codex and Cursor.",
107047
107407
  '--no-lease skips the lease and needs --reason "<why>". Never hand-roll a flock: the lease already queues every agent on the machine.',
107048
107408
  "Output: a check prints the last 60 lines of its output and one summary line with `log: <path>` to the full log; --full streams all of it."
107049
107409
  ];
@@ -107160,7 +107520,7 @@ var VERBS = [
107160
107520
  {
107161
107521
  name: "check",
107162
107522
  group: "delivery",
107163
- usage: 'workit check <name> [--full] [--task <id>] [--base <ref>] [--no-lease --reason "<why>"] | workit check [--name <n>] [--shell] [--full] [--timeout <s>] [--task <id>] [--base <ref>] [--heavy [--wait <s>]] [--no-lease --reason "<why>"] -- <cmd…>',
107523
+ usage: 'workit check <name> [--full] [--task <id>] [--base <ref>] [--wait <dur> | --no-wait] [--no-lease --reason "<why>"] | workit check [--name <n>] [--shell] [--full] [--timeout <s>] [--task <id>] [--base <ref>] [--heavy [--wait <dur> | --no-wait]] [--no-lease --reason "<why>"] -- <cmd…>',
107164
107524
  flagHints: {
107165
107525
  list: "workit check <name> runs a configured check; a name that is not configured prints the valid ones (workit check test|lint|typecheck|check)"
107166
107526
  },
@@ -107168,6 +107528,7 @@ var VERBS = [
107168
107528
  examples: [
107169
107529
  "workit check test",
107170
107530
  "workit check --heavy -- npx jest",
107531
+ "workit check test --wait 45m",
107171
107532
  "workit check --name unit -- bun test test/unit",
107172
107533
  'workit check test --no-lease --reason "machine is idle, debugging a hang"'
107173
107534
  ],
@@ -107182,16 +107543,16 @@ var VERBS = [
107182
107543
  {
107183
107544
  name: "status",
107184
107545
  usage: "workit lock status",
107185
- summary: "Show each lease slot: free, held (check, pid, session, age, directory) or stale"
107546
+ summary: "Show each lease slot (free, held by check, pid, session, age, directory, or stale) and the queue of waiting checks in arrival order"
107186
107547
  },
107187
107548
  {
107188
107549
  name: "wait",
107189
- usage: "workit lock wait [--timeout <s>]",
107550
+ usage: "workit lock wait [--timeout <dur>]",
107190
107551
  summary: "Wait until a lease slot is free, without taking it (exit 0 free, 4 still busy at the timeout)"
107191
107552
  },
107192
107553
  {
107193
107554
  name: "run",
107194
- usage: "workit lock run [--shell] [--wait <s>] [--timeout <s>] -- <cmd…>",
107555
+ usage: "workit lock run [--shell] [--wait <dur> | --no-wait] [--timeout <s>] -- <cmd…>",
107195
107556
  summary: "Run a command under the lease: the same as workit check --heavy -- <cmd…>"
107196
107557
  },
107197
107558
  {
@@ -107408,6 +107769,11 @@ var VERBS = [
107408
107769
  usage: "workit stack sync [--name <n>] [--local] [--dry-run] [--force <branch>]…",
107409
107770
  summary: "Restack onto the moved bases, lease-push and retarget the PRs"
107410
107771
  },
107772
+ {
107773
+ name: "restack",
107774
+ usage: "workit stack restack [--name <n>] [--dry-run] [--force <branch>]…",
107775
+ summary: "After a parent merged (squash too): sync, then delete the merged stack branches locally"
107776
+ },
107411
107777
  {
107412
107778
  name: "land",
107413
107779
  usage: "workit stack land [--name <n>] [--dry-run] [--max <n>] [--method squash|merge|rebase] [--unverified --reason <why>] [--base-red-ok --reason <why>] [--timeout 20m] [--interval 30s]",