@rungs/cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 dirname8, join as join13, resolve as resolve4 } 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,136 @@ 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 (Array.isArray(fx.fragments) && typeof fx.version === "string") {
884
+ const dir = fx.dir ?? "changelog.d";
885
+ const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
886
+ `));
887
+ written.push(write("package.json", JSON.stringify({ version: fx.version })));
888
+ return written;
889
+ }
890
+ if (typeof fx.matching_files === "number") {
891
+ const base = fx.location ?? dirname3(targetPath(table));
892
+ const marker = fx.exempt ? `<!-- ${fx.exempt} -->
893
+ ` : "";
894
+ return Array.from(
895
+ { length: fx.matching_files },
896
+ (_, i) => write(join6(base === "." ? "" : base, `probe-${i}.md`), `${marker}# probe ${i}
897
+ `)
898
+ );
899
+ }
900
+ const contentKeys = ["frontmatter", "sections", "opening", "body", "row", "table"];
901
+ if (contentKeys.some((k) => k in fx)) {
902
+ const parts = [];
903
+ if (fx.table) parts.push(`## ${fx.table}`, "");
904
+ if (fx.frontmatter && typeof fx.frontmatter === "object") {
905
+ parts.push("---");
906
+ for (const [k, v] of Object.entries(fx.frontmatter)) parts.push(`${k}: ${v}`);
907
+ parts.push("---", "");
908
+ }
909
+ if (fx.opening) parts.push(String(fx.opening), "");
910
+ for (const s of fx.sections ?? []) parts.push(`## ${s}`, "", "text", "");
911
+ if (fx.row && typeof fx.row === "object") {
912
+ const cols = Object.keys(fx.row);
913
+ parts.push(
914
+ `| ${cols.join(" | ")} |`,
915
+ `| ${cols.map(() => "---").join(" | ")} |`,
916
+ `| ${cols.map((c2) => fx.row[c2]).join(" | ")} |`,
917
+ ""
918
+ );
919
+ }
920
+ if (fx.body) parts.push(String(fx.body), "");
921
+ return [write(fx.file ?? targetPath(table), `${parts.join("\n")}
922
+ `)];
923
+ }
924
+ return null;
925
+ }
926
+ var CONTEXT_FREE = /* @__PURE__ */ new Set([
927
+ "frontmatter-schema",
928
+ "sections",
929
+ "file-budget",
930
+ "register-schema",
931
+ "file-population",
932
+ "changelog-freshness"
933
+ ]);
934
+ function deparam(spec, dir) {
935
+ 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;
936
+ return walk3(spec);
937
+ }
938
+ function runSelfTests(gateId, engine, table, blocks) {
939
+ const out = [];
940
+ for (const b of blocks) {
941
+ const expect = b.expect === "fail" ? "fail" : "pass";
942
+ if (!CONTEXT_FREE.has(engine)) {
943
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `${engine} fixtures need context the fixture does not carry` });
944
+ continue;
945
+ }
946
+ if (!(engine in ENGINES)) {
947
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine '${engine}' not implemented` });
948
+ continue;
949
+ }
950
+ const root = mkdtempSync(join6(tmpdir(), "rungs-selftest-"));
951
+ try {
952
+ const files = build(root, table, b.fixture, b.input);
953
+ let spec = b.fixture?.opted_in ? Array.isArray(table) ? table.map((s) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in } : table;
954
+ if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
955
+ if (!files) {
956
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
957
+ continue;
958
+ }
959
+ let findings;
960
+ try {
961
+ findings = ENGINES[engine](spec, root, files).findings;
962
+ } catch (e) {
963
+ out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine threw: ${e.message}`.slice(0, 90) });
964
+ continue;
965
+ }
966
+ const fired = findings.length > 0;
967
+ out.push(
968
+ 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"}` }
969
+ );
970
+ } finally {
971
+ rmSync(root, { recursive: true, force: true });
972
+ }
973
+ }
974
+ return out;
975
+ }
826
976
 
827
977
  // src/engines2.ts
828
978
  import { readFileSync as readFileSync5 } from "node:fs";
829
979
  import { execSync } from "node:child_process";
830
- import { join as join6 } from "node:path";
980
+ import { join as join7 } from "node:path";
831
981
  var read = (root, rel) => {
832
982
  try {
833
- return readFileSync5(join6(root, rel), "utf8");
983
+ return readFileSync5(join7(root, rel), "utf8");
834
984
  } catch {
835
985
  return "";
836
986
  }
@@ -924,40 +1074,44 @@ var renderFreshness = (t, root, files) => {
924
1074
  var registerSchema = (t, root, files) => {
925
1075
  const findings = [];
926
1076
  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` });
1077
+ const specs = [t, ...Object.values(t).filter((v) => v && typeof v === "object" && !Array.isArray(v) && v.table)];
1078
+ for (const t2 of specs) {
1079
+ const targets = t2.file ?? specs[0].file ? [t2.file ?? specs[0].file] : expand(files, t2.scan);
1080
+ for (const rel of targets) {
1081
+ const text = read(root, rel);
1082
+ if (!text) continue;
1083
+ for (const table of parseTables(text)) {
1084
+ const heading = sectionOf(text, table.headerLine).replace(/^#+\s*/, "").trim().toLowerCase();
1085
+ if (t2.table && !heading.startsWith(String(t2.table).toLowerCase())) continue;
1086
+ const cols = t2.required_cols ?? t2.table_columns ?? [];
1087
+ const present = cols.filter(
1088
+ (c2) => table.headers.some((h) => h.toLowerCase() === String(c2).toLowerCase())
1089
+ );
1090
+ if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
1091
+ for (const c2 of cols) {
1092
+ if (!present.includes(c2)) findings.push({ file: rel, message: `register table missing column '${c2}'` });
952
1093
  }
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` });
1094
+ for (const row of table.rows) {
1095
+ if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
1096
+ examined++;
1097
+ for (const [key, values] of Object.entries(t2.enum ?? {})) {
1098
+ const v = strip(row[key]);
1099
+ if (v && !values.map(String).includes(v)) {
1100
+ findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
1101
+ }
1102
+ }
1103
+ for (const c2 of t2.non_empty ?? []) {
1104
+ if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
1105
+ }
1106
+ for (const cond of t2.conditional ?? []) {
1107
+ const matches = Object.entries(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
1108
+ if (!matches) continue;
1109
+ for (const c2 of cond.non_empty ?? []) {
1110
+ const v = strip(row[c2]);
1111
+ if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' required when ${JSON.stringify(cond.when)}` });
1112
+ else if (cond.min_words?.[c2] && v.split(/\s+/).length < cond.min_words[c2]) {
1113
+ findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is too thin to be a reason` });
1114
+ }
961
1115
  }
962
1116
  }
963
1117
  }
@@ -1073,6 +1227,16 @@ var crossReference = (t, root, files) => {
1073
1227
  }
1074
1228
  return { findings, examined: skills.length };
1075
1229
  };
1230
+ function landedWork(root, branch, base) {
1231
+ const git = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
1232
+ try {
1233
+ const tip = git(`rev-parse ${branch}`);
1234
+ if (tip === git(`rev-parse ${base}`)) return false;
1235
+ return git(`log ${base} --merges --format=%P`).split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
1236
+ } catch {
1237
+ return true;
1238
+ }
1239
+ }
1076
1240
  var gitStatusReconcile = (t, root, files) => {
1077
1241
  const findings = [];
1078
1242
  let merged;
@@ -1094,7 +1258,7 @@ var gitStatusReconcile = (t, root, files) => {
1094
1258
  const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
1095
1259
  if (!branch || !status) continue;
1096
1260
  examined++;
1097
- if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
1261
+ if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status) && landedWork(root, branch, t.integration_branch ?? "main")) {
1098
1262
  findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
1099
1263
  }
1100
1264
  }
@@ -1137,16 +1301,54 @@ var computedClaim = (t, root, files) => {
1137
1301
  // src/engines3.ts
1138
1302
  import { readFileSync as readFileSync6 } from "node:fs";
1139
1303
  import { execSync as execSync2 } from "node:child_process";
1140
- import { join as join7 } from "node:path";
1304
+ import { join as join8 } from "node:path";
1141
1305
  var read2 = (root, rel) => {
1142
1306
  try {
1143
- return readFileSync6(join7(root, rel), "utf8");
1307
+ return readFileSync6(join8(root, rel), "utf8");
1144
1308
  } catch {
1145
1309
  return "";
1146
1310
  }
1147
1311
  };
1148
1312
  var expand2 = (files, p, f = []) => [...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
1149
1313
  var escapeRe2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1314
+ function versionParts(s) {
1315
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
1316
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
1317
+ }
1318
+ function versionCmp(a, b) {
1319
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
1320
+ }
1321
+ var changelogFreshness = (t, root, files) => {
1322
+ const specs = Array.isArray(t) ? t : [t];
1323
+ const findings = [];
1324
+ let examined = 0;
1325
+ for (const spec of specs) {
1326
+ const src = spec.version ?? {};
1327
+ let current = null;
1328
+ for (const rel of matchAny(files, src.file ?? "package.json")) {
1329
+ try {
1330
+ const raw = (src.path ?? "version").split(".").reduce((o, k) => o?.[k], JSON.parse(read2(root, rel)));
1331
+ current = versionParts(String(raw ?? ""));
1332
+ } catch {
1333
+ }
1334
+ if (current) break;
1335
+ }
1336
+ if (!current) continue;
1337
+ for (const rel of expand2(files, spec.fragments, [])) {
1338
+ const name = rel.split("/").pop().replace(/\.md$/, "");
1339
+ const v = versionParts(name);
1340
+ if (!v) continue;
1341
+ examined++;
1342
+ if (versionCmp(v, current) < 0) {
1343
+ findings.push({
1344
+ file: rel,
1345
+ 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`
1346
+ });
1347
+ }
1348
+ }
1349
+ }
1350
+ return { findings, examined };
1351
+ };
1150
1352
  var exempted2 = (text, marker) => !!marker && new RegExp(`${escapeRe2(marker)}\\s*\\S`).test(text);
