@rungs/cli 0.2.0 → 0.3.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/README.md CHANGED
@@ -138,6 +138,10 @@ reported as diverged and left alone.
138
138
  | `rungs setup git [path]` | Install the merge drivers `.gitattributes` names |
139
139
  | `rungs modules` | List the set and audit the manifests |
140
140
  | `rungs backlog archive` | Move finished items to `archive/`, repointing every link that cites them |
141
+ | `rungs session start <branch>` | Cut a branch and worktree from the last **verified** merge, not the tip |
142
+ | `rungs preflight [path]` | Did the integration branch change files *you* changed? |
143
+ | `rungs land <branch>` | Merge → verify the merged tree → advance with a compare-and-swap, or refuse and park it |
144
+ | `rungs worktrees [path]` | Which worktrees are merged, prunable, or merged and still dirty |
141
145
 
142
146
  | Option | Effect |
143
147
  | --- | --- |
@@ -169,6 +173,10 @@ you when you are installing above your level.
169
173
  | **4** docs that restate each other | `doc-authority` |
170
174
  | **5** 5+ concurrent sessions | `concurrency` |
171
175
 
176
+ The last four commands are the `concurrency` loop. They drive git, which nothing else in rungs
177
+ does — [ADR-0009](docs/decisions/ADR-0009-rungs-drives-git.md) sets the rules they obey: verify
178
+ before you advance, never destroy (only refuse), and never hold the integration branch.
179
+
172
180
  `concurrency` refuses to install without `--confirm-threshold`, because below
173
181
  five simultaneous sessions every mechanism in it costs more than it returns.
174
182
  Selling rung 5 to a rung-1 repo is the most likely way this tool does harm.
@@ -212,22 +220,27 @@ Four promises that shape everything else:
212
220
 
213
221
  ## Status
214
222
 
215
- **Next release: v0.2.0** — assembled from [`changelog.d/0.2.0.md`](changelog.d/0.2.0.md) and
216
- prepared in [`package.json`](package.json), untagged until the
217
- [release runbook](docs/design/release-runbook.md) is run to completion. That number is checked
218
- against the manifest by the `docs-version-claims` gate, so this sentence cannot drift from what
219
- ships.
223
+ **Current release: v0.3.0** — that number is checked against [`package.json`](package.json) by the
224
+ `docs-version-claims` gate, so this sentence cannot drift from what ships — and the gate fails if
225
+ the sentence is reworded out of existence, because a claim nothing checks any more should not look
226
+ the same as a claim that passes.
227
+
228
+ It deliberately does **not** say "and it is on npm". Whether this version is tagged and published
229
+ is a fact about the registry, not about the manifest, and it was wrong here for two days once
230
+ already.
220
231
 
