@rungs/cli 0.2.0 → 0.3.1

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/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { fileURLToPath as fileURLToPath3 } from "node:url";
5
- import { dirname as dirname8, join as join13, resolve as resolve4 } from "node:path";
4
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
5
+ import { dirname as dirname9, join as join14, resolve as resolve5 } from "node:path";
6
6
 
7
7
  // src/manifest.ts
8
8
  import { readdirSync as readdirSync2, readFileSync, statSync as statSync2 } from "node:fs";
@@ -850,14 +850,15 @@ function writeReport(repoRoot, entries, harnesses, stamp) {
850
850
 
851
851
  // src/check.ts
852
852
  import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
853
- import { execSync as execSync3 } from "node:child_process";
853
+ import { execSync } from "node:child_process";
854
854
  import { dirname as dirname5, join as join10 } from "node:path";
855
- import { fileURLToPath } from "node:url";
855
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
856
856
  import { parse as parse2 } from "smol-toml";
857
857
 
858
858
  // src/engines.ts
859
859
  import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
860
860
  import { join as join9, dirname as dirname4, resolve as resolve2 } from "node:path";
861
+ import { fileURLToPath } from "node:url";
861
862
  import { parse as parseToml } from "smol-toml";
862
863
 
863
864
  // src/selftest.ts
@@ -880,6 +881,11 @@ function build(root, table, fx, input) {
880
881
  if (typeof input === "string") return [write(targetPath(table), `${input}
881
882
  `)];
882
883
  if (!fx || typeof fx !== "object") return null;
884
+ if (fx.packages && typeof fx.packages === "object") {
885
+ return Object.entries(fx.packages).map(
886
+ ([rel, version]) => write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }))
887
+ );
888
+ }
883
889
  if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
884
890
  const dir = fx.dir ?? "changelog.d";
885
891
  const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
@@ -929,7 +935,8 @@ var CONTEXT_FREE = /* @__PURE__ */ new Set([
929
935
  "file-budget",
930
936
  "register-schema",
931
937
  "file-population",
932
- "changelog-freshness"
938
+ "changelog-freshness",
939
+ "computed-claim"
933
940
  ]);
934
941
  function deparam(spec, dir) {
935
942
  const walk3 = (v) => typeof v === "string" ? v.replace(/\{\{[^}]+\}\}/g, dir) : Array.isArray(v) ? v.map(walk3) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk3(x)])) : v;