1151
1353
  function tableRows(text, near) {
1152
1354
  const lines = text.split("\n");
@@ -1266,11 +1468,52 @@ var mergeDriverCheck = (t, root) => {
1266
1468
  }
1267
1469
  return { findings, examined: declared.length };
1268
1470
  };
1471
+ var boardReconcile = (t, root, _files) => {
1472
+ const rel = t.file;
1473
+ const text = read2(root, rel);
1474
+ if (!text) return { findings: [{ message: `board not found at ${rel}` }], examined: 0 };
1475
+ if (exempted2(text, t.exempt_marker)) return { findings: [], examined: 0 };
1476
+ const groups = t.groups ?? {};
1477
+ const dir = rel.split("/").slice(0, -1).join("/");
1478
+ const findings = [];
1479
+ let heading = "";
1480
+ let examined = 0;
1481
+ for (const line of text.split("\n")) {
1482
+ const h = /^##\s+(.+?)\s*$/.exec(line);
1483
+ if (h) {
1484
+ heading = h[1];
1485
+ continue;
1486
+ }
1487
+ if (!line.startsWith("|")) continue;
1488
+ const link = /^\|\s*\[[^\]]+\]\(([^)]+)\)/.exec(line);
1489
+ if (!link) continue;
1490
+ if (!Object.hasOwn(groups, heading)) continue;
1491
+ examined++;
1492
+ const target = `${dir}/${link[1]}`.replace(/[^/]+\/\.\.\//g, "");
1493
+ const item = read2(root, target);
1494
+ if (!item) {
1495
+ findings.push({ file: rel, message: `row under '${heading}' links to a missing file: ${link[1]}` });
1496
+ continue;
1497
+ }
1498
+ const status = /^status:\s*(\S+)/m.exec(item)?.[1] ?? "";
1499
+ if (!groups[heading].includes(status)) {
1500
+ findings.push({
1501
+ file: rel,
1502
+ message: `${link[1]} is under '${heading}' but its status is '${status}' (expected ${groups[heading].join(" | ")})`
1503
+ });
1504
+ }
1505
+ }
1506
+ const seen = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((m) => m[1]));
1507
+ for (const g of Object.keys(groups)) {
1508
+ if (!seen.has(g)) findings.push({ file: rel, message: `declared group '${g}' has no heading in the board` });
1509
+ }
1510
+ return { findings, examined };
1511
+ };
1269
1512
 
1270
1513
  // src/engines.ts