221
- What is **published** is deliberately not restated here. It lives on
222
- [the versions page](https://docs.rungscli.com/versions/), which is the one place that tracks it
223
- this file and the roadmap both carried their own copy and both said `v0.1.2` for two days after
224
- v0.1.3 went to npm.
232
+ Whether a release is **pending**, and what npm currently serves, live on
233
+ [the versions page](https://docs.rungscli.com/versions/) the one place that tracks them. This
234
+ file and the roadmap both carried their own copy and both said `v0.1.2` for two days after v0.1.3
235
+ went to npm. Cutting the next one follows the [release runbook](docs/design/release-runbook.md).
225
236
 
226
- rungs is installed in its own repo and its gates run on every change — 25 pass,
227
- 0 fail (`rungs check`, 2026-08-17). A clean consumer has also installed the packed
228
- artifact and completed the doctor → init → add → check → render → upgrade/eject
229
- journey locally. Detection is [verified against all four source repos](docs/design/detection-verification.md).
230
- Not yet done: an install from the public registry and a cross-platform release matrix.
237
+ rungs is installed in its own repo and its 29 gates run on every change — 29 pass,
238
+ 0 fail (`rungs check`, 2026-08-17). A clean consumer has installed from the **public
239
+ registry** and run the binary, and another completed the doctor → init → add → check →
240
+ render upgrade/eject journey from a packed artifact. Detection is
241
+ [verified against all four source repos](docs/design/detection-verification.md).
242
+ Not yet done: a cross-platform release matrix — only Windows is verified — and writing
243
+ to a repo rungs did not scaffold.
231
244
 
232
245
  Expect module *contents* to move. The command surface is settled.
233
246
 
@@ -248,7 +261,7 @@ nobody paid for does not ship.
248
261
  | [`docs/design/`](docs/design/README.md) | Product brief, module catalogue, verification |
249
262
  | [`docs/decisions/`](docs/decisions/README.md) | ADRs |
250
263
  | [`modules/`](modules/README.md) | The fifteen modules |
251
- | [`src/`](src/) | The CLI, ~4,700 lines (`wc -l src/*.ts`, 2026-08-17; held within 10% by `docs-version-claims`) |
264
+ | [`src/`](src/) | The CLI, ~5,100 lines (`wc -l src/*.ts`, 2026-08-17; held within 10% by `docs-version-claims`) |
252
265
 
253
266
  ## Licence
254
267
 
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { fileURLToPath as fileURLToPath3 } from "node:url";
5
- import { dirname as dirname8, join as join13, resolve as resolve4 } from "node:path";
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";
@@ -880,6 +880,11 @@ function build(root, table, fx, input) {
880
880
  if (typeof input === "string") return [write(targetPath(table), `${input}
881
881
  `)];
882
882
  if (!fx || typeof fx !== "object") return null;
883
+ if (fx.packages && typeof fx.packages === "object") {
884
+ return Object.entries(fx.packages).map(
885
+ ([rel, version]) => write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }))
886
+ );
887
+ }
883
888
  if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
884
889
  const dir = fx.dir ?? "changelog.d";
885
890
  const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
@@ -929,7 +934,8 @@ var CONTEXT_FREE = /* @__PURE__ */ new Set([
929
934
  "file-budget",
930
935
  "register-schema",
931
936
  "file-population",
932
- "changelog-freshness"
937
+ "changelog-freshness",
938
+ "computed-claim"
933
939
  ]);
934
940
  function deparam(spec, dir) {
935
941
  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 +958,10 @@ function runSelfTests(gateId, engine, table, blocks) {
952
958
  const files = build(root, table, b.fixture, b.input);
953
959
  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
960
  if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
961
+ if (Array.isArray(b.fixture?.exclude)) {
962
+ const ex = b.fixture.exclude;
963
+ spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
964
+ }
955
965
  if (!files) {
956
966
  out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
957
967
  continue;
@@ -1228,11 +1238,11 @@ var crossReference = (t, root, files) => {
1228
1238
  return { findings, examined: skills.length };
1229
1239
  };
1230
1240
  function landedWork(root, branch, base) {
1231
- const git = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
1241
+ const git2 = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
1232
1242
  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));
1243
+ const tip = git2(`rev-parse ${branch}`);
1244
+ if (tip === git2(`rev-parse ${base}`)) return false;
1245
+ return git2(`log ${base} --merges --format=%P`).split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
1236
1246
  } catch {
1237
1247
  return true;
1238
1248
  }
@@ -1270,8 +1280,10 @@ var computedClaim = (t, root, files) => {
1270
1280
  let examined = 0;
1271
1281
  for (const spec of specs) {
1272
1282
  const values = /* @__PURE__ */ new Map();
1283
+ const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
1273
1284
  for (const src of spec.sources ?? []) {
1274
1285
  for (const rel of matchAny(files, src.file)) {
1286
+ if (excluded(rel)) continue;
1275
1287
  const text = read(root, rel);
1276
1288
  let v;
1277
1289
  if (src.path && rel.endsWith(".json")) {
@@ -1290,8 +1302,9 @@ var computedClaim = (t, root, files) => {
1290
1302
  }
1291
1303
  const distinct = new Set(values.values());
1292
1304
  if (spec.rule === "all-agree" && distinct.size > 1) {
1305
+ const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
1293
1306
  findings.push({
1294
- message: `${spec.id} disagrees across ${values.size} locations: ${[...distinct].join(", ")} \u2014 run \`${spec.autofix}\``
1307
+ 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
1308
  });
1296
1309
  }
1297
1310
  }
@@ -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";
@@ -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 } 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 execFileSync("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(fileURLToPath3(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";