@@ -952,6 +959,10 @@ function runSelfTests(gateId, engine, table, blocks) {
952
959
  const files = build(root, table, b.fixture, b.input);
953
960
  let spec = b.fixture?.opted_in ? Array.isArray(table) ? table.map((s) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in } : table;
954
961
  if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
962
+ if (Array.isArray(b.fixture?.exclude)) {
963
+ const ex = b.fixture.exclude;
964
+ spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
965
+ }
955
966
  if (!files) {
956
967
  out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
957
968
  continue;
@@ -976,7 +987,7 @@ function runSelfTests(gateId, engine, table, blocks) {
976
987
 
977
988
  // src/engines2.ts
978
989
  import { readFileSync as readFileSync5 } from "node:fs";
979
- import { execSync } from "node:child_process";
990
+ import { execFileSync } from "node:child_process";
980
991
  import { join as join7 } from "node:path";
981
992
  var read = (root, rel) => {
982
993
  try {
@@ -1227,12 +1238,13 @@ var crossReference = (t, root, files) => {
1227
1238
  }
1228
1239
  return { findings, examined: skills.length };
1229
1240
  };
1241
+ var gitArgs = (root, args2) => execFileSync("git", args2, { cwd: root, stdio: "pipe" }).toString().trim();
1230
1242
  function landedWork(root, branch, base) {
1231
- const git = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
1243
+ const git2 = (...args2) => gitArgs(root, args2);
1232
1244
  try {
1233
- const tip = git(`rev-parse ${branch}`);
1234
- if (tip === git(`rev-parse ${base}`)) return false;
1235
- return git(`log ${base} --merges --format=%P`).split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
1245
+ const tip = git2("rev-parse", branch);
1246
+ if (tip === git2("rev-parse", base)) return false;
1247
+ return git2("log", base, "--merges", "--format=%P").split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
1236
1248
  } catch {
1237
1249
  return true;
1238
1250
  }
@@ -1242,10 +1254,7 @@ var gitStatusReconcile = (t, root, files) => {
1242
1254
  let merged;
1243
1255
  try {
1244
1256
  merged = new Set(
1245
- execSync(`git branch --merged ${t.integration_branch ?? "main"} --format=%(refname:short)`, {
1246
- cwd: root,
1247
- stdio: "pipe"
1248
- }).toString().split("\n").map((s) => s.trim()).filter(Boolean)
1257
+ gitArgs(root, ["branch", "--merged", t.integration_branch ?? "main", "--format=%(refname:short)"]).split("\n").map((s) => s.trim()).filter(Boolean)
1249
1258
  );
1250
1259
  } catch {
1251
1260
  return { findings: [{ message: "cannot read git branches; status not reconciled" }], examined: 0 };
@@ -1270,8 +1279,10 @@ var computedClaim = (t, root, files) => {
1270
1279
  let examined = 0;
1271
1280
  for (const spec of specs) {
1272
1281
  const values = /* @__PURE__ */ new Map();
1282
+ const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
1273
1283
  for (const src of spec.sources ?? []) {
1274
1284
  for (const rel of matchAny(files, src.file)) {
1285
+ if (excluded(rel)) continue;
1275
1286
  const text = read(root, rel);
1276
1287
  let v;
1277
1288
  if (src.path && rel.endsWith(".json")) {
@@ -1290,8 +1301,9 @@ var computedClaim = (t, root, files) => {
1290
1301
  }
1291
1302
  const distinct = new Set(values.values());
1292
1303
  if (spec.rule === "all-agree" && distinct.size > 1) {
1304
+ const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
1293
1305
  findings.push({
1294
- message: `${spec.id} disagrees across ${values.size} locations: ${[...distinct].join(", ")} \u2014 run \`${spec.autofix}\``
1306
+ message: `${spec.id} disagrees across ${values.size} locations: ${where}` + (spec.autofix ? ` \u2014 run \`${spec.autofix}\`` : "") + (spec.exclude?.length ? "" : ". If one of these is versioned independently, list it in `exclude`.")
1295
1307
  });
1296
1308
  }
1297
1309
  }
@@ -1300,7 +1312,7 @@ var computedClaim = (t, root, files) => {
1300
1312
 
1301
1313
  // src/engines3.ts
1302
1314
  import { readFileSync as readFileSync6 } from "node:fs";
1303
- import { execSync as execSync2 } from "node:child_process";
1315
+ import { execFileSync as execFileSync2 } from "node:child_process";
1304
1316
  import { join as join8 } from "node:path";
1305
1317
  var read2 = (root, rel) => {
1306
1318
  try {
@@ -1434,7 +1446,7 @@ var rulePropagation = (t, root, files) => {
1434
1446
  var gitState = (t, root) => {
1435
1447
  let out;
1436
1448
  try {
1437
- out = execSync2("git worktree list --porcelain", { cwd: root, stdio: "pipe" }).toString();
1449
+ out = execFileSync2("git", ["worktree", "list", "--porcelain"], { cwd: root, stdio: "pipe" }).toString();
1438
1450
  } catch {
1439
1451
  return { findings: [{ message: "cannot read git worktrees; checkout state unknown" }], examined: 0 };
1440
1452
  }
@@ -1459,7 +1471,7 @@ var mergeDriverCheck = (t, root) => {
1459
1471
  for (const driver of required) {
1460
1472
  let configured = "";
1461
1473
  try {
1462
- configured = execSync2(`git config --get merge.${driver}.driver`, { cwd: root, stdio: "pipe" }).toString().trim();
1474
+ configured = execFileSync2("git", ["config", "--get", `merge.${driver}.driver`], { cwd: root, stdio: "pipe" }).toString().trim();
1463
1475
  } catch {
1464
1476
  }
1465
1477
  if (!configured) {
@@ -1511,6 +1523,7 @@ var boardReconcile = (t, root, _files) => {
1511
1523
  };
1512
1524
 
1513
1525
  // src/engines.ts
1526
+ var CLI_MODULES = join9(dirname4(fileURLToPath(import.meta.url)), "..", "modules");
1514
1527
  var read3 = (root, rel) => {
1515
1528
  try {
1516
1529
  return readFileSync7(join9(root, rel), "utf8");
@@ -1712,7 +1725,7 @@ var gateMeta = (_t, root) => {
1712
1725
  const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
1713
1726
  if (!id || kind !== "declared" || !table) continue;
1714
1727
  examined++;
1715
- const tablePath = join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname4(table), "gates", table.split("/").pop());
1728
+ const tablePath = join9(CLI_MODULES, dirname4(table), "gates", table.split("/").pop());
1716
1729
  const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
1717
1730
  const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)].map((m) => m[0]).filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
1718
1731
  for (const direction of ["pass", "fail"]) {
@@ -1738,7 +1751,7 @@ function optedInExtensions(rel, spec) {
1738
1751
  const name = rel.split("/").slice(-2)[0];
1739
1752
  if (!name) return /* @__PURE__ */ new Set();
1740
1753
  try {
1741
- const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1754
+ const mods = loadAllModules(CLI_MODULES);
1742
1755
  const owner = mods.find((m) => m.skills?.[name]?.extensions);
1743
1756
  return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
1744
1757
  } catch {
@@ -1749,7 +1762,7 @@ var escapeRe3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1749
1762
  function parseTable(path, module) {
1750
1763
  if (!existsSync6(path)) return null;
1751
1764
  try {
1752
- const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1765
+ const mods = loadAllModules(CLI_MODULES);
1753
1766
  const params = resolveParams(mods, {}, ".");
1754
1767
  return parseToml(substitute(readFileSync7(path, "utf8"), module, params));
1755
1768
  } catch {
@@ -1802,7 +1815,7 @@ function isImplemented(engine) {
1802
1815
  }
1803
1816
 
1804
1817
  // src/check.ts
1805
- var MODULES = join10(dirname5(fileURLToPath(import.meta.url)), "..", "modules");
1818
+ var MODULES = join10(dirname5(fileURLToPath2(import.meta.url)), "..", "modules");
1806
1819
  function loadRegistry(repoRoot) {
1807
1820
  const path = join10(repoRoot, ".ai", "gates.toml");
1808
1821
  if (!existsSync7(path)) return { runner: {}, gates: [] };
@@ -1825,7 +1838,7 @@ var UnknownTierError = class extends Error {
1825
1838
  this.declared = declared;
1826
1839
  }
1827
1840
  };
1828
- function runGates(repoRoot, tier, now = () => Date.now()) {
1841
+ function runGates(repoRoot, tier, now = () => Date.now(), only) {
1829
1842
  const { runner, gates } = loadRegistry(repoRoot);
1830
1843
  const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
1831
1844
  if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
@@ -1835,6 +1848,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
1835
1848
  const runs = [];
1836
1849
  for (const g of gates) {
1837
1850
  if (g.trigger) continue;
1851
+ if (only && !only.has(g.id)) continue;
1838
1852
  if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
1839
1853
  const started = now();
1840
1854
  let status = "pass";
@@ -1842,7 +1856,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
1842
1856
  let examined = 0;
1843
1857
  if (g.kind === "command" && g.command) {
1844
1858
  try {
1845
- execSync3(g.command, { cwd: repoRoot, stdio: "pipe" });
1859
+ execSync(g.command, { cwd: repoRoot, stdio: "pipe" });
1846
1860
  } catch (e) {
1847
1861
  status = "fail";
1848
1862
  findings = [{ message: String(e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-3).join(" ") }];
@@ -1964,10 +1978,10 @@ function ledgerQuestions(repoRoot, gates) {
1964
1978
  // src/lifecycle.ts
1965
1979
  import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
1966
1980
  import { dirname as dirname6, join as join11 } from "node:path";
1967
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1968
- import { execSync as execSync4 } from "node:child_process";
1981
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1982
+ import { execFileSync as execFileSync3 } from "node:child_process";
1969
1983
  import { parse as parse3 } from "smol-toml";
1970
- var SRC = dirname6(fileURLToPath2(import.meta.url));
1984
+ var SRC = dirname6(fileURLToPath3(import.meta.url));
1971
1985
  var PROFILES = {
1972
1986
  minimal: ["instructions"],
1973
1987
  tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
@@ -2180,8 +2194,8 @@ function setupGit(repoRoot, dryRun = false) {
2180
2194
  ');process.exit(1)"` : "git merge-file -L ours -L base -L theirs %A %O %B";
2181
2195
  if (!dryRun) {
2182
2196
  try {
2183
- execSync4(`git config merge.${d}.name "rungs ${d.replace("rungs-", "")} driver"`, { cwd: repoRoot, stdio: "pipe" });
2184
- execSync4(`git config merge.${d}.driver ${JSON.stringify(cmd2)}`, { cwd: repoRoot, stdio: "pipe" });
2197
+ execFileSync3("git", ["config", `merge.${d}.name`, `rungs ${d.replace("rungs-", "")} driver`], { cwd: repoRoot, stdio: "pipe" });
2198
+ execFileSync3("git", ["config", `merge.${d}.driver`, cmd2], { cwd: repoRoot, stdio: "pipe" });
2185
2199
  } catch {
2186
2200
  continue;
2187
2201
  }
@@ -2191,7 +2205,7 @@ function setupGit(repoRoot, dryRun = false) {
2191
2205
  let rerere = false;
2192
2206
  if (!dryRun) {
2193
2207
  try {
2194
- execSync4("git config rerere.enabled true", { cwd: repoRoot, stdio: "pipe" });
2208
+ execFileSync3("git", ["config", "rerere.enabled", "true"], { cwd: repoRoot, stdio: "pipe" });
2195
2209
  rerere = true;
2196
2210
  } catch {
2197
2211
  }
@@ -2286,15 +2300,17 @@ function planArchive(repoRoot, backlogRoot = "docs/backlog") {
2286
2300
  const files = walk(repoRoot);
2287
2301
  const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
2288
2302
  for (const rel of items) {
2289
- if (/README\.md$/i.test(rel) || /TEMPLATE\.md$/i.test(rel)) continue;
2303
+ const base = posix(rel).split("/").pop();
2304
+ if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
2290
2305
  const text = readFileSync10(join12(repoRoot, rel), "utf8");
2291
2306
  const status = field(text, "status");
2292
2307
  const id = field(text, "id");
2293
2308
  if (!FINISHED.has(status)) continue;
2294
2309
  if (field(text, "type") === "epic") {
2295
2310
  const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2311
+ const archived = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, archiveDir)) + "/") && f.endsWith(".md"));
2296
2312
  const unfinished = children.filter((c2) => {
2297
- const f = items.find((i) => i.includes(`${c2}-`));
2313
+ const f = items.find((i) => i.includes(`${c2}-`)) ?? archived.find((i) => i.includes(`${c2}-`));
2298
2314
  return !f || !FINISHED.has(field(readFileSync10(join12(repoRoot, f), "utf8"), "status"));
2299
2315
  });
2300
2316
  if (unfinished.length) {
@@ -2362,10 +2378,268 @@ function applyArchive(repoRoot, plan) {
2362
2378
  }
2363
2379
  }
2364
2380
 
2381
+ // src/concurrency.ts
2382
+ import { existsSync as existsSync10, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync6, unlinkSync } from "node:fs";
2383
+ import { execFileSync as execFileSync4 } from "node:child_process";
2384
+ import { hostname } from "node:os";
2385
+ import { join as join13, resolve as resolve4, dirname as dirname8, basename as basename2 } from "node:path";
2386
+ import { tmpdir as tmpdir2 } from "node:os";
2387
+ function loopParams(root) {
2388
+ const p = installedParams(root).concurrency ?? {};
2389
+ const integration = String(p.integration_branch ?? "main");
2390
+ const greenPrefix = String(p.green_prefix ?? "green/");
2391
+ return {
2392
+ integration,
2393
+ // The green ref marks the last *verified* merge of the integration branch,
2394
+ // so it is prefix + that branch — not prefix + whatever you are cutting.
2395
+ greenRef: `${greenPrefix}${integration}`,
2396
+ integPrefix: String(p.integ_prefix ?? "integ/")
2397
+ };
2398
+ }
2399
+ function git(root, args2) {
2400
+ return execFileSync4("git", args2, { cwd: root, stdio: "pipe", encoding: "utf8" }).trim();
2401
+ }
2402
+ function gitOk(root, args2) {
2403
+ try {
2404
+ git(root, args2);
2405
+ return true;
2406
+ } catch {
2407
+ return false;
2408
+ }
2409
+ }
2410
+ function revParse(root, ref) {
2411
+ try {
2412
+ return git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
2413
+ } catch {
2414
+ return null;
2415
+ }
2416
+ }
2417
+ function sessionStart(root, branch, at, dryRun = false) {
2418
+ const { integration, greenRef } = loopParams(root);
2419
+ const lines = [];
2420
+ if (!branch) return { ok: false, lines: ["a branch name is required: `rungs session start <branch> [path]`"] };
2421
+ if (revParse(root, `refs/heads/${branch}`)) {
2422
+ return { ok: false, lines: [`branch '${branch}' already exists \u2014 pick another name, or check out the worktree that holds it`] };
2423
+ }
2424
+ const green = revParse(root, `refs/heads/${greenRef}`);
2425
+ const base = green ? greenRef : integration;
2426
+ const baseSha = green ?? revParse(root, integration);
2427
+ if (!baseSha) return { ok: false, lines: [`neither '${greenRef}' nor '${integration}' resolves \u2014 is this the right repo?`] };
2428
+ if (green) {
2429
+ lines.push(`base ${greenRef} (${baseSha.slice(0, 8)}) \u2014 the last verified merge`);
2430
+ } else {
2431
+ lines.push(`no ${greenRef} ref yet \u2014 cutting from the tip of ${integration} (${baseSha.slice(0, 8)}) instead.`);
2432
+ lines.push(`That tip has not been verified by a land. The first successful \`rungs land\` creates ${greenRef}.`);
2433
+ }
2434
+ const path = resolve4(at ?? join13(dirname8(root), `${basename2(root)}-${branch.replace(/[^\w.-]+/g, "-")}`));
2435
+ if (existsSync10(path)) return { ok: false, lines: [`${path} already exists \u2014 rungs never writes over a directory it did not create`] };
2436
+ lines.push(`worktree ${path}`);
2437
+ lines.push(`branch ${branch}`);
2438
+ if (dryRun) return { ok: true, lines };
2439
+ try {
2440
+ git(root, ["worktree", "add", "-b", branch, path, baseSha]);
2441
+ } catch (e) {
2442
+ return { ok: false, lines: [...lines, `git refused: ${String(e.stderr ?? e.message).trim().split("\n").slice(-2).join(" ")}`] };
2443
+ }
2444
+ return { ok: true, lines };
2445
+ }
2446
+ function preflight(root) {
2447
+ const { integration } = loopParams(root);
2448
+ if (!revParse(root, integration)) return { ok: false, lines: [`'${integration}' does not resolve \u2014 is this the right repo?`] };
2449
+ let base;
2450
+ try {
2451
+ base = git(root, ["merge-base", "HEAD", integration]);
2452
+ } catch {
2453
+ return { ok: false, lines: [`no merge base between HEAD and ${integration}; nothing to compare`] };
2454
+ }
2455
+ const names = (args2) => new Set(git(root, args2).split("\n").map((s) => s.trim()).filter(Boolean));
2456
+ const theirs = names(["diff", "--name-only", base, integration]);
2457
+ const mine = /* @__PURE__ */ new Set([
2458
+ ...names(["diff", "--name-only", base, "HEAD"]),
2459
+ ...names(["diff", "--name-only", "HEAD"]),
2460
+ ...names(["diff", "--name-only", "--cached"])
2461
+ ]);
2462
+ const ahead = Number(git(root, ["rev-list", "--count", `${base}..${integration}`]));
2463
+ const overlap = [...mine].filter((f) => theirs.has(f)).sort();
2464
+ const lines = [
2465
+ `${integration} is ${ahead} commit(s) ahead of your base, touching ${theirs.size} file(s).`,
2466
+ `You have touched ${mine.size} file(s).`
2467
+ ];
2468
+ if (!overlap.length) {
2469
+ lines.push("No overlap. The commit count is not the signal \u2014 these two sets not intersecting is.");
2470
+ return { ok: true, lines };
2471
+ }
2472
+ lines.push(`${overlap.length} file(s) changed on both sides:`);
2473
+ for (const f of overlap.slice(0, 20)) lines.push(` ${f}`);
2474
+ if (overlap.length > 20) lines.push(` \u2026and ${overlap.length - 20} more`);
2475
+ lines.push("Merge sooner rather than later. Shared code is a scheduling problem, not a tooling one.");
2476
+ return { ok: true, lines };
2477
+ }
2478
+ function lockPath(root) {
2479
+ return join13(git(root, ["rev-parse", "--git-common-dir"]).replace(/^\.git$/, join13(root, ".git")), "rungs-land.lock");
2480
+ }
2481
+ function alive(pid) {
2482
+ try {
2483
+ process.kill(pid, 0);
2484
+ return true;
2485
+ } catch (e) {
2486
+ return e?.code === "EPERM";
2487
+ }
2488
+ }
2489
+ function land(root, branch, runner, dryRun = false) {
2490
+ const { integration, greenRef, integPrefix } = loopParams(root);
2491
+ const lines = [];
2492
+ if (!branch) return { ok: false, lines: ["a branch name is required: `rungs land <branch>`"] };
2493
+ const head = revParse(root, `refs/heads/${branch}`);
2494
+ if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
2495
+ const before = revParse(root, `refs/heads/${integration}`);
2496
+ if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
2497
+ const lp = lockPath(root);
2498
+ if (existsSync10(lp)) {
2499
+ try {
2500
+ const held = JSON.parse(readFileSync11(lp, "utf8"));
2501
+ if (held.host === hostname() && alive(held.pid)) {
2502
+ return {
2503
+ ok: false,
2504
+ lines: [
2505
+ `another land is in progress: pid ${held.pid} on ${held.host}, landing '${held.branch}' since ${held.started}.`,
2506
+ "Concurrent landing is refused, not silently merged."
2507
+ ]
2508
+ };
2509
+ }
2510
+ lines.push(`taking over a stale lock from pid ${held.pid} (${held.started}) \u2014 that process is gone.`);
2511
+ } catch {
2512
+ lines.push("an unreadable lock file was replaced.");
2513
+ }
2514
+ }
2515
+ if (dryRun) {
2516
+ lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
2517
+ return { ok: true, lines };
2518
+ }
2519
+ const lock = { pid: process.pid, host: hostname(), started: (/* @__PURE__ */ new Date()).toISOString(), branch };
2520
+ writeFileSync6(lp, JSON.stringify(lock));
2521
+ const scratch = mkdtempSync2(join13(tmpdir2(), "rungs-land-"));
2522
+ const parked = `${integPrefix}${branch}`;
2523
+ try {
2524
+ git(root, ["worktree", "add", "--detach", scratch, before]);
2525
+ try {
2526
+ git(scratch, ["-c", "user.email=rungs@localhost", "-c", "user.name=rungs", "merge", "--no-ff", "-m", `land ${branch}`, head]);
2527
+ } catch (e) {
2528
+ const conflicts = (() => {
2529
+ try {
2530
+ return git(scratch, ["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean);
2531
+ } catch {
2532
+ return [];
2533
+ }
2534
+ })();
2535
+ lines.push(`merge conflict \u2014 ${integration} is unchanged.`);
2536
+ for (const f of conflicts.slice(0, 15)) lines.push(` ${f}`);
2537
+ lines.push("Reconcile generated artifacts by regenerating, never by merging text.");
2538
+ return { ok: false, lines };
2539
+ }
2540
+ const merged = git(scratch, ["rev-parse", "HEAD"]);
2541
+ const res = runner(scratch);
2542
+ lines.push(`merged tree ${merged.slice(0, 8)} \u2014 ${res.pass} pass \xB7 ${res.failing.length} fail`);
2543
+ if (res.failing.length) {
2544
+ const ids = new Set(res.failing.map((f) => f.id));
2545
+ let base = null;
2546
+ try {
2547
+ git(scratch, ["reset", "--hard", before]);
2548
+ base = runner(scratch, ids);
2549
+ } catch {
2550
+ base = null;
2551
+ }
2552
+ const attributable = base !== null && base.failing.length + base.pass >= ids.size;
2553
+ const baseFindings = new Map((base?.failing ?? []).map((f) => [f.id, new Set(f.findings)]));
2554
+ const introduced = [];
2555
+ const inherited = [];
2556
+ for (const f of res.failing) {
2557
+ const seen = attributable ? baseFindings.get(f.id) ?? /* @__PURE__ */ new Set() : null;
2558
+ const fresh = seen ? f.findings.filter((x) => !seen.has(x)) : f.findings;
2559
+ if (fresh.length) introduced.push({ id: f.id, findings: fresh });
2560
+ else inherited.push(f);
2561
+ }
2562
+ for (const f of inherited) {
2563
+ lines.push(` inherited ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
2564
+ }
2565
+ for (const f of introduced) {
2566
+ lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
2567
+ for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
2568
+ }
2569
+ if (base === null) {
2570
+ lines.push(" The merge base could not be gated, so nothing here is attributable and all of it blocks.");
2571
+ } else if (!attributable) {
2572
+ lines.push(" Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.");
2573
+ }
2574
+ if (introduced.length) {
2575
+ git(root, ["update-ref", `refs/heads/${parked}`, merged]);
2576
+ lines.push(
2577
+ `${introduced.length} introduced by this branch. ${integration} is unchanged, and the merged tree is parked on '${parked}' \u2014 fix it there and land again.`
2578
+ );
2579
+ return { ok: false, lines };
2580
+ }
2581
+ lines.push(
2582
+ `${inherited.length} failure(s), all already red on ${integration} before this branch. Landing anyway \u2014 they are not this branch's to fix, and blocking on them is how a gate gets bypassed.`
2583
+ );
2584
+ git(scratch, ["reset", "--hard", merged]);
2585
+ }
2586
+ try {
2587
+ git(root, ["update-ref", `refs/heads/${integration}`, merged, before]);
2588
+ } catch {
2589
+ git(root, ["update-ref", `refs/heads/${parked}`, merged]);
2590
+ return {
2591
+ ok: false,
2592
+ lines: [
2593
+ ...lines,
2594
+ `${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
2595
+ `Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`
2596
+ ]
2597
+ };
2598
+ }
2599
+ git(root, ["update-ref", `refs/heads/${greenRef}`, merged]);
2600
+ lines.push(`${integration} \u2192 ${merged.slice(0, 8)}, and ${greenRef} now marks it verified.`);
2601
+ if (revParse(root, `refs/heads/${parked}`)) git(root, ["update-ref", "-d", `refs/heads/${parked}`]);
2602
+ return { ok: true, lines };
2603
+ } finally {
2604
+ try {
2605
+ git(root, ["worktree", "remove", "--force", scratch]);
2606
+ } catch {
2607
+ rmSync2(scratch, { recursive: true, force: true });
2608
+ try {
2609
+ git(root, ["worktree", "prune"]);
2610
+ } catch {
2611
+ }
2612
+ }
2613
+ try {
2614
+ unlinkSync(lp);
2615
+ } catch {
2616
+ }
2617
+ }
2618
+ }
2619
+ function worktrees(root) {
2620
+ const { integration } = loopParams(root);
2621
+ const out = git(root, ["worktree", "list", "--porcelain"]);
2622
+ const rows = [];
2623
+ for (const block of out.split("\n\n").filter((b) => b.trim())) {
2624
+ const path = block.match(/^worktree (.+)$/m)?.[1];
2625
+ const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
2626
+ if (!path || !branch || branch === integration) continue;
2627
+ const merged = gitOk(root, ["merge-base", "--is-ancestor", branch, integration]);
2628
+ let dirty = false;
2629
+ try {
2630
+ dirty = git(path, ["status", "--porcelain"]).length > 0;
2631
+ } catch {
2632
+ dirty = false;
2633
+ }
2634
+ rows.push({ path, branch, merged, dirty });
2635
+ }
2636
+ return { rows, integration };
2637
+ }
2638
+
2365
2639
  // src/cli.ts
2366
- import { existsSync as existsSync10 } from "node:fs";
2367
- var HERE = dirname8(fileURLToPath3(import.meta.url));
2368
- var MODULES2 = join13(HERE, "..", "modules");
2640
+ import { existsSync as existsSync11 } from "node:fs";
2641
+ var HERE = dirname9(fileURLToPath4(import.meta.url));
2642
+ var MODULES2 = join14(HERE, "..", "modules");
2369
2643
  var c = {
2370
2644
  dim: (s) => `\x1B[2m${s}\x1B[0m`,
2371
2645
  bold: (s) => `\x1B[1m${s}\x1B[0m`,
@@ -2423,7 +2697,7 @@ ${mods.length} modules
2423
2697
  return issues.length === 0 ? 0 : 1;
2424
2698
  }
2425
2699
  function cmdDoctor(target, doExplain = false) {
2426
- const root = resolve4(target);
2700
+ const root = resolve5(target);
2427
2701
  const mods = loadAllModules(MODULES2);
2428
2702
  console.log(c.bold(`
2429
2703
  rungs doctor \u2014 ${root}
@@ -2594,6 +2868,29 @@ function cmdAdd(names, root, dryRun, harnesses, stamp) {
2594
2868
  }
2595
2869
  (overrides[modName] ??= {})[param] = rhs.join("=");
2596
2870
  }
2871
+ for (const [modName, vals] of Object.entries(overrides)) {
2872
+ const mod = mods.find((m) => m.name === modName);
2873
+ if (!mod) {
2874
+ console.log(
2875
+ c.red(`
2876
+ --set names a module that does not exist: ${modName}`) + c.dim(`
2877
+ Known: ${mods.map((m) => m.name).join(", ")}
2878
+ `)
2879
+ );
2880
+ return 1;
2881
+ }
2882
+ for (const k of Object.keys(vals)) {
2883
+ if (!(k in mod.params)) {
2884
+ const known = Object.keys(mod.params);
2885
+ console.log(
2886
+ c.red(`
2887
+ --set names a parameter ${modName} does not have: ${k}`) + c.dim(`
2888
+ ${known.length ? `${modName} takes: ${known.join(", ")}` : `${modName} takes no parameters`}`) + c.dim("\n `rungs modules --params` lists every parameter and its default.\n")
2889
+ );
2890
+ return 1;
2891
+ }
2892
+ }
2893
+ }
2597
2894
  const params = resolveParams(mods, overrides, root);
2598
2895
  for (const [m, vals] of Object.entries(overrides)) {
2599
2896
  for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
@@ -2712,6 +3009,49 @@ rungs render \u2014 ${root}
2712
3009
  }
2713
3010
  return 0;
2714
3011
  }
3012
+ function report(r) {
3013
+ console.log();
3014
+ for (const l of r.lines) console.log(` ${r.ok ? l : c.yellow(l)}`);
3015
+ console.log();
3016
+ return r.ok ? 0 : 1;
3017
+ }
3018
+ function landRunner(dir, only) {
3019
+ const runs = runGates(dir, void 0, void 0, only);
3020
+ const failing = runs.filter((r) => r.status === "fail" || r.status === "error");
3021
+ return {
3022
+ pass: runs.filter((r) => r.status === "pass").length,
3023
+ // `file: message`, so the same broken link in the same file is the same
3024
+ // finding across two runs, and a *new* one is visibly not.
3025
+ failing: failing.map((r) => ({
3026
+ id: r.id,
3027
+ findings: r.findings.map((f) => `${f.file ? `${f.file}: ` : ""}${f.message}`)
3028
+ }))
3029
+ };
3030
+ }
3031
+ function cmdWorktrees(root) {
3032
+ const { rows, integration } = worktrees(root);
3033
+ console.log(c.bold(`
3034
+ rungs worktrees \u2014 merged into ${integration}?
3035
+ `));
3036
+ if (!rows.length) {
3037
+ console.log(c.dim(" no linked worktrees. `rungs session start <branch>` creates one.\n"));
3038
+ return 0;
3039
+ }
3040
+ for (const w of rows) {
3041
+ const state = w.merged && w.dirty ? c.red("merged \xB7 DIRTY") : w.merged ? c.green("merged \xB7 prunable") : c.dim("in flight");
3042
+ console.log(` ${state.padEnd(28)} ${w.branch.padEnd(30)} ${c.dim(w.path)}`);
3043
+ }
3044
+ const risky = rows.filter((w) => w.merged && w.dirty);
3045
+ const prunable = rows.filter((w) => w.merged && !w.dirty);
3046
+ console.log();
3047
+ if (risky.length) {
3048
+ console.log(c.red(` ${risky.length} worktree(s) hold uncommitted work on a branch that already landed.`));
3049
+ console.log(c.dim(" That is where work actually gets lost. Commit it somewhere or decide to drop it."));
3050
+ }
3051
+ if (prunable.length) console.log(c.dim(` ${prunable.length} prunable. Removing a worktree is your call, not this command's.`));
3052
+ console.log();
3053
+ return 0;
3054
+ }
2715
3055
  function cmdCheck(root, tier, stamp) {
2716
3056
  let runs;
2717
3057
  try {
@@ -2782,7 +3122,7 @@ function cmdBacklogArchive(root, dryRun) {
2782
3122
  const record = readRecord(root);
2783
3123
  const configured = record?.modules["backlog"]?.params?.root;
2784
3124
  const backlogRoot = `docs/${configured ?? "backlog"}`;
2785
- if (!existsSync10(join13(root, ...backlogRoot.split("/"), "items"))) {
3125
+ if (!existsSync11(join14(root, ...backlogRoot.split("/"), "items"))) {
2786
3126
  console.log(c.red(`
2787
3127
  no backlog at ${backlogRoot}/items
2788
3128
  `));
@@ -2907,7 +3247,11 @@ var COMMANDS = [
2907
3247
  ["eject [path]", "materialise the engines; stop depending on rungs"],
2908
3248
  ["setup git [path]", "install the merge drivers .gitattributes names"],
2909
3249
  ["modules", "list the module set and audit the manifests"],
2910
- ["backlog archive [path]", "move finished items to archive/, repointing every link"]
3250
+ ["backlog archive [path]", "move finished items to archive/, repointing every link"],
3251
+ ["session start <branch>", "cut a branch and worktree from the last verified merge"],
3252
+ ["preflight [path]", "did the integration branch change files you changed?"],
3253
+ ["land <branch>", "merge \u2192 verify the merged tree \u2192 advance, or refuse and park it"],
3254
+ ["worktrees [path]", "which worktrees are merged, prunable, or merged and still dirty"]
2911
3255
  ];
2912
3256
  var FLAGS = [
2913
3257
  ["--dry-run", "report what would happen, write nothing"],
@@ -2986,22 +3330,29 @@ switch (cmd) {
2986
3330
  unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
2987
3331
  process.exit(1);
2988
3332
  }
2989
- process.exit(cmdBacklogArchive(resolve4(args[1] ?? process.cwd()), flags.has("--dry-run")));
3333
+ process.exit(cmdBacklogArchive(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run")));
2990
3334
  }
2991
3335
  case "check": {
2992
3336
  const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
2993
- process.exit(cmdCheck(resolve4(args[0] ?? process.cwd()), tier, STAMP));
3337
+ process.exit(cmdCheck(resolve5(args[0] ?? process.cwd()), tier, STAMP));
2994
3338
  }
2995
3339
  case "init": {
2996
3340
  const profile = args[1] ?? "tracked";
2997
- process.exit(cmdInit(resolve4(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
3341
+ process.exit(cmdInit(resolve5(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
2998
3342
  }
2999
3343
  case "upgrade":
3000
- process.exit(cmdUpgrade(resolve4(args[0] ?? process.cwd()), flags.has("--apply")));
3344
+ process.exit(cmdUpgrade(resolve5(args[0] ?? process.cwd()), flags.has("--apply")));
3001
3345
  case "eject":
3002
- process.exit(cmdEject(resolve4(args[0] ?? process.cwd()), flags.has("--dry-run")));
3346
+ process.exit(cmdEject(resolve5(args[0] ?? process.cwd()), flags.has("--dry-run")));
3003
3347
  case "setup": {
3004
- const r = setupGit(resolve4(args[1] ?? process.cwd()), flags.has("--dry-run"));
3348
+ if (args[0] !== "git") {
3349
+ console.log(
3350
+ c.red(`
3351
+ unknown: rungs setup ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `git`, and the path comes after it: `rungs setup git [path]`.\n")
3352
+ );
3353
+ process.exit(1);
3354
+ }
3355
+ const r = setupGit(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run"));
3005
3356
  console.log(
3006
3357
  r.drivers.length ? `
3007
3358
  installed ${r.drivers.length} merge driver(s): ${r.drivers.join(", ")}` + (r.rerere ? c.dim(" \xB7 rerere on") : "") + c.dim("\n Declared drivers were inert until now \u2014 a fresh clone needs this once.\n") : c.dim("\n no rungs merge drivers declared in .gitattributes\n")
@@ -3009,11 +3360,25 @@ switch (cmd) {
3009
3360
  process.exit(0);
3010
3361
  }
3011
3362
  case "render":
3012
- process.exit(cmdRender(resolve4(args[0] ?? process.cwd()), HARNESSES, STAMP));
3363
+ process.exit(cmdRender(resolve5(args[0] ?? process.cwd()), HARNESSES, STAMP));
3364
+ case "session": {
3365
+ if (args[0] !== "start") {
3366
+ console.log(c.red(`
3367
+ unknown: rungs session ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `start`: `rungs session start <branch> [path]`.\n"));
3368
+ process.exit(1);
3369
+ }
3370
+ process.exit(report(sessionStart(process.cwd(), args[1], args[2], flags.has("--dry-run"))));
3371
+ }
3372
+ case "preflight":
3373
+ process.exit(report(preflight(resolve5(args[0] ?? process.cwd()))));
3374
+ case "land":
3375
+ process.exit(report(land(process.cwd(), args[0], landRunner, flags.has("--dry-run"))));
3376
+ case "worktrees":
3377
+ process.exit(cmdWorktrees(resolve5(args[0] ?? process.cwd())));
3013
3378
  case "add": {
3014
3379
  const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
3015
3380
  const names = flags.has("--into") ? args.slice(0, -1) : args;
3016
- process.exit(cmdAdd(names, resolve4(target), flags.has("--dry-run"), HARNESSES, STAMP));
3381
+ process.exit(cmdAdd(names, resolve5(target), flags.has("--dry-run"), HARNESSES, STAMP));
3017
3382
  }
3018
3383
  default: {
3019
3384
  const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";