1271
1514
  var read3 = (root, rel) => {
1272
1515
  try {
1273
- return readFileSync7(join8(root, rel), "utf8");
1516
+ return readFileSync7(join9(root, rel), "utf8");
1274
1517
  } catch {
1275
1518
  return "";
1276
1519
  }
@@ -1289,10 +1532,12 @@ var fileBudget = (t, root, files) => {
1289
1532
  const findings = [];
1290
1533
  let examined = 0;
1291
1534
  for (const rel of targets) {
1292
- if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
1535
+ if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
1293
1536
  examined++;
1294
1537
  const n = loadedLines(read3(root, rel));
1295
- if (n > t.max_lines) findings.push({ file: rel, message: `${n} lines, budget ${t.max_lines}` });
1538
+ if (n > t.max_lines) {
1539
+ findings.push({ file: rel, message: `${n} loaded lines (blank lines and comments excluded), budget ${t.max_lines}` });
1540
+ }
1296
1541
  }
1297
1542
  return { findings, examined };
1298
1543
  };
@@ -1304,10 +1549,11 @@ var sections = (t, root, files) => {
1304
1549
  const targets = dropGenerated(root, spec.file ? [spec.file] : expand3(files, spec.scan));
1305
1550
  const excluded = new Set(expand3(files, spec.exclude, []));
1306
1551
  for (const rel of targets) {
1307
- if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
1552
+ if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
1308
1553
  examined++;
1309
1554
  const text = read3(root, rel);
1310
- const heads = [...text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1]);
1555
+ const matches = [...text.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)];
1556
+ const heads = matches.map((m) => m[2]);
1311
1557
  for (const want of spec.required ?? []) {
1312
1558
  const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
1313
1559
  if (idx === -1) {
@@ -1315,8 +1561,9 @@ var sections = (t, root, files) => {
1315
1561
  continue;
1316
1562
  }
1317
1563
  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();
1564
+ const level = matches[idx][1].length;
1565
+ const after = text.slice(matches[idx].index + matches[idx][0].length);
1566
+ const body = after.split(new RegExp(`^#{1,${level}}\\s+`, "m"))[0].replace(/<!--[\s\S]*?-->/g, "").trim();
1320
1567
  if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
1321
1568
  }
1322
1569
  }
@@ -1346,21 +1593,70 @@ var frontmatterSchema = (t, root, files) => {
1346
1593
  if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
1347
1594
  }
1348
1595
  if (spec.allowed) {
1596
+ const optedIn = spec.extensions_allowed_from ? optedInExtensions(rel, spec) : /* @__PURE__ */ new Set();
1349
1597
  for (const k of keys) {
1350
- if (!spec.allowed.includes(k)) findings.push({ file: rel, message: `non-spec key '${k}'` });
1598
+ if (spec.allowed.includes(k) || optedIn.has(k)) continue;
1599
+ findings.push({ file: rel, message: `non-spec key '${k}'` });
1351
1600
  }
1352
1601
  }
1602
+ const field2 = (k) => m[1].match(new RegExp(`^${k}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
1353
1603
  for (const [key, values] of Object.entries(spec.enum ?? {})) {
1354
- const v = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
1604
+ const v = field2(key);
1355
1605
  if (v && !values.map(String).includes(v)) {
1356
1606
  findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
1357
1607
  }
1358
1608
  }
1609
+ for (const pair of spec.reciprocal?.pairs ?? []) {
1610
+ const from = field2(pair.from);
1611
+ if (!from) continue;
1612
+ const target = expand3(files, spec.scan).find((r) => r.includes(from.replace(/\.md$/, "")));
1613
+ if (!target) {
1614
+ findings.push({ file: rel, message: `${pair.from} names '${from}', which is not a record here` });
1615
+ continue;
1616
+ }
1617
+ const back = read3(root, target).match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
1618
+ const id = field2("id") ?? "";
1619
+ if (!new RegExp(`^${pair.to}:\\s*.*${escapeRe3(id)}`, "m").test(back)) {
1620
+ findings.push({ file: rel, message: `${pair.from} \u2192 ${from}, but it does not name this record back in '${pair.to}'` });
1621
+ }
1622
+ }
1623
+ for (const [status, requires] of Object.entries(spec.reciprocal?.required_when ?? {})) {
1624
+ if (field2("status") === status && !field2(String(requires))) {
1625
+ findings.push({ file: rel, message: `status is '${status}' but '${requires}' is absent` });
1626
+ }
1627
+ }
1359
1628
  }
1360
1629
  }
1361
1630
  return { findings, examined };
1362
1631
  };
1632
+ function resolvesHere(root, rel, href) {
1633
+ const from = dirname4(rel);
1634
+ const decoded = decodeURIComponent(href);
1635
+ if (existsSync6(resolve2(root, from, decoded))) return true;
1636
+ const stripped = decoded.replace(/:\d+(?::\d+)?$/, "");
1637
+ return stripped !== decoded && existsSync6(resolve2(root, from, stripped));
1638
+ }
1639
+ function backtickedPaths(rel, text, root, hints) {
1640
+ const out = [];
1641
+ const seen = /* @__PURE__ */ new Set();
1642
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
1643
+ const raw = m[1].trim();
1644
+ if (seen.has(raw) || !hints.some((h) => raw.includes(h))) continue;
1645
+ seen.add(raw);
1646
+ if (raw.startsWith("/")) continue;
1647
+ if (/[*?{}#\s]|^\w+:/.test(raw)) continue;
1648
+ if (!raw.includes("/")) continue;
1649
+ if (!/\.[a-z0-9]{1,5}$/i.test(raw)) continue;
1650
+ const bare = raw.replace(/^\.\//, "");
1651
+ if (!existsSync6(join9(root, bare)) && !existsSync6(resolve2(root, dirname4(rel), bare))) {
1652
+ out.push({ message: `stale path in a code span \u2192 ${raw}` });
1653
+ }
1654
+ }
1655
+ return out;
1656
+ }
1363
1657
  var linkIntegrity = (t, root, files) => {
1658
+ if (Array.isArray(t)) t = t[0] ?? {};
1659
+ const checks = t.check ?? ["relative_markdown_links"];
1364
1660
  const scan = expand3(files, t.scan, ["**/*.md"]);
1365
1661
  const excluded = new Set(expand3(files, t.exclude, []));
1366
1662
  const findings = [];
@@ -1370,11 +1666,14 @@ var linkIntegrity = (t, root, files) => {
1370
1666
  const text = read3(root, rel);
1371
1667
  examined++;
1372
1668
  if (/path-ok:\s*\S/.test(text)) continue;
1669
+ if (checks.includes("backticked_paths")) {
1670
+ findings.push(...backtickedPaths(rel, text, root, t.path_hint ?? ["/"]).map((f) => ({ ...f, file: rel })));
1671
+ }
1672
+ if (!checks.includes("relative_markdown_links")) continue;
1373
1673
  const scannable = text.replace(/`+[^`\n]*`+/g, (s) => " ".repeat(s.length));
1374
1674
  for (const m of scannable.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
1375
1675
  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]}` });
1676
+ if (!resolvesHere(root, rel, m[1])) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
1378
1677
  }
1379
1678
  }
1380
1679
  return { findings, examined };
@@ -1392,13 +1691,17 @@ var filePopulation = (t, root, files) => {
1392
1691
  const findings = [];
1393
1692
  const failAt = t.fail_at ?? Infinity;
1394
1693
  if (hits.length >= failAt) {
1395
- findings.push({ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` });
1694
+ const scanned = [t.scan ?? []].flat();
1695
+ findings.push({
1696
+ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` + (scanned.length ? ` \u2014 matched against ${scanned.join(", ")}` : "")
1697
+ });
1396
1698
  }
1397
1699
  return { findings, examined: hits.length };
1398
1700
  };
1399
1701
  var gateMeta = (_t, root) => {
1400
1702
  const findings = [];
1401
- const registry = join8(root, ".ai", "gates.toml");
1703
+ let unrun = 0;
1704
+ const registry = join9(root, ".ai", "gates.toml");
1402
1705
  if (!existsSync6(registry)) return { findings, examined: 0 };
1403
1706
  const text = readFileSync7(registry, "utf8");
1404
1707
  const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
@@ -1409,7 +1712,7 @@ var gateMeta = (_t, root) => {
1409
1712
  const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
1410
1713
  if (!id || kind !== "declared" || !table) continue;
1411
1714
  examined++;
1412
- const tablePath = join8(dirname3(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname3(table), "gates", table.split("/").pop());
1715
+ const tablePath = join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname4(table), "gates", table.split("/").pop());
1413
1716
  const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
1414
1717
  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
1718
  for (const direction of ["pass", "fail"]) {
@@ -1417,10 +1720,61 @@ var gateMeta = (_t, root) => {
1417
1720
  findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
1418
1721
  }
1419
1722
  }
1723
+ const engine = entry.match(/^engine\s*=\s*"(.+)"/m)?.[1];
1724
+ const parsed = parseTable(tablePath, table.split("/")[0]);
1725
+ if (engine && parsed) {
1726
+ 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 }));
1727
+ for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
1728
+ if (r.outcome === "mismatch") findings.push({ message: `self-test for '${id}' ${r.detail}` });
1729
+ else if (r.outcome === "unrun") unrun++;
1730
+ }
1731
+ }
1420
1732
  }
1733
+ if (unrun) console.error(` ${unrun} self-test fixture(s) have no builder and did not run \u2014 not passes (F-018)`);
1421
1734
  return { findings, examined };
1422
1735
  };
1736
+ function optedInExtensions(rel, spec) {
1737
+ if (Array.isArray(spec.extensions_opted_in)) return new Set(spec.extensions_opted_in.map(String));
1738
+ const name = rel.split("/").slice(-2)[0];
1739
+ if (!name) return /* @__PURE__ */ new Set();
1740
+ try {
1741
+ const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1742
+ const owner = mods.find((m) => m.skills?.[name]?.extensions);
1743
+ return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
1744
+ } catch {
1745
+ return /* @__PURE__ */ new Set();
1746
+ }
1747
+ }
1423
1748
  var escapeRe3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1749
+ function parseTable(path, module) {
1750
+ if (!existsSync6(path)) return null;
1751
+ try {
1752
+ const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
1753
+ const params = resolveParams(mods, {}, ".");
1754
+ return parseToml(substitute(readFileSync7(path, "utf8"), module, params));
1755
+ } catch {
1756
+ return null;
1757
+ }
1758
+ }
1759
+ var tableKeyFor = (engine) => ({
1760
+ "file-budget": "file_budget",
1761
+ "frontmatter-schema": "frontmatter_schema",
1762
+ "link-integrity": "link_integrity",
1763
+ "file-population": "file_population",
1764
+ "render-freshness": "render_freshness",
1765
+ "register-schema": "register_schema",
1766
+ "self-declared-closure": "self_declared_closure",
1767
+ "filename-schema": "filename_schema",
1768
+ "cross-reference": "cross_reference",
1769
+ "git-status-reconcile": "merged_status",
1770
+ "computed-claim": "computed_claim",
1771
+ "term-ownership": "term_ownership",
1772
+ "rule-propagation": "rule_propagation",
1773
+ "git-state": "git_state",
1774
+ "merge-driver-check": "merge_driver_check",
1775
+ "board-reconcile": "board_reconcile",
1776
+ "changelog-freshness": "changelog_freshness"
1777
+ })[engine] ?? engine;
1424
1778
  var ENGINES = {
1425
1779
  "file-budget": fileBudget,
1426
1780
  sections,
@@ -1439,27 +1793,49 @@ var ENGINES = {
1439
1793
  "term-ownership": termOwnership,
1440
1794
  "rule-propagation": rulePropagation,
1441
1795
  "git-state": gitState,
1442
- "merge-driver-check": mergeDriverCheck
1796
+ "merge-driver-check": mergeDriverCheck,
1797
+ "board-reconcile": boardReconcile,
1798
+ "changelog-freshness": changelogFreshness
1443
1799
  };
1444
1800
  function isImplemented(engine) {
1445
1801
  return engine in ENGINES;
1446
1802
  }
1447
1803
 
1448
1804
  // src/check.ts
1449
- var MODULES = join9(dirname4(fileURLToPath(import.meta.url)), "..", "modules");
1805
+ var MODULES = join10(dirname5(fileURLToPath(import.meta.url)), "..", "modules");
1450
1806
  function loadRegistry(repoRoot) {
1451
- const path = join9(repoRoot, ".ai", "gates.toml");
1807
+ const path = join10(repoRoot, ".ai", "gates.toml");
1452
1808
  if (!existsSync7(path)) return { runner: {}, gates: [] };
1453
1809
  const raw = parse2(readFileSync8(path, "utf8"));
1454
1810
  return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
1455
1811
  }
1812
+ function tierSelects(runnerTiers, requested, gateTier) {
1813
+ if (!gateTier) return true;
1814
+ const at = runnerTiers.indexOf(requested);
1815
+ const of = runnerTiers.indexOf(gateTier);
1816
+ if (at < 0 || of < 0) return gateTier === requested;
1817
+ return of <= at;
1818
+ }
1819
+ var UnknownTierError = class extends Error {
1820
+ requested;
1821
+ declared;
1822
+ constructor(requested, declared) {
1823
+ super(`unknown tier "${requested}"`);
1824
+ this.requested = requested;
1825
+ this.declared = declared;
1826
+ }
1827
+ };
1456
1828
  function runGates(repoRoot, tier, now = () => Date.now()) {
1457
- const { gates } = loadRegistry(repoRoot);
1829
+ const { runner, gates } = loadRegistry(repoRoot);
1830
+ const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
1831
+ if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
1832
+ throw new UnknownTierError(tier, runnerTiers);
1833
+ }
1458
1834
  const files = walk(repoRoot);
1459
1835
  const runs = [];
1460
1836
  for (const g of gates) {
1461
1837
  if (g.trigger) continue;
1462
- if (tier && g.tier && g.tier !== tier) continue;
1838
+ if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
1463
1839
  const started = now();
1464
1840
  let status = "pass";
1465
1841
  let findings = [];
@@ -1515,7 +1891,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
1515
1891
  function loadTable(ref, repoRoot) {
1516
1892
  if (!ref) return null;
1517
1893
  const [mod, file] = ref.split("/");
1518
- const path = join9(MODULES, mod, "gates", file);
1894
+ const path = join10(MODULES, mod, "gates", file);
1519
1895
  if (!existsSync7(path)) return null;
1520
1896
  try {
1521
1897
  return parse2(substitute(readFileSync8(path, "utf8"), mod, installedParams(repoRoot)));
@@ -1527,7 +1903,7 @@ var paramCache = null;
1527
1903
  function installedParams(repoRoot) {
1528
1904
  if (paramCache?.root === repoRoot) return paramCache.params;
1529
1905
  const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
1530
- const recordPath = join9(repoRoot, ".ai", "rungs.toml");
1906
+ const recordPath = join10(repoRoot, ".ai", "rungs.toml");
1531
1907
  if (existsSync7(recordPath)) {
1532
1908
  try {
1533
1909
  const rec = parse2(readFileSync8(recordPath, "utf8"));
@@ -1558,17 +1934,18 @@ var tableKey = (engine) => ({
1558
1934
  "term-ownership": "term_ownership",
1559
1935
  "rule-propagation": "rule_propagation",
1560
1936
  "git-state": "git_state",
1561
- "merge-driver-check": "merge_driver_check"
1937
+ "merge-driver-check": "merge_driver_check",
1938
+ "board-reconcile": "board_reconcile"
1562
1939
  })[engine] ?? engine;
1563
1940
  function appendLedger(repoRoot, runs, stamp) {
1564
1941
  const { runner } = loadRegistry(repoRoot);
1565
1942
  if (runner.ledger === false) return;
1566
- const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
1943
+ const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
1567
1944
  const lines = runs.map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined })).join("\n");
1568
1945
  appendFileSync(path, lines + "\n");
1569
1946
  }
1570
1947
  function ledgerQuestions(repoRoot, gates) {
1571
- const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
1948
+ const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
1572
1949
  if (!existsSync7(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
1573
1950
  const rows = readFileSync8(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1574
1951
  const by = /* @__PURE__ */ new Map();
@@ -1585,12 +1962,12 @@ function ledgerQuestions(repoRoot, gates) {
1585
1962
  }
1586
1963
 
1587
1964
  // 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";
1965
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
1966
+ import { dirname as dirname6, join as join11 } from "node:path";
1590
1967
  import { fileURLToPath as fileURLToPath2 } from "node:url";
1591
1968
  import { execSync as execSync4 } from "node:child_process";
1592
1969
  import { parse as parse3 } from "smol-toml";
1593
- var SRC = dirname5(fileURLToPath2(import.meta.url));
1970
+ var SRC = dirname6(fileURLToPath2(import.meta.url));
1594
1971
  var PROFILES = {
1595
1972
  minimal: ["instructions"],
1596
1973
  tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
@@ -1599,7 +1976,7 @@ var PROFILES = {
1599
1976
  fleet: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority", "concurrency", "design-sync"]
1600
1977
  };
1601
1978
  function readRecord(repoRoot) {
1602
- const p = join10(repoRoot, ".ai", "rungs.toml");
1979
+ const p = join11(repoRoot, ".ai", "rungs.toml");
1603
1980
  if (!existsSync8(p)) return null;
1604
1981
  try {
1605
1982
  const raw = parse3(readFileSync9(p, "utf8"));
@@ -1620,7 +1997,7 @@ function planUpgrade(repoRoot, mods, record) {
1620
1997
  const kept = new Set(installed.kept?.files ?? []);
1621
1998
  for (const [rel, wouldEmit] of emitted) {
1622
1999
  if (kept.has(rel)) continue;
1623
- const full = join10(repoRoot, rel);
2000
+ const full = join11(repoRoot, rel);
1624
2001
  if (!existsSync8(full)) {
1625
2002
  files.push({ rel, state: "missing" });
1626
2003
  continue;
@@ -1639,18 +2016,63 @@ function applyUpgrade(repoRoot, mods, record, plan) {
1639
2016
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
1640
2017
  const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
1641
2018
  let written = 0;
2019
+ const rewritten = /* @__PURE__ */ new Map();
1642
2020
  for (const item of plan) {
1643
2021
  const mod = mods.find((m) => m.name === item.module);
1644
2022
  const emitted = emittedFiles(mod, params, skillsDir);
1645
2023
  for (const f of item.files) {
1646
2024
  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));
2025
+ const full = join11(repoRoot, f.rel);
2026
+ const content = emitted.get(f.rel);
2027
+ mkdirSync4(dirname6(full), { recursive: true });
2028
+ writeFileSync4(full, content);
2029
+ if (!rewritten.has(mod.name)) rewritten.set(mod.name, /* @__PURE__ */ new Map());
2030
+ rewritten.get(mod.name).set(f.rel, contentHash(content));
1650
2031
  written++;
1651
2032
  }
1652
2033
  }
1653
- return written;
2034
+ const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)).filter(Boolean);
2035
+ const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
2036
+ const recorded = updateRecordAfterUpgrade(
2037
+ repoRoot,
2038
+ upgraded.map((m) => ({ module: m.name, version: m.version, hashes: rewritten.get(m.name) ?? /* @__PURE__ */ new Map() }))
2039
+ );
2040
+ return { written, gates: gateActions.length, recorded };
2041
+ }
2042
+ function updateRecordAfterUpgrade(repoRoot, updates) {
2043
+ const path = join11(repoRoot, ".ai", "rungs.toml");
2044
+ if (!existsSync8(path) || !updates.length) return 0;
2045
+ const lines = readFileSync9(path, "utf8").split("\n");
2046
+ const byModule = new Map(updates.map((u) => [u.module, u]));
2047
+ let changed = 0;
2048
+ let current = null;
2049
+ const out = [];
2050
+ for (const line of lines) {
2051
+ const header = /^\[modules\.([^\].]+)(\.[^\]]+)?\]/.exec(line);
2052
+ if (header) {
2053
+ current = byModule.has(header[1]) ? { module: header[1], hashes: header[2] === ".hashes" } : null;
2054
+ out.push(line);
2055
+ continue;
2056
+ }
2057
+ if (current && !current.hashes && /^version\s*=/.test(line)) {
2058
+ const next = `version = "${byModule.get(current.module).version}"`;
2059
+ if (next !== line) changed++;
2060
+ out.push(next);
2061
+ continue;
2062
+ }
2063
+ if (current?.hashes) {
2064
+ const entry = /^"([^"]+)"\s*=/.exec(line);
2065
+ const replacement = entry && byModule.get(current.module).hashes.get(entry[1]);
2066
+ if (replacement) {
2067
+ out.push(`"${entry[1]}" = "${replacement}"`);
2068
+ changed++;
2069
+ continue;
2070
+ }
2071
+ }
2072
+ out.push(line);
2073
+ }
2074
+ writeFileSync4(path, out.join("\n"));
2075
+ return changed;
1654
2076
  }
1655
2077
  function paramsFrom(record) {
1656
2078
  const out = {};
@@ -1660,7 +2082,7 @@ function paramsFrom(record) {
1660
2082
  return out;
1661
2083
  }
1662
2084
  function eject(repoRoot, mods, dryRun = false) {
1663
- const dest = join10(repoRoot, ".rungs");
2085
+ const dest = join11(repoRoot, ".rungs");
1664
2086
  const engines = ["glob.ts", "engines.ts", "engines2.ts"];
1665
2087
  const { gates } = loadRegistry(repoRoot);
1666
2088
  const declared = gates.filter((g) => g.kind === "declared" && g.table);
@@ -1670,23 +2092,23 @@ function eject(repoRoot, mods, dryRun = false) {
1670
2092
  for (const t of tables) actions.push(`.rungs/tables/${t.replace("/", "-").replace(/.toml$/, ".json")}`);
1671
2093
  actions.push(".rungs/run-gate.mjs", ".ai/gates.toml (rewritten to command gates)");
1672
2094
  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));
2095
+ mkdirSync4(join11(dest, "tables"), { recursive: true });
2096
+ for (const f of engines) copyFileSync(join11(SRC, f), join11(dest, f));
1675
2097
  const record = readRecord(repoRoot);
1676
2098
  const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
1677
2099
  for (const t of tables) {
1678
2100
  const [mod, file] = t.split("/");
1679
- const src = join10(SRC, "..", "modules", mod, "gates", file);
2101
+ const src = join11(SRC, "..", "modules", mod, "gates", file);
1680
2102
  if (!existsSync8(src)) continue;
1681
2103
  try {
1682
2104
  const parsed = parse3(substitute(readFileSync9(src, "utf8"), mod, params));
1683
- writeFileSync3(join10(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
2105
+ writeFileSync4(join11(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
1684
2106
  } catch {
1685
2107
  }
1686
2108
  }
1687
- writeFileSync3(join10(dest, "run-gate.mjs"), RUNNER);
1688
- writeFileSync3(join10(dest, "README.md"), EJECT_README);
1689
- const registry = join10(repoRoot, ".ai", "gates.toml");
2109
+ writeFileSync4(join11(dest, "run-gate.mjs"), RUNNER);
2110
+ writeFileSync4(join11(dest, "README.md"), EJECT_README);
2111
+ const registry = join11(repoRoot, ".ai", "gates.toml");
1690
2112
  let text = readFileSync9(registry, "utf8");
1691
2113
  for (const g of declared) {
1692
2114
  text = text.replace(
@@ -1695,7 +2117,7 @@ function eject(repoRoot, mods, dryRun = false) {
1695
2117
  command = "node .rungs/run-gate.mjs ${g.id}"`
1696
2118
  );
1697
2119
  }
