@rungs/cli 0.1.3 → 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.
Files changed (40) hide show
  1. package/README.md +61 -11
  2. package/dist/cli.js +1250 -129
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +13 -0
  5. package/modules/adr/gates/adr.toml +14 -2
  6. package/modules/adr/module.toml +19 -1
  7. package/modules/audit/module.toml +1 -0
  8. package/modules/backlog/gates/ids.toml +33 -0
  9. package/modules/backlog/module.toml +61 -1
  10. package/modules/ci/files/{{workflow_path}} +9 -1
  11. package/modules/ci/module.toml +2 -1
  12. package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
  13. package/modules/concurrency/module.toml +3 -1
  14. package/modules/design-sync/module.toml +2 -0
  15. package/modules/doc-authority/module.toml +4 -0
  16. package/modules/findings/module.toml +3 -0
  17. package/modules/gates/gates/structural.toml +61 -17
  18. package/modules/gates/module.toml +5 -0
  19. package/modules/instructions/module.toml +4 -0
  20. package/modules/release/gates/release.toml +72 -4
  21. package/modules/release/module.toml +16 -1
  22. package/modules/release/skills/cut-release/SKILL.md +8 -1
  23. package/modules/session/module.toml +4 -2
  24. package/modules/skills/module.toml +3 -0
  25. package/modules/specs/module.toml +4 -0
  26. package/modules/workflows/module.toml +2 -0
  27. package/package.json +1 -1
  28. package/src/add.ts +64 -2
  29. package/src/backlog.ts +197 -0
  30. package/src/check.ts +56 -6
  31. package/src/cli.ts +406 -27
  32. package/src/concurrency.ts +412 -0
  33. package/src/engines.ts +261 -13
  34. package/src/engines2.ts +89 -4
  35. package/src/engines3.ts +147 -0
  36. package/src/explain.ts +189 -0
  37. package/src/lifecycle.ts +90 -3
  38. package/src/manifest.ts +13 -1
  39. package/src/selftest.ts +237 -0
  40. package/src/types.ts +34 -0
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 dirname6, join as join11, resolve as resolve3 } 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";
@@ -106,6 +106,7 @@ function loadManifest(dir) {
106
106
  params: raw.params ?? {},
107
107
  gates: raw.gates ?? [],
108
108
  detect: raw.detect ?? {},
109
+ skills: raw.skills ?? {},
109
110
  provenance: raw.provenance,
110
111
  threshold: raw.threshold,
111
112
  dir
@@ -161,6 +162,13 @@ function auditModules(mods) {
161
162
  if (!g.why?.trim()) {
162
163
  issues.push({ module: mod.name, kind: "gate-no-why", detail: `gate '${g.id}' has no 'why'` });
163
164
  }
165
+ if (g.kind === "declared" && !g.applicability) {
166
+ issues.push({
167
+ module: mod.name,
168
+ kind: "gate-no-applicability",
169
+ detail: `gate '${g.id}' does not declare applicability (repo-content | our-artifacts | our-schema)`
170
+ });
171
+ }
164
172
  }
165
173
  }
166
174
  return issues;
@@ -209,9 +217,9 @@ function resolveParams(mods, overrides = {}, repoRoot) {
209
217
  }
210
218
  return out;
211
219
  }
212
- function markers(targetPath, module, version) {
220
+ function markers(targetPath2, module, version) {
213
221
  const hash = /\.(toml|ya?ml|gitignore|gitattributes|sh|ps1|conf|properties)$|(^|\/)\.(gitignore|gitattributes)$/.test(
214
- targetPath
222
+ targetPath2
215
223
  );
216
224
  return hash ? { begin: `# rungs:begin ${module}@${version}`, end: `# rungs:end ${module}` } : { begin: `<!-- rungs:begin ${module}@${version} -->`, end: `<!-- rungs:end ${module} -->` };
217
225
  }
@@ -225,8 +233,8 @@ function mergeBlock(existing, fragment, module) {
225
233
  const after = existing.slice(e.index + e[0].length);
226
234
  return `${before}${fragment.trim()}${after}`;
227
235
  }
228
- const sep2 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
229
- return `${existing}${sep2}${fragment.trim()}
236
+ const sep3 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
237
+ return `${existing}${sep3}${fragment.trim()}
230
238
  `;
231
239
  }
232
240
 
@@ -267,7 +275,7 @@ function addModule(mod, repoRoot, params, opts = {}) {
267
275
  const base = join3(mod.dir, "skills");
268
276
  const dir = opts.skillsDir ?? ".claude/skills";
269
277
  for (const rel of walk(base)) {
270
- write(`${dir}/${rel}`, sub(readFileSync2(join3(base, rel), "utf8")), "skill");
278
+ write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync2(join3(base, rel), "utf8"))), "skill");
271
279
  }
272
280
  }
273
281
  if (has("fragments")) {
@@ -346,6 +354,18 @@ var gateEntry = (mod) => (g) => {
346
354
  if (g.why) lines.push(`why = """${g.why.trim()}"""`);
347
355
  return lines.join("\n");
348
356
  };
357
+ function blockedByParadigm(order, paradigms) {
358
+ const blocked = /* @__PURE__ */ new Map();
359
+ for (const mod of order) {
360
+ if (paradigms.has(mod.name)) {
361
+ blocked.set(mod.name, mod.name);
362
+ continue;
363
+ }
364
+ const dep = mod.requires.find((d) => blocked.has(d));
365
+ if (dep) blocked.set(mod.name, blocked.get(dep));
366
+ }
367
+ return blocked;
368
+ }
349
369
  function resolveInstallOrder(requested, all) {
350
370
  const byName = new Map(all.map((m) => [m.name, m]));
351
371
  const order = [];
@@ -379,6 +399,19 @@ function resolveInstallOrder(requested, all) {
379
399
  }
380
400
  var contentHash = (s) => createHash("sha256").update(s.replace(/\r\n/g, "\n")).digest("hex").slice(0, 12);
381
401
  var SHARED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes", ".ai/gates.toml"]);
402
+ function withOptedInExtensions(mod, rel, content) {
403
+ const name = rel.split(/[\\/]/)[0];
404
+ const extensions = mod.skills?.[name]?.extensions;
405
+ if (!extensions || !Object.keys(extensions).length) return content;
406
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
407
+ if (!m) return content;
408
+ const added = Object.entries(extensions).filter(([k]) => !new RegExp(`^${k}:`, "m").test(m[1])).map(([k, v]) => `${k}: ${v}`);
409
+ if (!added.length) return content;
410
+ return content.replace(/^---\n[\s\S]*?\n---/, `---
411
+ ${m[1]}
412
+ ${added.join("\n")}
413
+ ---`);
414
+ }
382
415
  function emittedFiles(mod, params, skillsDir = ".claude/skills") {
383
416
  const out = /* @__PURE__ */ new Map();
384
417
  const sub = (t) => substitute(t, mod.name, params);
@@ -392,7 +425,9 @@ function emittedFiles(mod, params, skillsDir = ".claude/skills") {
392
425
  for (const rel of walk(base)) {
393
426
  const target = sub(prefix + rel).split("\\").join("/");
394
427
  if (SHARED.has(target)) continue;
395
- out.set(target, sub(readFileSync2(join3(base, rel), "utf8")));
428
+ let content = sub(readFileSync2(join3(base, rel), "utf8"));
429
+ if (dir === "skills") content = withOptedInExtensions(mod, rel, content);
430
+ out.set(target, content);
396
431
  }
397
432
  }
398
433
  return out;
@@ -816,21 +851,146 @@ function writeReport(repoRoot, entries, harnesses, stamp) {
816
851
  // src/check.ts
817
852
  import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
818
853
  import { execSync as execSync3 } from "node:child_process";
819
- import { dirname as dirname4, join as join9 } from "node:path";
854
+ import { dirname as dirname5, join as join10 } from "node:path";
820
855
  import { fileURLToPath } from "node:url";
821
856
  import { parse as parse2 } from "smol-toml";
822
857
 
823
858
  // src/engines.ts
824
859
  import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
825
- import { join as join8, dirname as dirname3, resolve as resolve2 } from "node:path";
860
+ import { join as join9, dirname as dirname4, resolve as resolve2 } from "node:path";
861
+ import { parse as parseToml } from "smol-toml";
862
+
863
+ // src/selftest.ts
864
+ import { mkdirSync as mkdirSync3, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
865
+ import { tmpdir } from "node:os";
866
+ import { dirname as dirname3, join as join6 } from "node:path";
867
+ function targetPath(table) {
868
+ const t = Array.isArray(table) ? table[0] ?? {} : table ?? {};
869
+ const pattern = t.file ?? [t.scan ?? []].flat()[0] ?? "**/*.md";
870
+ if (!pattern.includes("*")) return pattern;
871
+ return pattern.replace(/\*\*\//g, "probe/").replace(/\/\*\*/g, "/probe").replace(/\*/g, "probe").replace(/probe\.probe$/, "probe.md");
872
+ }
873
+ function build(root, table, fx, input) {
874
+ const write = (rel, body) => {
875
+ const full = join6(root, rel);
876
+ mkdirSync3(dirname3(full), { recursive: true });
877
+ writeFileSync3(full, body);
878
+ return rel;
879
+ };
880
+ if (typeof input === "string") return [write(targetPath(table), `${input}
881
+ `)];
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
+ }
888
+ if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
889
+ const dir = fx.dir ?? "changelog.d";
890
+ const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
891
+ `));
892
+ written.push(write("package.json", JSON.stringify({ version: fx.version })));
893
+ return written;
894
+ }
895
+ if (typeof fx.matching_files === "number") {
896
+ const base = fx.location ?? dirname3(targetPath(table));
897
+ const marker = fx.exempt ? `<!-- ${fx.exempt} -->
898
+ ` : "";
899
+ return Array.from(
900
+ { length: fx.matching_files },
901
+ (_, i) => write(join6(base === "." ? "" : base, `probe-${i}.md`), `${marker}# probe ${i}
902
+ `)
903
+ );
904
+ }
905
+ const contentKeys = ["frontmatter", "sections", "opening", "body", "row", "table"];
906
+ if (contentKeys.some((k) => k in fx)) {
907
+ const parts = [];
908
+ if (fx.table) parts.push(`## ${fx.table}`, "");
909
+ if (fx.frontmatter && typeof fx.frontmatter === "object") {
910
+ parts.push("---");
911
+ for (const [k, v] of Object.entries(fx.frontmatter)) parts.push(`${k}: ${v}`);
912
+ parts.push("---", "");
913
+ }
914
+ if (fx.opening) parts.push(String(fx.opening), "");
915
+ for (const s of fx.sections ?? []) parts.push(`## ${s}`, "", "text", "");
916
+ if (fx.row && typeof fx.row === "object") {
917
+ const cols = Object.keys(fx.row);
918
+ parts.push(
919
+ `| ${cols.join(" | ")} |`,
920
+ `| ${cols.map(() => "---").join(" | ")} |`,
921
+ `| ${cols.map((c2) => fx.row[c2]).join(" | ")} |`,
922
+ ""
923
+ );
924
+ }
925
+ if (fx.body) parts.push(String(fx.body), "");
926
+ return [write(fx.file ?? targetPath(table), `${parts.join("\n")}
927
+ `)];
928
+ }
929
+ return null;
930
+ }
931
+ var CONTEXT_FREE = /* @__PURE__ */ new Set([
932
+ "frontmatter-schema",
933
+ "sections",
934
+ "file-budget",
935
+ "register-schema",
936
+ "file-population",
937
+ "changelog-freshness",
938
+ "computed-claim"
939
+ ]);
940
+ function deparam(spec, dir) {
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;
942
+ return walk3(spec);
943
+ }
944
+ function runSelfTests(gateId, engine, table, blocks) {
945
+ const out = [];
946
+ for (const b of blocks) {
947
+ const expect = b.expect === "fail" ? "fail" : "pass";
948
+ if (!CONTEXT_FREE.has(engine)) {
949
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `${engine} fixtures need context the fixture does not carry` });
950
+ continue;
951
+ }
952
+ if (!(engine in ENGINES)) {
953
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine '${engine}' not implemented` });
954
+ continue;
955
+ }
956
+ const root = mkdtempSync(join6(tmpdir(), "rungs-selftest-"));
957
+ try {
958
+ const files = build(root, table, b.fixture, b.input);
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;
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
+ }
965
+ if (!files) {
966
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
967
+ continue;
968
+ }
969
+ let findings;
970
+ try {
971
+ findings = ENGINES[engine](spec, root, files).findings;
972
+ } catch (e) {
973
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine threw: ${e.message}`.slice(0, 90) });
974
+ continue;
975
+ }
976
+ const fired = findings.length > 0;
977
+ out.push(
978
+ fired === (expect === "fail") ? { gate: gateId, expect, outcome: "ok" } : { gate: gateId, expect, outcome: "mismatch", detail: `expected ${expect}, ${fired ? `fired: ${findings[0].message}`.slice(0, 80) : "did not fire"}` }
979
+ );
980
+ } finally {
981
+ rmSync(root, { recursive: true, force: true });
982
+ }
983
+ }
984
+ return out;
985
+ }
826
986
 
