@brainervirus/workit-claude-code 7.3.0 → 7.5.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/dist/workit.js CHANGED
@@ -65,7 +65,7 @@ var package_default;
65
65
  var init_package = __esm(() => {
66
66
  package_default = {
67
67
  name: "@brainervirus/workit-cli",
68
- version: "7.3.0",
68
+ version: "7.5.0",
69
69
  private: false,
70
70
  description: "Workit CLI — setup wizard, doctor, and task control for agentic coding workflows",
71
71
  keywords: [
@@ -103,9 +103,9 @@ var init_package = __esm(() => {
103
103
  },
104
104
  dependencies: {
105
105
  "@babel/parser": "7.29.9",
106
- "@brainervirus/workit-core": "^7.3.0",
107
- "@brainervirus/workit-cursor": "^7.3.0",
108
- "@brainervirus/workit-opencode": "^7.3.0",
106
+ "@brainervirus/workit-core": "^7.5.0",
107
+ "@brainervirus/workit-cursor": "^7.5.0",
108
+ "@brainervirus/workit-opencode": "^7.5.0",
109
109
  "@inkjs/ui": "2.0.0",
110
110
  ink: "7.1.1",
111
111
  react: "19.2.8"
@@ -63144,7 +63144,8 @@ var init_skill_manifests = __esm(() => {
63144
63144
  "workit-test-audit",
63145
63145
  "workit-deslop",
63146
63146
  "workit-fanout",
63147
- "workit-verify-app"
63147
+ "workit-verify-app",
63148
+ "workit-retro"
63148
63149
  ];
63149
63150
  if (false) {}
63150
63151
  });
@@ -69916,6 +69917,270 @@ var init_launch = __esm(() => {
69916
69917
  init_upgrade();
69917
69918
  });
69918
69919
 
69920
+ // packages/workit-core/src/knowledge.ts
69921
+ import { spawnSync as spawnSync14 } from "node:child_process";
69922
+ import fs17 from "node:fs";
69923
+ import path36 from "node:path";
69924
+ function lintKnowledge(root) {
69925
+ const seen = new Set;
69926
+ const unreadable = [];
69927
+ const load = (file) => {
69928
+ const doc = read(root, file);
69929
+ if (doc === "unreadable")
69930
+ unreadable.push(file);
69931
+ if (doc === null || doc === "unreadable" || seen.has(doc.real))
69932
+ return null;
69933
+ seen.add(doc.real);
69934
+ return doc;
69935
+ };
69936
+ const steering = STEERING_FILES.map(load).filter((doc) => doc !== null);
69937
+ const needBased = NEED_BASED_FILES.map(load).filter((doc) => doc !== null);
69938
+ const findings = [];
69939
+ for (const doc of steering) {
69940
+ const bytes = Buffer.byteLength(doc.text);
69941
+ if (bytes > AGENTS_BYTE_BUDGET)
69942
+ findings.push({
69943
+ rule: "agents-budget",
69944
+ file: doc.file,
69945
+ line: null,
69946
+ message: `${bytes} bytes, over the ${AGENTS_BYTE_BUDGET}-byte budget; move detail behind a pointer or into a check, and name what each addition removes`
69947
+ });
69948
+ }
69949
+ for (const doc of needBased)
69950
+ if (scaffoldOnly(doc))
69951
+ findings.push({
69952
+ rule: "scaffold-file",
69953
+ file: doc.file,
69954
+ line: null,
69955
+ message: "holds no entry (headings, comments or placeholders only); delete it, or create it in the same edit as its first real entry"
69956
+ });
69957
+ const tracked = trackedTopLevel(root);
69958
+ for (const doc of [...steering, ...needBased])
69959
+ findings.push(...brokenLinks(root, doc, tracked));
69960
+ const standards = needBased.find((doc) => doc.file === STANDARDS_FILE);
69961
+ if (standards)
69962
+ findings.push(...duplicateRules(steering, standards));
69963
+ return {
69964
+ root,
69965
+ budget: AGENTS_BYTE_BUDGET,
69966
+ files: [...steering, ...needBased].map((doc) => ({
69967
+ file: doc.file,
69968
+ bytes: Buffer.byteLength(doc.text)
69969
+ })),
69970
+ findings,
69971
+ unreadable
69972
+ };
69973
+ }
69974
+ var AGENTS_BYTE_BUDGET, STEERING_FILES, NEED_BASED_FILES, STANDARDS_FILE = "CODING_STANDARDS.md", MIN_RULE_WORDS = 6, FENCE, HEADING, TABLE_SEPARATOR, LIST_ITEM, PLACEHOLDER2, INDENTED_CODE, blank = (line) => (line ?? "").trim() === "", proseLines = (lines) => {
69975
+ const out = [];
69976
+ let fence = null;
69977
+ let comment = false;
69978
+ let indentedCode = false;
69979
+ let inList = false;
69980
+ lines.forEach((raw, index) => {
69981
+ if (fence) {
69982
+ const close = FENCE.exec(raw);
69983
+ if (close && close[1][0] === fence[0] && close[1].length >= fence.length && !close[2].trim())
69984
+ fence = null;
69985
+ return;
69986
+ }
69987
+ if (!comment) {
69988
+ const open = FENCE.exec(raw);
69989
+ if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
69990
+ fence = open[1];
69991
+ indentedCode = false;
69992
+ return;
69993
+ }
69994
+ if (INDENTED_CODE.test(raw) && !blank(raw)) {
69995
+ if (indentedCode || !inList && blank(lines[index - 1])) {
69996
+ indentedCode = true;
69997
+ return;
69998
+ }
69999
+ } else if (!blank(raw)) {
70000
+ indentedCode = false;
70001
+ inList = LIST_ITEM.test(raw) || inList && /^\s/.test(raw);
70002
+ }
70003
+ }
70004
+ let text = raw;
70005
+ if (comment) {
70006
+ const end = text.indexOf("-->");
70007
+ if (end === -1)
70008
+ return;
70009
+ comment = false;
70010
+ text = text.slice(end + 3);
70011
+ }
70012
+ text = text.replace(/<!--[\s\S]*?-->/g, "");
70013
+ const open = text.indexOf("<!--");
70014
+ if (open !== -1) {
70015
+ comment = true;
70016
+ text = text.slice(0, open);
70017
+ }
70018
+ out.push({ index, text });
70019
+ });
70020
+ return out;
70021
+ }, withoutFrontMatter = (lines) => {
70022
+ if (lines[0]?.trim() !== "---")
70023
+ return lines;
70024
+ const end = lines.indexOf("---", 1);
70025
+ return end === -1 ? lines : [...lines.slice(0, end + 1).map(() => ""), ...lines.slice(end + 1)];
70026
+ }, read = (root, file) => {
70027
+ const full = path36.join(root, file);
70028
+ try {
70029
+ fs17.lstatSync(full);
70030
+ } catch {
70031
+ return null;
70032
+ }
70033
+ try {
70034
+ if (!fs17.statSync(full).isFile())
70035
+ return null;
70036
+ const text = fs17.readFileSync(full, "utf8");
70037
+ return { file, text, lines: text.split(/\r?\n/), real: fs17.realpathSync(full) };
70038
+ } catch {
70039
+ return "unreadable";
70040
+ }
70041
+ }, trackedTopLevel = (root) => {
70042
+ const run = spawnSync14("git", ["ls-files", "-z", "--cached"], {
70043
+ cwd: root,
70044
+ encoding: "utf8",
70045
+ maxBuffer: 256 * 1024 * 1024,
70046
+ timeout: 20000,
70047
+ windowsHide: true
70048
+ });
70049
+ if (run.status !== 0)
70050
+ return null;
70051
+ return new Set(run.stdout.split("\x00").filter(Boolean).map((file) => file.split("/")[0]));
70052
+ }, exists = (target) => {
70053
+ try {
70054
+ fs17.statSync(target);
70055
+ return true;
70056
+ } catch {
70057
+ return false;
70058
+ }
70059
+ }, LINK, REFERENCE_DEFINITION, CODE_SPAN, PATH_LIKE, isExternal = (target) => /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#") || target.startsWith("//"), localTarget = (target) => {
70060
+ const bare = target.replace(/[#?].*$/, "");
70061
+ try {
70062
+ return decodeURIComponent(bare);
70063
+ } catch {
70064
+ return bare;
70065
+ }
70066
+ }, brokenLinks = (root, doc, tracked) => {
70067
+ const findings = [];
70068
+ const base = path36.dirname(path36.join(root, doc.file));
70069
+ const missing = (target, line, how) => findings.push({
70070
+ rule: "broken-link",
70071
+ file: doc.file,
70072
+ line,
70073
+ message: `${how} \`${target}\` points at a missing file`
70074
+ });
70075
+ for (const { index, text } of proseLines(doc.lines)) {
70076
+ const prose = text.replace(CODE_SPAN, (span) => " ".repeat(span.length));
70077
+ const targets = [...prose.matchAll(LINK)].map((match) => match[1] ?? match[2]);
70078
+ const definition = REFERENCE_DEFINITION.exec(prose)?.[1];
70079
+ if (definition)
70080
+ targets.push(definition);
70081
+ for (const target of targets) {
70082
+ if (isExternal(target))
70083
+ continue;
70084
+ const local = localTarget(target);
70085
+ if (!local)
70086
+ continue;
70087
+ const resolved = local.startsWith("/") ? path36.join(root, local) : path36.resolve(base, local);
70088
+ if (!exists(resolved))
70089
+ missing(target, index + 1, "link");
70090
+ }
70091
+ for (const [, span] of text.matchAll(CODE_SPAN)) {
70092
+ const candidate = span.replace(/:\d+(?::\d+)?$/, "");
70093
+ if (!PATH_LIKE.test(candidate))
70094
+ continue;
70095
+ const relative = /^\.\.?\//.test(candidate);
70096
+ const first = candidate.split("/")[0];
70097
+ if (!relative && !(tracked ? tracked.has(first) : exists(path36.join(root, first))))
70098
+ continue;
70099
+ const resolved = relative ? path36.resolve(base, candidate) : path36.join(root, candidate);
70100
+ if (!exists(resolved))
70101
+ missing(span, index + 1, "pointer");
70102
+ }
70103
+ }
70104
+ return findings;
70105
+ }, isPlaceholder2 = (text) => PLACEHOLDER2.test(text.replace(LIST_ITEM, "").replace(/[*_`]/g, "").replace(/^\[\s?\]\s*/, "").trim()), scaffoldOnly = (doc) => {
70106
+ const lines = proseLines(withoutFrontMatter(doc.lines));
70107
+ return lines.every(({ text }, position) => {
70108
+ const trimmed = text.trim();
70109
+ if (trimmed === "" || HEADING.test(text) || /^([-*_])(\s*\1){2,}$/.test(trimmed))
70110
+ return true;
70111
+ if (TABLE_SEPARATOR.test(text))
70112
+ return true;
70113
+ const next = lines[position + 1]?.text ?? "";
70114
+ if (trimmed.startsWith("|") && TABLE_SEPARATOR.test(next))
70115
+ return true;
70116
+ if (trimmed.startsWith("|"))
70117
+ return trimmed.split("|").map((cell) => cell.trim()).every((cell) => cell === "" || isPlaceholder2(cell));
70118
+ return isPlaceholder2(trimmed);
70119
+ });
70120
+ }, ruleUnits = (doc) => {
70121
+ const units = [];
70122
+ let current = null;
70123
+ const flush = () => {
70124
+ if (current)
70125
+ units.push(current);
70126
+ current = null;
70127
+ };
70128
+ for (const { index, text } of proseLines(doc.lines)) {
70129
+ const trimmed = text.trim();
70130
+ if (trimmed === "" || HEADING.test(text) || trimmed.startsWith("|")) {
70131
+ flush();
70132
+ continue;
70133
+ }
70134
+ if (LIST_ITEM.test(text) || !current) {
70135
+ flush();
70136
+ current = { line: index + 1, text: trimmed.replace(LIST_ITEM, "") };
70137
+ } else
70138
+ current.text += ` ${trimmed}`;
70139
+ }
70140
+ flush();
70141
+ return units;
70142
+ }, normalize = (sentence) => sentence.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`]/g, "").toLowerCase().replace(/\s+/g, " ").replace(/[\s.,;:!?]+$/, "").trim(), sentences = (doc) => {
70143
+ const out = new Map;
70144
+ for (const unit of ruleUnits(doc))
70145
+ for (const sentence of unit.text.split(/(?<=[.!?])\s+/)) {
70146
+ const key = normalize(sentence);
70147
+ if (key.split(" ").length >= MIN_RULE_WORDS && !out.has(key))
70148
+ out.set(key, unit.line);
70149
+ }
70150
+ return out;
70151
+ }, duplicateRules = (steering, standards) => {
70152
+ const findings = [];
70153
+ const rules = sentences(standards);
70154
+ for (const doc of steering)
70155
+ for (const [key, line] of sentences(doc)) {
70156
+ const standardsLine = rules.get(key);
70157
+ if (standardsLine === undefined)
70158
+ continue;
70159
+ findings.push({
70160
+ rule: "duplicate-rule",
70161
+ file: doc.file,
70162
+ line,
70163
+ message: `repeats ${standards.file}:${standardsLine} ("${key.slice(0, 60)}${key.length > 60 ? "…" : ""}"); keep the rule in one file`
70164
+ });
70165
+ }
70166
+ return findings;
70167
+ };
70168
+ var init_knowledge = __esm(() => {
70169
+ AGENTS_BYTE_BUDGET = 8 * 1024;
70170
+ STEERING_FILES = ["AGENTS.md", "CLAUDE.md"];
70171
+ NEED_BASED_FILES = ["CODING_STANDARDS.md", "GLOSSARY.md"];
70172
+ FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
70173
+ HEADING = /^\s{0,3}#{1,6}(\s|$)/;
70174
+ TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
70175
+ LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
70176
+ PLACEHOLDER2 = /^(?:(?:tbd|todo|tba|fixme)\b.*|n\/a|none|none yet|coming soon|placeholder|\.\.\.|…|-)?[.:]?$/i;
70177
+ INDENTED_CODE = /^(?: {4}|\t)/;
70178
+ LINK = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|((?:[^()\s<>]|\([^()\s]*\))+))(?:\s+["'(][^)]*)?\)/g;
70179
+ REFERENCE_DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*<?(\S+?)>?(?:\s|$)/;
70180
+ CODE_SPAN = /`([^`\n]+)`/g;
70181
+ PATH_LIKE = /^(?:\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+\/?$/;
70182
+ });
70183
+
69919
70184
  // packages/workit-cli/src/prompt.ts
69920
70185
  import { createInterface } from "node:readline/promises";
69921
70186
  async function askLine(question, output = process.stdout) {
@@ -69999,8 +70264,23 @@ async function run5(argv, io) {
69999
70264
  fixLock = clearStaleMetadataLock(root, { force });
70000
70265
  }
70001
70266
  const report = runDoctor({ host: "cli", cwd: io.cwd, workspaceRoot: root });
70267
+ let knowledge = null;
70268
+ try {
70269
+ knowledge = lintKnowledge(root);
70270
+ if (knowledge.unreadable.length > 0)
70271
+ knowledge = null;
70272
+ } catch {
70273
+ knowledge = null;
70274
+ }
70275
+ const agents = knowledge?.files.find((file) => file.file === "AGENTS.md");
70276
+ const knowledgeLine = knowledge ? `info knowledge — ${agents ? `AGENTS.md ${agents.bytes} of ${knowledge.budget} bytes` : "no AGENTS.md"}; ${knowledge.findings.length} finding(s) (workit knowledge lint)` : "info knowledge — unavailable";
70002
70277
  if (argv.includes("--json")) {
70003
- io.stdout(`${JSON.stringify(fixLock ? { ...report, fixLock } : report, null, 2)}
70278
+ const summary = knowledge ? {
70279
+ agentsBytes: agents?.bytes ?? null,
70280
+ budget: knowledge.budget,
70281
+ findings: knowledge.findings.length
70282
+ } : { unavailable: true };
70283
+ io.stdout(`${JSON.stringify({ ...report, ...fixLock ? { fixLock } : {}, knowledge: summary }, null, 2)}
70004
70284
  `);
70005
70285
  return report.exitCode;
70006
70286
  }
@@ -70019,12 +70299,15 @@ async function run5(argv, io) {
70019
70299
  io.stdout(` fix: ${check.fix}
70020
70300
  `);
70021
70301
  }
70302
+ io.stdout(`${knowledgeLine}
70303
+ `);
70022
70304
  io.stdout(`passed ${report.summary.passed} / warned ${report.summary.warned} / failed ${report.summary.failed}
70023
70305
  `);
70024
70306
  return report.exitCode;
70025
70307
  }
70026
70308
  var init_doctor2 = __esm(() => {
70027
70309
  init_doctor();
70310
+ init_knowledge();
70028
70311
  init_store_lock();
70029
70312
  init_output();
70030
70313
  init_prompt();
@@ -70135,11 +70418,11 @@ var init_redact = __esm(() => {
70135
70418
  });
70136
70419
 
70137
70420
  // packages/workit-core/src/checks.ts
70138
- import { spawn, spawnSync as spawnSync14 } from "node:child_process";
70421
+ import { spawn, spawnSync as spawnSync15 } from "node:child_process";
70139
70422
  import { createHash as createHash14 } from "node:crypto";
70140
- import fs17 from "node:fs";
70423
+ import fs18 from "node:fs";
70141
70424
  import os13 from "node:os";
70142
- import path36 from "node:path";
70425
+ import path37 from "node:path";
70143
70426
  import { StringDecoder } from "node:string_decoder";
70144
70427
  function tailLines(text, lines = TAIL_LINES) {
70145
70428
  const all = text.replace(/\r\n?/gu, `
@@ -70259,10 +70542,10 @@ function planSpawn(argv, options) {
70259
70542
  return { file: argv[0], args: [], verbatim: false, shell: true };
70260
70543
  if (options.platform !== "win32")
70261
70544
  return { file: argv[0], args: argv.slice(1), verbatim: false, shell: false };
70262
- const win = path36.win32;
70545
+ const win = path37.win32;
70263
70546
  const isFile = options.isFile ?? ((file) => {
70264
70547
  try {
70265
- return fs17.statSync(file).isFile();
70548
+ return fs18.statSync(file).isFile();
70266
70549
  } catch {
70267
70550
  return false;
70268
70551
  }
@@ -70314,7 +70597,7 @@ function runCheckCommand(argv, options) {
70314
70597
  if (!child?.pid)
70315
70598
  return;
70316
70599
  if (windows)
70317
- spawnSync14("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
70600
+ spawnSync15("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
70318
70601
  stdio: "ignore",
70319
70602
  windowsHide: true
70320
70603
  });
@@ -70416,13 +70699,13 @@ function runCheckCommand(argv, options) {
70416
70699
  function storeLog(storeRoot, log) {
70417
70700
  const digest = logDigest(log);
70418
70701
  const ref = `blobs/logs/${digest.slice("sha256:".length)}.log`;
70419
- const file = path36.join(storeRoot, ref);
70702
+ const file = path37.join(storeRoot, ref);
70420
70703
  try {
70421
- if (!fs17.existsSync(file)) {
70422
- fs17.mkdirSync(path36.dirname(file), { recursive: true });
70704
+ if (!fs18.existsSync(file)) {
70705
+ fs18.mkdirSync(path37.dirname(file), { recursive: true });
70423
70706
  const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
70424
- fs17.writeFileSync(temp, log, { mode: 384 });
70425
- fs17.renameSync(temp, file);
70707
+ fs18.writeFileSync(temp, log, { mode: 384 });
70708
+ fs18.renameSync(temp, file);
70426
70709
  }
70427
70710
  return { digest, ref };
70428
70711
  } catch {
@@ -70432,17 +70715,17 @@ function storeLog(storeRoot, log) {
70432
70715
  function pruneCheckLogs(storeRoot, options = {}) {
70433
70716
  const limits = { ...LOG_RETENTION, ...options.retention };
70434
70717
  const now = options.now ?? Date.now();
70435
- const dir = path36.join(storeRoot, "blobs", "logs");
70718
+ const dir = path37.join(storeRoot, "blobs", "logs");
70436
70719
  const report = { removed: 0, removedBytes: 0, kept: 0 };
70437
70720
  let names;
70438
70721
  try {
70439
- names = fs17.readdirSync(dir);
70722
+ names = fs18.readdirSync(dir);
70440
70723
  } catch {
70441
70724
  return report;
70442
70725
  }
70443
70726
  const files = names.flatMap((name) => {
70444
70727
  try {
70445
- const stat = fs17.statSync(path36.join(dir, name));
70728
+ const stat = fs18.statSync(path37.join(dir, name));
70446
70729
  return stat.isFile() ? [{ name, size: stat.size, mtimeMs: stat.mtimeMs }] : [];
70447
70730
  } catch {
70448
70731
  return [];
@@ -70461,7 +70744,7 @@ function pruneCheckLogs(storeRoot, options = {}) {
70461
70744
  continue;
70462
70745
  if (!options.dryRun)
70463
70746
  try {
70464
- fs17.rmSync(path36.join(dir, file.name), { force: true });
70747
+ fs18.rmSync(path37.join(dir, file.name), { force: true });
70465
70748
  } catch {
70466
70749
  continue;
70467
70750
  }
@@ -70712,7 +70995,7 @@ var exports_check = {};
70712
70995
  __export(exports_check, {
70713
70996
  run: () => run9
70714
70997
  });
70715
- import path37 from "node:path";
70998
+ import path38 from "node:path";
70716
70999
  function parse5(argv) {
70717
71000
  const split = argv.indexOf("--");
70718
71001
  const head = split >= 0 ? argv.slice(0, split) : [...argv];
@@ -70895,7 +71178,7 @@ async function run9(argv, io) {
70895
71178
  if (options.shell && command.length !== 1)
70896
71179
  return emit(io, fail("invalid_input", '--shell takes the command as one string: workit check --shell -- "<cmd>"'));
70897
71180
  }
70898
- const relCwd = posix(path37.relative(top, runCwd));
71181
+ const relCwd = posix(path38.relative(top, runCwd));
70899
71182
  const configured = !options.shell && matchesNamedCheck(config, name, command, relCwd);
70900
71183
  const actor = actorFromEnv(io.env);
70901
71184
  const branchKey = codeKey(io.cwd, { base: options.base });
@@ -71019,7 +71302,7 @@ async function run9(argv, io) {
71019
71302
  return recordError.code === "busy" ? 4 : recordError.code === "invalid_input" ? 2 : 5;
71020
71303
  return 0;
71021
71304
  }
71022
- var USAGE3 = "workit check <name> | workit check [--name <n>] [--shell] [--timeout <s>] [--task <id>] [--json] -- <cmd…>", RECORD_TAIL_LINES = 20, RECORD_TAIL_CHARS = 200, posix = (value) => value.split(path37.sep).join("/") || ".", ENGINE_CODES2, FINGERPRINT_VARS;
71305
+ var USAGE3 = "workit check <name> | workit check [--name <n>] [--shell] [--timeout <s>] [--task <id>] [--json] -- <cmd…>", RECORD_TAIL_LINES = 20, RECORD_TAIL_CHARS = 200, posix = (value) => value.split(path38.sep).join("/") || ".", ENGINE_CODES2, FINGERPRINT_VARS;
71023
71306
  var init_check = __esm(() => {
71024
71307
  init_check_config();
71025
71308
  init_checks3();
@@ -71040,7 +71323,7 @@ var init_check = __esm(() => {
71040
71323
  var success2 = (data) => ({ ok: true, data }), failure3 = (code, error, unblock) => ({ ok: false, code, error, ...unblock ? { unblock } : {} });
71041
71324
 
71042
71325
  // packages/workit-core/src/git/ops.ts
71043
- import { spawnSync as spawnSync15 } from "node:child_process";
71326
+ import { spawnSync as spawnSync16 } from "node:child_process";
71044
71327
  function dirtEntries(cwd) {
71045
71328
  const run = git5(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
71046
71329
  if (!run.ok)
@@ -71384,7 +71667,7 @@ ${run.stdout}`;
71384
71667
  return /stale info|cannot lock ref|fetch first|non-fast-forward/iu.test(text) ? { ok: false, lease: true, error: "the remote branch tip changed before deletion" } : { ok: false, lease: false, error: gitError(run) };
71385
71668
  }
71386
71669
  var COMMIT_TIMEOUT_MS, PUSH_TIMEOUT_MS = 120000, git5 = (cwd, args, options = {}) => {
71387
- const result = spawnSync15("git", args, {
71670
+ const result = spawnSync16("git", args, {
71388
71671
  cwd,
71389
71672
  encoding: "utf8",
71390
71673
  env: { ...options.env ?? process.env, GIT_TERMINAL_PROMPT: "0" },
@@ -71420,11 +71703,11 @@ var init_ops = __esm(() => {
71420
71703
  });
71421
71704
 
71422
71705
  // packages/workit-core/src/forge/reruns.ts
71423
- import fs18 from "node:fs";
71424
- import path38 from "node:path";
71706
+ import fs19 from "node:fs";
71707
+ import path39 from "node:path";
71425
71708
  function rerunLogPath(cwd) {
71426
71709
  const common = gitCommonDir(cwd);
71427
- return common ? path38.join(common, "workit", "ci-reruns.jsonl") : null;
71710
+ return common ? path39.join(common, "workit", "ci-reruns.jsonl") : null;
71428
71711
  }
71429
71712
  function rerunCounts(cwd, key) {
71430
71713
  const counts = new Map;
@@ -71433,7 +71716,7 @@ function rerunCounts(cwd, key) {
71433
71716
  return counts;
71434
71717
  let text;
71435
71718
  try {
71436
- text = fs18.readFileSync(file, "utf8");
71719
+ text = fs19.readFileSync(file, "utf8");
71437
71720
  } catch {
71438
71721
  return counts;
71439
71722
  }
@@ -71459,8 +71742,8 @@ function recordReruns(cwd, rows) {
71459
71742
  if (!file)
71460
71743
  return false;
71461
71744
  try {
71462
- fs18.mkdirSync(path38.dirname(file), { recursive: true });
71463
- fs18.appendFileSync(file, rows.map((row) => `${JSON.stringify(row)}
71745
+ fs19.mkdirSync(path39.dirname(file), { recursive: true });
71746
+ fs19.appendFileSync(file, rows.map((row) => `${JSON.stringify(row)}
71464
71747
  `).join(""));
71465
71748
  return true;
71466
71749
  } catch {
@@ -71472,18 +71755,18 @@ function withRerunLock(cwd, fn) {
71472
71755
  if (!file)
71473
71756
  return fn();
71474
71757
  const lock = `${file.slice(0, -".jsonl".length)}.lock`;
71475
- fs18.mkdirSync(path38.dirname(lock), { recursive: true });
71758
+ fs19.mkdirSync(path39.dirname(lock), { recursive: true });
71476
71759
  const until = Date.now() + LOCK_WAIT_MS2;
71477
71760
  for (;; ) {
71478
71761
  try {
71479
- fs18.closeSync(fs18.openSync(lock, "wx"));
71762
+ fs19.closeSync(fs19.openSync(lock, "wx"));
71480
71763
  break;
71481
71764
  } catch (error) {
71482
71765
  if (error.code !== "EEXIST")
71483
71766
  throw error;
71484
71767
  try {
71485
- if (Date.now() - fs18.statSync(lock).mtimeMs > LOCK_STALE_MS2) {
71486
- fs18.rmSync(lock, { force: true });
71768
+ if (Date.now() - fs19.statSync(lock).mtimeMs > LOCK_STALE_MS2) {
71769
+ fs19.rmSync(lock, { force: true });
71487
71770
  continue;
71488
71771
  }
71489
71772
  } catch {
@@ -71497,7 +71780,7 @@ function withRerunLock(cwd, fn) {
71497
71780
  try {
71498
71781
  return fn();
71499
71782
  } finally {
71500
- fs18.rmSync(lock, { force: true });
71783
+ fs19.rmSync(lock, { force: true });
71501
71784
  }
71502
71785
  }
71503
71786
  var isRow = (value) => {
@@ -71865,7 +72148,7 @@ var init_report = __esm(() => {
71865
72148
  });
71866
72149
 
71867
72150
  // packages/workit-core/src/forge/pr-ops.ts
71868
- import { spawnSync as spawnSync16 } from "node:child_process";
72151
+ import { spawnSync as spawnSync17 } from "node:child_process";
71869
72152
  function fillFromCommits(cwd, resolved, base) {
71870
72153
  const tracking = `refs/remotes/${resolved.baseRemote}/${base}`;
71871
72154
  if (!base.startsWith("-"))
@@ -71873,7 +72156,7 @@ function fillFromCommits(cwd, resolved, base) {
71873
72156
  const from = resolveRef(cwd, tracking) ?? resolveRef(cwd, `refs/heads/${base}`);
71874
72157
  if (!from)
71875
72158
  return failure3("not_found", `base ${base} is not available locally`, `git fetch ${resolved.baseRemote} ${base}`);
71876
- const log = spawnSync16("git", ["log", "--reverse", "--no-merges", "--format=%s%x1f%b%x1e", `${from}..HEAD`], {
72159
+ const log = spawnSync17("git", ["log", "--reverse", "--no-merges", "--format=%s%x1f%b%x1e", `${from}..HEAD`], {
71877
72160
  cwd,
71878
72161
  encoding: "utf8",
71879
72162
  timeout: GIT_TIMEOUTS.local,
@@ -72148,17 +72431,17 @@ var init_pr_ops = __esm(() => {
72148
72431
  });
72149
72432
 
72150
72433
  // packages/workit-core/src/forge/exec.ts
72151
- import { spawnSync as spawnSync17 } from "node:child_process";
72152
- import fs19 from "node:fs";
72153
- import path39 from "node:path";
72434
+ import { spawnSync as spawnSync18 } from "node:child_process";
72435
+ import fs20 from "node:fs";
72436
+ import path40 from "node:path";
72154
72437
  function findExecutable(bin, env) {
72155
- const dirs = (env.PATH ?? env.Path ?? "").split(path39.delimiter).filter(Boolean);
72438
+ const dirs = (env.PATH ?? env.Path ?? "").split(path40.delimiter).filter(Boolean);
72156
72439
  const exts = process.platform === "win32" ? [".exe"] : [""];
72157
72440
  for (const dir of dirs)
72158
72441
  for (const ext of exts) {
72159
- const candidate = path39.join(dir, `${bin}${ext}`);
72442
+ const candidate = path40.join(dir, `${bin}${ext}`);
72160
72443
  try {
72161
- const stat = fs19.statSync(candidate);
72444
+ const stat = fs20.statSync(candidate);
72162
72445
  if (stat.isFile())
72163
72446
  return candidate;
72164
72447
  } catch {}
@@ -72183,7 +72466,7 @@ function systemRunner(kind, apiHost, env = process.env) {
72183
72466
  if (!exe)
72184
72467
  return { status: null, stdout: "", stderr: "", timedOut: false, missing: true };
72185
72468
  const token = options.token ? kind === "github" ? { GH_TOKEN: options.token, GH_ENTERPRISE_TOKEN: options.token } : { GITLAB_TOKEN: options.token } : {};
72186
- const result = spawnSync17(exe, [...args], {
72469
+ const result = spawnSync18(exe, [...args], {
72187
72470
  encoding: "utf8",
72188
72471
  env: { ...childEnv, ...token },
72189
72472
  maxBuffer: MAX_BUFFER2,
@@ -72853,7 +73136,7 @@ var init_gitlab = __esm(() => {
72853
73136
  });
72854
73137
 
72855
73138
  // packages/workit-core/src/forge/resolve.ts
72856
- import fs20 from "node:fs";
73139
+ import fs21 from "node:fs";
72857
73140
  function resolveForge(cwd, options = {}) {
72858
73141
  let workspace;
72859
73142
  try {
@@ -72882,7 +73165,7 @@ function resolveForge(cwd, options = {}) {
72882
73165
  if (derived.via === "host_name" && !explicit)
72883
73166
  return failure3("blocked", `token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`, `if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`);
72884
73167
  try {
72885
- token = fs20.readFileSync(tokenFile.trim(), "utf8").trim();
73168
+ token = fs21.readFileSync(tokenFile.trim(), "utf8").trim();
72886
73169
  } catch {
72887
73170
  return failure3("blocked", `workspace ${workspace?.name ?? "?"} vcs.tokenFile cannot be read`, "fix vcs.tokenFile in ~/.config/workit/workspaces.json");
72888
73171
  }
@@ -72972,6 +73255,33 @@ var init_resolve = __esm(() => {
72972
73255
  init_gitlab();
72973
73256
  });
72974
73257
 
73258
+ // packages/workit-core/src/duration.ts
73259
+ function parseDuration2(value) {
73260
+ const text = value.trim().toLowerCase();
73261
+ if (/^\d+(?:\.\d+)?$/u.test(text))
73262
+ return Math.round(Number(text) * 1000);
73263
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)\s*/gu)];
73264
+ if (parts.length === 0 || parts.map((part) => part[0]).join("") !== text)
73265
+ return null;
73266
+ let total = 0;
73267
+ for (const [, amount, unit] of parts) {
73268
+ const factor = UNITS.find(([pattern]) => pattern.test(unit))?.[1];
73269
+ if (factor === undefined)
73270
+ return null;
73271
+ total += Number(amount) * factor;
73272
+ }
73273
+ return Math.round(total);
73274
+ }
73275
+ var UNITS;
73276
+ var init_duration = __esm(() => {
73277
+ UNITS = [
73278
+ [/^(?:ms|milliseconds?)$/u, 1],
73279
+ [/^(?:s|secs?|seconds?)$/u, 1000],
73280
+ [/^(?:m|mins?|minutes?)$/u, 60000],
73281
+ [/^(?:h|hrs?|hours?)$/u, 3600000]
73282
+ ];
73283
+ });
73284
+
72975
73285
  // packages/workit-cli/src/verbs/forge-common.ts
72976
73286
  function parseFlags(argv, spec) {
72977
73287
  const parsed = { values: {}, booleans: new Set, lists: {}, positionals: [] };
@@ -73009,14 +73319,6 @@ function positiveInt(value, flag) {
73009
73319
  return null;
73010
73320
  return /^[1-9]\d{0,8}$/u.test(value) ? Number(value) : `${flag} must be a positive integer`;
73011
73321
  }
73012
- function parseDuration2(value) {
73013
- const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/u.exec(value.trim());
73014
- if (!match)
73015
- return null;
73016
- const unit = match[2] ?? "s";
73017
- const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : 3600000;
73018
- return Math.round(Number(match[1]) * factor);
73019
- }
73020
73322
  function releaseTrunk(io, track, branch) {
73021
73323
  const resolved = vcsConfig("resolve", io.cwd, {
73022
73324
  track,
@@ -73099,6 +73401,7 @@ var forgeDeps, usage3 = (io, message, line) => emit(io, fail("invalid_input", me
73099
73401
  var init_forge_common = __esm(() => {
73100
73402
  init_resolve();
73101
73403
  init_vcs_config();
73404
+ init_duration();
73102
73405
  init_output();
73103
73406
  forgeDeps = {
73104
73407
  runner: undefined,
@@ -73114,7 +73417,7 @@ __export(exports_pr, {
73114
73417
  run: () => run10
73115
73418
  });
73116
73419
  import { readFileSync as readFileSync18 } from "node:fs";
73117
- import path40 from "node:path";
73420
+ import path41 from "node:path";
73118
73421
  async function status(argv, io) {
73119
73422
  const flags = parseFlags(argv, { pr: "value", branch: "value", "log-lines": "value" });
73120
73423
  if (typeof flags === "string")
@@ -73185,7 +73488,7 @@ async function create4(argv, io) {
73185
73488
  let body = flags.values.body ?? null;
73186
73489
  if (flags.values["body-file"] !== undefined) {
73187
73490
  try {
73188
- body = readFileSync18(path40.resolve(io.cwd, flags.values["body-file"]), "utf8");
73491
+ body = readFileSync18(path41.resolve(io.cwd, flags.values["body-file"]), "utf8");
73189
73492
  } catch {
73190
73493
  return usage3(io, `cannot read --body-file ${flags.values["body-file"]}`, CREATE_USAGE);
73191
73494
  }
@@ -73677,7 +73980,7 @@ var init_git2 = __esm(() => {
73677
73980
  });
73678
73981
 
73679
73982
  // packages/workit-core/src/forge/verify.ts
73680
- import { spawnSync as spawnSync18 } from "node:child_process";
73983
+ import { spawnSync as spawnSync19 } from "node:child_process";
73681
73984
  function finish(cwd, report, actor) {
73682
73985
  const delivered = report.observations.length > 0 && report.observations.every((entry) => entry.ok);
73683
73986
  const out = { ...report, delivered };
@@ -73890,7 +74193,7 @@ var NPM_NAME, SEMVER, SAFE_ARG, systemNpm = (args) => {
73890
74193
  };
73891
74194
  const found = process.platform === "win32" ? findExecutable("npm.cmd", process.env) : null;
73892
74195
  const npmCmd = found && !/["%!^]/u.test(found) ? `"${found}"` : "npm.cmd";
73893
- const result = process.platform === "win32" ? spawnSync18(`${npmCmd} ${args.join(" ")}`, { ...options, shell: true }) : spawnSync18("npm", [...args], options);
74196
+ const result = process.platform === "win32" ? spawnSync19(`${npmCmd} ${args.join(" ")}`, { ...options, shell: true }) : spawnSync19("npm", [...args], options);
73894
74197
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
73895
74198
  }, short3 = (sha) => sha ? sha.slice(0, 12) : "(absent)", npmView = (stdout) => {
73896
74199
  try {
@@ -73997,10 +74300,10 @@ var init_verify_delivery = __esm(() => {
73997
74300
  });
73998
74301
 
73999
74302
  // packages/workit-core/src/stack.ts
74000
- import { spawnSync as spawnSync19 } from "node:child_process";
74303
+ import { spawnSync as spawnSync20 } from "node:child_process";
74001
74304
  import { createHash as createHash15, randomBytes as randomBytes3 } from "node:crypto";
74002
- import fs21 from "node:fs";
74003
- import path41 from "node:path";
74305
+ import fs22 from "node:fs";
74306
+ import path42 from "node:path";
74004
74307
  function worktreeOf(cwd, branch) {
74005
74308
  const list = gitRun3(cwd, ["worktree", "list", "--porcelain"]);
74006
74309
  if (!list.ok)
@@ -74023,7 +74326,7 @@ function rebasingBranch2(dir) {
74023
74326
  if (!found.ok)
74024
74327
  continue;
74025
74328
  try {
74026
- return fs21.readFileSync(found.stdout.trim(), "utf8").trim();
74329
+ return fs22.readFileSync(found.stdout.trim(), "utf8").trim();
74027
74330
  } catch {}
74028
74331
  }
74029
74332
  return null;
@@ -74031,7 +74334,7 @@ function rebasingBranch2(dir) {
74031
74334
  function rebaseInProgress(dir) {
74032
74335
  for (const name of ["rebase-merge", "rebase-apply"]) {
74033
74336
  const found = gitRun3(dir, ["rev-parse", "--path-format=absolute", "--git-path", name]);
74034
- if (found.ok && fs21.existsSync(found.stdout.trim()))
74337
+ if (found.ok && fs22.existsSync(found.stdout.trim()))
74035
74338
  return true;
74036
74339
  }
74037
74340
  return false;
@@ -74043,8 +74346,8 @@ function gitRestack(cwd, input) {
74043
74346
  const scratch = scratchDir(cwd);
74044
74347
  if (!scratch)
74045
74348
  return { ok: false, conflict: false, worktree: cwd, error: "no store root" };
74046
- fs21.mkdirSync(scratch, { recursive: true });
74047
- dir = path41.join(scratch, `${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes3(4).toString("hex")}`);
74349
+ fs22.mkdirSync(scratch, { recursive: true });
74350
+ dir = path42.join(scratch, `${input.branch.replace(/[^A-Za-z0-9._-]+/gu, "_").slice(0, 40)}-${randomBytes3(4).toString("hex")}`);
74048
74351
  const added = gitRun3(cwd, ["worktree", "add", "-q", dir, input.branch], GIT_TIMEOUTS.worktree);
74049
74352
  if (!added.ok)
74050
74353
  return { ok: false, conflict: false, worktree: cwd, error: firstLine(added) };
@@ -74082,10 +74385,10 @@ function gitRestack(cwd, input) {
74082
74385
  }
74083
74386
  function pruneScratch(cwd) {
74084
74387
  const scratch = scratchDir(cwd);
74085
- if (!scratch || !fs21.existsSync(scratch))
74388
+ if (!scratch || !fs22.existsSync(scratch))
74086
74389
  return;
74087
- for (const name of fs21.readdirSync(scratch)) {
74088
- const dir = path41.join(scratch, name);
74390
+ for (const name of fs22.readdirSync(scratch)) {
74391
+ const dir = path42.join(scratch, name);
74089
74392
  if (!rebaseInProgress(dir) && !trackedDirt(dir))
74090
74393
  gitRun3(cwd, ["worktree", "remove", "--force", dir]);
74091
74394
  }
@@ -74095,7 +74398,7 @@ function stacksDir(cwd) {
74095
74398
  const root = storeRoot(cwd);
74096
74399
  if (!root.ok)
74097
74400
  return stackFail(root.code, root.error, root.unblock);
74098
- return { ok: true, data: path41.join(root.value.root, "stacks") };
74401
+ return { ok: true, data: path42.join(root.value.root, "stacks") };
74099
74402
  }
74100
74403
  function parseStack(raw) {
74101
74404
  if (!isRecord7(raw) || !Array.isArray(raw.branches))
@@ -74147,10 +74450,10 @@ function readStack(cwd, name) {
74147
74450
  const dir = stacksDir(cwd);
74148
74451
  if (!dir.ok)
74149
74452
  return dir;
74150
- const file = path41.join(dir.data, fileFor(name));
74453
+ const file = path42.join(dir.data, fileFor(name));
74151
74454
  let text;
74152
74455
  try {
74153
- text = fs21.readFileSync(file, "utf8");
74456
+ text = fs22.readFileSync(file, "utf8");
74154
74457
  } catch (error) {
74155
74458
  if (error.code === "ENOENT")
74156
74459
  return { ok: true, data: null };
@@ -74168,17 +74471,17 @@ function writeStack(cwd, stack, now = new Date) {
74168
74471
  const dir = stacksDir(cwd);
74169
74472
  if (!dir.ok)
74170
74473
  return dir;
74171
- const file = path41.join(dir.data, fileFor(stack.name));
74474
+ const file = path42.join(dir.data, fileFor(stack.name));
74172
74475
  const temp = `${file}.${process.pid}.tmp`;
74173
74476
  try {
74174
- fs21.mkdirSync(dir.data, { recursive: true });
74477
+ fs22.mkdirSync(dir.data, { recursive: true });
74175
74478
  stack.updatedAt = now.toISOString();
74176
- fs21.writeFileSync(temp, `${JSON.stringify(stack, null, 2)}
74479
+ fs22.writeFileSync(temp, `${JSON.stringify(stack, null, 2)}
74177
74480
  `);
74178
- fs21.renameSync(temp, file);
74481
+ fs22.renameSync(temp, file);
74179
74482
  return { ok: true, data: undefined };
74180
74483
  } catch (error) {
74181
- fs21.rmSync(temp, { force: true });
74484
+ fs22.rmSync(temp, { force: true });
74182
74485
  return stackFail("unavailable", `cannot write ${file}: ${error.message}`);
74183
74486
  }
74184
74487
  }
@@ -74188,7 +74491,7 @@ function listStacks(cwd) {
74188
74491
  return dir;
74189
74492
  let names = [];
74190
74493
  try {
74191
- names = fs21.readdirSync(dir.data).filter((name) => name.endsWith(".json"));
74494
+ names = fs22.readdirSync(dir.data).filter((name) => name.endsWith(".json"));
74192
74495
  } catch {
74193
74496
  return { ok: true, data: [] };
74194
74497
  }
@@ -74196,7 +74499,7 @@ function listStacks(cwd) {
74196
74499
  for (const name of names) {
74197
74500
  let parsed = null;
74198
74501
  try {
74199
- parsed = parseStack(JSON.parse(fs21.readFileSync(path41.join(dir.data, name), "utf8")));
74502
+ parsed = parseStack(JSON.parse(fs22.readFileSync(path42.join(dir.data, name), "utf8")));
74200
74503
  } catch {
74201
74504
  parsed = null;
74202
74505
  }
@@ -74243,7 +74546,7 @@ function stackLockStale(raw, age) {
74243
74546
  }
74244
74547
  function takeLock(lock) {
74245
74548
  try {
74246
- fs21.mkdirSync(lock);
74549
+ fs22.mkdirSync(lock);
74247
74550
  } catch (error) {
74248
74551
  if (error.code === "EEXIST")
74249
74552
  return null;
@@ -74255,24 +74558,24 @@ function takeLock(lock) {
74255
74558
  host: localLockHost(),
74256
74559
  nonce: randomBytes3(8).toString("hex")
74257
74560
  };
74258
- fs21.writeFileSync(path41.join(lock, OWNER), JSON.stringify(owner));
74561
+ fs22.writeFileSync(path42.join(lock, OWNER), JSON.stringify(owner));
74259
74562
  return owner;
74260
74563
  }
74261
74564
  function reapLock(lock, observed) {
74262
74565
  const tomb = `${lock}.reap-${randomBytes3(6).toString("hex")}`;
74263
74566
  try {
74264
- fs21.renameSync(lock, tomb);
74567
+ fs22.renameSync(lock, tomb);
74265
74568
  } catch {
74266
74569
  return false;
74267
74570
  }
74268
74571
  if (readOwner(tomb).raw === observed) {
74269
- fs21.rmSync(tomb, { recursive: true, force: true });
74572
+ fs22.rmSync(tomb, { recursive: true, force: true });
74270
74573
  return true;
74271
74574
  }
74272
74575
  try {
74273
- fs21.renameSync(tomb, lock);
74576
+ fs22.renameSync(tomb, lock);
74274
74577
  } catch {
74275
- fs21.rmSync(tomb, { recursive: true, force: true });
74578
+ fs22.rmSync(tomb, { recursive: true, force: true });
74276
74579
  }
74277
74580
  return false;
74278
74581
  }
@@ -74282,8 +74585,8 @@ function releaseLock(lock, owner) {
74282
74585
  return;
74283
74586
  const tomb = `${lock}.release-${randomBytes3(6).toString("hex")}`;
74284
74587
  try {
74285
- fs21.renameSync(lock, tomb);
74286
- fs21.rmSync(tomb, { recursive: true, force: true });
74588
+ fs22.renameSync(lock, tomb);
74589
+ fs22.rmSync(tomb, { recursive: true, force: true });
74287
74590
  } catch {}
74288
74591
  }
74289
74592
  async function withStackLock(cwd, name, fn, waitMs = LOCK_WAIT_MS3) {
@@ -74292,9 +74595,9 @@ async function withStackLock(cwd, name, fn, waitMs = LOCK_WAIT_MS3) {
74292
74595
  return dir;
74293
74596
  if (!validName(name))
74294
74597
  return stackFail("invalid_input", "a stack name must be 1-200 printable characters");
74295
- const lock = path41.join(dir.data, `${fileFor(name)}.lock`);
74598
+ const lock = path42.join(dir.data, `${fileFor(name)}.lock`);
74296
74599
  try {
74297
- fs21.mkdirSync(dir.data, { recursive: true });
74600
+ fs22.mkdirSync(dir.data, { recursive: true });
74298
74601
  } catch (error) {
74299
74602
  return stackFail("unavailable", `cannot create ${dir.data}: ${error.message}`);
74300
74603
  }
@@ -74322,7 +74625,7 @@ async function withStackLock(cwd, name, fn, waitMs = LOCK_WAIT_MS3) {
74322
74625
  const touch = () => {
74323
74626
  try {
74324
74627
  const now = new Date;
74325
- fs21.utimesSync(path41.join(lock, OWNER), now, now);
74628
+ fs22.utimesSync(path42.join(lock, OWNER), now, now);
74326
74629
  } catch {}
74327
74630
  };
74328
74631
  const beat = setInterval(touch, HEARTBEAT_MS);
@@ -74510,7 +74813,7 @@ function commitKeys(cwd, range) {
74510
74813
  const patches = new Map;
74511
74814
  const diff = gitRun3(cwd, ["log", "--no-merges", "-p", "--format=commit %H", range]);
74512
74815
  if (diff.ok && diff.stdout.trim()) {
74513
- const ids = spawnSync19("git", ["patch-id", "--stable"], {
74816
+ const ids = spawnSync20("git", ["patch-id", "--stable"], {
74514
74817
  cwd,
74515
74818
  input: diff.stdout,
74516
74819
  encoding: "utf8",
@@ -75118,7 +75421,7 @@ var STACK_VERSION = 1, stackFail = (code, error, unblock, data) => ({
75118
75421
  ...unblock ? { unblock } : {},
75119
75422
  ...data ? { data } : {}
75120
75423
  }), fromForge = (result) => stackFail(result.code, result.error, result.unblock), stackDeps, gitRun3 = (cwd, args, timeoutMs = GIT_TIMEOUTS.local) => {
75121
- const run = spawnSync19("git", args, {
75424
+ const run = spawnSync20("git", args, {
75122
75425
  cwd,
75123
75426
  encoding: "utf8",
75124
75427
  timeout: timeoutMs,
@@ -75146,7 +75449,7 @@ var STACK_VERSION = 1, stackFail = (code, error, unblock, data) => ({
75146
75449
  return !status.ok || status.stdout.trim() !== "";
75147
75450
  }, scratchDir = (cwd) => {
75148
75451
  const dir = stacksDir(cwd);
75149
- return dir.ok ? path41.join(dir.data, "worktrees") : null;
75452
+ return dir.ok ? path42.join(dir.data, "worktrees") : null;
75150
75453
  }, firstLine = (run) => (`${run.stderr}
75151
75454
  ${run.stdout}`.split(`
75152
75455
  `).find((line) => line.trim()) ?? "git failed").trim().slice(0, 300), stackFileName = (name) => `${name.replace(/[^A-Za-z0-9._-]+/gu, "_").replace(/^[._]+/u, "").slice(0, 48).toLowerCase()}-${createHash15("sha256").update(name).digest("hex").slice(0, 12)}.json`, fileFor, validName = (name) => {
@@ -75161,14 +75464,14 @@ ${run.stdout}`.split(`
75161
75464
  }, isRecord7 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), str3 = (value) => typeof value === "string" && value ? value : null, LOCK_WAIT_MS3 = 2000, HEARTBEAT_MS = 20000, HEARTBEAT_STALE_MS, OWNERLESS_STALE_MS = 30000, OWNER = "owner", readOwner = (lock) => {
75162
75465
  let raw = null;
75163
75466
  try {
75164
- raw = fs21.readFileSync(path41.join(lock, OWNER), "utf8");
75467
+ raw = fs22.readFileSync(path42.join(lock, OWNER), "utf8");
75165
75468
  } catch {
75166
75469
  raw = null;
75167
75470
  }
75168
75471
  let age = null;
75169
- for (const target of [path41.join(lock, OWNER), lock])
75472
+ for (const target of [path42.join(lock, OWNER), lock])
75170
75473
  try {
75171
- age = Date.now() - fs21.statSync(target).mtimeMs;
75474
+ age = Date.now() - fs22.statSync(target).mtimeMs;
75172
75475
  break;
75173
75476
  } catch {}
75174
75477
  return { raw, age };
@@ -75408,12 +75711,37 @@ var init_stack2 = __esm(() => {
75408
75711
  });
75409
75712
 
75410
75713
  // packages/workit-core/src/fanout.ts
75411
- import { spawnSync as spawnSync20 } from "node:child_process";
75412
- import fs22 from "node:fs";
75413
- import path42 from "node:path";
75714
+ import { spawnSync as spawnSync21 } from "node:child_process";
75715
+ import fs23 from "node:fs";
75716
+ import path43 from "node:path";
75717
+ function expandBraces(pattern, limit = 64) {
75718
+ for (let index = 0;index < pattern.length; index += 1) {
75719
+ if (pattern[index] === "\\") {
75720
+ index += 1;
75721
+ continue;
75722
+ }
75723
+ const close = pattern.indexOf("}", index);
75724
+ if (pattern[index] !== "{" || close < index)
75725
+ continue;
75726
+ const head = pattern.slice(0, index);
75727
+ const tail = pattern.slice(close + 1);
75728
+ const out = [];
75729
+ for (const alternative of pattern.slice(index + 1, close).split(","))
75730
+ for (const rest of expandBraces(tail, limit)) {
75731
+ if (out.length >= limit)
75732
+ return out;
75733
+ out.push(`${head}${alternative}${rest}`);
75734
+ }
75735
+ return out;
75736
+ }
75737
+ return [pattern];
75738
+ }
75414
75739
  function scopeSamples(pattern) {
75415
75740
  if (!hasWildcard(pattern))
75416
75741
  return [unescape2(pattern)];
75742
+ return [...new Set(expandBraces(pattern).map(sampleOne))];
75743
+ }
75744
+ function sampleOne(pattern) {
75417
75745
  let out = "";
75418
75746
  for (let index = 0;index < pattern.length; index += 1) {
75419
75747
  const char = pattern[index];
@@ -75432,14 +75760,10 @@ function scopeSamples(pattern) {
75432
75760
  out += "<any>";
75433
75761
  else if (char === "?")
75434
75762
  out += "x";
75435
- else if (char === "{" && pattern.indexOf("}", index) > index) {
75436
- const close = pattern.indexOf("}", index);
75437
- out += pattern.slice(index + 1, close).split(",")[0];
75438
- index = close;
75439
- } else
75763
+ else
75440
75764
  out += char;
75441
75765
  }
75442
- return [out];
75766
+ return out;
75443
75767
  }
75444
75768
  function globSource(pattern) {
75445
75769
  let out = "";
@@ -75480,7 +75804,20 @@ function matchesScope(pattern, file) {
75480
75804
  return regex.test(file) || !hasWildcard(pattern) && file.startsWith(`${unescape2(pattern)}/`);
75481
75805
  }
75482
75806
  function normalizePattern(raw) {
75483
- let pattern = raw.trim().replace(/\\(?![[\]{}*?])/gu, "/");
75807
+ let pattern = raw.trim();
75808
+ if (pattern.includes("\\")) {
75809
+ if (/\\[*?{}]/u.test(pattern))
75810
+ return {
75811
+ error: "has \\*, \\?, \\{ or \\}: only \\[ and \\] are escapes; use / as the separator (src/**/*.ts)"
75812
+ };
75813
+ const brackets = /\\[[\]]/u.test(pattern);
75814
+ if (brackets && pattern.replace(/\\[[\]]/gu, "").includes("\\"))
75815
+ return {
75816
+ error: "mixes \\[ or \\] escapes with other backslashes, which is ambiguous: use / as the separator (app/\\[id\\]/page.tsx)"
75817
+ };
75818
+ if (!brackets)
75819
+ pattern = pattern.replaceAll("\\", "/");
75820
+ }
75484
75821
  while (pattern.startsWith("./"))
75485
75822
  pattern = pattern.slice(2);
75486
75823
  if (pattern === "." || pattern === "")
@@ -75607,7 +75944,7 @@ function parsePlan(raw, defaults, name) {
75607
75944
  id,
75608
75945
  branch,
75609
75946
  base: optional("base") ?? "",
75610
- worktree: optional("worktree") ?? path42.join(defaults.worktreeRoot, id),
75947
+ worktree: path43.resolve(defaults.root, optional("worktree") ?? path43.join(defaults.worktreeRoot, id)),
75611
75948
  tier,
75612
75949
  dependsOn: [...new Set(dependsOn ?? [])],
75613
75950
  scope,
@@ -75783,6 +76120,30 @@ function analyzeOverlap(plan, candidates) {
75783
76120
  });
75784
76121
  return { overlaps, conflicts };
75785
76122
  }
76123
+ function caseIndex(files) {
76124
+ const index = new Map;
76125
+ for (const file of files) {
76126
+ const parts = file.split("/");
76127
+ for (let end = 1;end <= parts.length; end += 1) {
76128
+ const prefix = parts.slice(0, end).join("/");
76129
+ const key = prefix.toLowerCase();
76130
+ (index.get(key) ?? index.set(key, new Set).get(key))?.add(prefix);
76131
+ }
76132
+ }
76133
+ return index;
76134
+ }
76135
+ function caseClash(index, file) {
76136
+ const parts = file.split("/");
76137
+ for (let end = 1;end <= parts.length; end += 1) {
76138
+ const prefix = parts.slice(0, end).join("/");
76139
+ if (prefix.includes("<any>"))
76140
+ return null;
76141
+ const spellings = index.get(prefix.toLowerCase());
76142
+ if (spellings && !spellings.has(prefix))
76143
+ return { path: prefix, trunk: [...spellings].toSorted()[0] };
76144
+ }
76145
+ return null;
76146
+ }
75786
76147
  function branchRef(cwd, branch) {
75787
76148
  if (!branch || branch.startsWith("-"))
75788
76149
  return null;
@@ -75805,16 +76166,19 @@ function defaultTrunk(cwd) {
75805
76166
  const base = defaultBase(cwd);
75806
76167
  return base ? base.replace(/^origin\//u, "") : "main";
75807
76168
  }
75808
- function worktreeRoot(cwd) {
76169
+ function mainCheckout(cwd) {
75809
76170
  const common = gitCommonDir(cwd);
75810
- const main = common && path42.basename(common) === ".git" ? path42.dirname(common) : cwd;
75811
- return path42.join(path42.dirname(main), `${path42.basename(main)}-wt`);
76171
+ return common && path43.basename(common) === ".git" ? path43.dirname(common) : path43.resolve(cwd);
76172
+ }
76173
+ function worktreeRoot(cwd) {
76174
+ const main = mainCheckout(cwd);
76175
+ return path43.join(path43.dirname(main), `${path43.basename(main)}-wt`);
75812
76176
  }
75813
76177
  function fanoutsDir(cwd) {
75814
76178
  const root = storeRoot(cwd);
75815
76179
  if (!root.ok)
75816
76180
  return fanoutFail(root.code, root.error, root.unblock);
75817
- return { ok: true, data: path42.join(root.value.root, "fanouts") };
76181
+ return { ok: true, data: path43.join(root.value.root, "fanouts") };
75818
76182
  }
75819
76183
  function parseStored(raw) {
75820
76184
  if (!isRecord8(raw) || typeof raw.name !== "string" || typeof raw.trunk !== "string")
@@ -75871,10 +76235,10 @@ function readFanout(cwd, name) {
75871
76235
  const dir = fanoutsDir(cwd);
75872
76236
  if (!dir.ok)
75873
76237
  return dir;
75874
- const file = path42.join(dir.data, stackFileName(name));
76238
+ const file = path43.join(dir.data, stackFileName(name));
75875
76239
  let parsed = null;
75876
76240
  try {
75877
- parsed = parseStored(JSON.parse(fs22.readFileSync(file, "utf8")));
76241
+ parsed = parseStored(JSON.parse(fs23.readFileSync(file, "utf8")));
75878
76242
  } catch (error) {
75879
76243
  if (error.code === "ENOENT")
75880
76244
  return { ok: true, data: null };
@@ -75888,7 +76252,7 @@ function listFanouts(cwd) {
75888
76252
  return dir;
75889
76253
  let names = [];
75890
76254
  try {
75891
- names = fs22.readdirSync(dir.data).filter((entry) => entry.endsWith(".json"));
76255
+ names = fs23.readdirSync(dir.data).filter((entry) => entry.endsWith(".json"));
75892
76256
  } catch {
75893
76257
  return { ok: true, data: [] };
75894
76258
  }
@@ -75896,7 +76260,7 @@ function listFanouts(cwd) {
75896
76260
  for (const entry of names) {
75897
76261
  let parsed = null;
75898
76262
  try {
75899
- parsed = parseStored(JSON.parse(fs22.readFileSync(path42.join(dir.data, entry), "utf8")));
76263
+ parsed = parseStored(JSON.parse(fs23.readFileSync(path43.join(dir.data, entry), "utf8")));
75900
76264
  } catch {
75901
76265
  parsed = null;
75902
76266
  }
@@ -75926,16 +76290,16 @@ function writeFanout(cwd, plan) {
75926
76290
  const dir = fanoutsDir(cwd);
75927
76291
  if (!dir.ok)
75928
76292
  return dir;
75929
- const file = path42.join(dir.data, stackFileName(plan.name));
76293
+ const file = path43.join(dir.data, stackFileName(plan.name));
75930
76294
  const temp = `${file}.${process.pid}.tmp`;
75931
76295
  try {
75932
- fs22.mkdirSync(dir.data, { recursive: true });
75933
- fs22.writeFileSync(temp, `${JSON.stringify(plan, null, 2)}
76296
+ fs23.mkdirSync(dir.data, { recursive: true });
76297
+ fs23.writeFileSync(temp, `${JSON.stringify(plan, null, 2)}
75934
76298
  `);
75935
- fs22.renameSync(temp, file);
76299
+ fs23.renameSync(temp, file);
75936
76300
  return { ok: true, data: file };
75937
76301
  } catch (error) {
75938
- fs22.rmSync(temp, { force: true });
76302
+ fs23.rmSync(temp, { force: true });
75939
76303
  return fanoutFail("unavailable", `cannot write ${file}: ${error.message}`);
75940
76304
  }
75941
76305
  }
@@ -75943,7 +76307,11 @@ function planFanout(cwd, input, now = new Date) {
75943
76307
  const dir = fanoutsDir(cwd);
75944
76308
  if (!dir.ok)
75945
76309
  return dir;
75946
- const parsed = parsePlan(input.trunk && isRecord8(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw, { trunk: input.trunk ?? defaultTrunk(cwd), worktreeRoot: worktreeRoot(cwd) }, input.name);
76310
+ const parsed = parsePlan(input.trunk && isRecord8(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw, {
76311
+ trunk: input.trunk ?? defaultTrunk(cwd),
76312
+ root: mainCheckout(cwd),
76313
+ worktreeRoot: worktreeRoot(cwd)
76314
+ }, input.name);
75947
76315
  if (!parsed.ok)
75948
76316
  return fanoutFail("invalid_input", `the plan is incomplete: ${parsed.problems[0]}${parsed.problems.length > 1 ? ` (+${parsed.problems.length - 1} more)` : ""}`, "fill every listed field, then run workit fanout plan again", { problems: parsed.problems });
75949
76317
  const plan = parsed.plan;
@@ -75958,6 +76326,20 @@ function planFanout(cwd, input, now = new Date) {
75958
76326
  const { overlaps, conflicts } = analyzeOverlap(plan, [...tracked, ...samples]);
75959
76327
  if (conflicts.length)
75960
76328
  return fanoutFail("blocked", `${conflicts.length} file-scope overlap${conflicts.length === 1 ? "" : "s"} between slices: ${conflicts[0].slices.join(" and ")} on ${conflicts[0].paths[0]}${conflicts[0].paths.length > 1 ? ` (+${conflicts[0].paths.length - 1})` : ""}`, conflicts[0].suggestion.text, { name: plan.name, conflicts, overlaps });
76329
+ const trunkCase = caseIndex(tracked);
76330
+ const clashes = [];
76331
+ for (const slice of plan.slices) {
76332
+ const seen = new Set;
76333
+ for (const sample of [...slice.scope, ...slice.owns].flatMap(scopeSamples)) {
76334
+ const clash = caseClash(trunkCase, sample);
76335
+ if (clash && !seen.has(clash.path)) {
76336
+ seen.add(clash.path);
76337
+ clashes.push({ slice: slice.id, ...clash });
76338
+ }
76339
+ }
76340
+ }
76341
+ if (clashes.length)
76342
+ return fanoutFail("blocked", `slice ${clashes[0].slice} names ${clashes[0].path}, which differs only in case from ${plan.trunk}'s ${clashes[0].trunk} (one path on macOS and Windows)`, `use ${plan.trunk}'s spelling ${clashes[0].trunk} in slice ${clashes[0].slice}'s scope, then workit fanout plan again`, { name: plan.name, caseCollisions: clashes, overlaps });
75961
76343
  const existing = readFanout(cwd, plan.name);
75962
76344
  const previous = existing.ok ? existing.data : null;
75963
76345
  plan.createdAt = previous?.createdAt || now.toISOString();
@@ -76014,7 +76396,7 @@ var FANOUT_VERSION = 1, TIERS, fanoutFail = (code, error, unblock, data) => ({
76014
76396
  error,
76015
76397
  ...unblock ? { unblock } : {},
76016
76398
  ...data ? { data } : {}
76017
- }), DEFAULT_SHARED, escapeRegExp4 = (text) => text.replace(/[.+^$()|[\]\\{}*?]/gu, "\\$&"), unescape2 = (pattern) => pattern.replace(/\\(.)/gu, "$1"), hasWildcard = (pattern) => /[*?{]/u.test(pattern.replace(/\\./gu, "")), compiled, matchesAny = (patterns, file) => patterns.some((pattern) => matchesScope(pattern, file)), isRecord8 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), PLACEHOLDER2, filled = (text) => text.trim() !== "" && !PLACEHOLDER2.test(text.trim()), textList = (value) => {
76399
+ }), DEFAULT_SHARED, escapeRegExp4 = (text) => text.replace(/[.+^$()|[\]\\{}*?]/gu, "\\$&"), unescape2 = (pattern) => pattern.replace(/\\(.)/gu, "$1"), hasWildcard = (pattern) => /[*?{]/u.test(pattern.replace(/\\./gu, "")), compiled, matchesAny = (patterns, file) => patterns.some((pattern) => matchesScope(pattern, file)), isRecord8 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), PLACEHOLDER3, filled = (text) => text.trim() !== "" && !PLACEHOLDER3.test(text.trim()), textList = (value) => {
76018
76400
  if (typeof value === "string")
76019
76401
  return [value];
76020
76402
  if (Array.isArray(value) && value.every((item) => typeof item === "string"))
@@ -76032,11 +76414,11 @@ var FANOUT_VERSION = 1, TIERS, fanoutFail = (code, error, unblock, data) => ({
76032
76414
  edges,
76033
76415
  text: edges.map((edge) => `add "${edge.dependsOn}" to "dependsOn" of slice ${edge.slice}`).join("; ")
76034
76416
  };
76035
- }, gitRun4 = (cwd, args) => {
76036
- const run = spawnSync20("git", args, {
76417
+ }, gitRun4 = (cwd, args, timeoutMs = GIT_TIMEOUTS.local) => {
76418
+ const run = spawnSync21("git", args, {
76037
76419
  cwd,
76038
76420
  encoding: "utf8",
76039
- timeout: GIT_TIMEOUTS.local,
76421
+ timeout: timeoutMs,
76040
76422
  killSignal: "SIGKILL",
76041
76423
  windowsHide: true,
76042
76424
  maxBuffer: 64 * 1024 * 1024,
@@ -76065,10 +76447,204 @@ var init_fanout = __esm(() => {
76065
76447
  "CHANGELOG.md"
76066
76448
  ];
76067
76449
  compiled = new Map;
76068
- PLACEHOLDER2 = /^(?:<[^>]*>|tbd|todo|fixme|\?+|\.\.\.|…)$/iu;
76450
+ PLACEHOLDER3 = /^(?:<[^>]*>|tbd|todo|fixme|\?+|\.\.\.|…)$/iu;
76069
76451
  ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
76070
76452
  });
76071
76453
 
76454
+ // packages/workit-core/src/fanout-landed.ts
76455
+ import { spawnSync as spawnSync22 } from "node:child_process";
76456
+ function forgeBreaker(resolved) {
76457
+ if (!resolved)
76458
+ return { forge: null, down: () => null };
76459
+ let tripped = null;
76460
+ const guard = (call) => (...args) => {
76461
+ if (tripped)
76462
+ return { ok: false, code: "unavailable", error: tripped };
76463
+ const result = call(...args);
76464
+ if (!result.ok && result.code === "unavailable")
76465
+ tripped = result.error;
76466
+ return result;
76467
+ };
76468
+ const inner = resolved.forge;
76469
+ const forge = Object.create(inner);
76470
+ forge.findPr = guard(inner.findPr.bind(inner));
76471
+ forge.prStatus = guard(inner.prStatus.bind(inner));
76472
+ return { forge: { ...resolved, forge }, down: () => tripped };
76473
+ }
76474
+ function branchCreatedAt(cwd, branch) {
76475
+ if (!branch || branch.startsWith("-"))
76476
+ return null;
76477
+ const log = gitRun4(cwd, ["log", "-g", "--format=%H", `refs/heads/${branch}`, "--"]);
76478
+ if (!log.ok)
76479
+ return null;
76480
+ return log.stdout.trim().split(`
76481
+ `).findLast(Boolean) ?? null;
76482
+ }
76483
+ function hasOwnCommits2(cwd, branch, start, head) {
76484
+ if (!branch || branch.startsWith("-"))
76485
+ return false;
76486
+ const log = gitRun4(cwd, ["log", "-g", "--format=%H%x00%gs", `refs/heads/${branch}`, "--"]);
76487
+ if (!log.ok)
76488
+ return false;
76489
+ for (const line of log.stdout.split(`
76490
+ `)) {
76491
+ const [sha, subject = ""] = line.split("\x00");
76492
+ if (!sha)
76493
+ continue;
76494
+ const made = /^(?:commit(?: \((?:initial|amend)\))?|cherry-pick|rebase(?: -i)? \(pick\)):/u.test(subject);
76495
+ const onto = /^rebase(?: -i)? \(finish\): \S+ onto ([0-9a-f]+)/u.exec(subject)?.[1];
76496
+ if (!made && !(onto && !sha.startsWith(onto) && !onto.startsWith(sha)))
76497
+ continue;
76498
+ if (sha !== head && !isAncestor2(cwd, sha, head))
76499
+ continue;
76500
+ if (start && (sha === start || isAncestor2(cwd, sha, start)))
76501
+ continue;
76502
+ return true;
76503
+ }
76504
+ return false;
76505
+ }
76506
+ function reverted(cwd, trunk, landedHead, start) {
76507
+ const fork = mergeBase(cwd, landedHead, trunk);
76508
+ if (fork && fork !== landedHead)
76509
+ return unchangedSince(cwd, fork, landedHead, trunk);
76510
+ const before = start && start !== landedHead && isAncestor2(cwd, start, landedHead) ? start : gitRun4(cwd, ["rev-parse", "--verify", "-q", `${landedHead}^1`]).stdout.trim();
76511
+ return before ? unchangedSince(cwd, before, landedHead, trunk) : false;
76512
+ }
76513
+ function unchangedSince(cwd, before, after, trunk) {
76514
+ const paths = gitRun4(cwd, ["diff", "--name-only", "--no-renames", "-z", before, after]);
76515
+ const files = paths.ok ? paths.stdout.split("\x00").filter(Boolean) : [];
76516
+ if (files.length === 0)
76517
+ return false;
76518
+ return gitRun4(cwd, ["diff", "--quiet", before, trunk, "--", ...files]).ok;
76519
+ }
76520
+ function trunkPatchIds(ctx, fork) {
76521
+ const cache = ctx.cache ??= new Map;
76522
+ const known = cache.get(fork);
76523
+ if (known)
76524
+ return known;
76525
+ const ids = new Set;
76526
+ const log = gitRun4(ctx.cwd, [
76527
+ "log",
76528
+ "--no-merges",
76529
+ `--max-count=${TRUNK_SCAN}`,
76530
+ "-p",
76531
+ "--format=commit %H",
76532
+ "-U3",
76533
+ "--diff-algorithm=myers",
76534
+ "--indent-heuristic",
76535
+ "--no-color",
76536
+ "--no-ext-diff",
76537
+ "--no-textconv",
76538
+ "--no-renames",
76539
+ "--full-index",
76540
+ "--src-prefix=a/",
76541
+ "--dst-prefix=b/",
76542
+ `${fork}..${ctx.trunk}`,
76543
+ "--"
76544
+ ]);
76545
+ if (log.ok && log.stdout.trim()) {
76546
+ const run = spawnSync22("git", ["patch-id", "--stable"], {
76547
+ cwd: ctx.cwd,
76548
+ input: log.stdout,
76549
+ encoding: "utf8",
76550
+ timeout: GIT_TIMEOUTS.local,
76551
+ windowsHide: true,
76552
+ maxBuffer: 64 * 1024 * 1024
76553
+ });
76554
+ for (const line of (run.stdout ?? "").split(`
76555
+ `)) {
76556
+ const id = line.trim().split(/\s+/u)[0];
76557
+ if (id)
76558
+ ids.add(id);
76559
+ }
76560
+ }
76561
+ cache.set(fork, ids);
76562
+ return ids;
76563
+ }
76564
+ function linked(rows, input, slice, head) {
76565
+ const since = input.since ? Date.parse(input.since) : Number.NaN;
76566
+ return rows.some((row) => (Number.isNaN(since) || Date.parse(String(row.at)) >= since) && (row.type === "fanout.worktree.created" && row.fanout === input.fanout && row.slice === slice.id || head !== null && row.branch === slice.branch && row.head === head));
76567
+ }
76568
+ function detectLanding(ctx, slice, input) {
76569
+ const out = {
76570
+ landed: false,
76571
+ how: null,
76572
+ pr: null,
76573
+ landedHead: null,
76574
+ forgeError: null,
76575
+ note: null
76576
+ };
76577
+ const landed = (how, landedHead) => {
76578
+ if (!hasCommit(ctx.cwd, landedHead) || !reverted(ctx.cwd, ctx.trunk, landedHead, input.createdFrom))
76579
+ return { ...out, landed: true, how, landedHead };
76580
+ return {
76581
+ ...out,
76582
+ note: `landed (${how}${out.pr ? ` #${out.pr.number}` : ""}), then reverted on the trunk: its paths read as before it`
76583
+ };
76584
+ };
76585
+ const { head } = input;
76586
+ if (ctx.forge) {
76587
+ const found = ctx.forge.forge.findPr(slice.branch, {
76588
+ owner: ctx.forge.headRepo.split("/")[0] ?? null,
76589
+ projectId: ctx.forge.headProjectId,
76590
+ sha: head
76591
+ });
76592
+ if (!found.ok)
76593
+ out.forgeError = found.error;
76594
+ else if (found.data) {
76595
+ out.pr = found.data;
76596
+ if (found.data.state === "merged") {
76597
+ const merged = found.data.headSha;
76598
+ const label = `#${found.data.number}`;
76599
+ if (!head) {
76600
+ if (!linked(input.rows, input, slice, merged)) {
76601
+ out.pr = null;
76602
+ out.note = `${slice.branch} is gone; merged ${label} is not linked to this slice in the ledger (an older branch of the same name?)`;
76603
+ return out;
76604
+ }
76605
+ return merged ? landed("pr_merged", merged) : { ...out, landed: true, how: "pr_merged" };
76606
+ }
76607
+ if (!merged || head === merged || isAncestor2(ctx.cwd, head, merged))
76608
+ return landed("pr_merged", merged ?? head);
76609
+ out.note = isAncestor2(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)`;
76610
+ return out;
76611
+ }
76612
+ }
76613
+ }
76614
+ if (!head)
76615
+ return out;
76616
+ const fork = mergeBase(ctx.cwd, head, ctx.trunk);
76617
+ if (fork === head) {
76618
+ return hasOwnCommits2(ctx.cwd, slice.branch, input.createdFrom, head) ? landed("on_trunk", head) : out;
76619
+ }
76620
+ if (!fork)
76621
+ return out;
76622
+ const trunkIds = trunkPatchIds(ctx, fork);
76623
+ if (trunkIds.size === 0)
76624
+ return out;
76625
+ const froms = [fork];
76626
+ if (input.parentHead && input.parentHead !== head && isAncestor2(ctx.cwd, input.parentHead, head))
76627
+ froms.push(input.parentHead);
76628
+ for (const from of froms) {
76629
+ const id = patchId(ctx.cwd, from, head);
76630
+ if (id && trunkIds.has(id))
76631
+ return landed("squash_patch", head);
76632
+ }
76633
+ return out;
76634
+ }
76635
+ function sliceStart(cwd, rows, fanout, slice) {
76636
+ const reflog = branchCreatedAt(cwd, slice.branch);
76637
+ if (reflog)
76638
+ return reflog;
76639
+ const row = rows.findLast((candidate) => candidate.type === "fanout.worktree.created" && candidate.fanout === fanout && candidate.slice === slice.id && typeof candidate.baseSha === "string");
76640
+ return row?.baseSha ?? null;
76641
+ }
76642
+ var isAncestor2 = (cwd, a, b) => gitRun4(cwd, ["merge-base", "--is-ancestor", a, b]).ok, TRUNK_SCAN = 500;
76643
+ var init_fanout_landed = __esm(() => {
76644
+ init_rev();
76645
+ init_fanout();
76646
+ });
76647
+
76072
76648
  // packages/workit-core/src/fanout-check.ts
76073
76649
  function mergeConflicts(cwd, a, b) {
76074
76650
  const run = gitRun4(cwd, [
@@ -76102,10 +76678,15 @@ function checkFanout(cwd, plan, options = {}) {
76102
76678
  const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
76103
76679
  const ledger = readLedger(cwd);
76104
76680
  const rows = ledger.ok ? ledger.value.rows : [];
76681
+ const breaker = forgeBreaker(options.forge ?? null);
76682
+ const landings = sliceLandings(cwd, plan, trunk, tips, rows, breaker.forge);
76683
+ const trunkCase = caseIndex(nulList2(gitRun4(cwd, ["ls-tree", "-r", "-z", "--name-only", trunk]).stdout));
76105
76684
  const checks = new Map;
76106
76685
  const changedBy = new Map;
76107
- for (const slice of plan.slices)
76108
- checks.set(slice.id, inspect(cwd, plan, slice, trunk, tips, rows, changedBy));
76686
+ for (const slice of plan.slices) {
76687
+ const landing = landings.get(slice.id);
76688
+ checks.set(slice.id, landing.landed ? landedCheck(slice, tips.get(slice.id) ?? null, landing) : inspect(cwd, plan, slice, { trunk, trunkCase, tips, landings, rows }, changedBy));
76689
+ }
76109
76690
  const conflicts = [];
76110
76691
  for (let i = 0;i < order.length; i += 1)
76111
76692
  for (let j = i + 1;j < order.length; j += 1) {
@@ -76113,7 +76694,9 @@ function checkFanout(cwd, plan, options = {}) {
76113
76694
  const second = order[j];
76114
76695
  const a = tips.get(first);
76115
76696
  const b = tips.get(second);
76116
- if (!a || !b || isAncestor2(cwd, a, b) || isAncestor2(cwd, b, a))
76697
+ if (landings.get(first)?.landed || landings.get(second)?.landed)
76698
+ continue;
76699
+ if (!a || !b || isAncestor3(cwd, a, b) || isAncestor3(cwd, b, a))
76117
76700
  continue;
76118
76701
  const check = checks.get(second);
76119
76702
  const theirs = new Map((changedBy.get(first) ?? []).map((file) => [file.toLowerCase(), file]));
@@ -76151,19 +76734,29 @@ function checkFanout(cwd, plan, options = {}) {
76151
76734
  }
76152
76735
  for (const id of order) {
76153
76736
  const check = checks.get(id);
76154
- check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter((dep) => checks.get(dep)?.status !== "ready");
76737
+ if (check.status === "landed")
76738
+ continue;
76739
+ check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter((dep) => checks.get(dep)?.status !== "ready" && checks.get(dep)?.status !== "landed");
76155
76740
  if (check.waitsFor.length)
76156
76741
  check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
76157
76742
  check.status = check.reasons.length ? "blocked" : "ready";
76158
76743
  }
76159
76744
  const selected = options.only?.length ? new Set(options.only) : null;
76160
76745
  const slices = order.filter((id) => !selected || selected.has(id)).map((id) => checks.get(id));
76161
- const ready = slices.every((check) => check.status === "ready");
76746
+ const ready = slices.every((check) => check.status !== "blocked");
76162
76747
  const blocked = slices.find((check) => check.status === "blocked");
76163
76748
  const notes = plan.v === FANOUT_VERSION ? [] : [
76164
76749
  `plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`
76165
76750
  ];
76166
- const next = blocked ? unblockFor(blocked, plan) : `land in this order: ${slices.map((check) => check.id).join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`;
76751
+ const forgeErrors = [...landings.values()].flatMap((landing) => landing.forgeError ? [landing.forgeError] : []);
76752
+ if (!options.forge)
76753
+ notes.push("landed slices detected with git only (no forge): a squash merge may be missed");
76754
+ else if (breaker.down())
76755
+ notes.push(`forge went down (${breaker.down()}); git alone decided the remaining slices`);
76756
+ else if (forgeErrors.length)
76757
+ notes.push(`forge lookups failed (${forgeErrors[0]}); git alone decided those slices`);
76758
+ const toLand = slices.filter((check) => check.status === "ready").map((check) => check.id);
76759
+ const next = blocked ? unblockFor(blocked, plan) : toLand.length ? `land in this order: ${toLand.join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)` : "every slice has landed";
76167
76760
  return {
76168
76761
  ok: true,
76169
76762
  data: {
@@ -76179,28 +76772,61 @@ function checkFanout(cwd, plan, options = {}) {
76179
76772
  }
76180
76773
  };
76181
76774
  }
76182
- function inspect(cwd, plan, slice, trunk, tips, rows, changedBy) {
76183
- const head = tips.get(slice.id) ?? null;
76184
- const check = {
76185
- id: slice.id,
76186
- branch: slice.branch,
76187
- head,
76188
- base: slice.base,
76189
- status: "blocked",
76190
- changed: 0,
76191
- outOfScope: [],
76192
- trunkConflicts: [],
76193
- siblingConflicts: [],
76194
- waitsFor: [],
76195
- verdict: { accepted: false, basis: "missing" },
76196
- reasons: []
76775
+ function sliceLandings(cwd, plan, trunk, tips, rows, forge) {
76776
+ const ctx = { cwd, trunk, forge };
76777
+ const out = new Map;
76778
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
76779
+ for (const id of landingOrder(plan.slices)) {
76780
+ const slice = byId.get(id);
76781
+ const parent = plan.slices.find((other) => other.branch === slice.base);
76782
+ const parentHead = parent ? tips.get(parent.id) ?? out.get(parent.id)?.landedHead ?? null : null;
76783
+ out.set(id, detectLanding(ctx, slice, {
76784
+ head: tips.get(id) ?? null,
76785
+ createdFrom: sliceStart(cwd, rows, plan.name, slice),
76786
+ parentHead,
76787
+ fanout: plan.name,
76788
+ since: plan.createdAt || null,
76789
+ rows
76790
+ }));
76791
+ }
76792
+ return out;
76793
+ }
76794
+ function landedCheck(slice, head, landing) {
76795
+ return {
76796
+ ...blank2(slice, head),
76797
+ status: "landed",
76798
+ landed: { how: landing.how ?? "pr_merged", pr: landing.pr?.number ?? null }
76197
76799
  };
76800
+ }
76801
+ function trunkCaseCollisions(cwd, ctx, head, changed) {
76802
+ const headFiles = treeFiles(cwd, head);
76803
+ const forkFiles = treeFiles(cwd, gitRun4(cwd, ["merge-base", head, ctx.trunk]).stdout.trim() || null);
76804
+ if (!headFiles)
76805
+ return [];
76806
+ const present = (files, prefix) => Boolean(files && (files.has(prefix) || [...files].some((file) => file.startsWith(`${prefix}/`))));
76807
+ const out = new Set;
76808
+ for (const file of changed) {
76809
+ if (!headFiles.has(file))
76810
+ continue;
76811
+ const clash = caseClash(ctx.trunkCase, file);
76812
+ if (!clash)
76813
+ continue;
76814
+ if (present(forkFiles, clash.trunk) && !present(headFiles, clash.trunk))
76815
+ continue;
76816
+ out.add(`${clash.path} ~ ${clash.trunk}`);
76817
+ }
76818
+ return [...out].toSorted();
76819
+ }
76820
+ function inspect(cwd, plan, slice, ctx, changedBy) {
76821
+ const { trunk, tips, rows } = ctx;
76822
+ const head = tips.get(slice.id) ?? null;
76823
+ const check = blank2(slice, head);
76198
76824
  if (!head) {
76199
- check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
76825
+ check.reasons.push(`branch ${slice.branch} not found (not started, or not fetched)`);
76200
76826
  return check;
76201
76827
  }
76202
76828
  const parent = plan.slices.find((other) => other.branch === slice.base);
76203
- const baseRef = slice.base === plan.trunk ? trunk : parent ? tips.get(parent.id) ?? null : branchRef(cwd, slice.base);
76829
+ const baseRef = slice.base === plan.trunk ? trunk : parent ? tips.get(parent.id) ?? ctx.landings.get(parent.id)?.landedHead ?? null : branchRef(cwd, slice.base);
76204
76830
  if (!SAFE(baseRef)) {
76205
76831
  check.reasons.push(`base ${slice.base} not found`);
76206
76832
  return check;
@@ -76218,6 +76844,9 @@ function inspect(cwd, plan, slice, trunk, tips, rows, changedBy) {
76218
76844
  check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
76219
76845
  if (check.outOfScope.length)
76220
76846
  check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
76847
+ check.caseCollisions = trunkCaseCollisions(cwd, ctx, head, changed);
76848
+ if (check.caseCollisions.length)
76849
+ check.reasons.push(`differs only in case from ${plan.trunk}: ${check.caseCollisions.join(", ")}`);
76221
76850
  const trunkResult = mergeConflicts(cwd, trunk, head);
76222
76851
  if (Array.isArray(trunkResult)) {
76223
76852
  check.trunkConflicts = trunkResult;
@@ -76231,9 +76860,11 @@ function inspect(cwd, plan, slice, trunk, tips, rows, changedBy) {
76231
76860
  }
76232
76861
  function unblockFor(check, plan) {
76233
76862
  if (!check.head)
76234
- return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
76863
+ return `start or fetch ${check.branch}, then workit fanout check (a landed slice is detected through its merged PR when the forge is reachable)`;
76235
76864
  if (check.outOfScope.length)
76236
76865
  return `move ${check.outOfScope.join(", ")} off ${check.branch} (a follow-up slice), or widen slice ${check.id}'s scope with workit fanout plan, then workit fanout check`;
76866
+ if (check.caseCollisions.length)
76867
+ return `rename to ${plan.trunk}'s spelling on ${check.branch} (git mv), then workit fanout check`;
76237
76868
  if (check.trunkConflicts.length)
76238
76869
  return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
76239
76870
  if (check.siblingConflicts.length) {
@@ -76244,10 +76875,552 @@ function unblockFor(check, plan) {
76244
76875
  return `unblock ${check.waitsFor.join(", ")} first`;
76245
76876
  return check.reasons[0] ?? "workit fanout check";
76246
76877
  }
76247
- var SAFE = (value) => Boolean(value) && !value?.startsWith("-"), isAncestor2 = (cwd, a, b) => gitRun4(cwd, ["merge-base", "--is-ancestor", a, b]).ok, resolves = (cwd, ref) => gitRun4(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
76878
+ var SAFE = (value) => Boolean(value) && !value?.startsWith("-"), isAncestor3 = (cwd, a, b) => gitRun4(cwd, ["merge-base", "--is-ancestor", a, b]).ok, resolves = (cwd, ref) => gitRun4(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok, blank2 = (slice, head) => ({
76879
+ id: slice.id,
76880
+ branch: slice.branch,
76881
+ head,
76882
+ base: slice.base,
76883
+ status: "blocked",
76884
+ changed: 0,
76885
+ outOfScope: [],
76886
+ caseCollisions: [],
76887
+ trunkConflicts: [],
76888
+ siblingConflicts: [],
76889
+ waitsFor: [],
76890
+ verdict: { accepted: false, basis: "missing" },
76891
+ landed: null,
76892
+ reasons: []
76893
+ }), treeFiles = (cwd, rev) => {
76894
+ if (!rev)
76895
+ return null;
76896
+ const run = gitRun4(cwd, ["ls-tree", "-r", "-z", "--name-only", rev]);
76897
+ return run.ok ? new Set(nulList2(run.stdout)) : null;
76898
+ };
76248
76899
  var init_fanout_check = __esm(() => {
76249
76900
  init_ledger();
76250
76901
  init_fanout();
76902
+ init_fanout_landed();
76903
+ });
76904
+
76905
+ // packages/workit-core/src/fanout-status.ts
76906
+ function timeboxMs(text) {
76907
+ if (!text || /^\s*\d+(?:\.\d+)?\s*$/u.test(text))
76908
+ return null;
76909
+ const ms = parseDuration2(text);
76910
+ return ms && ms > 0 ? ms : null;
76911
+ }
76912
+ function activity(cwd, plan, slice, rows) {
76913
+ const head = branchRef(cwd, slice.branch);
76914
+ const commit = head ? isoOf(gitRun4(cwd, ["log", "-1", "--format=%ct", head, "--"]).stdout) : null;
76915
+ const moved = /@\{(\d+)\}/u.exec(gitRun4(cwd, [
76916
+ "log",
76917
+ "-g",
76918
+ "-1",
76919
+ "--date=unix",
76920
+ "--format=%gd",
76921
+ `refs/heads/${slice.branch}`,
76922
+ "--"
76923
+ ]).stdout);
76924
+ const reflog = moved ? isoOf(moved[1]) : null;
76925
+ const ledger = rows.filter((row) => row.branch === slice.branch || row.fanout === plan.name && row.slice === slice.id).map((row) => row.at);
76926
+ return {
76927
+ head,
76928
+ commit,
76929
+ last: newest(commit, reflog, ...ledger),
76930
+ worktreeCreated: rows.some((row) => row.type === "fanout.worktree.created" && row.fanout === plan.name && row.slice === slice.id)
76931
+ };
76932
+ }
76933
+ function fanoutStatus(cwd, plan, options) {
76934
+ const now = options.now ?? new Date;
76935
+ const breaker = forgeBreaker(options.forge);
76936
+ const forge = breaker.forge;
76937
+ const trunk = trunkRef(cwd, plan.trunk);
76938
+ if (!trunk)
76939
+ return fanoutFail("unavailable", `trunk ${plan.trunk} does not resolve here`, `git fetch origin ${plan.trunk}`);
76940
+ const ledger = readLedger(cwd);
76941
+ const rows = ledger.ok ? ledger.value.rows : [];
76942
+ const notes = [];
76943
+ if (!ledger.ok)
76944
+ notes.push(`ledger unreadable (${ledger.error}); verdicts read as missing`);
76945
+ if (!forge)
76946
+ notes.push(`forge unavailable${options.forgeError ? ` (${options.forgeError})` : ""}: PR and CI not shown; landed detection uses git only, so a squash merge may be missed`);
76947
+ const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
76948
+ const landings = sliceLandings(cwd, plan, trunk, tips, rows, forge);
76949
+ const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
76950
+ const order = landingOrder(plan.slices);
76951
+ const out = new Map;
76952
+ for (const id of order) {
76953
+ const slice = byId.get(id);
76954
+ const landing = landings.get(id);
76955
+ const seen = activity(cwd, plan, slice, rows);
76956
+ const threshold = options.stuckAfterMs ?? timeboxMs(slice.timebox) ?? DEFAULT_STUCK_MS;
76957
+ const idle = seen.last ? Math.max(0, now.getTime() - Date.parse(seen.last)) : null;
76958
+ const verdict = seen.head ? checkVerdicts(cwd, slice.branch, rows) : null;
76959
+ const row = {
76960
+ id,
76961
+ branch: slice.branch,
76962
+ base: slice.base,
76963
+ worktree: slice.worktree,
76964
+ dependsOn: slice.dependsOn,
76965
+ branchExists: seen.head !== null,
76966
+ head: seen.head,
76967
+ lastCommitAt: seen.commit,
76968
+ lastActivityAt: seen.last,
76969
+ idleMinutes: idle === null ? null : Math.floor(idle / 60000),
76970
+ stuckAfterMinutes: Math.round(threshold / 60000),
76971
+ pr: landing?.pr ? { number: landing.pr.number, url: landing.pr.url, state: landing.pr.state } : null,
76972
+ ci: "unknown",
76973
+ verdict: {
76974
+ accepted: verdict?.accepted.accepted ?? false,
76975
+ basis: verdict?.current.basis ?? "none",
76976
+ review: verdict?.review ?? "unreviewed"
76977
+ },
76978
+ landed: landing?.landed && landing.how ? { how: landing.how, pr: landing.pr?.number ?? null } : null,
76979
+ stuck: false,
76980
+ waitsFor: [],
76981
+ state: "not_started",
76982
+ reasons: [],
76983
+ notes: [
76984
+ ...landing?.note ? [landing.note] : [],
76985
+ ...landing?.forgeError ? [`forge: ${landing.forgeError}`] : []
76986
+ ]
76987
+ };
76988
+ const answered = forge !== null && !landing?.forgeError;
76989
+ if (answered && landing?.pr && landing.pr.state === "open" && !row.landed) {
76990
+ const status = forge.forge.prStatus(landing.pr.number);
76991
+ if (status.ok)
76992
+ row.ci = checksState(gatingChecks(status.data).checks, missingRequired(status.data));
76993
+ else
76994
+ row.notes.push(`forge: ${status.error}`);
76995
+ }
76996
+ row.waitsFor = slice.dependsOn.filter((dep) => !out.get(dep)?.landed);
76997
+ out.set(id, row);
76998
+ if (row.landed) {
76999
+ row.state = "landed";
77000
+ continue;
77001
+ }
77002
+ const started = row.branchExists || seen.worktreeCreated;
77003
+ if (!row.branchExists)
77004
+ row.reasons.push("no branch yet");
77005
+ if (!row.verdict.accepted)
77006
+ row.reasons.push(`verdict ${row.verdict.basis === "none" ? "missing" : row.verdict.basis} on the current head`);
77007
+ if (answered && row.branchExists) {
77008
+ if (!row.pr)
77009
+ row.reasons.push("no PR");
77010
+ else if (row.pr.state !== "open")
77011
+ row.reasons.push(`PR ${row.pr.state}`);
77012
+ else if (row.ci === "failing" || row.ci === "pending")
77013
+ row.reasons.push(`CI ${row.ci}`);
77014
+ }
77015
+ row.stuck = started && !row.verdict.accepted && idle !== null && idle > threshold;
77016
+ if (row.stuck)
77017
+ row.reasons.push(`no activity for ${row.idleMinutes} min`);
77018
+ }
77019
+ const suggested = [];
77020
+ for (const id of order) {
77021
+ const row = out.get(id);
77022
+ if (row.landed || row.reasons.length)
77023
+ continue;
77024
+ if (row.waitsFor.every((dep) => suggested.includes(dep)))
77025
+ suggested.push(id);
77026
+ }
77027
+ const spawnable = [];
77028
+ for (const id of order) {
77029
+ const row = out.get(id);
77030
+ if (row.landed)
77031
+ continue;
77032
+ const parent = plan.slices.find((other) => other.branch === row.base);
77033
+ if (suggested.includes(id))
77034
+ row.state = "ready";
77035
+ else if (row.stuck)
77036
+ row.state = "stuck";
77037
+ else if (row.branchExists)
77038
+ row.state = "active";
77039
+ else if (row.waitsFor.length === 0 || parent && row.waitsFor.length === 1 && row.waitsFor[0] === parent.id && out.get(parent.id)?.verdict.accepted) {
77040
+ const created = rows.some((candidate) => candidate.type === "fanout.worktree.created" && candidate.fanout === plan.name && candidate.slice === id);
77041
+ row.state = created ? "active" : "not_started";
77042
+ if (!created)
77043
+ spawnable.push(id);
77044
+ } else
77045
+ row.state = "waiting";
77046
+ if (row.waitsFor.length && !suggested.includes(id))
77047
+ row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
77048
+ }
77049
+ const stuck = order.filter((id) => out.get(id)?.stuck);
77050
+ const down = breaker.down();
77051
+ if (down)
77052
+ notes.push(`forge went down during this run (${down}): later slices show no PR or CI, and git alone decided whether they landed`);
77053
+ const online = forge !== null && !down;
77054
+ const noPr = order.filter((id) => {
77055
+ const row = out.get(id);
77056
+ return !row.landed && row.reasons.length === 1 && row.reasons[0] === "no PR";
77057
+ });
77058
+ const next = stuck.length ? `replace ${stuck.join(", ")}: stop the worker, then workit fanout worktree release ${stuck[0]} (records git status first) and respawn it in MODE: resume` : suggested.length ? online ? `land in this order: ${suggested.join(", ")} (workit fanout check first; workit pr merge each)` : `land in this order: ${suggested.join(", ")} once each has a PR (offline: PRs unchecked; open a missing one with workit git push && workit pr create --fill, then workit fanout status online)` : noPr.length ? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch` : spawnable.length ? `spawn ${spawnable.join(", ")} (workit fanout worktree create <slice> on hosts without native worktrees)` : order.every((id) => out.get(id)?.landed) ? "every slice has landed" : "wait for workers, then workit fanout status again";
77059
+ return {
77060
+ ok: true,
77061
+ data: {
77062
+ name: plan.name,
77063
+ trunk: plan.trunk,
77064
+ trunkRef: trunk,
77065
+ forge: {
77066
+ available: online,
77067
+ error: online ? null : down ?? options.forgeError ?? null
77068
+ },
77069
+ slices: order.map((id) => out.get(id)),
77070
+ landingOrder: suggested,
77071
+ spawnable,
77072
+ stuck,
77073
+ next,
77074
+ notes
77075
+ }
77076
+ };
77077
+ }
77078
+ var DEFAULT_STUCK_MS, isoOf = (seconds) => {
77079
+ const value = Number(seconds.trim());
77080
+ return Number.isFinite(value) && value > 0 ? new Date(value * 1000).toISOString() : null;
77081
+ }, newest = (...values) => values.filter((value) => Boolean(value) && !Number.isNaN(Date.parse(value ?? ""))).reduce((best, value) => best === null || Date.parse(value) > Date.parse(best) ? value : best, null);
77082
+ var init_fanout_status = __esm(() => {
77083
+ init_duration();
77084
+ init_report();
77085
+ init_ledger();
77086
+ init_fanout_check();
77087
+ init_fanout_landed();
77088
+ init_fanout();
77089
+ DEFAULT_STUCK_MS = 30 * 60000;
77090
+ });
77091
+
77092
+ // packages/workit-core/src/fanout-worktree.ts
77093
+ import fs24 from "node:fs";
77094
+ import path44 from "node:path";
77095
+ function rowSample(lines) {
77096
+ const out = [];
77097
+ let bytes = 0;
77098
+ for (const line of lines) {
77099
+ const entry = line.slice(0, 200);
77100
+ bytes += Buffer.byteLength(entry) + 3;
77101
+ if (bytes > DIRTY_ROW_BYTES)
77102
+ break;
77103
+ out.push(entry);
77104
+ }
77105
+ return out;
77106
+ }
77107
+ function listWorktrees(cwd) {
77108
+ const out = [];
77109
+ let current = null;
77110
+ for (const field of gitRun4(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\x00")) {
77111
+ if (field.startsWith("worktree ")) {
77112
+ current = { path: field.slice(9), head: null, branch: null, locked: false };
77113
+ out.push(current);
77114
+ } else if (!current)
77115
+ continue;
77116
+ else if (field.startsWith("HEAD "))
77117
+ current.head = field.slice(5);
77118
+ else if (field.startsWith("branch "))
77119
+ current.branch = field.slice(7);
77120
+ else if (field === "locked" || field.startsWith("locked "))
77121
+ current.locked = true;
77122
+ }
77123
+ return out;
77124
+ }
77125
+ function createdRow(cwd, plan, slice, target) {
77126
+ const ledger = readLedger(cwd);
77127
+ if (!ledger.ok)
77128
+ return null;
77129
+ const atPath = (row) => row.fanout === plan.name && row.slice === slice.id && typeof row.path === "string" && samePath(row.path, target);
77130
+ const rows = ledger.value.rows;
77131
+ const row = rows.findLast((candidate) => candidate.type === "fanout.worktree.created" && atPath(candidate));
77132
+ if (!row)
77133
+ return null;
77134
+ const removedSince = rows.some((candidate) => candidate.seq > row.seq && candidate.type === "fanout.worktree.released" && candidate.outcome === "removed" && atPath(candidate));
77135
+ if (removedSince)
77136
+ return null;
77137
+ const made = adminDirTime(target);
77138
+ if (made === null || made + CLOCK_SLACK_MS < Date.parse(row.at))
77139
+ return null;
77140
+ return row;
77141
+ }
77142
+ function adminDirTime(worktree) {
77143
+ const dir = gitRun4(worktree, ["rev-parse", "--absolute-git-dir"]);
77144
+ if (!dir.ok)
77145
+ return null;
77146
+ try {
77147
+ const stat = fs24.statSync(dir.stdout.trim());
77148
+ return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
77149
+ } catch {
77150
+ return null;
77151
+ }
77152
+ }
77153
+ function findSlice(plan, id) {
77154
+ const slice = plan.slices.find((candidate) => candidate.id === id);
77155
+ return slice ? { ok: true, data: slice } : fanoutFail("invalid_input", `${id}: not a slice of fanout ${plan.name}`, `slices: ${plan.slices.map((candidate) => candidate.id).join(", ")}`);
77156
+ }
77157
+ function excludeScratch(cwd) {
77158
+ const common = gitCommonDir(cwd);
77159
+ if (!common)
77160
+ return;
77161
+ const file = path44.join(common, "info", "exclude");
77162
+ let text = "";
77163
+ try {
77164
+ text = fs24.readFileSync(file, "utf8");
77165
+ } catch {
77166
+ text = "";
77167
+ }
77168
+ if (text.split(/\r?\n/u).includes(EXCLUDE_LINE))
77169
+ return;
77170
+ fs24.mkdirSync(path44.dirname(file), { recursive: true });
77171
+ fs24.appendFileSync(file, `${text && !text.endsWith(`
77172
+ `) ? `
77173
+ ` : ""}# workit fanout: per-worker scratch dirs
77174
+ ${EXCLUDE_LINE}
77175
+ `);
77176
+ }
77177
+ function ensureScratch(worktree) {
77178
+ const scratch = path44.join(worktree, SCRATCH_DIR);
77179
+ fs24.mkdirSync(scratch, { recursive: true });
77180
+ return scratch;
77181
+ }
77182
+ function createSliceWorktree(cwd, plan, input) {
77183
+ const found = findSlice(plan, input.slice);
77184
+ if (!found.ok)
77185
+ return found;
77186
+ const slice = found.data;
77187
+ if (!slice.worktree)
77188
+ return fanoutFail("invalid_input", `slice ${slice.id} has no worktree path`, "re-plan it with workit fanout plan");
77189
+ const target = slicePath(cwd, slice);
77190
+ const worktrees = listWorktrees(cwd);
77191
+ const localRef = `refs/heads/${slice.branch}`;
77192
+ const existing = worktrees.find((entry) => samePath(entry.path, target));
77193
+ const base = {
77194
+ fanout: plan.name,
77195
+ slice: slice.id,
77196
+ branch: slice.branch,
77197
+ base: slice.base,
77198
+ path: target
77199
+ };
77200
+ if (existing) {
77201
+ if (existing.branch !== null && existing.branch !== localRef)
77202
+ return fanoutFail("blocked", `${target} is a worktree on ${existing.branch.replace(/^refs\/heads\//u, "")}, not ${slice.branch}`, `release it first, or set slice ${slice.id}'s worktree to another path`);
77203
+ if (!createdRow(cwd, plan, slice, target))
77204
+ return fanoutFail("blocked", `${target} is a worktree that workit fanout worktree create did not make for slice ${slice.id}`, `remove it yourself (git worktree remove ${target}) once you know what it holds`);
77205
+ excludeScratch(cwd);
77206
+ return {
77207
+ ok: true,
77208
+ data: {
77209
+ ...base,
77210
+ scratch: ensureScratch(target),
77211
+ head: existing.head ?? "",
77212
+ mode: resolveRef(cwd, localRef) ? "resume" : "new",
77213
+ created: false,
77214
+ notes: ["the worktree already exists; nothing was changed"]
77215
+ }
77216
+ };
77217
+ }
77218
+ const elsewhere = worktrees.find((entry) => entry.branch === localRef);
77219
+ if (elsewhere)
77220
+ return fanoutFail("blocked", `${slice.branch} is checked out at ${elsewhere.path}: never two live workers on one branch`, `stop that worker, then workit fanout worktree release ${slice.id} (or git worktree remove ${elsewhere.path})`);
77221
+ let dirExisted = false;
77222
+ try {
77223
+ const stat = fs24.statSync(target);
77224
+ if (!stat.isDirectory() || fs24.readdirSync(target).length > 0)
77225
+ return fanoutFail("blocked", `${target} exists and is ${stat.isDirectory() ? "not empty" : "not a directory"} (not a worktree of this repository)`, `move it away, or set slice ${slice.id}'s worktree to another path`);
77226
+ dirExisted = true;
77227
+ } catch (error) {
77228
+ if (error.code !== "ENOENT")
77229
+ return fanoutFail("failed", `cannot read ${target}: ${error.message}`);
77230
+ }
77231
+ const notes = [];
77232
+ let mode = "resume";
77233
+ if (resolveRef(cwd, localRef)) {
77234
+ const added = gitRun4(cwd, ["worktree", "add", target, slice.branch], SLOW);
77235
+ if (!added.ok)
77236
+ return worktreeFailed(added.stderr);
77237
+ } else {
77238
+ const remote = pushRemoteName(cwd) ?? "origin";
77239
+ const remoteRef = `refs/remotes/${remote}/${slice.branch}`;
77240
+ if (resolveRef(cwd, remoteRef)) {
77241
+ const added = gitRun4(cwd, ["worktree", "add", "-b", slice.branch, target, remoteRef], SLOW);
77242
+ if (!added.ok)
77243
+ return worktreeFailed(added.stderr);
77244
+ notes.push(`resumed ${slice.branch} from ${remote}/${slice.branch}`);
77245
+ } else {
77246
+ mode = "new";
77247
+ const parent = plan.slices.find((other) => other.branch === slice.base);
77248
+ const from = slice.base === plan.trunk ? trunkRef(cwd, plan.trunk) : parent ? branchRef(cwd, parent.branch) : branchRef(cwd, slice.base);
77249
+ if (!from)
77250
+ 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}`);
77251
+ const added = gitRun4(cwd, ["worktree", "add", "--detach", target, from], SLOW);
77252
+ if (!added.ok)
77253
+ return worktreeFailed(added.stderr);
77254
+ const branched = gitBranch(target, { name: slice.branch, base: slice.base });
77255
+ if (!branched.ok) {
77256
+ gitRun4(cwd, ["worktree", "remove", target], SLOW);
77257
+ 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}`);
77258
+ }
77259
+ notes.push(...branched.data.notes);
77260
+ }
77261
+ }
77262
+ excludeScratch(cwd);
77263
+ const scratch = ensureScratch(target);
77264
+ const head = gitRun4(target, ["rev-parse", "HEAD"]).stdout.trim();
77265
+ const baseSha = mode === "new" ? head : null;
77266
+ appendObserved(cwd, {
77267
+ type: "fanout.worktree.created",
77268
+ actor: input.actor,
77269
+ fanout: plan.name,
77270
+ slice: slice.id,
77271
+ branch: slice.branch,
77272
+ path: target,
77273
+ scratch,
77274
+ mode,
77275
+ head,
77276
+ dirExisted,
77277
+ planCreatedAt: plan.createdAt,
77278
+ ...baseSha ? { baseSha } : {}
77279
+ });
77280
+ return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
77281
+ }
77282
+ function releaseSliceWorktree(cwd, plan, input) {
77283
+ const found = findSlice(plan, input.slice);
77284
+ if (!found.ok)
77285
+ return found;
77286
+ const slice = found.data;
77287
+ const target = slicePath(cwd, slice);
77288
+ const worktrees = listWorktrees(cwd);
77289
+ const index = worktrees.findIndex((entry) => samePath(entry.path, target));
77290
+ const out = {
77291
+ fanout: plan.name,
77292
+ slice: slice.id,
77293
+ branch: slice.branch,
77294
+ path: target,
77295
+ head: null,
77296
+ dirty: [],
77297
+ forced: input.force,
77298
+ removed: false,
77299
+ unpushed: null,
77300
+ notes: []
77301
+ };
77302
+ if (index < 0) {
77303
+ if (fs24.existsSync(target))
77304
+ return fanoutFail("blocked", `${target} is not a worktree of this repository; nothing was removed`, "remove it yourself once you know what it holds");
77305
+ out.notes.push(`no worktree at ${target}; nothing to release`);
77306
+ return { ok: true, data: out };
77307
+ }
77308
+ const entry = worktrees[index];
77309
+ if (index === 0)
77310
+ return fanoutFail("blocked", `${target} is the main checkout; it is never released`);
77311
+ if (inside2(cwd, entry.path))
77312
+ return fanoutFail("blocked", `this command runs inside ${entry.path}`, `run it from the main checkout: workit --cwd <main checkout> fanout worktree release ${slice.id}`);
77313
+ if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
77314
+ return fanoutFail("blocked", `${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`);
77315
+ const made = createdRow(cwd, plan, slice, entry.path);
77316
+ if (!made)
77317
+ return fanoutFail("blocked", `${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`, `remove it yourself (git worktree remove ${entry.path}) once you know what it holds`);
77318
+ out.head = entry.head;
77319
+ const status = gitRun4(entry.path, [
77320
+ "status",
77321
+ "--porcelain=v1",
77322
+ "-z",
77323
+ "--untracked-files=all",
77324
+ "--ignore-submodules=none"
77325
+ ]);
77326
+ if (!status.ok)
77327
+ return fanoutFail("failed", `git status in ${entry.path} failed: ${status.stderr.trim().split(`
77328
+ `)[0]}`, "nothing was removed; inspect the worktree by hand");
77329
+ const entries = porcelainEntries(status.stdout);
77330
+ const inScratch = (line) => line.slice(3).startsWith(`${SCRATCH_DIR}/`);
77331
+ out.dirty = entries.filter((line) => !inScratch(line));
77332
+ out.unpushed = unpushedCount(cwd, slice.branch);
77333
+ const refused = out.dirty.length > 0 && !input.force;
77334
+ const recorded = appendObserved(cwd, {
77335
+ type: "fanout.worktree.released",
77336
+ actor: input.actor,
77337
+ fanout: plan.name,
77338
+ slice: slice.id,
77339
+ branch: slice.branch,
77340
+ path: entry.path,
77341
+ head: entry.head,
77342
+ uncommittedCount: out.dirty.length,
77343
+ uncommitted: rowSample(out.dirty),
77344
+ forced: input.force,
77345
+ outcome: refused ? "refused" : "removed"
77346
+ });
77347
+ if (!recorded.ok)
77348
+ return fanoutFail("unavailable", `cannot record the worktree state in the ledger (${recorded.error}); nothing was removed`);
77349
+ if (refused)
77350
+ return fanoutFail("blocked", `${entry.path} has ${out.dirty.length} uncommitted change${out.dirty.length === 1 ? "" : "s"} (${out.dirty.slice(0, 3).join(", ")}${out.dirty.length > 3 ? ", …" : ""}); recorded in the ledger, nothing removed`, `commit them on ${slice.branch} (workit git commit), or pass --force to drop them`, { ...out });
77351
+ const args = ["worktree", "remove", ...input.force ? ["--force"] : [], entry.path];
77352
+ let removed = gitRun4(cwd, args, SLOW);
77353
+ if (!removed.ok && entries.some(inScratch)) {
77354
+ removeScratch(entry.path);
77355
+ removed = gitRun4(cwd, args, SLOW);
77356
+ }
77357
+ if (!removed.ok)
77358
+ return fanoutFail("failed", `git worktree remove failed: ${removed.stderr.trim().split(`
77359
+ `)[0]}`, entry.locked ? `git worktree unlock ${entry.path}, then release again` : undefined, { ...out });
77360
+ out.removed = true;
77361
+ if (made.dirExisted === true)
77362
+ fs24.mkdirSync(entry.path, { recursive: true });
77363
+ if (out.unpushed)
77364
+ out.notes.push(`${slice.branch} is kept with ${out.unpushed} commit(s) not on the remote`);
77365
+ return { ok: true, data: out };
77366
+ }
77367
+ function porcelainEntries(stdout) {
77368
+ const fields = nulList2(stdout);
77369
+ const out = [];
77370
+ for (let index = 0;index < fields.length; index += 1) {
77371
+ const entry = fields[index];
77372
+ out.push(entry);
77373
+ if (entry[0] === "R" || entry[0] === "C")
77374
+ index += 1;
77375
+ }
77376
+ return out;
77377
+ }
77378
+ function unpushedCount(cwd, branch) {
77379
+ const remote = pushRemoteName(cwd) ?? "origin";
77380
+ const local = resolveRef(cwd, `refs/heads/${branch}`);
77381
+ if (!local)
77382
+ return null;
77383
+ const tracking = resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
77384
+ if (!tracking)
77385
+ return null;
77386
+ const count = gitRun4(cwd, ["rev-list", "--count", `${tracking}..${local}`]);
77387
+ return count.ok ? Number(count.stdout.trim()) : null;
77388
+ }
77389
+ function removeScratch(worktree) {
77390
+ const scratch = path44.join(worktree, SCRATCH_DIR);
77391
+ let stat;
77392
+ try {
77393
+ stat = fs24.lstatSync(scratch);
77394
+ } catch {
77395
+ return;
77396
+ }
77397
+ if (stat.isSymbolicLink() || !stat.isDirectory())
77398
+ fs24.unlinkSync(scratch);
77399
+ else
77400
+ fs24.rmSync(scratch, { recursive: true, force: true, maxRetries: 2 });
77401
+ }
77402
+ var SLOW, SCRATCH_DIR = ".workit-scratch", EXCLUDE_LINE, DIRTY_ROW_BYTES = 2000, canonical2 = (target) => {
77403
+ try {
77404
+ return fs24.realpathSync.native(target);
77405
+ } catch {
77406
+ return path44.resolve(target);
77407
+ }
77408
+ }, samePath = (a, b) => {
77409
+ const left = canonical2(a);
77410
+ const right = canonical2(b);
77411
+ return process.platform === "win32" || process.platform === "darwin" ? left.toLowerCase() === right.toLowerCase() : left === right;
77412
+ }, inside2 = (child, parent) => {
77413
+ const relative = path44.relative(canonical2(parent), canonical2(child));
77414
+ return relative === "" || !relative.startsWith("..") && !path44.isAbsolute(relative);
77415
+ }, slicePath = (cwd, slice) => path44.isAbsolute(slice.worktree) ? slice.worktree : path44.resolve(mainCheckout(cwd), slice.worktree), CLOCK_SLACK_MS = 2000, worktreeFailed = (stderr) => fanoutFail("failed", `git worktree add failed: ${stderr.trim().split(`
77416
+ `)[0] || "unknown error"}`);
77417
+ var init_fanout_worktree = __esm(() => {
77418
+ init_ops();
77419
+ init_rev();
77420
+ init_ledger();
77421
+ init_fanout();
77422
+ SLOW = GIT_TIMEOUTS.worktree;
77423
+ EXCLUDE_LINE = `/${SCRATCH_DIR}/`;
76251
77424
  });
76252
77425
 
76253
77426
  // packages/workit-cli/src/verbs/fanout.ts
@@ -76255,8 +77428,14 @@ var exports_fanout = {};
76255
77428
  __export(exports_fanout, {
76256
77429
  run: () => run15
76257
77430
  });
76258
- import fs23 from "node:fs";
76259
- import path43 from "node:path";
77431
+ import fs25 from "node:fs";
77432
+ import path45 from "node:path";
77433
+ function forgeFor(io, offline) {
77434
+ if (offline)
77435
+ return { forge: null, error: "--offline" };
77436
+ const connected = connect(io, null);
77437
+ return connected.ok ? { forge: connected.data, error: null } : { forge: null, error: connected.error };
77438
+ }
76260
77439
  function renderBlockedPlan(io, result) {
76261
77440
  if (io.json || result.code !== "blocked" && result.code !== "invalid_input")
76262
77441
  return;
@@ -76264,6 +77443,11 @@ function renderBlockedPlan(io, result) {
76264
77443
  if (Array.isArray(problems))
76265
77444
  for (const problem of problems)
76266
77445
  io.stderr(` - ${problem}
77446
+ `);
77447
+ const clashes = result.data?.caseCollisions;
77448
+ if (Array.isArray(clashes))
77449
+ for (const clash of clashes)
77450
+ io.stderr(` - ${clash.slice}: ${clash.path} ~ trunk's ${clash.trunk}
76267
77451
  `);
76268
77452
  const conflicts = result.data?.conflicts;
76269
77453
  if (Array.isArray(conflicts))
@@ -76301,10 +77485,10 @@ function plan2(argv, io) {
76301
77485
  return usage3(io, "pass --trunk or --track, not both (--track picks the trunk)", PLAN_USAGE2);
76302
77486
  if (flags.positionals.length !== 1)
76303
77487
  return usage3(io, flags.positionals.length ? `unexpected argument ${flags.positionals[1]}` : "missing <plan.json>", PLAN_USAGE2);
76304
- const file = path43.resolve(io.cwd, flags.positionals[0]);
77488
+ const file = path45.resolve(io.cwd, flags.positionals[0]);
76305
77489
  let raw;
76306
77490
  try {
76307
- raw = JSON.parse(fs23.readFileSync(file, "utf8"));
77491
+ raw = JSON.parse(fs25.readFileSync(file, "utf8"));
76308
77492
  } catch (error) {
76309
77493
  return usage3(io, `cannot read ${file} as JSON: ${error.message}`, PLAN_USAGE2);
76310
77494
  }
@@ -76333,7 +77517,7 @@ function plan2(argv, io) {
76333
77517
  ]);
76334
77518
  }
76335
77519
  function check2(argv, io) {
76336
- const flags = parseFlags(argv, { name: "value", base: "value" });
77520
+ const flags = parseFlags(argv, { name: "value", base: "value", offline: "boolean" });
76337
77521
  if (typeof flags === "string")
76338
77522
  return usage3(io, flags, CHECK_USAGE);
76339
77523
  const selected = selectFanout(io.cwd, flags.values.name ?? null, currentBranch(io.cwd));
@@ -76341,7 +77525,8 @@ function check2(argv, io) {
76341
77525
  return failed(io, selected);
76342
77526
  const result = checkFanout(io.cwd, selected.data, {
76343
77527
  only: flags.positionals,
76344
- trunkRef: flags.values.base ?? null
77528
+ trunkRef: flags.values.base ?? null,
77529
+ forge: forgeFor(io, flags.booleans.has("offline")).forge
76345
77530
  });
76346
77531
  if (!result.ok)
76347
77532
  return failed(io, result);
@@ -76357,17 +77542,92 @@ function check2(argv, io) {
76357
77542
  unblock: result.data.next
76358
77543
  }));
76359
77544
  }
77545
+ function status3(argv, io) {
77546
+ const flags = parseFlags(argv, { name: "value", "stuck-after": "value", offline: "boolean" });
77547
+ if (typeof flags === "string")
77548
+ return usage3(io, flags, STATUS_USAGE3);
77549
+ if (flags.positionals.length)
77550
+ return usage3(io, `unexpected argument ${flags.positionals[0]}`, STATUS_USAGE3);
77551
+ const raw = flags.values["stuck-after"];
77552
+ const stuckAfterMs = raw === undefined ? null : parseDuration2(raw);
77553
+ if (stuckAfterMs === 0 || raw !== undefined && stuckAfterMs === null)
77554
+ return usage3(io, "--stuck-after takes a duration such as 30m, 45m or 2h", STATUS_USAGE3);
77555
+ const selected = selectFanout(io.cwd, flags.values.name ?? null, currentBranch(io.cwd));
77556
+ if (!selected.ok)
77557
+ return failed(io, selected);
77558
+ const forge = forgeFor(io, flags.booleans.has("offline"));
77559
+ const result = fanoutStatus(io.cwd, selected.data, {
77560
+ forge: forge.forge,
77561
+ forgeError: forge.error,
77562
+ stuckAfterMs
77563
+ });
77564
+ if (!result.ok)
77565
+ return failed(io, result);
77566
+ return emit(io, ok(result.data), renderStatus2);
77567
+ }
77568
+ function worktree(argv, io) {
77569
+ const [action, ...rest] = argv;
77570
+ const flags = parseFlags(rest, { name: "value", force: "boolean" });
77571
+ if (typeof flags === "string")
77572
+ return usage3(io, flags, WORKTREE_USAGE);
77573
+ if (action !== "create" && action !== "release")
77574
+ return usage3(io, action ? `unknown worktree action "${action}"` : "missing create or release", WORKTREE_USAGE);
77575
+ if (flags.positionals.length !== 1)
77576
+ return usage3(io, flags.positionals.length ? `unexpected argument ${flags.positionals[1]}` : "missing <slice>", WORKTREE_USAGE);
77577
+ if (action === "create" && flags.booleans.has("force"))
77578
+ return usage3(io, "--force applies to release only", WORKTREE_USAGE);
77579
+ const selected = selectFanout(io.cwd, flags.values.name ?? null, currentBranch(io.cwd));
77580
+ if (!selected.ok)
77581
+ return failed(io, selected);
77582
+ const actor = actorFromEnv(io.env);
77583
+ if (action === "create") {
77584
+ const result = createSliceWorktree(io.cwd, selected.data, {
77585
+ slice: flags.positionals[0],
77586
+ actor
77587
+ });
77588
+ if (!result.ok)
77589
+ return failed(io, result);
77590
+ return emit(io, ok(result.data), (data) => [
77591
+ `${data.created ? "created" : "kept"} worktree for slice ${data.slice} (${data.mode}): ${data.path}`,
77592
+ ` branch ${data.branch} <- ${data.base} at ${short5(data.head)}`,
77593
+ ` scratch ${data.scratch} (put it in the worker brief: temp files go here)`,
77594
+ ...data.notes.map((note) => `note: ${note}`)
77595
+ ]);
77596
+ }
77597
+ const result = releaseSliceWorktree(io.cwd, selected.data, {
77598
+ slice: flags.positionals[0],
77599
+ force: flags.booleans.has("force"),
77600
+ actor
77601
+ });
77602
+ if (!result.ok) {
77603
+ const code = failed(io, result);
77604
+ const dirty = result.data?.dirty;
77605
+ if (!io.json && Array.isArray(dirty))
77606
+ for (const line of dirty)
77607
+ io.stderr(` ${line}
77608
+ `);
77609
+ return code;
77610
+ }
77611
+ return emit(io, ok(result.data), (data) => [
77612
+ data.removed ? `released slice ${data.slice}: removed ${data.path}${data.dirty.length ? ` (dropped ${data.dirty.length} uncommitted change${data.dirty.length === 1 ? "" : "s"}, recorded in the ledger)` : ""}; branch ${data.branch} kept` : `slice ${data.slice}: nothing released`,
77613
+ ...data.notes.map((note) => `note: ${note}`)
77614
+ ]);
77615
+ }
76360
77616
  async function run15(argv, io) {
76361
77617
  const [sub, ...rest] = argv.filter((arg) => arg !== "--json");
76362
77618
  if (sub === "plan")
76363
77619
  return plan2(rest, io);
76364
77620
  if (sub === "check")
76365
77621
  return check2(rest, io);
77622
+ if (sub === "status")
77623
+ return status3(rest, io);
77624
+ if (sub === "worktree")
77625
+ return worktree(rest, io);
76366
77626
  return usage3(io, sub && !sub.startsWith("-") ? `unknown fanout subcommand "${sub}"` : "missing fanout subcommand", USAGE8);
76367
77627
  }
76368
- var PLAN_USAGE2 = "workit fanout plan <plan.json> [--name <n>] [--trunk <b> | --track <t>] [--json]", CHECK_USAGE = "workit fanout check [<slice>…] [--name <n>] [--base <ref>] [--json]", USAGE8 = "workit fanout plan|check ... (workit help fanout)", failed = (io, result) => emit(io, fail(result.code, result.error, { unblock: result.unblock, data: result.data ?? {} })), renderCheck = (data) => [
77628
+ var PLAN_USAGE2 = "workit fanout plan <plan.json> [--name <n>] [--trunk <b> | --track <t>] [--json]", CHECK_USAGE = "workit fanout check [<slice>…] [--name <n>] [--base <ref>] [--offline] [--json]", STATUS_USAGE3 = "workit fanout status [--name <n>] [--stuck-after 30m] [--offline] [--json]", WORKTREE_USAGE = "workit fanout worktree create <slice> [--name <n>] | worktree release <slice> [--name <n>] [--force] [--json]", USAGE8 = "workit fanout plan|check|status|worktree ... (workit help fanout)", failed = (io, result) => emit(io, fail(result.code, result.error, { unblock: result.unblock, data: result.data ?? {} })), renderCheck = (data) => [
76369
77629
  `fanout ${data.name} against ${data.trunkRef}: ${data.ready ? "ready to land" : "blocked"}`,
76370
- ...data.slices.map((slice) => ` ${slice.status === "ready" ? "ok " : "x "}${slice.id} (${slice.branch}) ${slice.changed} file${slice.changed === 1 ? "" : "s"} · verdict ${slice.verdict.accepted ? "accepted" : slice.verdict.basis}${slice.reasons.length ? `
77630
+ ...data.slices.map((slice) => slice.status === "landed" ? ` -- ${slice.id} (${slice.branch}) landed (${slice.landed?.how}${slice.landed?.pr ? ` #${slice.landed.pr}` : ""})` : ` ${slice.status === "ready" ? "ok " : "x "}${slice.id} (${slice.branch}) ${slice.changed} file${slice.changed === 1 ? "" : "s"} · verdict ${slice.verdict.accepted ? "accepted" : slice.verdict.basis}${slice.reasons.length ? `
76371
77631
  ${slice.reasons.join(`
76372
77632
  `)}` : ""}`),
76373
77633
  ...data.conflicts.map((conflict) => ` conflict ${conflict.first} x ${conflict.second}: ${conflict.paths.join(", ")}
@@ -76375,9 +77635,37 @@ var PLAN_USAGE2 = "workit fanout plan <plan.json> [--name <n>] [--trunk <b> | --
76375
77635
  `landing order: ${data.landingOrder.join(", ")}`,
76376
77636
  `next: ${data.next}`,
76377
77637
  ...data.notes.map((note) => `note: ${note}`)
76378
- ];
77638
+ ], short5 = (sha) => sha ? sha.slice(0, 7) : "-", renderStatus2 = (data) => {
77639
+ const counts = new Map;
77640
+ for (const slice of data.slices)
77641
+ counts.set(slice.state, (counts.get(slice.state) ?? 0) + 1);
77642
+ return [
77643
+ `fanout ${data.name} on ${data.trunk}: ${[...counts].map(([state, count]) => `${count} ${state.replace("_", " ")}`).join(", ")} · forge ${data.forge.available ? "on" : "off"}`,
77644
+ ...data.slices.map((slice) => {
77645
+ const parts = [
77646
+ slice.branchExists ? short5(slice.head) : "no branch",
77647
+ slice.lastCommitAt && slice.idleMinutes !== null ? `idle ${slice.idleMinutes} min` : null,
77648
+ slice.pr ? `PR #${slice.pr.number} ${slice.pr.state}` : null,
77649
+ slice.ci === "unknown" ? null : `CI ${slice.ci}`,
77650
+ `verdict ${slice.verdict.accepted ? "accepted" : slice.verdict.basis === "none" ? "missing" : slice.verdict.basis}`
77651
+ ].filter(Boolean);
77652
+ const label = slice.state === "stuck" ? "STUCK" : slice.state.replace("_", " ");
77653
+ const detail = slice.landed ? `landed (${slice.landed.how}${slice.landed.pr ? ` #${slice.landed.pr}` : ""})` : parts.join(" · ");
77654
+ const why = slice.state === "landed" ? [] : [...slice.reasons, ...slice.notes];
77655
+ return ` ${label.padEnd(11)} ${slice.id} (${slice.branch}) ${detail}${why.length ? `
77656
+ ${why.join(`
77657
+ `)}` : ""}`;
77658
+ }),
77659
+ `landing order: ${data.landingOrder.length ? data.landingOrder.join(", ") : "(none ready)"}`,
77660
+ ...data.spawnable.length ? [`spawnable: ${data.spawnable.join(", ")}`] : [],
77661
+ `next: ${data.next}`,
77662
+ ...data.notes.map((note) => `note: ${note}`)
77663
+ ];
77664
+ };
76379
77665
  var init_fanout2 = __esm(() => {
76380
77666
  init_fanout_check();
77667
+ init_fanout_status();
77668
+ init_fanout_worktree();
76381
77669
  init_fanout();
76382
77670
  init_rev();
76383
77671
  init_ledger();
@@ -91532,7 +92820,7 @@ var init_ast = __esm(() => {
91532
92820
 
91533
92821
  // packages/workit-cli/src/test-audit/rules.ts
91534
92822
  import { builtinModules } from "node:module";
91535
- import path44 from "node:path";
92823
+ import path46 from "node:path";
91536
92824
  function testCallee(call) {
91537
92825
  let callee = call.callee;
91538
92826
  if (callee.type === "CallExpression")
@@ -92025,12 +93313,12 @@ function checkConstantsOnly(ctx, emit) {
92025
93313
  }
92026
93314
  function checkOverMocking(ctx, emit) {
92027
93315
  const { program } = ctx.parsed;
92028
- const unit = path44.basename(ctx.file).replace(/\.(?:[cm]?[jt]sx?)$/, "").replace(/\.(?:test|spec)$/, "");
93316
+ const unit = path46.basename(ctx.file).replace(/\.(?:[cm]?[jt]sx?)$/, "").replace(/\.(?:test|spec)$/, "");
92029
93317
  const mocked = find2(program, (node) => node.type === "CallExpression" && MODULE_MOCK.test(calleeName(node.callee) ?? "") && node.arguments[0]?.type === "StringLiteral");
92030
93318
  const internal = mocked.filter((call) => /^(?:\.|@\/|~\/)/.test(call.arguments[0].value));
92031
93319
  for (const call of mocked) {
92032
93320
  const spec = call.arguments[0].value;
92033
- const base = path44.basename(spec.replace(/\/index(?:\.[cm]?[jt]sx?)?$/, "")).replace(/\.(?:[cm]?[jt]sx?)$/, "");
93321
+ const base = path46.basename(spec.replace(/\/index(?:\.[cm]?[jt]sx?)?$/, "")).replace(/\.(?:[cm]?[jt]sx?)$/, "");
92034
93322
  if (base === unit)
92035
93323
  emit({
92036
93324
  rule: "over-mocking",
@@ -92418,11 +93706,11 @@ var init_rules = __esm(() => {
92418
93706
  });
92419
93707
 
92420
93708
  // packages/workit-cli/src/test-audit/audit.ts
92421
- import { spawnSync as spawnSync21 } from "node:child_process";
93709
+ import { spawnSync as spawnSync23 } from "node:child_process";
92422
93710
  import { existsSync as existsSync18, readdirSync as readdirSync8, readFileSync as readFileSync19, statSync as statSync7 } from "node:fs";
92423
- import path45 from "node:path";
93711
+ import path47 from "node:path";
92424
93712
  function git6(cwd, args) {
92425
- const run = spawnSync21("git", ["-c", "core.quotepath=off", ...args], {
93713
+ const run = spawnSync23("git", ["-c", "core.quotepath=off", ...args], {
92426
93714
  cwd,
92427
93715
  encoding: "utf8",
92428
93716
  maxBuffer: 64 * 1024 * 1024
@@ -92433,7 +93721,7 @@ function walkTests(dir, out) {
92433
93721
  for (const entry of readdirSync8(dir, { withFileTypes: true })) {
92434
93722
  if (entry.name.startsWith(".") && entry.name !== "." && entry.isDirectory())
92435
93723
  continue;
92436
- const full = path45.join(dir, entry.name);
93724
+ const full = path47.join(dir, entry.name);
92437
93725
  if (entry.isDirectory()) {
92438
93726
  if (!SKIP_DIRS.has(entry.name))
92439
93727
  walkTests(full, out);
@@ -92445,7 +93733,7 @@ function expandPaths(cwd, inputs) {
92445
93733
  const files = [];
92446
93734
  const missing = [];
92447
93735
  for (const input of inputs.length > 0 ? inputs : ["."]) {
92448
- const full = path45.resolve(cwd, input);
93736
+ const full = path47.resolve(cwd, input);
92449
93737
  if (!existsSync18(full))
92450
93738
  missing.push(input);
92451
93739
  else if (statSync7(full).isDirectory())
@@ -92482,11 +93770,11 @@ function changedLines(cwd, base) {
92482
93770
  for (let offset = 0;offset < count; offset += 1)
92483
93771
  lines.add(start + offset);
92484
93772
  }
92485
- changed.set(path45.join(root, file), lines);
93773
+ changed.set(path47.join(root, file), lines);
92486
93774
  }
92487
93775
  const untracked = git6(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
92488
93776
  for (const file of untracked.stdout.split("\x00").filter(Boolean))
92489
- changed.set(path45.join(root, file), "all");
93777
+ changed.set(path47.join(root, file), "all");
92490
93778
  return changed;
92491
93779
  }
92492
93780
  function auditFiles(files, display) {
@@ -92548,7 +93836,7 @@ function auditFiles(files, display) {
92548
93836
  durationMs: Math.round(performance.now() - started)
92549
93837
  };
92550
93838
  }
92551
- var TEST_FILE, CODE_FILE, SKIP_DIRS, toPosix = (file) => file.split(path45.sep).join("/"), SEVERITY_ORDER, ruleIds;
93839
+ var TEST_FILE, CODE_FILE, SKIP_DIRS, toPosix = (file) => file.split(path47.sep).join("/"), SEVERITY_ORDER, ruleIds;
92552
93840
  var init_audit = __esm(() => {
92553
93841
  init_ast();
92554
93842
  init_rules();
@@ -92578,7 +93866,7 @@ import {
92578
93866
  copyFileSync as copyFileSync6
92579
93867
  } from "node:fs";
92580
93868
  import os14 from "node:os";
92581
- import path46 from "node:path";
93869
+ import path48 from "node:path";
92582
93870
  function mutantsFor(source, file, lines) {
92583
93871
  const parsed = parseSource(source, file);
92584
93872
  const out = [];
@@ -92651,7 +93939,7 @@ function relatedTests(source, testFiles) {
92651
93939
  for (const match of text.matchAll(SPECIFIER)) {
92652
93940
  const spec = match[1];
92653
93941
  if (spec.startsWith(".")) {
92654
- if (stripExt(path46.posix.join(path46.posix.dirname(file), spec)) === target)
93942
+ if (stripExt(path48.posix.join(path48.posix.dirname(file), spec)) === target)
92655
93943
  return true;
92656
93944
  } else if (spec.includes("/") && stripExt(spec).split("/").slice(-2).join("/") === tail)
92657
93945
  return true;
@@ -92671,12 +93959,12 @@ function removeStaleCopies(tmp = os14.tmpdir()) {
92671
93959
  for (const name of names) {
92672
93960
  if (!name.startsWith(TEMP_PREFIX))
92673
93961
  continue;
92674
- const dir = path46.join(tmp, name);
93962
+ const dir = path48.join(tmp, name);
92675
93963
  try {
92676
93964
  const stat = lstatSync7(dir);
92677
93965
  if (!stat.isDirectory() || uid !== undefined && stat.uid !== uid)
92678
93966
  continue;
92679
- const pid = Number(readFileSync20(path46.join(dir, PID_FILE), "utf8"));
93967
+ const pid = Number(readFileSync20(path48.join(dir, PID_FILE), "utf8"));
92680
93968
  if (Number.isInteger(pid) && pid > 0 && alive(pid))
92681
93969
  continue;
92682
93970
  } catch (error) {
@@ -92693,8 +93981,8 @@ function removeStaleCopies(tmp = os14.tmpdir()) {
92693
93981
  function linkNodeModules(origin, target, repo, copy) {
92694
93982
  mkdirSync9(target, { recursive: true });
92695
93983
  for (const entry of readdirSync9(origin, { withFileTypes: true })) {
92696
- const from = path46.join(origin, entry.name);
92697
- const to = path46.join(target, entry.name);
93984
+ const from = path48.join(origin, entry.name);
93985
+ const to = path48.join(target, entry.name);
92698
93986
  if (entry.name.startsWith("@") && entry.isDirectory()) {
92699
93987
  linkNodeModules(from, to, repo, copy);
92700
93988
  continue;
@@ -92710,9 +93998,9 @@ function linkNodeModules(origin, target, repo, copy) {
92710
93998
  } catch {
92711
93999
  continue;
92712
94000
  }
92713
- const relative = path46.relative(repo, real);
92714
- if (!relative.startsWith("..") && !path46.isAbsolute(relative) && !relative.split(path46.sep).includes("node_modules")) {
92715
- symlinkSync(path46.join(copy, relative), to, "junction");
94001
+ const relative = path48.relative(repo, real);
94002
+ if (!relative.startsWith("..") && !path48.isAbsolute(relative) && !relative.split(path48.sep).includes("node_modules")) {
94003
+ symlinkSync(path48.join(copy, relative), to, "junction");
92716
94004
  continue;
92717
94005
  }
92718
94006
  }
@@ -92727,8 +94015,8 @@ function copySymlink(repo, tree, from, to) {
92727
94015
  real = null;
92728
94016
  }
92729
94017
  const realRepo = realpathSync9(repo);
92730
- if (real && inside2(realRepo, real)) {
92731
- symlinkSync(path46.relative(path46.dirname(to), path46.join(tree, path46.relative(realRepo, real))), to);
94018
+ if (real && inside3(realRepo, real)) {
94019
+ symlinkSync(path48.relative(path48.dirname(to), path48.join(tree, path48.relative(realRepo, real))), to);
92732
94020
  return;
92733
94021
  }
92734
94022
  if (real && statSync8(real).isFile())
@@ -92737,10 +94025,10 @@ function copySymlink(repo, tree, from, to) {
92737
94025
  symlinkSync(readlinkSync3(from), to);
92738
94026
  }
92739
94027
  function writeInside(copy, file, content) {
92740
- const target = path46.join(copy, file);
94028
+ const target = path48.join(copy, file);
92741
94029
  try {
92742
94030
  const stat = lstatSync7(target);
92743
- if (!stat.isFile() || !inside2(realpathSync9(copy), realpathSync9(path46.dirname(target))))
94031
+ if (!stat.isFile() || !inside3(realpathSync9(copy), realpathSync9(path48.dirname(target))))
92744
94032
  return false;
92745
94033
  } catch {
92746
94034
  return false;
@@ -92751,9 +94039,9 @@ function writeInside(copy, file, content) {
92751
94039
  return true;
92752
94040
  }
92753
94041
  function copyWorkingTree(repo) {
92754
- const dir = mkdtempSync4(path46.join(os14.tmpdir(), TEMP_PREFIX));
92755
- writeFileSync9(path46.join(dir, PID_FILE), String(process.pid));
92756
- const tree = path46.join(dir, "tree");
94042
+ const dir = mkdtempSync4(path48.join(os14.tmpdir(), TEMP_PREFIX));
94043
+ writeFileSync9(path48.join(dir, PID_FILE), String(process.pid));
94044
+ const tree = path48.join(dir, "tree");
92757
94045
  const head = git6(repo, ["rev-parse", "--verify", "--quiet", "HEAD"]).stdout.trim();
92758
94046
  if (head && git6(dir, ["clone", "-q", "--shared", "--no-checkout", repo, tree]).ok) {
92759
94047
  const branch = git6(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout.trim();
@@ -92770,29 +94058,29 @@ function copyWorkingTree(repo) {
92770
94058
  for (const relative of listed) {
92771
94059
  if (!relative)
92772
94060
  continue;
92773
- const from = path46.join(repo, relative);
94061
+ const from = path48.join(repo, relative);
92774
94062
  let stat;
92775
94063
  try {
92776
94064
  stat = lstatSync7(from);
92777
94065
  } catch {
92778
94066
  continue;
92779
94067
  }
92780
- const to = path46.join(tree, relative);
92781
- mkdirSync9(path46.dirname(to), { recursive: true });
94068
+ const to = path48.join(tree, relative);
94069
+ mkdirSync9(path48.dirname(to), { recursive: true });
92782
94070
  if (stat.isSymbolicLink())
92783
94071
  copySymlink(repo, tree, from, to);
92784
94072
  else if (stat.isFile())
92785
94073
  copyFileSync6(from, to);
92786
- let parent = path46.dirname(relative);
94074
+ let parent = path48.dirname(relative);
92787
94075
  while (parent !== "." && !dirs.has(parent)) {
92788
94076
  dirs.add(parent);
92789
- parent = path46.dirname(parent);
94077
+ parent = path48.dirname(parent);
92790
94078
  }
92791
94079
  }
92792
94080
  for (const parent of dirs) {
92793
- const modules = path46.join(repo, parent, "node_modules");
92794
- if (existsSync19(modules) && !existsSync19(path46.join(tree, parent, "node_modules")))
92795
- linkNodeModules(modules, path46.join(tree, parent, "node_modules"), repo, tree);
94081
+ const modules = path48.join(repo, parent, "node_modules");
94082
+ if (existsSync19(modules) && !existsSync19(path48.join(tree, parent, "node_modules")))
94083
+ linkNodeModules(modules, path48.join(tree, parent, "node_modules"), repo, tree);
92796
94084
  }
92797
94085
  return { dir, tree };
92798
94086
  }
@@ -92831,14 +94119,14 @@ async function runMutation(options) {
92831
94119
  const tracked = git6(repo, ["ls-files", "-co", "--exclude-standard", "-z"]).stdout.split("\x00").filter(Boolean);
92832
94120
  const testFiles = tracked.filter((file) => TEST_FILE.test(file)).map((file) => {
92833
94121
  try {
92834
- return { file, text: readFileSync20(path46.join(repo, file), "utf8") };
94122
+ return { file, text: readFileSync20(path48.join(repo, file), "utf8") };
92835
94123
  } catch {
92836
94124
  return { file, text: "" };
92837
94125
  }
92838
94126
  });
92839
94127
  const candidates = [];
92840
94128
  for (const [absolute, lines] of [...options.changed].toSorted(([a], [b]) => a.localeCompare(b))) {
92841
- const relative = toPosix(path46.relative(repo, absolute));
94129
+ const relative = toPosix(path48.relative(repo, absolute));
92842
94130
  if (!CODE_FILE.test(relative) || TEST_FILE.test(relative) || relative.endsWith(".d.ts"))
92843
94131
  continue;
92844
94132
  if (NOT_SOURCE.test(relative) || !existsSync19(absolute))
@@ -92852,9 +94140,9 @@ async function runMutation(options) {
92852
94140
  for (const mutant of mutants)
92853
94141
  if (!related.has(mutant.file))
92854
94142
  related.set(mutant.file, relatedTests(mutant.file, testFiles));
92855
- const cwdRelative = path46.relative(repo, options.cwd);
94143
+ const cwdRelative = path48.relative(repo, options.cwd);
92856
94144
  const asArg = (file) => {
92857
- const relative = toPosix(path46.relative(cwdRelative, file));
94145
+ const relative = toPosix(path48.relative(cwdRelative, file));
92858
94146
  return shellQuote(relative.startsWith(".") ? relative : `./${relative}`);
92859
94147
  };
92860
94148
  const commandFor = (tests) => options.command.replaceAll("{files}", () => tests.map(asArg).join(" "));
@@ -92889,7 +94177,7 @@ async function runMutation(options) {
92889
94177
  return finish2(options, 0, results, counts, started);
92890
94178
  removeStaleCopies();
92891
94179
  const { dir: copyDir, tree: copy } = copyWorkingTree(repo);
92892
- const copyCwd = path46.join(copy, cwdRelative);
94180
+ const copyCwd = path48.join(copy, cwdRelative);
92893
94181
  let current = null;
92894
94182
  const restore = () => {
92895
94183
  if (current)
@@ -92924,7 +94212,7 @@ async function runMutation(options) {
92924
94212
  record({ ...mutant, status: "skipped", durationMs: 0, tests, reason: "budget" });
92925
94213
  continue;
92926
94214
  }
92927
- const original = readFileSync20(path46.join(copy, mutant.file), "utf8");
94215
+ const original = readFileSync20(path48.join(copy, mutant.file), "utf8");
92928
94216
  const mutated = original.slice(0, mutant.start) + mutant.replacement + original.slice(mutant.end);
92929
94217
  current = { file: mutant.file, original };
92930
94218
  if (!writeInside(copy, mutant.file, mutated)) {
@@ -92980,9 +94268,9 @@ var NOT_SOURCE, FLIPS, isStringy = (node) => node.type === "StringLiteral" || no
92980
94268
  } catch (error) {
92981
94269
  return error.code === "EPERM";
92982
94270
  }
92983
- }, inside2 = (root, file) => {
92984
- const relative = path46.relative(root, file);
92985
- return relative === "" || !relative.startsWith("..") && !path46.isAbsolute(relative);
94271
+ }, inside3 = (root, file) => {
94272
+ const relative = path48.relative(root, file);
94273
+ return relative === "" || !relative.startsWith("..") && !path48.isAbsolute(relative);
92986
94274
  }, active, killTree = (child) => {
92987
94275
  try {
92988
94276
  if (child.pid && process.platform !== "win32")
@@ -93031,7 +94319,7 @@ __export(exports_test_audit, {
93031
94319
  run: () => run17
93032
94320
  });
93033
94321
  import { existsSync as existsSync20 } from "node:fs";
93034
- import path47 from "node:path";
94322
+ import path49 from "node:path";
93035
94323
  function parse7(argv) {
93036
94324
  const options = {
93037
94325
  paths: [],
@@ -93131,8 +94419,8 @@ async function run17(argv, io) {
93131
94419
  return emit(io, fail("invalid_input", `${options}
93132
94420
  usage: ${USAGE10}`));
93133
94421
  const top = git6(io.cwd, ["rev-parse", "--show-toplevel"]);
93134
- const repo = top.ok ? path47.resolve(top.stdout.trim()) : null;
93135
- const display = (file) => toPosix(path47.relative(io.cwd, file) || file);
94422
+ const repo = top.ok ? path49.resolve(top.stdout.trim()) : null;
94423
+ const display = (file) => toPosix(path49.relative(io.cwd, file) || file);
93136
94424
  let base = null;
93137
94425
  let changed = null;
93138
94426
  if (options.diff || options.mutate) {
@@ -93233,6 +94521,38 @@ var init_test_audit = __esm(() => {
93233
94521
  ]);
93234
94522
  });
93235
94523
 
94524
+ // packages/workit-cli/src/verbs/knowledge.ts
94525
+ var exports_knowledge = {};
94526
+ __export(exports_knowledge, {
94527
+ run: () => run18
94528
+ });
94529
+ async function run18(argv, io) {
94530
+ const [sub, ...rest] = argv.filter((arg) => arg !== "--json");
94531
+ if (sub !== "lint" || rest.length > 0)
94532
+ return emit(io, fail("invalid_input", `usage: ${USAGE11}`));
94533
+ const report = lintKnowledge(checkRoot(io.cwd));
94534
+ if (report.unreadable.length > 0)
94535
+ return emit(io, fail("unavailable", `knowledge lint: cannot read ${report.unreadable.join(", ")}`, {
94536
+ data: report,
94537
+ unblock: `make ${report.unreadable.join(", ")} readable, then re-run workit knowledge lint`
94538
+ }));
94539
+ const summary = `knowledge lint: ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"} in ${report.files.length} file${report.files.length === 1 ? "" : "s"} (${report.files.map((file) => `${file.file} ${file.bytes} B`).join(", ") || "no knowledge files"}; AGENTS.md budget ${report.budget} B)`;
94540
+ if (io.json)
94541
+ return emit(io, report.findings.length === 0 ? ok(report) : fail("failed", summary, { data: report }));
94542
+ for (const finding of report.findings)
94543
+ io.stdout(`${finding.file}${finding.line === null ? "" : `:${finding.line}`} [${finding.rule}] ${finding.message}
94544
+ `);
94545
+ io.stdout(`${summary}
94546
+ `);
94547
+ return report.findings.length === 0 ? 0 : 1;
94548
+ }
94549
+ var USAGE11 = "workit knowledge lint [--json]";
94550
+ var init_knowledge2 = __esm(() => {
94551
+ init_check_config();
94552
+ init_knowledge();
94553
+ init_output();
94554
+ });
94555
+
93236
94556
  // packages/workit-core/src/core/youtrack-tools.ts
93237
94557
  async function logTimeUpdate(input, operation = defaultOperations) {
93238
94558
  try {
@@ -93340,14 +94660,14 @@ var init_youtrack_writes = __esm(() => {
93340
94660
  // packages/workit-cli/src/verbs/youtrack.ts
93341
94661
  var exports_youtrack = {};
93342
94662
  __export(exports_youtrack, {
93343
- run: () => run18
94663
+ run: () => run19
93344
94664
  });
93345
94665
  import { readFileSync as readFileSync21 } from "node:fs";
93346
- import path48 from "node:path";
93347
- async function run18(argv, io) {
94666
+ import path50 from "node:path";
94667
+ async function run19(argv, io) {
93348
94668
  const [sub, ...rest] = argv;
93349
94669
  if (sub !== "note" && sub !== "time" && sub !== "meeting")
93350
- return usage3(io, `unknown subcommand "${sub ?? ""}"`, USAGE11);
94670
+ return usage3(io, `unknown subcommand "${sub ?? ""}"`, USAGE12);
93351
94671
  const flags = parseFlags(rest, {
93352
94672
  markdown: "value",
93353
94673
  file: "value",
@@ -93356,18 +94676,18 @@ async function run18(argv, io) {
93356
94676
  date: "value"
93357
94677
  });
93358
94678
  if (typeof flags === "string")
93359
- return usage3(io, flags, USAGE11);
94679
+ return usage3(io, flags, USAGE12);
93360
94680
  const [issueId, extra] = flags.positionals;
93361
94681
  if (!issueId || !ISSUE_RE2.test(issueId))
93362
- return usage3(io, "an issue id like ABC-123 is required", USAGE11);
94682
+ return usage3(io, "an issue id like ABC-123 is required", USAGE12);
93363
94683
  if (extra !== undefined)
93364
- return usage3(io, `unexpected argument ${extra}`, USAGE11);
94684
+ return usage3(io, `unexpected argument ${extra}`, USAGE12);
93365
94685
  const minutes = positiveInt(flags.values.minutes, "--minutes");
93366
94686
  if (typeof minutes === "string")
93367
- return usage3(io, minutes, USAGE11);
94687
+ return usage3(io, minutes, USAGE12);
93368
94688
  const date = youTrackWorkDateMs(flags.values.date ?? "auto");
93369
94689
  if ("error" in date)
93370
- return usage3(io, date.error, USAGE11);
94690
+ return usage3(io, date.error, USAGE12);
93371
94691
  const dateMs = date.data.dateMs;
93372
94692
  const markedDay = flags.values.date !== undefined && flags.values.date !== "auto" ? { dateMs } : {};
93373
94693
  const root = io.cwd;
@@ -93385,24 +94705,24 @@ async function run18(argv, io) {
93385
94705
  };
93386
94706
  if (sub === "note") {
93387
94707
  if (Boolean(flags.values.markdown) === Boolean(flags.values.file))
93388
- return usage3(io, "pass exactly one of --markdown or --file", USAGE11);
94708
+ return usage3(io, "pass exactly one of --markdown or --file", USAGE12);
93389
94709
  let markdown = flags.values.markdown ?? "";
93390
94710
  if (flags.values.file)
93391
94711
  try {
93392
- markdown = readFileSync21(path48.resolve(io.cwd, flags.values.file), "utf8");
94712
+ markdown = readFileSync21(path50.resolve(io.cwd, flags.values.file), "utf8");
93393
94713
  } catch (error) {
93394
- return usage3(io, `cannot read ${flags.values.file}: ${String(error)}`, USAGE11);
94714
+ return usage3(io, `cannot read ${flags.values.file}: ${String(error)}`, USAGE12);
93395
94715
  }
93396
94716
  if (!markdown.trim())
93397
- return usage3(io, "the note is empty", USAGE11);
94717
+ return usage3(io, "the note is empty", USAGE12);
93398
94718
  const missing = step("comment", "comments", { operation: "note", issueId, markdown }, markdown, async (text) => commentResult(await postComment(issueId, text, root))) ?? (minutes ? step("time", "workItems", { operation: "note.time", issueId, markdown, minutes, ...markedDay }, "workit update", (text) => logTimeUpdate({ issueId, minutes, text, dateMs, workspace_root: root })) : null);
93399
94719
  if (missing)
93400
94720
  return emit(io, fail("unavailable", missing));
93401
94721
  } else {
93402
94722
  if (!minutes)
93403
- return usage3(io, `${sub} needs --minutes <n>`, USAGE11);
94723
+ return usage3(io, `${sub} needs --minutes <n>`, USAGE12);
93404
94724
  if (sub === "meeting" && !flags.values.text)
93405
- return usage3(io, "meeting needs --text <what the meeting was>", USAGE11);
94725
+ return usage3(io, "meeting needs --text <what the meeting was>", USAGE12);
93406
94726
  const text = flags.values.text ?? "";
93407
94727
  const missing = step(sub, "workItems", { operation: sub, issueId, minutes, text, ...markedDay }, text, (marked) => logTimeUpdate({ issueId, minutes, text: marked, dateMs, workspace_root: root }));
93408
94728
  if (missing)
@@ -93431,7 +94751,7 @@ async function run18(argv, io) {
93431
94751
  ...data.steps.flatMap((entry) => entry.note ? [` note (${entry.step}): ${entry.note}`] : [])
93432
94752
  ]);
93433
94753
  }
93434
- var USAGE11 = "workit youtrack note <ISSUE> (--markdown <t> | --file <p>) [--minutes <n>] [--date auto|YYYY-MM-DD] | youtrack time <ISSUE> --minutes <n> [--text <t>] [--date …] | youtrack meeting <ISSUE> --minutes <n> --text <t> [--date …]", commentResult = (value) => {
94754
+ var USAGE12 = "workit youtrack note <ISSUE> (--markdown <t> | --file <p>) [--minutes <n>] [--date auto|YYYY-MM-DD] | youtrack time <ISSUE> --minutes <n> [--text <t>] [--date …] | youtrack meeting <ISSUE> --minutes <n> --text <t> [--date …]", commentResult = (value) => {
93435
94755
  try {
93436
94756
  return { ok: true, data: unwrap2(value) };
93437
94757
  } catch (error) {
@@ -93455,14 +94775,14 @@ var init_youtrack2 = __esm(() => {
93455
94775
  // packages/workit-cli/src/verbs/changelog.ts
93456
94776
  var exports_changelog = {};
93457
94777
  __export(exports_changelog, {
93458
- run: () => run19
94778
+ run: () => run20
93459
94779
  });
93460
94780
  import { readFileSync as readFileSync22 } from "node:fs";
93461
- import path49 from "node:path";
93462
- async function run19(argv, io) {
94781
+ import path51 from "node:path";
94782
+ async function run20(argv, io) {
93463
94783
  const [sub, ...rest] = argv;
93464
94784
  if (sub !== "apply")
93465
- return usage3(io, `unknown subcommand "${sub ?? ""}"`, USAGE12);
94785
+ return usage3(io, `unknown subcommand "${sub ?? ""}"`, USAGE13);
93466
94786
  const flags = parseFlags(rest, {
93467
94787
  entries: "value",
93468
94788
  path: "value",
@@ -93470,18 +94790,18 @@ async function run19(argv, io) {
93470
94790
  preview: "boolean"
93471
94791
  });
93472
94792
  if (typeof flags === "string")
93473
- return usage3(io, flags, USAGE12);
94793
+ return usage3(io, flags, USAGE13);
93474
94794
  if (flags.positionals.length)
93475
- return usage3(io, `unexpected argument ${flags.positionals[0]}`, USAGE12);
94795
+ return usage3(io, `unexpected argument ${flags.positionals[0]}`, USAGE13);
93476
94796
  const normalizeOnly = flags.booleans.has("normalize-only");
93477
94797
  if (!normalizeOnly && !flags.values.entries)
93478
- return usage3(io, "pass --entries <JSON> or --normalize-only", USAGE12);
94798
+ return usage3(io, "pass --entries <JSON> or --normalize-only", USAGE13);
93479
94799
  let entries;
93480
94800
  if (flags.values.entries)
93481
94801
  try {
93482
94802
  entries = readEntries(flags.values.entries, io.cwd);
93483
94803
  } catch (error) {
93484
- return usage3(io, `--entries is not valid JSON: ${String(error)}`, USAGE12);
94804
+ return usage3(io, `--entries is not valid JSON: ${String(error)}`, USAGE13);
93485
94805
  }
93486
94806
  const input = {
93487
94807
  entries,
@@ -93503,8 +94823,8 @@ async function run19(argv, io) {
93503
94823
  `changelog updated: ${String(applied.path ?? "CHANGELOG.md")}`
93504
94824
  ]);
93505
94825
  }
93506
- var USAGE12 = "workit changelog apply (--entries <JSON|@file|-> | --normalize-only) [--path CHANGELOG.md] [--preview]", readEntries = (raw, cwd) => {
93507
- const text = raw === "-" ? readFileSync22(0, "utf8") : raw.startsWith("@") ? readFileSync22(path49.resolve(cwd, raw.slice(1)), "utf8") : raw;
94826
+ var USAGE13 = "workit changelog apply (--entries <JSON|@file|-> | --normalize-only) [--path CHANGELOG.md] [--preview]", readEntries = (raw, cwd) => {
94827
+ const text = raw === "-" ? readFileSync22(0, "utf8") : raw.startsWith("@") ? readFileSync22(path51.resolve(cwd, raw.slice(1)), "utf8") : raw;
93508
94828
  return JSON.parse(text);
93509
94829
  };
93510
94830
  var init_changelog2 = __esm(() => {
@@ -93516,12 +94836,12 @@ var init_changelog2 = __esm(() => {
93516
94836
  // packages/workit-cli/src/verbs/handoff.ts
93517
94837
  var exports_handoff = {};
93518
94838
  __export(exports_handoff, {
93519
- run: () => run20
94839
+ run: () => run21
93520
94840
  });
93521
94841
  import { parseArgs as parseArgs2 } from "node:util";
93522
94842
  function human2(brief) {
93523
94843
  const lines = [
93524
- `branch: ${brief.branch ?? "(detached)"} @ ${short5(brief.head)}${brief.subject ? ` ${brief.subject}` : ""}`
94844
+ `branch: ${brief.branch ?? "(detached)"} @ ${short6(brief.head)}${brief.subject ? ` ${brief.subject}` : ""}`
93525
94845
  ];
93526
94846
  if (brief.base)
93527
94847
  lines.push(`base: ${brief.base}${brief.ahead === null ? "" : ` (ahead ${brief.ahead}, behind ${brief.behind})`}`);
@@ -93564,11 +94884,11 @@ async function resumeBrief(argv, io) {
93564
94884
  strict: true
93565
94885
  }));
93566
94886
  } catch (error) {
93567
- return emit(io, fail("invalid_input", error.message, { unblock: USAGE13 }));
94887
+ return emit(io, fail("invalid_input", error.message, { unblock: USAGE14 }));
93568
94888
  }
93569
94889
  const last = values.last === undefined ? 10 : Number(values.last);
93570
94890
  if (!Number.isInteger(last) || last < 1)
93571
- return emit(io, fail("invalid_input", "--last must be a positive integer", { unblock: USAGE13 }));
94891
+ return emit(io, fail("invalid_input", "--last must be a positive integer", { unblock: USAGE14 }));
93572
94892
  const built = buildHandoff(io.cwd, {
93573
94893
  last,
93574
94894
  note: values.note ?? null,
@@ -93586,13 +94906,13 @@ async function resumeBrief(argv, io) {
93586
94906
  }
93587
94907
  return emit(io, ok({ ...data, recorded }), human2);
93588
94908
  }
93589
- async function run20(argv, io) {
94909
+ async function run21(argv, io) {
93590
94910
  if (!argv.includes("--task"))
93591
94911
  return resumeBrief(argv, io);
93592
94912
  await Promise.resolve().then(() => init_task());
93593
94913
  return runTaskCommand(["handoff", ...argv]);
93594
94914
  }
93595
- var USAGE13 = "workit handoff [--note <text>] [--next <text>] [--record] [--last <n>] [--json]", short5 = (sha) => sha ? sha.slice(0, 12) : "no commit";
94915
+ var USAGE14 = "workit handoff [--note <text>] [--next <text>] [--record] [--last <n>] [--json]", short6 = (sha) => sha ? sha.slice(0, 12) : "no commit";
93596
94916
  var init_handoff = __esm(() => {
93597
94917
  init_ledger();
93598
94918
  init_output();
@@ -93601,7 +94921,7 @@ var init_handoff = __esm(() => {
93601
94921
  // packages/workit-cli/src/main.ts
93602
94922
  init_package();
93603
94923
  init_output();
93604
- import path50 from "node:path";
94924
+ import path52 from "node:path";
93605
94925
 
93606
94926
  // packages/workit-cli/src/verbs/registry.ts
93607
94927
  var TASK_FAMILY_NAMES = [
@@ -93716,8 +95036,8 @@ var VERBS = [
93716
95036
  {
93717
95037
  name: "fanout",
93718
95038
  group: "delivery",
93719
- usage: "workit fanout plan <plan.json> [--name <n>] [--trunk <b> | --track <t>] | fanout check [<slice>…] [--name <n>] [--base <ref>] [--json]",
93720
- summary: "Register parallel slices (complete briefs, disjoint scopes, shared-file owners), then gate fan-in: out-of-scope edits, trunk and sibling merge conflicts, landing order",
95039
+ usage: "workit fanout plan <plan.json> [--name <n>] [--trunk <b> | --track <t>] | fanout check [<slice>…] [--name <n>] [--base <ref>] [--offline] | fanout status [--name <n>] [--stuck-after 30m] [--offline] | fanout worktree create|release <slice> [--force] [--json]",
95040
+ summary: "Register parallel slices (complete briefs, disjoint scopes, shared-file owners), watch them (STUCK, PR, CI, verdict, landed, landing order), make and release slice worktrees, and gate fan-in: out-of-scope edits, trunk and sibling merge conflicts",
93721
95041
  load: () => Promise.resolve().then(() => (init_fanout2(), exports_fanout))
93722
95042
  },
93723
95043
  {
@@ -93734,6 +95054,13 @@ var VERBS = [
93734
95054
  summary: "Flag tautological and low-value tests with a suggested independent oracle; --mutate checks changed lines with diff-scoped mutation",
93735
95055
  load: () => Promise.resolve().then(() => (init_test_audit(), exports_test_audit))
93736
95056
  },
95057
+ {
95058
+ name: "knowledge",
95059
+ group: "delivery",
95060
+ usage: "workit knowledge lint [--json]",
95061
+ summary: "Lint agent knowledge files: AGENTS.md byte budget, broken local links, scaffold-only files, duplicated rules (exit 1 on findings)",
95062
+ load: () => Promise.resolve().then(() => (init_knowledge2(), exports_knowledge))
95063
+ },
93737
95064
  {
93738
95065
  name: "youtrack",
93739
95066
  group: "delivery",
@@ -93838,7 +95165,7 @@ var defaultIo = (json) => ({
93838
95165
  });
93839
95166
  async function main(argv, overrides = {}, options = {}) {
93840
95167
  const parsed = parseGlobals(argv);
93841
- const cwd = path50.resolve(overrides.cwd ?? process.cwd(), parsed.cwd ?? ".");
95168
+ const cwd = path52.resolve(overrides.cwd ?? process.cwd(), parsed.cwd ?? ".");
93842
95169
  const io = { ...defaultIo(parsed.json), ...overrides, json: parsed.json, cwd };
93843
95170
  if (parsed.error)
93844
95171
  return emit(io, fail("invalid_input", parsed.error));