1698
- writeFileSync3(registry, `${text}
2120
+ writeFileSync4(registry, `${text}
1699
2121
  # Ejected: gates above run from .rungs/ and no longer need rungs installed.
1700
2122
  `);
1701
2123
  return { actions, gates: declared.length };
@@ -1749,7 +2171,7 @@ itself to whoever finds it.
1749
2171
  To go back, delete this directory and re-run \`rungs add\`.
1750
2172
  `;
1751
2173
  function setupGit(repoRoot, dryRun = false) {
1752
- const attrs = join10(repoRoot, ".gitattributes");
2174
+ const attrs = join11(repoRoot, ".gitattributes");
1753
2175
  if (!existsSync8(attrs)) return { drivers: [], rerere: false };
1754
2176
  const drivers = [...new Set([...readFileSync9(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
1755
2177
  const done = [];
@@ -1777,9 +2199,173 @@ function setupGit(repoRoot, dryRun = false) {
1777
2199
  return { drivers: done, rerere };
1778
2200
  }
1779
2201
 
2202
+ // src/explain.ts
2203
+ var isRunnable = (g) => g.kind !== "command" && !g.trigger && !!g.engine;
2204
+ var IN_SCOPE = /* @__PURE__ */ new Set(["theirs", "ours-current", "ours-diverged"]);
2205
+ var FOREIGN_SAFE = /* @__PURE__ */ new Set(["repo-content"]);
2206
+ function explain(mods, results, repoRoot, files) {
2207
+ return explainWith(ENGINES, mods, results, repoRoot, files);
2208
+ }
2209
+ function explainWith(engines, mods, results, repoRoot, files) {
2210
+ const inScope = results.filter((r) => IN_SCOPE.has(r.state));
2211
+ const scope = inScope.map((r) => r.module);
2212
+ const stateOf = new Map(inScope.map((r) => [r.module, r.state]));
2213
+ const reported = [];
2214
+ const skipped = { command: 0, unimplemented: [], undeclared: [], errored: [] };
2215
+ for (const name of scope) {
2216
+ const mod = mods.find((m) => m.name === name);
2217
+ if (!mod) continue;
2218
+ const isOurs = stateOf.get(name) !== "theirs";
2219
+ for (const g of mod.gates) {
2220
+ if (!isRunnable(g)) {
2221
+ if (g.kind === "command") skipped.command++;
2222
+ continue;
2223
+ }
2224
+ if (!isOurs) {
2225
+ if (!g.applicability) {
2226
+ skipped.undeclared.push(g.id);
2227
+ continue;
2228
+ }
2229
+ if (!FOREIGN_SAFE.has(g.applicability)) continue;
2230
+ }
2231
+ if (!(g.engine in engines)) {
2232
+ skipped.unimplemented.push(g.id);
2233
+ continue;
2234
+ }
2235
+ const table = loadTable(g.table ? `${mod.name}/${g.table.replace(/^gates\//, "")}` : void 0, repoRoot);
2236
+ if (!table) {
2237
+ skipped.errored.push({ gate: g.id, message: `table '${g.table ?? "(none)"}' not found` });
2238
+ continue;
2239
+ }
2240
+ try {
2241
+ const key = tableKey(g.engine);
2242
+ let section = table[key] ?? table;
2243
+ if (Array.isArray(section) && section.some((s) => s?.id)) {
2244
+ const mine = section.filter((s) => !s.id || g.id.includes(s.id));
2245
+ if (mine.length) section = mine;
2246
+ }
2247
+ const r = engines[g.engine](section, repoRoot, files);
2248
+ if (r.findings.length) {
2249
+ reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
2250
+ }
2251
+ } catch (e) {
2252
+ skipped.errored.push({ gate: g.id, message: e.message });
2253
+ }
2254
+ }
2255
+ }
2256
+ return { reported: collapseDuplicates(reported), skipped, scope };
2257
+ }
2258
+ function collapseDuplicates(reported) {
2259
+ const out = [];
2260
+ const seen = /* @__PURE__ */ new Map();
2261
+ for (const r of reported) {
2262
+ const key = `${r.module} ${r.findings.map((f) => `${f.file ?? ""}|${f.message}`).join("")}`;
2263
+ const prior = seen.get(key);
2264
+ if (prior) {
2265
+ prior.gate = `${prior.gate} + ${r.gate}`;
2266
+ continue;
2267
+ }
2268
+ seen.set(key, r);
2269
+ out.push(r);
2270
+ }
2271
+ return out;
2272
+ }
2273
+
2274
+ // src/backlog.ts
2275
+ import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
2276
+ import { dirname as dirname7, join as join12, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
2277
+ var FINISHED = /* @__PURE__ */ new Set(["done", "rejected"]);
2278
+ var field = (text, name) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, "m"))?.[1] ?? "";
2279
+ var posix = (p) => p.split(sep2).join("/");
2280
+ var LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
2281
+ function planArchive(repoRoot, backlogRoot = "docs/backlog") {
2282
+ const itemsDir = join12(repoRoot, ...backlogRoot.split("/"), "items");
2283
+ const archiveDir = join12(repoRoot, ...backlogRoot.split("/"), "archive");
2284
+ const moves = [];
2285
+ const held = [];
2286
+ const files = walk(repoRoot);
2287
+ const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
2288
+ for (const rel of items) {
2289
+ if (/README\.md$/i.test(rel) || /TEMPLATE\.md$/i.test(rel)) continue;
2290
+ const text = readFileSync10(join12(repoRoot, rel), "utf8");
2291
+ const status = field(text, "status");
2292
+ const id = field(text, "id");
2293
+ if (!FINISHED.has(status)) continue;
2294
+ if (field(text, "type") === "epic") {
2295
+ const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
2296
+ const unfinished = children.filter((c2) => {
2297
+ const f = items.find((i) => i.includes(`${c2}-`));
2298
+ return !f || !FINISHED.has(field(readFileSync10(join12(repoRoot, f), "utf8"), "status"));
2299
+ });
2300
+ if (unfinished.length) {
2301
+ held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(", ")}` });
2302
+ continue;
2303
+ }
2304
+ }
2305
+ moves.push({
2306
+ id,
2307
+ status,
2308
+ from: rel,
2309
+ to: posix(join12(relative2(repoRoot, archiveDir), posix(rel).split("/").pop()))
2310
+ });
2311
+ }
2312
+ const moved = new Map(moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
2313
+ const rewrites = [];
2314
+ for (const rel of files) {
2315
+ if (!isRewritable(rel)) continue;
2316
+ const links = retargets(repoRoot, rel, moved).length;
2317
+ if (links || moved.has(resolve3(repoRoot, rel))) rewrites.push({ file: rel, links });
2318
+ }
2319
+ return { root: backlogRoot, moves, rewrites, held };
2320
+ }
2321
+ function isRewritable(rel) {
2322
+ const p = posix(rel);
2323
+ if (!p.endsWith(".md")) return false;
2324
+ return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith("node_modules/");
2325
+ }
2326
+ function retargets(repoRoot, rel, moved) {
2327
+ const oldDir = dirname7(resolve3(repoRoot, rel));
2328
+ const selfMoved = moved.get(resolve3(repoRoot, rel));
2329
+ const newDir = dirname7(resolve3(repoRoot, selfMoved ?? rel));
2330
+ const out = [];
2331
+ for (const m of readFileSync10(join12(repoRoot, rel), "utf8").matchAll(LINK)) {
2332
+ const href = m[1];
2333
+ if (href.includes("{{")) continue;
2334
+ const target = resolve3(oldDir, decodeURIComponent(href));
2335
+ const targetMoved = moved.get(target);
2336
+ if (!targetMoved && !selfMoved) continue;
2337
+ if (!targetMoved && !existsSync9(target)) continue;
2338
+ const targetNew = targetMoved ? resolve3(repoRoot, targetMoved) : target;
2339
+ const to = posix(relative2(newDir, targetNew));
2340
+ if (to !== posix(href)) out.push({ href, to });
2341
+ }
2342
+ return out;
2343
+ }
2344
+ function applyArchive(repoRoot, plan) {
2345
+ const moved = new Map(plan.moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
2346
+ for (const rel of walk(repoRoot)) {
2347
+ if (!isRewritable(rel)) continue;
2348
+ const edits = retargets(repoRoot, rel, moved);
2349
+ if (!edits.length) continue;
2350
+ const path = join12(repoRoot, rel);
2351
+ let text = readFileSync10(path, "utf8");
2352
+ text = text.replace(LINK, (whole, href, anchor) => {
2353
+ const edit = edits.find((e) => e.href === href);
2354
+ return edit ? `](${edit.to}${anchor})` : whole;
2355
+ });
2356
+ writeFileSync5(path, text);
2357
+ }
2358
+ for (const m of plan.moves) {
2359
+ const to = join12(repoRoot, ...m.to.split("/"));
2360
+ mkdirSync5(dirname7(to), { recursive: true });
2361
+ renameSync(join12(repoRoot, m.from), to);
2362
+ }
2363
+ }
2364
+
1780
2365
  // src/cli.ts
1781
- var HERE = dirname6(fileURLToPath3(import.meta.url));
1782
- var MODULES2 = join11(HERE, "..", "modules");
2366
+ import { existsSync as existsSync10 } from "node:fs";
2367
+ var HERE = dirname8(fileURLToPath3(import.meta.url));
2368
+ var MODULES2 = join13(HERE, "..", "modules");
1783
2369
  var c = {
1784
2370
  dim: (s) => `\x1B[2m${s}\x1B[0m`,
1785
2371
  bold: (s) => `\x1B[1m${s}\x1B[0m`,
@@ -1828,7 +2414,7 @@ ${mods.length} modules
1828
2414
  const issues = auditModules(mods);
1829
2415
  console.log();
1830
2416
  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"));
2417
+ 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
2418
  } else {
1833
2419
  console.log(c.red(` ${issues.length} issue(s):`));
1834
2420
  for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} \u2014 ${i.detail}`);
@@ -1836,8 +2422,8 @@ ${mods.length} modules
1836
2422
  console.log();
1837
2423
  return issues.length === 0 ? 0 : 1;
1838
2424
  }
1839
- function cmdDoctor(target) {
1840
- const root = resolve3(target);
2425
+ function cmdDoctor(target, doExplain = false) {
2426
+ const root = resolve4(target);
1841
2427
  const mods = loadAllModules(MODULES2);
1842
2428
  console.log(c.bold(`
1843
2429
  rungs doctor \u2014 ${root}
@@ -1906,6 +2492,9 @@ rungs doctor \u2014 ${root}
1906
2492
  console.log(c.dim(" This reports presence, never quality. It cannot tell whether an adopted"));
1907
2493
  console.log(c.dim(" system is good, complete, or working \u2014 only that files are where a"));
1908
2494
  console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
2495
+ reportLedger(root);
2496
+ if (doExplain) reportExplain(mods, results, root, files);
2497
+ else advertiseAnalysis(results);
1909
2498
  const theirs = byState("theirs");
1910
2499
  console.log(c.bold(" Next\n"));
1911
2500
  if (ours) {
@@ -1931,6 +2520,58 @@ rungs doctor \u2014 ${root}
1931
2520
  function firstSentence(s) {
1932
2521
  return s.trim().replace(/\s+/g, " ").split(/(?<=\.)\s/)[0];
1933
2522
  }
2523
+ function advertiseAnalysis(results) {
2524
+ const inScope = results.filter((r) => IN_SCOPE.has(r.state)).length;
2525
+ if (!inScope) return;
2526
+ console.log(c.bold(" Analysis\n"));
2527
+ console.log(` ${inScope} of these are things this repo already has, and can be checked against it.`);
2528
+ console.log(` ${c.cyan("rungs doctor --explain")} ${c.dim("\u2014 evidenced findings, and the incident behind each check")}
2529
+ `);
2530
+ }
2531
+ function reportExplain(mods, results, root, files) {
2532
+ const { reported, skipped, scope } = explain(mods, results, root, files);
2533
+ console.log(c.bold(" What it also checked\n"));
2534
+ if (!scope.length) {
2535
+ console.log(c.dim(" Nothing \u2014 detectors run only over what this repo already has, and"));
2536
+ console.log(c.dim(" detection found no equivalent of any module. There is nothing here to"));
2537
+ console.log(c.dim(" check that would not be checking our conventions against your repo.\n"));
2538
+ return;
2539
+ }
2540
+ const total = reported.reduce((n, r) => n + r.findings.length, 0);
2541
+ console.log(
2542
+ c.dim(` ran the detectors for ${scope.length} module(s) this repo already has: `) + c.dim(scope.join(" ")) + "\n"
2543
+ );
2544
+ for (const r of reported) {
2545
+ const n = r.findings.length;
2546
+ console.log(` ${c.yellow(r.gate.padEnd(34))} ${c.bold(String(n))} ${n === 1 ? "finding" : "findings"}`);
2547
+ for (const f of r.findings.slice(0, 4)) {
2548
+ console.log(c.dim(` ${f.file ? `${f.file}: ` : ""}${f.message}`));
2549
+ }
2550
+ if (n > 4) console.log(c.dim(` \u2026and ${n - 4} more`));
2551
+ if (r.why) console.log(c.dim(` why: ${firstSentence(r.why)}`));
2552
+ console.log();
2553
+ }
2554
+ if (!total) {
2555
+ console.log(c.dim(" No detector fired. That is not a clean bill of health \u2014 see below.\n"));
2556
+ }
2557
+ console.log(c.dim(" This is not an audit, and it is deliberately incomplete:"));
2558
+ console.log(c.dim(" \xB7 Detectors ran only for modules this repo already has an equivalent of."));
2559
+ console.log(c.dim(" \xB7 They read rungs-shaped inputs. A finding may be true and framed against"));
2560
+ console.log(c.dim(" a convention you never adopted \u2014 that is our defect, not yours."));
2561
+ if (skipped.command) {
2562
+ console.log(c.dim(` \xB7 ${skipped.command} command gate(s) not run. rungs does not execute commands in a repo it is only reading.`));
2563
+ }
2564
+ if (skipped.undeclared.length) {
2565
+ 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(" ")}`));
2566
+ }
2567
+ if (skipped.unimplemented.length) {
2568
+ console.log(c.dim(` \xB7 ${skipped.unimplemented.length} declared gate(s) have no engine and were skipped, never passed: ${skipped.unimplemented.join(" ")}`));
2569
+ }
2570
+ for (const e of skipped.errored) {
2571
+ console.log(c.dim(` \xB7 ${e.gate} could not run here (${e.message}) \u2014 a fact about this pass, not about your repo.`));
2572
+ }
2573
+ console.log();
2574
+ }
1934
2575
  function cmdAdd(names, root, dryRun, harnesses, stamp) {
1935
2576
  const mods = loadAllModules(MODULES2);
1936
2577
  const { order, missing } = resolveInstallOrder(names, mods);
@@ -1963,9 +2604,50 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
1963
2604
  `));
1964
2605
  if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(", ")}
1965
2606
  `));
2607
+ const scanned = scanRepo(root);
2608
+ const paradigms = new Set(
2609
+ order.map((m) => detect(m, root, scanned)).filter((r) => r.state === "paradigm").map((r) => r.module)
2610
+ );
2611
+ const overridden = flags.has("--confirm-paradigm");
2612
+ const blocked = overridden ? /* @__PURE__ */ new Map() : blockedByParadigm(order, paradigms);
2613
+ if (overridden && paradigms.size) {
2614
+ for (const name of paradigms) {
2615
+ const p = detect(order.find((m) => m.name === name), root, scanned).paradigm;
2616
+ console.log(
2617
+ c.yellow(` ${name}: installing over an existing ${p.id}`) + c.dim(` (${p.matched[0]}) \u2014 --confirm-paradigm`)
2618
+ );
2619
+ }
2620
+ console.log(c.dim(" You will have two systems for one job. That is a choice, not a merge.\n"));
2621
+ }
2622
+ let toInstall = order;
2623
+ if (blocked.size) {
2624
+ for (const mod of order) {
2625
+ const cause = blocked.get(mod.name);
2626
+ if (!cause) continue;
2627
+ if (cause === mod.name) {
2628
+ const p = detect(mod, root, scanned).paradigm;
2629
+ console.log(c.yellow(` ${mod.name}: this repo already does this another way \u2014 ${p.id}`));
2630
+ console.log(c.dim(` matched ${p.matched[0]}`));
2631
+ for (const line of (p.note ?? "").trim().split("\n")) console.log(c.dim(` ${line || ""}`));
2632
+ if (p.compare) console.log(c.dim(` compare: ${p.compare}`));
2633
+ } else {
2634
+ console.log(c.yellow(` ${mod.name}: not installed \u2014 it requires ${cause}.`));
2635
+ }
2636
+ }
2637
+ toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
2638
+ const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
2639
+ if (dropped.length) {
2640
+ console.log(c.dim(` ${dropped.map((m) => m.name).join(", ")} not written \u2014 pulled in only for the above`));
2641
+ }
2642
+ console.log(
2643
+ c.dim(`
2644
+ Pass --confirm-paradigm to install anyway.`) + (toInstall.length ? c.dim(" Continuing with the rest.\n") : c.dim(" Nothing was written.\n"))
2645
+ );
2646
+ if (!toInstall.length) return 1;
2647
+ }
1966
2648
  const installed = [];
1967
2649
  const wrote = /* @__PURE__ */ new Map();
1968
- for (const mod of order) {
2650
+ for (const mod of toInstall) {
1969
2651
  if (mod.threshold?.confirm && !dryRun && !flags.has("--confirm-threshold")) {
1970
2652
  console.log(
1971
2653
  c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) + c.dim(" Skipped \u2014 pass --confirm-threshold to install it.\n")
@@ -2031,9 +2713,26 @@ rungs render \u2014 ${root}
2031
2713
  return 0;
2032
2714
  }
2033
2715
  function cmdCheck(root, tier, stamp) {
2034
- const runs = runGates(root, tier);
2716
+ let runs;
2717
+ try {
2718
+ runs = runGates(root, tier);
2719
+ } catch (e) {
2720
+ if (!(e instanceof UnknownTierError)) throw e;
2721
+ console.log(c.yellow(`
2722
+ unknown tier "${e.requested}"`) + c.dim(` \u2014 this repo declares ${e.declared.join(", ")}.`));
2723
+ console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
2724
+ return 1;
2725
+ }
2035
2726
  if (!runs.length) {
2036
- console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
2727
+ const runnable = loadRegistry(root).gates.filter((g) => !g.trigger);
2728
+ if (runnable.length && tier) {
2729
+ const tiers = [...new Set(runnable.map((g) => g.tier).filter(Boolean))];
2730
+ console.log(c.yellow(`
2731
+ no gates in the ${tier} tier \u2014 ${runnable.length} are registered`) + c.dim(` (${tiers.length ? tiers.join(", ") : "none tiered"}).`));
2732
+ console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
2733
+ } else {
2734
+ console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
2735
+ }
2037
2736
  return 1;
2038
2737
  }
2039
2738
  appendLedger(root, runs, stamp);
@@ -2060,26 +2759,67 @@ rungs check \u2014 ${root}${tier ? ` (${tier} tier)` : ""}
2060
2759
  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
2760
  );
2062
2761
  }
2762
+ console.log();
2763
+ return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
2764
+ }
2765
+ function reportLedger(root) {
2063
2766
  const { gates } = loadRegistry(root);
2064
2767
  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."));
2768
+ if (!q.neverFired.length && !q.alwaysFires.length) return;
2769
+ console.log(c.bold(` Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
2770
+ for (const g of q.neverFired.slice(0, 3)) {
2771
+ console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ""))}`);
2772
+ console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
2080
2773
  }
2081
- console.log();
2082
- return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
2774
+ for (const g of q.alwaysFires.slice(0, 3)) {
2775
+ console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
2776
+ }
2777
+ console.log(c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran"));
2778
+ console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
2779
+ console.log(c.dim(" directly, and CI runs, are not counted.\n"));
2780
+ }
2781
+ function cmdBacklogArchive(root, dryRun) {
2782
+ const record = readRecord(root);
2783
+ const configured = record?.modules["backlog"]?.params?.root;
2784
+ const backlogRoot = `docs/${configured ?? "backlog"}`;
2785
+ if (!existsSync10(join13(root, ...backlogRoot.split("/"), "items"))) {
2786
+ console.log(c.red(`
2787
+ no backlog at ${backlogRoot}/items
2788
+ `));
2789
+ return 1;
2790
+ }
2791
+ const plan = planArchive(root, backlogRoot);
2792
+ console.log(c.bold(`
2793
+ rungs backlog archive \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
2794
+ `));
2795
+ for (const h of plan.held) console.log(c.yellow(` held ${h.file}`) + c.dim(` \u2014 ${h.reason}`));
2796
+ if (plan.held.length) console.log();
2797
+ if (!plan.moves.length) {
2798
+ console.log(c.dim(" nothing to archive \u2014 no item is done or rejected.\n"));
2799
+ return 0;
2800
+ }
2801
+ const byStatus = /* @__PURE__ */ new Map();
2802
+ for (const m of plan.moves) byStatus.set(m.status, (byStatus.get(m.status) ?? 0) + 1);
2803
+ console.log(
2804
+ ` ${c.bold(String(plan.moves.length))} item(s) \u2014 ${[...byStatus].map(([s, n]) => `${n} ${s}`).join(" \xB7 ")}`
2805
+ );
2806
+ for (const m of plan.moves.slice(0, 5)) console.log(c.dim(` ${m.from} \u2192 ${m.to}`));
2807
+ if (plan.moves.length > 5) console.log(c.dim(` \u2026and ${plan.moves.length - 5} more`));
2808
+ const touched = plan.rewrites.filter((r) => r.links);
2809
+ const links = touched.reduce((n, r) => n + r.links, 0);
2810
+ console.log(`
2811
+ ${c.bold(String(links))} link(s) repointed across ${touched.length} file(s)`);
2812
+ for (const r of touched.slice(0, 5)) console.log(c.dim(` ${r.file} (${r.links})`));
2813
+ if (touched.length > 5) console.log(c.dim(` \u2026and ${touched.length - 5} more`));
2814
+ if (dryRun) {
2815
+ console.log(c.dim("\n Nothing written. Drop --dry-run to apply.\n"));
2816
+ return 0;
2817
+ }
2818
+ applyArchive(root, plan);
2819
+ console.log(c.green(`
2820
+ archived ${plan.moves.length} item(s)`) + c.dim(" \u2014 ids stay spent and every citation still resolves."));
2821
+ console.log(c.dim(" Run `rungs check` to confirm.\n"));
2822
+ return 0;
2083
2823
  }
2084
2824
  function cmdInit(root, profile, dryRun, harnesses, stamp) {
2085
2825
  if (readRecord(root)) {
@@ -2122,10 +2862,15 @@ rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
2122
2862
  console.log(` ${c.yellow("diverged")} ${f.rel} ${c.dim("\u2014 yours, left alone")}`);
2123
2863
  }
2124
2864
  }
2125
- if (apply && stale) {
2126
- const written = applyUpgrade(root, mods, record, plan);
2865
+ if (apply) {
2866
+ const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
2867
+ const parts = [
2868
+ written ? `${written} file(s)` : "",
2869
+ gates ? `${gates} gate registration(s)` : "",
2870
+ recorded ? `${recorded} record line(s)` : ""
2871
+ ].filter(Boolean);
2127
2872
  console.log(c.green(`
2128
- updated ${written} file(s)`));
2873
+ updated ${parts.length ? parts.join(" \xB7 ") : "nothing"}`));
2129
2874
  }
2130
2875
  console.log(
2131
2876
  `
@@ -2161,10 +2906,13 @@ var COMMANDS = [
2161
2906
  ["upgrade [path]", "move to newer module versions, never touching what you edited"],
2162
2907
  ["eject [path]", "materialise the engines; stop depending on rungs"],
2163
2908
  ["setup git [path]", "install the merge drivers .gitattributes names"],
2164
- ["modules", "list the module set and audit the manifests"]
2909
+ ["modules", "list the module set and audit the manifests"],
2910
+ ["backlog archive [path]", "move finished items to archive/, repointing every link"]
2165
2911
  ];
2166
2912
  var FLAGS = [
2167
2913
  ["--dry-run", "report what would happen, write nothing"],
2914
+ ["--explain", "doctor: also run the detectors over what this repo already has"],
2915
+ ["--confirm-paradigm", "add: install a module this repo already solves another way"],
2168
2916
  ["--into <path>", "add: install into this repo instead of the working directory"],
2169
2917
  ["--set m.param=value", "add/init: override a module parameter. Repeatable"],
2170
2918
  ["--confirm-threshold", "add: install a module whose rung is above this repo"],
@@ -2231,21 +2979,29 @@ switch (cmd) {
2231
2979
  case "modules":
2232
2980
  process.exit(cmdModules(flags.has("--params")));
2233
2981
  case "doctor":
2234
- process.exit(cmdDoctor(args[0] ?? process.cwd()));
2982
+ process.exit(cmdDoctor(args[0] ?? process.cwd(), flags.has("--explain")));
2983
+ case "backlog": {
2984
+ if (args[0] !== "archive") {
2985
+ console.log(c.red(`
2986
+ unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
2987
+ process.exit(1);
2988
+ }
2989
+ process.exit(cmdBacklogArchive(resolve4(args[1] ?? process.cwd()), flags.has("--dry-run")));
2990
+ }
2235
2991
  case "check": {
2236
2992
  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));
2993
+ process.exit(cmdCheck(resolve4(args[0] ?? process.cwd()), tier, STAMP));
2238
2994
  }
2239
2995
  case "init": {
2240
2996
  const profile = args[1] ?? "tracked";
2241
- process.exit(cmdInit(resolve3(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
2997
+ process.exit(cmdInit(resolve4(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
2242
2998
  }
2243
2999
  case "upgrade":
2244
- process.exit(cmdUpgrade(resolve3(args[0] ?? process.cwd()), flags.has("--apply")));
3000
+ process.exit(cmdUpgrade(resolve4(args[0] ?? process.cwd()), flags.has("--apply")));
2245
3001
  case "eject":
2246
- process.exit(cmdEject(resolve3(args[0] ?? process.cwd()), flags.has("--dry-run")));
3002
+ process.exit(cmdEject(resolve4(args[0] ?? process.cwd()), flags.has("--dry-run")));
2247
3003
  case "setup": {
2248
- const r = setupGit(resolve3(args[1] ?? process.cwd()), flags.has("--dry-run"));
3004
+ const r = setupGit(resolve4(args[1] ?? process.cwd()), flags.has("--dry-run"));
2249
3005
  console.log(
2250
3006
  r.drivers.length ? `
2251
3007
  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 +3009,11 @@ switch (cmd) {
2253
3009
  process.exit(0);
2254
3010
  }
2255
3011
  case "render":
2256
- process.exit(cmdRender(resolve3(args[0] ?? process.cwd()), HARNESSES, STAMP));
3012
+ process.exit(cmdRender(resolve4(args[0] ?? process.cwd()), HARNESSES, STAMP));
2257
3013
  case "add": {
2258
3014
  const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
2259
3015
  const names = flags.has("--into") ? args.slice(0, -1) : args;
2260
- process.exit(cmdAdd(names, resolve3(target), flags.has("--dry-run"), HARNESSES, STAMP));
3016
+ process.exit(cmdAdd(names, resolve4(target), flags.has("--dry-run"), HARNESSES, STAMP));
2261
3017
  }
2262
3018
  default: {
2263
3019
  const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";