827
987
  // src/engines2.ts
828
988
  import { readFileSync as readFileSync5 } from "node:fs";
829
989
  import { execSync } from "node:child_process";
830
- import { join as join6 } from "node:path";
990
+ import { join as join7 } from "node:path";
831
991
  var read = (root, rel) => {
832
992
  try {
833
- return readFileSync5(join6(root, rel), "utf8");
993
+ return readFileSync5(join7(root, rel), "utf8");
834
994
  } catch {
835
995
  return "";
836
996
  }
@@ -924,40 +1084,44 @@ var renderFreshness = (t, root, files) => {
924
1084
  var registerSchema = (t, root, files) => {
925
1085
  const findings = [];
926
1086
  let examined = 0;
927
- const targets = t.file ? [t.file] : expand(files, t.scan);
928
- for (const rel of targets) {
929
- const text = read(root, rel);
930
- if (!text) continue;
931
- for (const table of parseTables(text)) {
932
- if (t.table && !sectionOf(text, table.headerLine).toLowerCase().includes(String(t.table).toLowerCase())) continue;
933
- const cols = t.required_cols ?? t.table_columns ?? [];
934
- const present = cols.filter(
935
- (c2) => table.headers.some((h) => h.toLowerCase() === String(c2).toLowerCase())
936
- );
937
- if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
938
- for (const c2 of cols) {
939
- if (!present.includes(c2)) findings.push({ file: rel, message: `register table missing column '${c2}'` });
940
- }
941
- for (const row of table.rows) {
942
- if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
943
- examined++;
944
- for (const [key, values] of Object.entries(t.enum ?? {})) {
945
- const v = strip(row[key]);
946
- if (v && !values.map(String).includes(v)) {
947
- findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
948
- }
949
- }
950
- for (const c2 of t.non_empty ?? []) {
951
- if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
1087
+ const specs = [t, ...Object.values(t).filter((v) => v && typeof v === "object" && !Array.isArray(v) && v.table)];
1088
+ for (const t2 of specs) {
1089
+ const targets = t2.file ?? specs[0].file ? [t2.file ?? specs[0].file] : expand(files, t2.scan);
1090
+ for (const rel of targets) {
1091
+ const text = read(root, rel);
1092
+ if (!text) continue;
1093
+ for (const table of parseTables(text)) {
1094
+ const heading = sectionOf(text, table.headerLine).replace(/^#+\s*/, "").trim().toLowerCase();
1095
+ if (t2.table && !heading.startsWith(String(t2.table).toLowerCase())) continue;
1096
+ const cols = t2.required_cols ?? t2.table_columns ?? [];
1097
+ const present = cols.filter(
1098
+ (c2) => table.headers.some((h) => h.toLowerCase() === String(c2).toLowerCase())
1099
+ );
1100
+ if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
1101
+ for (const c2 of cols) {
1102
+ if (!present.includes(c2)) findings.push({ file: rel, message: `register table missing column '${c2}'` });
952
1103
  }
953
- for (const cond of t.conditional ?? []) {
954
- const matches = Object.entries(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
955
- if (!matches) continue;
956
- for (const c2 of cond.non_empty ?? []) {
957
- const v = strip(row[c2]);
958
- if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' required when ${JSON.stringify(cond.when)}` });
959
- else if (cond.min_words?.[c2] && v.split(/\s+/).length < cond.min_words[c2]) {
960
- findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is too thin to be a reason` });
1104
+ for (const row of table.rows) {
1105
+ if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
1106
+ examined++;
1107
+ for (const [key, values] of Object.entries(t2.enum ?? {})) {
1108
+ const v = strip(row[key]);
1109
+ if (v && !values.map(String).includes(v)) {
1110
+ findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
1111
+ }
1112
+ }
1113
+ for (const c2 of t2.non_empty ?? []) {
1114
+ if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
1115
+ }
1116
+ for (const cond of t2.conditional ?? []) {
1117
+ const matches = Object.entries(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
1118
+ if (!matches) continue;
1119
+ for (const c2 of cond.non_empty ?? []) {
1120
+ const v = strip(row[c2]);
1121
+ if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' required when ${JSON.stringify(cond.when)}` });
1122
+ else if (cond.min_words?.[c2] && v.split(/\s+/).length < cond.min_words[c2]) {
1123
+ findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is too thin to be a reason` });
1124
+ }
961
1125
  }
962
1126
  }
963
1127
  }
@@ -1073,6 +1237,16 @@ var crossReference = (t, root, files) => {
1073
1237
  }
1074
1238
  return { findings, examined: skills.length };
1075
1239
  };
1240
+ function landedWork(root, branch, base) {
1241
+ const git2 = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
1242
+ try {
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));
1246
+ } catch {
1247
+ return true;
1248
+ }
1249
+ }
1076
1250
  var gitStatusReconcile = (t, root, files) => {
1077
1251
  const findings = [];
1078
1252
  let merged;
@@ -1094,7 +1268,7 @@ var gitStatusReconcile = (t, root, files) => {
1094
1268
  const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
1095
1269
  if (!branch || !status) continue;
1096
1270
  examined++;
1097
- if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
1271
+ if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status) && landedWork(root, branch, t.integration_branch ?? "main")) {
1098
1272
  findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
1099
1273
  }
1100
1274
  }
@@ -1106,8 +1280,10 @@ var computedClaim = (t, root, files) => {
1106
1280
  let examined = 0;
1107
1281
  for (const spec of specs) {
1108
1282
  const values = /* @__PURE__ */ new Map();
1283
+ const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
1109
1284
  for (const src of spec.sources ?? []) {
1110
1285
  for (const rel of matchAny(files, src.file)) {
1286
+ if (excluded(rel)) continue;
1111
1287
  const text = read(root, rel);
1112
1288
  let v;
1113
1289
  if (src.path && rel.endsWith(".json")) {
@@ -1126,8 +1302,9 @@ var computedClaim = (t, root, files) => {
1126
1302
  }
1127
1303
  const distinct = new Set(values.values());
1128
1304
  if (spec.rule === "all-agree" && distinct.size > 1) {
1305
+ const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
1129
1306
  findings.push({
1130
- 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`.")
1131
1308
  });
1132
1309
  }
1133
1310
  }
@@ -1137,16 +1314,54 @@ var computedClaim = (t, root, files) => {
1137
1314
  // src/engines3.ts
1138
1315
  import { readFileSync as readFileSync6 } from "node:fs";
1139
1316
  import { execSync as execSync2 } from "node:child_process";
1140
- import { join as join7 } from "node:path";
1317
+ import { join as join8 } from "node:path";
1141
1318
  var read2 = (root, rel) => {
1142
1319
  try {
1143
- return readFileSync6(join7(root, rel), "utf8");
1320
+ return readFileSync6(join8(root, rel), "utf8");
1144
1321
  } catch {
1145
1322
  return "";
1146
1323
  }
1147
1324
  };
1148
1325
  var expand2 = (files, p, f = []) => [...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
1149
1326
  var escapeRe2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1327
+ function versionParts(s) {
1328
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
1329
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1330
+ }
1331
+ function versionCmp(a, b) {
1332
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
1333
+ }
1334
+ var changelogFreshness = (t, root, files) => {
1335
+ const specs = Array.isArray(t) ? t : [t];
1336
+ const findings = [];
1337
+ let examined = 0;
1338
+ for (const spec of specs) {
1339
+ const src = spec.version ?? {};
1340
+ let current = null;
1341
+ for (const rel of matchAny(files, src.file ?? "package.json")) {
1342
+ try {
1343
+ const raw = (src.path ?? "version").split(".").reduce((o, k) => o?.[k], JSON.parse(read2(root, rel)));
1344
+ current = versionParts(String(raw ?? ""));
1345
+ } catch {
1346
+ }
1347
+ if (current) break;
1348
+ }
1349
+ if (!current) continue;
1350
+ for (const rel of expand2(files, spec.fragments, [])) {
1351
+ const name = rel.split("/").pop().replace(/\.md$/, "");
1352
+ const v = versionParts(name);
1353
+ if (!v) continue;
1354
+ examined++;
1355
+ if (versionCmp(v, current) < 0) {
1356
+ findings.push({
1357
+ file: rel,
1358
+ message: spec.message?.trim() || `fragment names ${name}, below the ${current.join(".")} being prepared \u2014 it was consumed by an earlier release and should have been deleted`
1359
+ });
1360
+ }
1361
+ }
1362
+ }
1363
+ return { findings, examined };
1364
+ };
1150
1365
  var exempted2 = (text, marker) => !!marker && new RegExp(`${escapeRe2(marker)}\\s*\\S`).test(text);
1151
1366
  function tableRows(text, near) {
1152
1367
  const lines = text.split("\n");
@@ -1266,11 +1481,52 @@ var mergeDriverCheck = (t, root) => {
1266
1481
  }
1267
1482
  return { findings, examined: declared.length };
1268
1483
  };
1484
+ var boardReconcile = (t, root, _files) => {
1485
+ const rel = t.file;
1486
+ const text = read2(root, rel);
1487
+ if (!text) return { findings: [{ message: `board not found at ${rel}` }], examined: 0 };
1488
+ if (exempted2(text, t.exempt_marker)) return { findings: [], examined: 0 };
1489
+ const groups = t.groups ?? {};
1490
+ const dir = rel.split("/").slice(0, -1).join("/");
1491
+ const findings = [];
1492
+ let heading = "";
1493
+ let examined = 0;
1494
+ for (const line of text.split("\n")) {
1495
+ const h = /^##\s+(.+?)\s*$/.exec(line);
1496
+ if (h) {
1497
+ heading = h[1];
1498
+ continue;
1499
+ }
1500
+ if (!line.startsWith("|")) continue;
1501
+ const link = /^\|\s*\[[^\]]+\]\(([^)]+)\)/.exec(line);
1502
+ if (!link) continue;
1503
+ if (!Object.hasOwn(groups, heading)) continue;
1504
+ examined++;
1505
+ const target = `${dir}/${link[1]}`.replace(/[^/]+\/\.\.\//g, "");
1506
+ const item = read2(root, target);
1507
+ if (!item) {
1508
+ findings.push({ file: rel, message: `row under '${heading}' links to a missing file: ${link[1]}` });
1509
+ continue;
1510
+ }
1511
+ const status = /^status:\s*(\S+)/m.exec(item)?.[1] ?? "";
1512
+ if (!groups[heading].includes(status)) {
1513
+ findings.push({
1514
+ file: rel,
1515
+ message: `${link[1]} is under '${heading}' but its status is '${status}' (expected ${groups[heading].join(" | ")})`
1516
+ });
1517
+ }
1518
+ }
1519
+ const seen = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((m) => m[1]));
1520
+ for (const g of Object.keys(groups)) {
1521
+ if (!seen.has(g)) findings.push({ file: rel, message: `declared group '${g}' has no heading in the board` });
1522
+ }
1523
+ return { findings, examined };
1524
+ };
1269
1525
 
1270
1526
  // src/engines.ts
1271
1527
  var read3 = (root, rel) => {
1272
1528
  try {
1273
- return readFileSync7(join8(root, rel), "utf8");
1529
+ return readFileSync7(join9(root, rel), "utf8");
1274
1530
  } catch {
1275
1531
  return "";
1276
1532
  }
@@ -1289,10 +1545,12 @@ var fileBudget = (t, root, files) => {
1289
1545
  const findings = [];
1290
1546
  let examined = 0;
1291
1547
  for (const rel of targets) {
1292
- if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
1548
+ if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
1293
1549
  examined++;
1294
1550
  const n = loadedLines(read3(root, rel));
1295
- if (n > t.max_lines) findings.push({ file: rel, message: `${n} lines, budget ${t.max_lines}` });
1551
+ if (n > t.max_lines) {
1552
+ findings.push({ file: rel, message: `${n} loaded lines (blank lines and comments excluded), budget ${t.max_lines}` });
1553
+ }
1296
1554
  }
1297
1555
  return { findings, examined };
1298
1556
  };
@@ -1304,10 +1562,11 @@ var sections = (t, root, files) => {
1304
1562
  const targets = dropGenerated(root, spec.file ? [spec.file] : expand3(files, spec.scan));
1305
1563
  const excluded = new Set(expand3(files, spec.exclude, []));
1306
1564
  for (const rel of targets) {
1307
- if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
1565
+ if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
1308
1566
  examined++;
1309
1567
  const text = read3(root, rel);
1310
- const heads = [...text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1]);
1568
+ const matches = [...text.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)];
1569
+ const heads = matches.map((m) => m[2]);
1311
1570
  for (const want of spec.required ?? []) {
1312
1571
  const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
1313
1572
  if (idx === -1) {
@@ -1315,8 +1574,9 @@ var sections = (t, root, files) => {
1315
1574
  continue;
1316
1575
  }
1317
1576
  if (spec.non_empty) {
1318
- const after = text.split(new RegExp(`^#{1,6}\\s+${escapeRe3(heads[idx])}\\s*$`, "m"))[1] ?? "";
1319
- const body = after.split(/^#{1,6}\s+/m)[0].replace(/<!--[\s\S]*?-->/g, "").trim();
1577
+ const level = matches[idx][1].length;
1578
+ const after = text.slice(matches[idx].index + matches[idx][0].length);
1579
+ const body = after.split(new RegExp(`^#{1,${level}}\\s+`, "m"))[0].replace(/<!--[\s\S]*?-->/g, "").trim();
1320
1580
  if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
1321
1581
  }
1322
1582
  }
@@ -1346,21 +1606,70 @@ var frontmatterSchema = (t, root, files) => {
1346
1606
  if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
1347
1607
  }
1348
1608
  if (spec.allowed) {
1609
+ const optedIn = spec.extensions_allowed_from ? optedInExtensions(rel, spec) : /* @__PURE__ */ new Set();
1349
1610
  for (const k of keys) {
1350
- if (!spec.allowed.includes(k)) findings.push({ file: rel, message: `non-spec key '${k}'` });
1611
+ if (spec.allowed.includes(k) || optedIn.has(k)) continue;
1612
+ findings.push({ file: rel, message: `non-spec key '${k}'` });
1351
1613
  }
1352
1614
  }
1615
+ const field2 = (k) => m[1].match(new RegExp(`^${k}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
1353
1616
  for (const [key, values] of Object.entries(spec.enum ?? {})) {
1354
- const v = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
1617
+ const v = field2(key);
1355
1618
  if (v && !values.map(String).includes(v)) {
1356
1619
  findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
1357
1620
  }
1358
1621
  }
1622
+ for (const pair of spec.reciprocal?.pairs ?? []) {
1623
+ const from = field2(pair.from);
1624
+ if (!from) continue;
1625
+ const target = expand3(files, spec.scan).find((r) => r.includes(from.replace(/\.md$/, "")));
1626
+ if (!target) {
1627
+ findings.push({ file: rel, message: `${pair.from} names '${from}', which is not a record here` });
1628
+ continue;
1629
+ }
1630
+ const back = read3(root, target).match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
1631
+ const id = field2("id") ?? "";
1632
+ if (!new RegExp(`^${pair.to}:\\s*.*${escapeRe3(id)}`, "m").test(back)) {
1633
+ findings.push({ file: rel, message: `${pair.from} \u2192 ${from}, but it does not name this record back in '${pair.to}'` });
1634
+ }
1635
+ }
1636
+ for (const [status, requires] of Object.entries(spec.reciprocal?.required_when ?? {})) {
1637
+ if (field2("status") === status && !field2(String(requires))) {
1638
+ findings.push({ file: rel, message: `status is '${status}' but '${requires}' is absent` });
1639
+ }
1640
+ }
1359
1641
  }
1360
1642
  }
1361
1643
  return { findings, examined };
1362
1644
  };
1645
+ function resolvesHere(root, rel, href) {
1646
+ const from = dirname4(rel);
1647
+ const decoded = decodeURIComponent(href);
1648
+ if (existsSync6(resolve2(root, from, decoded))) return true;
1649
+ const stripped = decoded.replace(/:\d+(?::\d+)?$/, "");
1650
+ return stripped !== decoded && existsSync6(resolve2(root, from, stripped));
1651
+ }
1652
+ function backtickedPaths(rel, text, root, hints) {
1653
+ const out = [];
1654
+ const seen = /* @__PURE__ */ new Set();
1655
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
1656
+ const raw = m[1].trim();
1657
+ if (seen.has(raw) || !hints.some((h) => raw.includes(h))) continue;
1658
+ seen.add(raw);
1659
+ if (raw.startsWith("/")) continue;
1660
+ if (/[*?{}#\s]|^\w+:/.test(raw)) continue;
1661
+ if (!raw.includes("/")) continue;
1662
+ if (!/\.[a-z0-9]{1,5}$/i.test(raw)) continue;
1663
+ const bare = raw.replace(/^\.\//, "");
1664
+ if (!existsSync6(join9(root, bare)) && !existsSync6(resolve2(root, dirname4(rel), bare))) {
1665
+ out.push({ message: `stale path in a code span \u2192 ${raw}` });
1666
+ }
1667
+ }
1668
+ return out;
1669
+ }
1363
1670
  var linkIntegrity = (t, root, files) => {
1671
+ if (Array.isArray(t)) t = t[0] ?? {};
1672
+ const checks = t.check ?? ["relative_markdown_links"];
1364
1673
  const scan = expand3(files, t.scan, ["**/*.md"]);
1365
1674
  const excluded = new Set(expand3(files, t.exclude, []));
1366
1675
  const findings = [];
@@ -1370,11 +1679,14 @@ var linkIntegrity = (t, root, files) => {
1370
1679
  const text = read3(root, rel);
1371
1680
  examined++;
1372
1681
  if (/path-ok:\s*\S/.test(text)) continue;
1682
+ if (checks.includes("backticked_paths")) {
1683
+ findings.push(...backtickedPaths(rel, text, root, t.path_hint ?? ["/"]).map((f) => ({ ...f, file: rel })));
1684
+ }
1685
+ if (!checks.includes("relative_markdown_links")) continue;
1373
1686
  const scannable = text.replace(/`+[^`\n]*`+/g, (s) => " ".repeat(s.length));
1374
1687
  for (const m of scannable.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
1375
1688
  if (/\{\{[a-z_.]+\}\}/.test(m[1])) continue;
1376
- const target = resolve2(root, dirname3(rel), decodeURIComponent(m[1]));
1377
- if (!existsSync6(target)) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
1689
+ if (!resolvesHere(root, rel, m[1])) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
1378
1690
  }
1379
1691
  }
1380
1692
  return { findings, examined };
@@ -1392,13 +1704,17 @@ var filePopulation = (t, root, files) => {
1392
1704
  const findings = [];
1393
1705
  const failAt = t.fail_at ?? Infinity;
1394
1706
  if (hits.length >= failAt) {
1395
- findings.push({ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` });
1707
+ const scanned = [t.scan ?? []].flat();
1708
+ findings.push({
1709
+ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` + (scanned.length ? ` \u2014 matched against ${scanned.join(", ")}` : "")
1710
+ });
1396
1711
  }
1397
1712
  return { findings, examined: hits.length };
1398
1713
  };
1399
1714
  var gateMeta = (_t, root) => {
1400
1715
  const findings = [];
1401
- const registry = join8(root, ".ai", "gates.toml");
1716
+ let unrun = 0;
1717
+ const registry = join9(root, ".ai", "gates.toml");
1402
1718
  if (!existsSync6(registry)) return { findings, examined: 0 };
1403
1719
  const text = readFileSync7(registry, "utf8");
1404
1720
  const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
@@ -1409,7 +1725,7 @@ var gateMeta = (_t, root) => {
1409
1725
  const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
1410
1726
  if (!id || kind !== "declared" || !table) continue;
1411
1727
  examined++;
1412
- const tablePath = join8(dirname3(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname3(table), "gates", table.split("/").pop());
1728
+ const tablePath = join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname4(table), "gates", table.split("/").pop());
1413
1729
  const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
1414
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}"`));
1415
1731
  for (const direction of ["pass", "fail"]) {
@@ -1417,10 +1733,61 @@ var gateMeta = (_t, root) => {
1417
1733
  findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
1418
1734
  }
1419
1735
  }
1736
+ const engine = entry.match(/^engine\s*=\s*"(.+)"/m)?.[1];
1737
+ const parsed = parseTable(tablePath, table.split("/")[0]);
1738
+ if (engine && parsed) {
1739
+ const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : []).filter((b) => b?.gate === id).map((b) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
1740
+ for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
1741
+ if (r.outcome === "mismatch") findings.push({ message: `self-test for '${id}' ${r.detail}` });
1742
+ else if (r.outcome === "unrun") unrun++;
1743
+ }
1744
+ }
1420
1745
  }
1746
+ if (unrun) console.error(` ${unrun} self-test fixture(s) have no builder and did not run \u2014 not passes (F-018)`);
1421
1747
  return { findings, examined };
1422
1748
  };
1749
+ function optedInExtensions(rel, spec) {
1750
+ if (Array.isArray(spec.extensions_opted_in)) return new Set(spec.extensions_opted_in.map(String));
1751
+ const name = rel.split("/").slice(-2)[0];
1752
+ if (!name) return /* @__PURE__ */ new Set();
1753
+ try {
1754
+ const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1755
+ const owner = mods.find((m) => m.skills?.[name]?.extensions);
1756
+ return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
1757
+ } catch {
1758
+ return /* @__PURE__ */ new Set();
1759
+ }
1760
+ }
1423
1761
  var escapeRe3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1762
+ function parseTable(path, module) {
1763
+ if (!existsSync6(path)) return null;
1764
+ try {
1765
+ const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1766
+ const params = resolveParams(mods, {}, ".");
1767
+ return parseToml(substitute(readFileSync7(path, "utf8"), module, params));
1768
+ } catch {
1769
+ return null;
1770
+ }
1771
+ }
1772
+ var tableKeyFor = (engine) => ({
1773
+ "file-budget": "file_budget",
1774
+ "frontmatter-schema": "frontmatter_schema",
1775
+ "link-integrity": "link_integrity",
1776
+ "file-population": "file_population",
1777
+ "render-freshness": "render_freshness",
1778
+ "register-schema": "register_schema",
1779
+ "self-declared-closure": "self_declared_closure",
1780
+ "filename-schema": "filename_schema",
1781
+ "cross-reference": "cross_reference",
1782
+ "git-status-reconcile": "merged_status",
1783
+ "computed-claim": "computed_claim",
1784
+ "term-ownership": "term_ownership",
1785
+ "rule-propagation": "rule_propagation",
1786
+ "git-state": "git_state",
1787
+ "merge-driver-check": "merge_driver_check",
1788
+ "board-reconcile": "board_reconcile",
1789
+ "changelog-freshness": "changelog_freshness"
1790
+ })[engine] ?? engine;
1424
1791
  var ENGINES = {
1425
1792
  "file-budget": fileBudget,
1426
1793
  sections,
@@ -1439,27 +1806,50 @@ var ENGINES = {
1439
1806
  "term-ownership": termOwnership,
1440
1807
  "rule-propagation": rulePropagation,
1441
1808
  "git-state": gitState,
1442
- "merge-driver-check": mergeDriverCheck
1809
+ "merge-driver-check": mergeDriverCheck,
1810
+ "board-reconcile": boardReconcile,
1811
+ "changelog-freshness": changelogFreshness
1443
1812
  };
1444
1813
  function isImplemented(engine) {
1445
1814
  return engine in ENGINES;
1446
1815
  }
1447
1816
 
1448
1817
  // src/check.ts
1449
- var MODULES = join9(dirname4(fileURLToPath(import.meta.url)), "..", "modules");
1818
+ var MODULES = join10(dirname5(fileURLToPath(import.meta.url)), "..", "modules");
1450
1819
  function loadRegistry(repoRoot) {
1451
- const path = join9(repoRoot, ".ai", "gates.toml");
1820
+ const path = join10(repoRoot, ".ai", "gates.toml");
1452
1821
  if (!existsSync7(path)) return { runner: {}, gates: [] };
1453
1822
  const raw = parse2(readFileSync8(path, "utf8"));
1454
1823
  return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
1455
1824
  }
1456
- function runGates(repoRoot, tier, now = () => Date.now()) {
1457
- const { gates } = loadRegistry(repoRoot);
1825
+ function tierSelects(runnerTiers, requested, gateTier) {
1826
+ if (!gateTier) return true;
1827
+ const at = runnerTiers.indexOf(requested);
1828
+ const of = runnerTiers.indexOf(gateTier);
1829
+ if (at < 0 || of < 0) return gateTier === requested;
1830
+ return of <= at;
1831
+ }
1832
+ var UnknownTierError = class extends Error {
1833
+ requested;
1834
+ declared;
1835
+ constructor(requested, declared) {
1836
+ super(`unknown tier "${requested}"`);
1837
+ this.requested = requested;
1838
+ this.declared = declared;
1839
+ }
1840
+ };
1841
+ function runGates(repoRoot, tier, now = () => Date.now(), only) {
1842
+ const { runner, gates } = loadRegistry(repoRoot);
1843
+ const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
1844
+ if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
1845
+ throw new UnknownTierError(tier, runnerTiers);
1846
+ }
1458
1847
  const files = walk(repoRoot);
1459
1848
  const runs = [];
1460
1849
  for (const g of gates) {
1461
1850
  if (g.trigger) continue;
1462
- if (tier && g.tier && g.tier !== tier) continue;
1851
+ if (only && !only.has(g.id)) continue;
1852
+ if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
1463
1853
  const started = now();
1464
1854
  let status = "pass";
1465
1855
  let findings = [];
@@ -1515,7 +1905,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
1515
1905
  function loadTable(ref, repoRoot) {
1516
1906
  if (!ref) return null;
1517
1907
  const [mod, file] = ref.split("/");
1518
- const path = join9(MODULES, mod, "gates", file);
1908
+ const path = join10(MODULES, mod, "gates", file);
1519
1909
  if (!existsSync7(path)) return null;
1520
1910
  try {
1521
1911
  return parse2(substitute(readFileSync8(path, "utf8"), mod, installedParams(repoRoot)));
@@ -1527,7 +1917,7 @@ var paramCache = null;
1527
1917
  function installedParams(repoRoot) {
1528
1918
  if (paramCache?.root === repoRoot) return paramCache.params;
1529
1919
  const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
1530
- const recordPath = join9(repoRoot, ".ai", "rungs.toml");
1920
+ const recordPath = join10(repoRoot, ".ai", "rungs.toml");
1531
1921
  if (existsSync7(recordPath)) {
1532
1922
  try {
1533
1923
  const rec = parse2(readFileSync8(recordPath, "utf8"));
@@ -1558,17 +1948,18 @@ var tableKey = (engine) => ({
1558
1948
  "term-ownership": "term_ownership",
1559
1949
  "rule-propagation": "rule_propagation",
1560
1950
  "git-state": "git_state",
1561
- "merge-driver-check": "merge_driver_check"
1951
+ "merge-driver-check": "merge_driver_check",
1952
+ "board-reconcile": "board_reconcile"
1562
1953
  })[engine] ?? engine;
1563
1954
  function appendLedger(repoRoot, runs, stamp) {
1564
1955
  const { runner } = loadRegistry(repoRoot);
1565
1956
  if (runner.ledger === false) return;
1566
- const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
1957
+ const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
1567
1958
  const lines = runs.map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined })).join("\n");
1568
1959
  appendFileSync(path, lines + "\n");
1569
1960
  }
1570
1961
  function ledgerQuestions(repoRoot, gates) {
1571
- const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
1962
+ const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
1572
1963
  if (!existsSync7(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
1573
1964
  const rows = readFileSync8(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1574
1965
  const by = /* @__PURE__ */ new Map();
@@ -1585,12 +1976,12 @@ function ledgerQuestions(repoRoot, gates) {
1585
1976
  }
1586
1977
 
1587
1978
  // src/lifecycle.ts
1588
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
1589
- import { dirname as dirname5, join as join10 } from "node:path";
1979
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
1980
+ import { dirname as dirname6, join as join11 } from "node:path";
1590
1981
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1591
1982
  import { execSync as execSync4 } from "node:child_process";
1592
1983
  import { parse as parse3 } from "smol-toml";
1593
- var SRC = dirname5(fileURLToPath2(import.meta.url));
1984
+ var SRC = dirname6(fileURLToPath2(import.meta.url));
1594
1985
  var PROFILES = {
1595
1986
  minimal: ["instructions"],
1596
1987
  tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
@@ -1599,7 +1990,7 @@ var PROFILES = {
1599
1990
  fleet: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority", "concurrency", "design-sync"]
1600
1991
  };
1601
1992
  function readRecord(repoRoot) {
1602
- const p = join10(repoRoot, ".ai", "rungs.toml");
1993
+ const p = join11(repoRoot, ".ai", "rungs.toml");
1603
1994
  if (!existsSync8(p)) return null;
1604
1995
  try {
1605
1996
  const raw = parse3(readFileSync9(p, "utf8"));
@@ -1620,7 +2011,7 @@ function planUpgrade(repoRoot, mods, record) {
1620
2011
  const kept = new Set(installed.kept?.files ?? []);
1621
2012
  for (const [rel, wouldEmit] of emitted) {
1622
2013
  if (kept.has(rel)) continue;
1623
- const full = join10(repoRoot, rel);
2014
+ const full = join11(repoRoot, rel);
1624
2015
  if (!existsSync8(full)) {
1625
2016
  files.push({ rel, state: "missing" });
1626
2017
  continue;
@@ -1639,18 +2030,63 @@ function applyUpgrade(repoRoot, mods, record, plan) {
1639
2030
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
1640
2031
  const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
1641
2032
  let written = 0;
2033
+ const rewritten = /* @__PURE__ */ new Map();
1642
2034
  for (const item of plan) {
1643
2035
  const mod = mods.find((m) => m.name === item.module);
1644
2036
  const emitted = emittedFiles(mod, params, skillsDir);
1645
2037
  for (const f of item.files) {
1646
2038
  if (f.state !== "stale" && f.state !== "missing") continue;
1647
- const full = join10(repoRoot, f.rel);
1648
- mkdirSync3(dirname5(full), { recursive: true });
1649
- writeFileSync3(full, emitted.get(f.rel));
2039
+ const full = join11(repoRoot, f.rel);
2040
+ const content = emitted.get(f.rel);
2041
+ mkdirSync4(dirname6(full), { recursive: true });
2042
+ writeFileSync4(full, content);
2043
+ if (!rewritten.has(mod.name)) rewritten.set(mod.name, /* @__PURE__ */ new Map());
2044
+ rewritten.get(mod.name).set(f.rel, contentHash(content));
1650
2045
  written++;
1651
2046
  }
1652
2047
  }
1653
- return written;
2048
+ const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)).filter(Boolean);
2049
+ const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
2050
+ const recorded = updateRecordAfterUpgrade(
2051
+ repoRoot,
2052
+ upgraded.map((m) => ({ module: m.name, version: m.version, hashes: rewritten.get(m.name) ?? /* @__PURE__ */ new Map() }))
2053
+ );
2054
+ return { written, gates: gateActions.length, recorded };
2055
+ }
2056
+ function updateRecordAfterUpgrade(repoRoot, updates) {
2057
+ const path = join11(repoRoot, ".ai", "rungs.toml");
2058
+ if (!existsSync8(path) || !updates.length) return 0;
2059
+ const lines = readFileSync9(path, "utf8").split("\n");
2060
+ const byModule = new Map(updates.map((u) => [u.module, u]));
2061
+ let changed = 0;
2062
+ let current = null;
2063
+ const out = [];
2064
+ for (const line of lines) {
2065
+ const header = /^\[modules\.([^\].]+)(\.[^\]]+)?\]/.exec(line);
2066
+ if (header) {
2067
+ current = byModule.has(header[1]) ? { module: header[1], hashes: header[2] === ".hashes" } : null;
2068
+ out.push(line);
2069
+ continue;
2070
+ }
2071
+ if (current && !current.hashes && /^version\s*=/.test(line)) {
2072
+ const next = `version = "${byModule.get(current.module).version}"`;
2073
+ if (next !== line) changed++;
2074
+ out.push(next);
2075
+ continue;
2076
+ }
2077
+ if (current?.hashes) {
2078
+ const entry = /^"([^"]+)"\s*=/.exec(line);
2079
+ const replacement = entry && byModule.get(current.module).hashes.get(entry[1]);
2080
+ if (replacement) {
2081
+ out.push(`"${entry[1]}" = "${replacement}"`);
2082
+ changed++;
2083
+ continue;
2084
+ }
2085
+ }
2086
+ out.push(line);
2087
+ }
2088
+ writeFileSync4(path, out.join("\n"));
2089
+ return changed;
1654
2090
  }
1655
2091
  function paramsFrom(record) {
1656
2092
  const out = {};
@@ -1660,7 +2096,7 @@ function paramsFrom(record) {
1660
2096
  return out;
1661
2097
  }
1662
2098
  function eject(repoRoot, mods, dryRun = false) {
1663
- const dest = join10(repoRoot, ".rungs");
2099
+ const dest = join11(repoRoot, ".rungs");
1664
2100
  const engines = ["glob.ts", "engines.ts", "engines2.ts"];
1665
2101
  const { gates } = loadRegistry(repoRoot);
1666
2102
  const declared = gates.filter((g) => g.kind === "declared" && g.table);
@@ -1670,23 +2106,23 @@ function eject(repoRoot, mods, dryRun = false) {
1670
2106
  for (const t of tables) actions.push(`.rungs/tables/${t.replace("/", "-").replace(/.toml$/, ".json")}`);
1671
2107
  actions.push(".rungs/run-gate.mjs", ".ai/gates.toml (rewritten to command gates)");
1672
2108
  if (dryRun) return { actions, gates: declared.length };
1673
- mkdirSync3(join10(dest, "tables"), { recursive: true });
1674
- for (const f of engines) copyFileSync(join10(SRC, f), join10(dest, f));
2109
+ mkdirSync4(join11(dest, "tables"), { recursive: true });
2110
+ for (const f of engines) copyFileSync(join11(SRC, f), join11(dest, f));
1675
2111
  const record = readRecord(repoRoot);
1676
2112
  const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
1677
2113
  for (const t of tables) {
1678
2114
  const [mod, file] = t.split("/");
1679
- const src = join10(SRC, "..", "modules", mod, "gates", file);
2115
+ const src = join11(SRC, "..", "modules", mod, "gates", file);
1680
2116
  if (!existsSync8(src)) continue;
1681
2117
  try {
1682
2118
  const parsed = parse3(substitute(readFileSync9(src, "utf8"), mod, params));
1683
- writeFileSync3(join10(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
2119
+ writeFileSync4(join11(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
1684
2120
  } catch {
1685
2121
  }
1686
2122
  }
1687
- writeFileSync3(join10(dest, "run-gate.mjs"), RUNNER);
1688
- writeFileSync3(join10(dest, "README.md"), EJECT_README);
1689
- const registry = join10(repoRoot, ".ai", "gates.toml");
2123
+ writeFileSync4(join11(dest, "run-gate.mjs"), RUNNER);
2124
+ writeFileSync4(join11(dest, "README.md"), EJECT_README);
2125
+ const registry = join11(repoRoot, ".ai", "gates.toml");
1690
2126
  let text = readFileSync9(registry, "utf8");
1691
2127
  for (const g of declared) {
1692
2128
  text = text.replace(
@@ -1695,7 +2131,7 @@ function eject(repoRoot, mods, dryRun = false) {
1695
2131
  command = "node .rungs/run-gate.mjs ${g.id}"`
1696
2132
  );
1697
2133
  }
1698
- writeFileSync3(registry, `${text}
2134
+ writeFileSync4(registry, `${text}
1699
2135
  # Ejected: gates above run from .rungs/ and no longer need rungs installed.
1700
2136
  `);
1701
2137
  return { actions, gates: declared.length };
@@ -1749,7 +2185,7 @@ itself to whoever finds it.
1749
2185
  To go back, delete this directory and re-run \`rungs add\`.
1750
2186
  `;
1751
2187
  function setupGit(repoRoot, dryRun = false) {
1752
- const attrs = join10(repoRoot, ".gitattributes");
2188
+ const attrs = join11(repoRoot, ".gitattributes");
1753
2189
  if (!existsSync8(attrs)) return { drivers: [], rerere: false };
1754
2190
  const drivers = [...new Set([...readFileSync9(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
1755
2191
  const done = [];
@@ -1777,9 +2213,433 @@ function setupGit(repoRoot, dryRun = false) {
1777
2213
  return { drivers: done, rerere };
1778
2214
  }
1779
2215
 
2216
+ // src/explain.ts
2217
+ var isRunnable = (g) => g.kind !== "command" && !g.trigger && !!g.engine;
2218
+ var IN_SCOPE = /* @__PURE__ */ new Set(["theirs", "ours-current", "ours-diverged"]);
2219
+ var FOREIGN_SAFE = /* @__PURE__ */ new Set(["repo-content"]);
2220
+ function explain(mods, results, repoRoot, files) {
2221
+ return explainWith(ENGINES, mods, results, repoRoot, files);
2222
+ }
2223
+ function explainWith(engines, mods, results, repoRoot, files) {
2224
+ const inScope = results.filter((r) => IN_SCOPE.has(r.state));
2225
+ const scope = inScope.map((r) => r.module);
2226
+ const stateOf = new Map(inScope.map((r) => [r.module, r.state]));
2227
+ const reported = [];
2228
+ const skipped = { command: 0, unimplemented: [], undeclared: [], errored: [] };
2229
+ for (const name of scope) {
2230
+ const mod = mods.find((m) => m.name === name);
2231
+ if (!mod) continue;
2232
+ const isOurs = stateOf.get(name) !== "theirs";
2233
+ for (const g of mod.gates) {
2234
+ if (!isRunnable(g)) {
2235
+ if (g.kind === "command") skipped.command++;
2236
+ continue;
2237
+ }
2238
+ if (!isOurs) {
2239
+ if (!g.applicability) {
2240
+ skipped.undeclared.push(g.id);
2241
+ continue;
2242
+ }
2243
+ if (!FOREIGN_SAFE.has(g.applicability)) continue;
2244
+ }
2245
+ if (!(g.engine in engines)) {
2246
+ skipped.unimplemented.push(g.id);
2247
+ continue;
2248
+ }
2249
+ const table = loadTable(g.table ? `${mod.name}/${g.table.replace(/^gates\//, "")}` : void 0, repoRoot);
2250
+ if (!table) {
2251
+ skipped.errored.push({ gate: g.id, message: `table '${g.table ?? "(none)"}' not found` });
2252
+ continue;
2253
+ }
2254
+ try {
2255
+ const key = tableKey(g.engine);
2256
+ let section = table[key] ?? table;
2257
+ if (Array.isArray(section) && section.some((s) => s?.id)) {
2258
+ const mine = section.filter((s) => !s.id || g.id.includes(s.id));
2259
+ if (mine.length) section = mine;
2260
+ }
2261
+ const r = engines[g.engine](section, repoRoot, files);
2262
+ if (r.findings.length) {
2263
+ reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
2264
+ }
2265
+ } catch (e) {
2266
+ skipped.errored.push({ gate: g.id, message: e.message });
2267
+ }
2268
+ }
2269
+ }
2270
+ return { reported: collapseDuplicates(reported), skipped, scope };
2271
+ }
2272
+ function collapseDuplicates(reported) {
2273
+ const out = [];
2274
+ const seen = /* @__PURE__ */ new Map();
2275
+ for (const r of reported) {
2276
+ const key = `${r.module} ${r.findings.map((f) => `${f.file ?? ""}|${f.message}`).join("")}`;
2277
+ const prior = seen.get(key);
2278
+ if (prior) {
2279
+ prior.gate = `${prior.gate} + ${r.gate}`;
2280
+ continue;
2281
+ }
2282
+ seen.set(key, r);
2283
+ out.push(r);
2284
+ }
2285
+ return out;
2286
+ }
2287
+
2288
+ // src/backlog.ts
2289
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
2290
+ import { dirname as dirname7, join as join12, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
2291
+ var FINISHED = /* @__PURE__ */ new Set(["done", "rejected"]);
2292
+ var field = (text, name) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, "m"))?.[1] ?? "";
2293
+ var posix = (p) => p.split(sep2).join("/");
2294
+ var LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
2295
+ function planArchive(repoRoot, backlogRoot = "docs/backlog") {
2296
+ const itemsDir = join12(repoRoot, ...backlogRoot.split("/"), "items");
2297
+ const archiveDir = join12(repoRoot, ...backlogRoot.split("/"), "archive");
2298
+ const moves = [];
2299
+ const held = [];
2300
+ const files = walk(repoRoot);
2301
+ const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
2302
+ for (const rel of items) {
2303
+ const base = posix(rel).split("/").pop();
2304
+ if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
2305
+ const text = readFileSync10(join12(repoRoot, rel), "utf8");
2306
+ const status = field(text, "status");
2307
+ const id = field(text, "id");
2308
+ if (!FINISHED.has(status)) continue;
2309
+ if (field(text, "type") === "epic") {
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"));
2312
+ const unfinished = children.filter((c2) => {
2313
+ const f = items.find((i) => i.includes(`${c2}-`)) ?? archived.find((i) => i.includes(`${c2}-`));
2314
+ return !f || !FINISHED.has(field(readFileSync10(join12(repoRoot, f), "utf8"), "status"));
2315
+ });
2316
+ if (unfinished.length) {
2317
+ held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(", ")}` });
2318
+ continue;
2319
+ }
2320
+ }
2321
+ moves.push({
2322
+ id,
2323
+ status,
2324
+ from: rel,
2325
+ to: posix(join12(relative2(repoRoot, archiveDir), posix(rel).split("/").pop()))
2326
+ });
2327
+ }
2328
+ const moved = new Map(moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
2329
+ const rewrites = [];
2330
+ for (const rel of files) {
2331
+ if (!isRewritable(rel)) continue;
2332
+ const links = retargets(repoRoot, rel, moved).length;
2333
+ if (links || moved.has(resolve3(repoRoot, rel))) rewrites.push({ file: rel, links });
2334
+ }
2335
+ return { root: backlogRoot, moves, rewrites, held };
2336
+ }
2337
+ function isRewritable(rel) {
2338
+ const p = posix(rel);
2339
+ if (!p.endsWith(".md")) return false;
2340
+ return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith("node_modules/");
2341
+ }
2342
+ function retargets(repoRoot, rel, moved) {
2343
+ const oldDir = dirname7(resolve3(repoRoot, rel));
2344
+ const selfMoved = moved.get(resolve3(repoRoot, rel));
2345
+ const newDir = dirname7(resolve3(repoRoot, selfMoved ?? rel));
2346
+ const out = [];
2347
+ for (const m of readFileSync10(join12(repoRoot, rel), "utf8").matchAll(LINK)) {
2348
+ const href = m[1];
2349
+ if (href.includes("{{")) continue;
2350
+ const target = resolve3(oldDir, decodeURIComponent(href));
2351
+ const targetMoved = moved.get(target);
2352
+ if (!targetMoved && !selfMoved) continue;
2353
+ if (!targetMoved && !existsSync9(target)) continue;
2354
+ const targetNew = targetMoved ? resolve3(repoRoot, targetMoved) : target;
2355
+ const to = posix(relative2(newDir, targetNew));
2356
+ if (to !== posix(href)) out.push({ href, to });
2357
+ }
2358
+ return out;
2359
+ }
2360
+ function applyArchive(repoRoot, plan) {
2361
+ const moved = new Map(plan.moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
2362
+ for (const rel of walk(repoRoot)) {
2363
+ if (!isRewritable(rel)) continue;
2364
+ const edits = retargets(repoRoot, rel, moved);
2365
+ if (!edits.length) continue;
2366
+ const path = join12(repoRoot, rel);
2367
+ let text = readFileSync10(path, "utf8");
2368
+ text = text.replace(LINK, (whole, href, anchor) => {
2369
+ const edit = edits.find((e) => e.href === href);
2370
+ return edit ? `](${edit.to}${anchor})` : whole;
2371
+ });
2372
+ writeFileSync5(path, text);
2373
+ }
2374
+ for (const m of plan.moves) {
2375
+ const to = join12(repoRoot, ...m.to.split("/"));
2376
+ mkdirSync5(dirname7(to), { recursive: true });
2377
+ renameSync(join12(repoRoot, m.from), to);
2378
+ }
2379
+ }
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
+
1780
2639
  // src/cli.ts
1781
- var HERE = dirname6(fileURLToPath3(import.meta.url));
1782
- var MODULES2 = join11(HERE, "..", "modules");
2640
+ import { existsSync as existsSync11 } from "node:fs";
2641
+ var HERE = dirname9(fileURLToPath3(import.meta.url));
2642
+ var MODULES2 = join14(HERE, "..", "modules");
1783
2643
  var c = {
1784
2644
  dim: (s) => `\x1B[2m${s}\x1B[0m`,
1785
2645
  bold: (s) => `\x1B[1m${s}\x1B[0m`,
@@ -1828,7 +2688,7 @@ ${mods.length} modules
1828
2688
  const issues = auditModules(mods);
1829
2689
  console.log();
1830
2690
  if (issues.length === 0) {
1831
- console.log(c.green(" audit clean") + c.dim(" \u2014 every parameter accounted for, every gate has a table and a why"));
2691
+ console.log(c.green(" audit clean") + c.dim(" \u2014 every parameter accounted for; every gate has a table, a why, and a declared applicability"));
1832
2692
  } else {
1833
2693
  console.log(c.red(` ${issues.length} issue(s):`));
1834
2694
  for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} \u2014 ${i.detail}`);
@@ -1836,8 +2696,8 @@ ${mods.length} modules
1836
2696
  console.log();
1837
2697
  return issues.length === 0 ? 0 : 1;
1838
2698
  }
1839
- function cmdDoctor(target) {
1840
- const root = resolve3(target);
2699
+ function cmdDoctor(target, doExplain = false) {
2700
+ const root = resolve5(target);
1841
2701
  const mods = loadAllModules(MODULES2);
1842
2702
  console.log(c.bold(`
1843
2703
  rungs doctor \u2014 ${root}
@@ -1906,6 +2766,9 @@ rungs doctor \u2014 ${root}
1906
2766
  console.log(c.dim(" This reports presence, never quality. It cannot tell whether an adopted"));
1907
2767
  console.log(c.dim(" system is good, complete, or working \u2014 only that files are where a"));
1908
2768
  console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
2769
+ reportLedger(root);
2770
+ if (doExplain) reportExplain(mods, results, root, files);
2771
+ else advertiseAnalysis(results);
1909
2772
  const theirs = byState("theirs");
1910
2773
  console.log(c.bold(" Next\n"));
1911
2774
  if (ours) {
@@ -1931,6 +2794,58 @@ rungs doctor \u2014 ${root}
1931
2794
  function firstSentence(s) {
1932
2795
  return s.trim().replace(/\s+/g, " ").split(/(?<=\.)\s/)[0];
1933
2796
  }
2797
+ function advertiseAnalysis(results) {
2798
+ const inScope = results.filter((r) => IN_SCOPE.has(r.state)).length;
2799
+ if (!inScope) return;
2800
+ console.log(c.bold(" Analysis\n"));
2801
+ console.log(` ${inScope} of these are things this repo already has, and can be checked against it.`);
2802
+ console.log(` ${c.cyan("rungs doctor --explain")} ${c.dim("\u2014 evidenced findings, and the incident behind each check")}
2803
+ `);
2804
+ }
2805
+ function reportExplain(mods, results, root, files) {
2806
+ const { reported, skipped, scope } = explain(mods, results, root, files);
2807
+ console.log(c.bold(" What it also checked\n"));
2808
+ if (!scope.length) {
2809
+ console.log(c.dim(" Nothing \u2014 detectors run only over what this repo already has, and"));
2810
+ console.log(c.dim(" detection found no equivalent of any module. There is nothing here to"));
2811
+ console.log(c.dim(" check that would not be checking our conventions against your repo.\n"));
2812
+ return;
2813
+ }
2814
+ const total = reported.reduce((n, r) => n + r.findings.length, 0);
2815
+ console.log(
2816
+ c.dim(` ran the detectors for ${scope.length} module(s) this repo already has: `) + c.dim(scope.join(" ")) + "\n"
2817
+ );
2818
+ for (const r of reported) {
2819
+ const n = r.findings.length;
2820
+ console.log(` ${c.yellow(r.gate.padEnd(34))} ${c.bold(String(n))} ${n === 1 ? "finding" : "findings"}`);
2821
+ for (const f of r.findings.slice(0, 4)) {
2822
+ console.log(c.dim(` ${f.file ? `${f.file}: ` : ""}${f.message}`));
2823
+ }
2824
+ if (n > 4) console.log(c.dim(` \u2026and ${n - 4} more`));
2825
+ if (r.why) console.log(c.dim(` why: ${firstSentence(r.why)}`));
2826
+ console.log();
2827
+ }
2828
+ if (!total) {
2829
+ console.log(c.dim(" No detector fired. That is not a clean bill of health \u2014 see below.\n"));
2830
+ }
2831
+ console.log(c.dim(" This is not an audit, and it is deliberately incomplete:"));
2832
+ console.log(c.dim(" \xB7 Detectors ran only for modules this repo already has an equivalent of."));
2833
+ console.log(c.dim(" \xB7 They read rungs-shaped inputs. A finding may be true and framed against"));
2834
+ console.log(c.dim(" a convention you never adopted \u2014 that is our defect, not yours."));
2835
+ if (skipped.command) {
2836
+ console.log(c.dim(` \xB7 ${skipped.command} command gate(s) not run. rungs does not execute commands in a repo it is only reading.`));
2837
+ }
2838
+ if (skipped.undeclared.length) {
2839
+ console.log(c.dim(` \xB7 ${skipped.undeclared.length} gate(s) never said whether they can read a repo like yours, so they did not: ${skipped.undeclared.join(" ")}`));
2840
+ }
2841
+ if (skipped.unimplemented.length) {
2842
+ console.log(c.dim(` \xB7 ${skipped.unimplemented.length} declared gate(s) have no engine and were skipped, never passed: ${skipped.unimplemented.join(" ")}`));
2843
+ }
2844
+ for (const e of skipped.errored) {
2845
+ console.log(c.dim(` \xB7 ${e.gate} could not run here (${e.message}) \u2014 a fact about this pass, not about your repo.`));
2846
+ }
2847
+ console.log();
2848
+ }
1934
2849
  function cmdAdd(names, root, dryRun, harnesses, stamp) {
1935
2850
  const mods = loadAllModules(MODULES2);
1936
2851
  const { order, missing } = resolveInstallOrder(names, mods);
@@ -1953,6 +2868,29 @@ function cmdAdd(names, root, dryRun, harnesses, stamp) {
1953
2868
  }
1954
2869
  (overrides[modName] ??= {})[param] = rhs.join("=");
1955
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
+ }
1956
2894
  const params = resolveParams(mods, overrides, root);
1957
2895
  for (const [m, vals] of Object.entries(overrides)) {
1958
2896
  for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
@@ -1963,9 +2901,50 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
1963
2901
  `));
1964
2902
  if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(", ")}
1965
2903
  `));
2904
+ const scanned = scanRepo(root);
2905
+ const paradigms = new Set(
2906
+ order.map((m) => detect(m, root, scanned)).filter((r) => r.state === "paradigm").map((r) => r.module)
2907
+ );
2908
+ const overridden = flags.has("--confirm-paradigm");
2909
+ const blocked = overridden ? /* @__PURE__ */ new Map() : blockedByParadigm(order, paradigms);
2910
+ if (overridden && paradigms.size) {
2911
+ for (const name of paradigms) {
2912
+ const p = detect(order.find((m) => m.name === name), root, scanned).paradigm;
2913
+ console.log(
2914
+ c.yellow(` ${name}: installing over an existing ${p.id}`) + c.dim(` (${p.matched[0]}) \u2014 --confirm-paradigm`)
2915
+ );
2916
+ }
2917
+ console.log(c.dim(" You will have two systems for one job. That is a choice, not a merge.\n"));
2918
+ }
2919
+ let toInstall = order;
2920
+ if (blocked.size) {
2921
+ for (const mod of order) {
2922
+ const cause = blocked.get(mod.name);
2923
+ if (!cause) continue;
2924
+ if (cause === mod.name) {
2925
+ const p = detect(mod, root, scanned).paradigm;
2926
+ console.log(c.yellow(` ${mod.name}: this repo already does this another way \u2014 ${p.id}`));
2927
+ console.log(c.dim(` matched ${p.matched[0]}`));
2928
+ for (const line of (p.note ?? "").trim().split("\n")) console.log(c.dim(` ${line || ""}`));
2929
+ if (p.compare) console.log(c.dim(` compare: ${p.compare}`));
2930
+ } else {
2931
+ console.log(c.yellow(` ${mod.name}: not installed \u2014 it requires ${cause}.`));
2932
+ }
2933
+ }
2934
+ toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
2935
+ const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
2936
+ if (dropped.length) {
2937
+ console.log(c.dim(` ${dropped.map((m) => m.name).join(", ")} not written \u2014 pulled in only for the above`));
2938
+ }
2939
+ console.log(
2940
+ c.dim(`
2941
+ Pass --confirm-paradigm to install anyway.`) + (toInstall.length ? c.dim(" Continuing with the rest.\n") : c.dim(" Nothing was written.\n"))
2942
+ );
2943
+ if (!toInstall.length) return 1;
2944
+ }
1966
2945
  const installed = [];
1967
2946
  const wrote = /* @__PURE__ */ new Map();
1968
- for (const mod of order) {
2947
+ for (const mod of toInstall) {
1969
2948
  if (mod.threshold?.confirm && !dryRun && !flags.has("--confirm-threshold")) {
1970
2949
  console.log(
1971
2950
  c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) + c.dim(" Skipped \u2014 pass --confirm-threshold to install it.\n")
@@ -2030,10 +3009,70 @@ rungs render \u2014 ${root}
2030
3009
  }
2031
3010
  return 0;
2032
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
+ }
2033
3055
  function cmdCheck(root, tier, stamp) {
2034
- const runs = runGates(root, tier);
3056
+ let runs;
3057
+ try {
3058
+ runs = runGates(root, tier);
3059
+ } catch (e) {
3060
+ if (!(e instanceof UnknownTierError)) throw e;
3061
+ console.log(c.yellow(`
3062
+ unknown tier "${e.requested}"`) + c.dim(` \u2014 this repo declares ${e.declared.join(", ")}.`));
3063
+ console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
3064
+ return 1;
3065
+ }
2035
3066
  if (!runs.length) {
2036
- console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
3067
+ const runnable = loadRegistry(root).gates.filter((g) => !g.trigger);
3068
+ if (runnable.length && tier) {
3069
+ const tiers = [...new Set(runnable.map((g) => g.tier).filter(Boolean))];
3070
+ console.log(c.yellow(`
3071
+ no gates in the ${tier} tier \u2014 ${runnable.length} are registered`) + c.dim(` (${tiers.length ? tiers.join(", ") : "none tiered"}).`));
3072
+ console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
3073
+ } else {
3074
+ console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
3075
+ }
2037
3076
  return 1;
2038
3077
  }
2039
3078
  appendLedger(root, runs, stamp);
@@ -2060,26 +3099,67 @@ rungs check \u2014 ${root}${tier ? ` (${tier} tier)` : ""}
2060
3099
  c.yellow("\n Unimplemented gates are not passes.") + c.dim(" A registry reporting green because most of its\n gates do nothing is the worst failure this tool could have, so they block.")
2061
3100
  );
2062
3101
  }
3102
+ console.log();
3103
+ return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
3104
+ }
3105
+ function reportLedger(root) {
2063
3106
  const { gates } = loadRegistry(root);
2064
3107
  const q = ledgerQuestions(root, gates);
2065
- if (q.neverFired.length || q.alwaysFires.length) {
2066
- console.log(c.bold(`
2067
- Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
2068
- for (const g of q.neverFired.slice(0, 3)) {
2069
- console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ""))}`);
2070
- console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
2071
- }
2072
- for (const g of q.alwaysFires.slice(0, 3)) {
2073
- console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
2074
- }
2075
- console.log(
2076
- c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran")
2077
- );
2078
- console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
2079
- console.log(c.dim(" directly, and CI runs, are not counted."));
3108
+ if (!q.neverFired.length && !q.alwaysFires.length) return;
3109
+ console.log(c.bold(` Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
3110
+ for (const g of q.neverFired.slice(0, 3)) {
3111
+ console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ""))}`);
3112
+ console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
2080
3113
  }
2081
- console.log();
2082
- return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
3114
+ for (const g of q.alwaysFires.slice(0, 3)) {
3115
+ console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
3116
+ }
3117
+ console.log(c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran"));
3118
+ console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
3119
+ console.log(c.dim(" directly, and CI runs, are not counted.\n"));
3120
+ }
3121
+ function cmdBacklogArchive(root, dryRun) {
3122
+ const record = readRecord(root);
3123
+ const configured = record?.modules["backlog"]?.params?.root;
3124
+ const backlogRoot = `docs/${configured ?? "backlog"}`;
3125
+ if (!existsSync11(join14(root, ...backlogRoot.split("/"), "items"))) {
3126
+ console.log(c.red(`
3127
+ no backlog at ${backlogRoot}/items
3128
+ `));
3129
+ return 1;
3130
+ }
3131
+ const plan = planArchive(root, backlogRoot);
3132
+ console.log(c.bold(`
3133
+ rungs backlog archive \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
3134
+ `));
3135
+ for (const h of plan.held) console.log(c.yellow(` held ${h.file}`) + c.dim(` \u2014 ${h.reason}`));
3136
+ if (plan.held.length) console.log();
3137
+ if (!plan.moves.length) {
3138
+ console.log(c.dim(" nothing to archive \u2014 no item is done or rejected.\n"));
3139
+ return 0;
3140
+ }
3141
+ const byStatus = /* @__PURE__ */ new Map();
3142
+ for (const m of plan.moves) byStatus.set(m.status, (byStatus.get(m.status) ?? 0) + 1);
3143
+ console.log(
3144
+ ` ${c.bold(String(plan.moves.length))} item(s) \u2014 ${[...byStatus].map(([s, n]) => `${n} ${s}`).join(" \xB7 ")}`
3145
+ );
3146
+ for (const m of plan.moves.slice(0, 5)) console.log(c.dim(` ${m.from} \u2192 ${m.to}`));
3147
+ if (plan.moves.length > 5) console.log(c.dim(` \u2026and ${plan.moves.length - 5} more`));
3148
+ const touched = plan.rewrites.filter((r) => r.links);
3149
+ const links = touched.reduce((n, r) => n + r.links, 0);
3150
+ console.log(`
3151
+ ${c.bold(String(links))} link(s) repointed across ${touched.length} file(s)`);
3152
+ for (const r of touched.slice(0, 5)) console.log(c.dim(` ${r.file} (${r.links})`));
3153
+ if (touched.length > 5) console.log(c.dim(` \u2026and ${touched.length - 5} more`));
3154
+ if (dryRun) {
3155
+ console.log(c.dim("\n Nothing written. Drop --dry-run to apply.\n"));
3156
+ return 0;
3157
+ }
3158
+ applyArchive(root, plan);
3159
+ console.log(c.green(`
3160
+ archived ${plan.moves.length} item(s)`) + c.dim(" \u2014 ids stay spent and every citation still resolves."));
3161
+ console.log(c.dim(" Run `rungs check` to confirm.\n"));
3162
+ return 0;
2083
3163
  }
2084
3164
  function cmdInit(root, profile, dryRun, harnesses, stamp) {
2085
3165
  if (readRecord(root)) {
@@ -2122,10 +3202,15 @@ rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
2122
3202
  console.log(` ${c.yellow("diverged")} ${f.rel} ${c.dim("\u2014 yours, left alone")}`);
2123
3203
  }
2124
3204
  }
2125
- if (apply && stale) {
2126
- const written = applyUpgrade(root, mods, record, plan);
3205
+ if (apply) {
3206
+ const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
3207
+ const parts = [
3208
+ written ? `${written} file(s)` : "",
3209
+ gates ? `${gates} gate registration(s)` : "",
3210
+ recorded ? `${recorded} record line(s)` : ""
3211
+ ].filter(Boolean);
2127
3212
  console.log(c.green(`
2128
- updated ${written} file(s)`));
3213
+ updated ${parts.length ? parts.join(" \xB7 ") : "nothing"}`));
2129
3214
  }
2130
3215
  console.log(
2131
3216
  `
@@ -2161,10 +3246,17 @@ var COMMANDS = [
2161
3246
  ["upgrade [path]", "move to newer module versions, never touching what you edited"],
2162
3247
  ["eject [path]", "materialise the engines; stop depending on rungs"],
2163
3248
  ["setup git [path]", "install the merge drivers .gitattributes names"],
2164
- ["modules", "list the module set and audit the manifests"]
3249
+ ["modules", "list the module set and audit the manifests"],
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"]
2165
3255
  ];
2166
3256
  var FLAGS = [
2167
3257
  ["--dry-run", "report what would happen, write nothing"],
3258
+ ["--explain", "doctor: also run the detectors over what this repo already has"],
3259
+ ["--confirm-paradigm", "add: install a module this repo already solves another way"],
2168
3260
  ["--into <path>", "add: install into this repo instead of the working directory"],
2169
3261
  ["--set m.param=value", "add/init: override a module parameter. Repeatable"],
2170
3262
  ["--confirm-threshold", "add: install a module whose rung is above this repo"],
@@ -2231,21 +3323,36 @@ switch (cmd) {
2231
3323
  case "modules":
2232
3324
  process.exit(cmdModules(flags.has("--params")));
2233
3325
  case "doctor":
2234
- process.exit(cmdDoctor(args[0] ?? process.cwd()));
3326
+ process.exit(cmdDoctor(args[0] ?? process.cwd(), flags.has("--explain")));
3327
+ case "backlog": {
3328
+ if (args[0] !== "archive") {
3329
+ console.log(c.red(`
3330
+ unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
3331
+ process.exit(1);
3332
+ }
3333
+ process.exit(cmdBacklogArchive(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run")));
3334
+ }
2235
3335
  case "check": {
2236
3336
  const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
2237
- process.exit(cmdCheck(resolve3(args[0] ?? process.cwd()), tier, STAMP));
3337
+ process.exit(cmdCheck(resolve5(args[0] ?? process.cwd()), tier, STAMP));
2238
3338
  }
2239
3339
  case "init": {
2240
3340
  const profile = args[1] ?? "tracked";
2241
- process.exit(cmdInit(resolve3(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));
2242
3342
  }
2243
3343
  case "upgrade":
2244
- process.exit(cmdUpgrade(resolve3(args[0] ?? process.cwd()), flags.has("--apply")));
3344
+ process.exit(cmdUpgrade(resolve5(args[0] ?? process.cwd()), flags.has("--apply")));
2245
3345
  case "eject":
2246
- process.exit(cmdEject(resolve3(args[0] ?? process.cwd()), flags.has("--dry-run")));
3346
+ process.exit(cmdEject(resolve5(args[0] ?? process.cwd()), flags.has("--dry-run")));
2247
3347
  case "setup": {
2248
- const r = setupGit(resolve3(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"));
2249
3356
  console.log(
2250
3357
  r.drivers.length ? `
2251
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")
@@ -2253,11 +3360,25 @@ switch (cmd) {
2253
3360
  process.exit(0);
2254
3361
  }
2255
3362
  case "render":
2256
- process.exit(cmdRender(resolve3(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())));
2257
3378
  case "add": {
2258
3379
  const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
2259
3380
  const names = flags.has("--into") ? args.slice(0, -1) : args;
2260
- process.exit(cmdAdd(names, resolve3(target), flags.has("--dry-run"), HARNESSES, STAMP));
3381
+ process.exit(cmdAdd(names, resolve5(target), flags.has("--dry-run"), HARNESSES, STAMP));
2261
3382
  }
2262
3383
  default: {
2263
3384
  const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";