@rungs/cli 0.3.1 → 0.4.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 (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2184 -478
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +18 -29
  62. package/src/engines2.ts +403 -20
  63. package/src/engines3.ts +111 -20
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +86 -27
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { fileURLToPath as fileURLToPath4 } from "node:url";
5
- import { dirname as dirname9, join as join14, resolve as resolve5 } from "node:path";
4
+ import { fileURLToPath as fileURLToPath5 } from "node:url";
5
+ import { dirname as dirname11, join as join15, resolve as resolve7 } from "node:path";
6
6
 
7
7
  // src/manifest.ts
8
8
  import { readdirSync as readdirSync2, readFileSync, statSync as statSync2 } from "node:fs";
@@ -112,9 +112,27 @@ function loadManifest(dir) {
112
112
  dir
113
113
  };
114
114
  const p = manifest.provenance;
115
- if (!p?.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
116
- if (!p?.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
117
- if (!p?.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
115
+ if (!p) throw new Error(`${name}: [provenance] is required`);
116
+ const kind = p.kind ?? "extracted";
117
+ if (kind !== "extracted" && kind !== "designed") {
118
+ throw new Error(`${name}: [provenance].kind must be 'extracted' or 'designed', not '${kind}'`);
119
+ }
120
+ if (kind === "extracted") {
121
+ if (!p.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
122
+ if (!p.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
123
+ if (!p.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
124
+ } else {
125
+ if (!p.rationale?.trim()) {
126
+ throw new Error(`${name}: [provenance].rationale is required when kind = "designed" \u2014 say why it exists, in the first person`);
127
+ }
128
+ if (p.incident?.trim()) {
129
+ throw new Error(`${name}: [provenance].incident belongs to an extracted module; a designed one has no incident to name`);
130
+ }
131
+ if (p.sources?.length) {
132
+ throw new Error(`${name}: [provenance].sources belongs to an extracted module; cite influences in the rationale instead`);
133
+ }
134
+ }
135
+ manifest.provenance = { ...p, kind };
118
136
  return manifest;
119
137
  }
120
138
  function loadAllModules(modulesRoot) {
@@ -175,16 +193,18 @@ function auditModules(mods) {
175
193
  }
176
194
 
177
195
  // src/detect.ts
178
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
196
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "node:fs";
179
197
  import { join as join4 } from "node:path";
180
198
 
181
199
  // src/add.ts
182
- import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
183
- import { dirname, join as join3 } from "node:path";
200
+ import { existsSync, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
201
+ import { dirname as dirname3, join as join3 } from "node:path";
184
202
  import { createHash } from "node:crypto";
185
203
 
186
204
  // src/substitute.ts
187
- import { basename, resolve } from "node:path";
205
+ import { readFileSync as readFileSync2 } from "node:fs";
206
+ import { basename, dirname, resolve } from "node:path";
207
+ import { fileURLToPath } from "node:url";
188
208
  function substitute(text, module, params) {
189
209
  return text.replace(/(^|[^$])\{\{([a-z_.]+)\}\}/g, (whole, lead, ref) => {
190
210
  const [a, b] = ref.includes(".") ? ref.split(".") : [module, ref];
@@ -201,8 +221,12 @@ function format(v) {
201
221
  function repoFacts(repoRoot) {
202
222
  return repoRoot ? { dirname: basename(resolve(repoRoot)) } : {};
203
223
  }
224
+ function rungsFacts() {
225
+ const packageJson = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
226
+ return { version: JSON.parse(readFileSync2(packageJson, "utf8")).version };
227
+ }
204
228
  function resolveParams(mods, overrides = {}, repoRoot) {
205
- const out = { repo: repoFacts(repoRoot) };
229
+ const out = {};
206
230
  for (const m of mods) {
207
231
  out[m.name] = {};
208
232
  for (const [k, spec] of Object.entries(m.params)) out[m.name][k] = spec.default;
@@ -210,6 +234,8 @@ function resolveParams(mods, overrides = {}, repoRoot) {
210
234
  for (const [mod, vals] of Object.entries(overrides)) {
211
235
  out[mod] = { ...out[mod] ?? {}, ...vals };
212
236
  }
237
+ out.repo = repoFacts(repoRoot);
238
+ out.rungs = rungsFacts();
213
239
  for (const m of mods) {
214
240
  for (const [k, v] of Object.entries(out[m.name])) {
215
241
  if (typeof v === "string" && v.includes("{{")) out[m.name][k] = substitute(v, m.name, out);
@@ -224,60 +250,331 @@ function markers(targetPath2, module, version) {
224
250
  return hash ? { begin: `# rungs:begin ${module}@${version}`, end: `# rungs:end ${module}` } : { begin: `<!-- rungs:begin ${module}@${version} -->`, end: `<!-- rungs:end ${module} -->` };
225
251
  }
226
252
  function mergeBlock(existing, fragment, module) {
227
- const beginRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:begin ${module}(?:@[\\w.\\-]+)?\\s*(?:-->)?[ \\t]*$`, "m");
228
- const endRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:end ${module}\\s*(?:-->)?[ \\t]*$`, "m");
253
+ const beginRe = new RegExp(
254
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:begin ${module}(?:@[\\w.\\-]+)?[ \\t]*(?:-->)?[ \\t]*$`,
255
+ "m"
256
+ );
257
+ const endRe = new RegExp(
258
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:end ${module}[ \\t]*(?:-->)?[ \\t]*$`,
259
+ "m"
260
+ );
229
261
  const b = existing.match(beginRe);
230
262
  const e = existing.match(endRe);
231
263
  if (b && e && b.index !== void 0 && e.index !== void 0 && e.index > b.index) {
232
264
  const before = existing.slice(0, b.index);
233
265
  const after = existing.slice(e.index + e[0].length);
234
- return `${before}${fragment.trim()}${after}`;
235
- }
236
- const sep3 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
237
- return `${existing}${sep3}${fragment.trim()}
266
+ const current = existing.slice(b.index, e.index + e[0].length);
267
+ const normalise = (value) => value.replace(/\r\n|\r|\n/g, "\n");
268
+ const replacement = fragment.trim();
269
+ if (normalise(current) === normalise(replacement)) return existing;
270
+ const newline = current.match(/\r\n|\r|\n/)?.[0] ?? existing.match(/\r\n|\r|\n/)?.[0] ?? "\n";
271
+ return `${before}${normalise(replacement).replace(/\n/g, newline)}${after}`;
272
+ }
273
+ const sep4 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
274
+ return `${existing}${sep4}${fragment.trim()}
238
275
  `;
239
276
  }
240
277
 
278
+ // src/emitted-path.ts
279
+ import { lstatSync, realpathSync, statSync as statSync3 } from "node:fs";
280
+ import { basename as basename2, dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve2, sep as sep2, win32 } from "node:path";
281
+
282
+ // src/storage-key.ts
283
+ function canonicalCaselessSegmentKey(segment) {
284
+ return segment.normalize("NFKD").toLowerCase().toUpperCase().normalize("NFKD");
285
+ }
286
+ function canonicalCaselessSegmentEqual(left, right) {
287
+ return canonicalCaselessSegmentKey(left) === canonicalCaselessSegmentKey(right);
288
+ }
289
+
290
+ // src/emitted-path.ts
291
+ var UnsafeEmittedPathError = class extends Error {
292
+ moduleName;
293
+ target;
294
+ reason;
295
+ constructor(moduleName, target, reason) {
296
+ super(`module '${moduleName}' emitted unsafe target ${JSON.stringify(target)}: ${reason}`);
297
+ this.name = "UnsafeEmittedPathError";
298
+ this.moduleName = moduleName;
299
+ this.target = target;
300
+ this.reason = reason;
301
+ }
302
+ };
303
+ var missingEntry = (error) => error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
304
+ function hasUnpairedUtf16Surrogate(value) {
305
+ for (let i = 0; i < value.length; i++) {
306
+ const unit = value.charCodeAt(i);
307
+ if (unit >= 55296 && unit <= 56319) {
308
+ const next = value.charCodeAt(i + 1);
309
+ if (!(next >= 56320 && next <= 57343)) return true;
310
+ i++;
311
+ continue;
312
+ }
313
+ if (unit >= 56320 && unit <= 57343) return true;
314
+ }
315
+ return false;
316
+ }
317
+ function canonicalCaselessAncestor(ancestor, descendant) {
318
+ const ancestorSegments = ancestor.split(sep2);
319
+ const descendantSegments = descendant.split(sep2);
320
+ return ancestorSegments.length < descendantSegments.length && ancestorSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, descendantSegments[index]));
321
+ }
322
+ function canonicalCaselessPathEqual(left, right) {
323
+ const leftSegments = left.split(sep2);
324
+ const rightSegments = right.split(sep2);
325
+ return leftSegments.length === rightSegments.length && leftSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, rightSegments[index]));
326
+ }
327
+ function canonicalWithMissing(path, moduleName, target) {
328
+ let cursor = resolve2(path);
329
+ const suffix = [];
330
+ for (; ; ) {
331
+ try {
332
+ lstatSync(cursor);
333
+ } catch (error) {
334
+ if (!missingEntry(error)) {
335
+ throw new UnsafeEmittedPathError(moduleName, target, "its existing ancestor cannot be inspected");
336
+ }
337
+ const parent = dirname2(cursor);
338
+ if (parent === cursor) {
339
+ throw new UnsafeEmittedPathError(moduleName, target, "no canonical existing ancestor can be established");
340
+ }
341
+ suffix.unshift(basename2(cursor));
342
+ cursor = parent;
343
+ continue;
344
+ }
345
+ let canonical;
346
+ try {
347
+ canonical = realpathSync.native(cursor);
348
+ } catch {
349
+ throw new UnsafeEmittedPathError(moduleName, target, "its existing ancestor cannot be resolved canonically");
350
+ }
351
+ if (suffix.length) {
352
+ try {
353
+ if (!statSync3(canonical).isDirectory()) {
354
+ throw new UnsafeEmittedPathError(moduleName, target, "its deepest existing ancestor is not a directory");
355
+ }
356
+ } catch (error) {
357
+ if (error instanceof UnsafeEmittedPathError) throw error;
358
+ throw new UnsafeEmittedPathError(moduleName, target, "its existing ancestor cannot be inspected");
359
+ }
360
+ }
361
+ return resolve2(canonical, ...suffix);
362
+ }
363
+ }
364
+ function resolveEmittedPath(repoRoot, moduleName, target) {
365
+ if (!target || target.includes("\0")) {
366
+ throw new UnsafeEmittedPathError(moduleName, target, "a non-empty portable relative file path is required");
367
+ }
368
+ const portable = target.replace(/\\/g, "/");
369
+ if (portable.startsWith("/") || win32.isAbsolute(target) || /^[A-Za-z]:/.test(portable)) {
370
+ throw new UnsafeEmittedPathError(moduleName, target, "absolute, rooted, and drive-relative paths are not allowed");
371
+ }
372
+ const segments = portable.split("/");
373
+ if (segments.includes("..")) {
374
+ throw new UnsafeEmittedPathError(moduleName, target, "parent traversal ('..') is not allowed");
375
+ }
376
+ if (segments.some((segment) => segment === "" || segment === ".")) {
377
+ throw new UnsafeEmittedPathError(moduleName, target, "empty and current-directory ('.') path segments are not allowed");
378
+ }
379
+ if (segments.some(hasUnpairedUtf16Surrogate)) {
380
+ throw new UnsafeEmittedPathError(
381
+ moduleName,
382
+ target,
383
+ "unpaired UTF-16 surrogate code units are not allowed in path segments"
384
+ );
385
+ }
386
+ if (segments.some((segment) => /[\u0000-\u001f<>:"|?*]/.test(segment) || /[ .]$/.test(segment))) {
387
+ throw new UnsafeEmittedPathError(
388
+ moduleName,
389
+ target,
390
+ "it contains a character or trailing suffix that is not a portable filename"
391
+ );
392
+ }
393
+ const windowsDevice = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
394
+ if (segments.some((segment) => windowsDevice.test(segment))) {
395
+ throw new UnsafeEmittedPathError(moduleName, target, "Windows device-name path segments are not allowed");
396
+ }
397
+ const canonicalRoot = canonicalWithMissing(repoRoot, moduleName, target);
398
+ const lexicalDestination = resolve2(repoRoot, ...segments);
399
+ const canonicalDestination = canonicalWithMissing(lexicalDestination, moduleName, target);
400
+ const fromRoot = relative2(canonicalRoot, canonicalDestination);
401
+ if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep2}`) || isAbsolute(fromRoot)) {
402
+ throw new UnsafeEmittedPathError(moduleName, target, "it resolves outside the canonical consumer repository");
403
+ }
404
+ let leafAlias = false;
405
+ try {
406
+ leafAlias = lstatSync(lexicalDestination).isSymbolicLink();
407
+ } catch (error) {
408
+ if (!missingEntry(error)) {
409
+ throw new UnsafeEmittedPathError(moduleName, target, "its destination cannot be inspected");
410
+ }
411
+ }
412
+ return { target: portable, absolute: canonicalDestination, leafAlias };
413
+ }
414
+ function preflightEmittedPaths(repoRoot, candidates) {
415
+ const resolved = candidates.map((candidate) => {
416
+ const destination = resolveEmittedPath(repoRoot, candidate.moduleName, candidate.target);
417
+ if (candidate.writeExisting) {
418
+ if (destination.leafAlias) {
419
+ throw new UnsafeEmittedPathError(
420
+ candidate.moduleName,
421
+ candidate.target,
422
+ "the destination is a symlink or junction leaf and this operation will not write through it"
423
+ );
424
+ }
425
+ try {
426
+ const leaf = lstatSync(destination.absolute);
427
+ if (!leaf.isFile()) {
428
+ throw new UnsafeEmittedPathError(
429
+ candidate.moduleName,
430
+ candidate.target,
431
+ "the existing destination is not a regular file"
432
+ );
433
+ }
434
+ if (leaf.nlink > 1) {
435
+ throw new UnsafeEmittedPathError(
436
+ candidate.moduleName,
437
+ candidate.target,
438
+ "the existing destination has multiple hard links and this operation will not overwrite it"
439
+ );
440
+ }
441
+ } catch (error) {
442
+ if (error instanceof UnsafeEmittedPathError) throw error;
443
+ if (!missingEntry(error)) {
444
+ throw new UnsafeEmittedPathError(
445
+ candidate.moduleName,
446
+ candidate.target,
447
+ "the destination cannot be inspected before writing"
448
+ );
449
+ }
450
+ }
451
+ }
452
+ return destination;
453
+ });
454
+ const seen = [];
455
+ for (let i = 0; i < candidates.length; i++) {
456
+ const candidate = candidates[i];
457
+ const destination = resolved[i];
458
+ const prior = seen.find((entry) => canonicalCaselessPathEqual(entry.resolved.absolute, destination.absolute));
459
+ if (!prior) {
460
+ const structural = seen.find(
461
+ (entry) => canonicalCaselessAncestor(entry.resolved.absolute, destination.absolute) || canonicalCaselessAncestor(destination.absolute, entry.resolved.absolute)
462
+ );
463
+ if (!structural) {
464
+ seen.push({ candidate, resolved: destination });
465
+ continue;
466
+ }
467
+ throw new UnsafeEmittedPathError(
468
+ candidate.moduleName,
469
+ candidate.target,
470
+ `it has a file/descendant collision with module '${structural.candidate.moduleName}' target '${structural.candidate.target}' after canonical resolution`
471
+ );
472
+ }
473
+ if (candidate.shared && prior.candidate.shared && destination.target === prior.resolved.target) continue;
474
+ throw new UnsafeEmittedPathError(
475
+ candidate.moduleName,
476
+ candidate.target,
477
+ `it collides with module '${prior.candidate.moduleName}' target '${prior.candidate.target}' after canonical resolution`
478
+ );
479
+ }
480
+ return resolved;
481
+ }
482
+
241
483
  // src/add.ts
242
484
  var FRAGMENT_TARGET = {
243
485
  "AGENTS.md": "AGENTS.md",
244
486
  gitignore: ".gitignore",
245
487
  gitattributes: ".gitattributes"
246
488
  };
489
+ var RESERVED_SHARED_SINKS = /* @__PURE__ */ new Set([
490
+ "AGENTS.md",
491
+ "CLAUDE.md",
492
+ ".gitignore",
493
+ ".gitattributes",
494
+ ".ai/gates.toml"
495
+ ]);
496
+ var SHARED_FILE_OWNERS = /* @__PURE__ */ new Map([
497
+ ["instructions", /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md"])],
498
+ ["gates", /* @__PURE__ */ new Set([".ai/gates.toml"])]
499
+ ]);
500
+ function ownsSharedFile(mod, sourceTarget, target) {
501
+ return sourceTarget === target && (SHARED_FILE_OWNERS.get(mod.name)?.has(sourceTarget) ?? false);
502
+ }
503
+ function reservedSharedSinkCandidates() {
504
+ return [...RESERVED_SHARED_SINKS].map((target) => ({
505
+ moduleName: "rungs reserved shared sink",
506
+ target,
507
+ shared: true
508
+ }));
509
+ }
510
+ function fileEmissions(mod, params, skillsDir = ".claude/skills") {
511
+ const out = [];
512
+ const sub = (text) => substitute(text, mod.name, params);
513
+ for (const [dir, prefix, disposition] of [
514
+ ["files", "", "create"],
515
+ ["rules", ".ai/rules/", "rule"],
516
+ ["skills", `${skillsDir}/`, "skill"]
517
+ ]) {
518
+ const base = join3(mod.dir, dir);
519
+ if (!existsSync(base)) continue;
520
+ for (const rel of walk(base)) {
521
+ const sourceTarget = (prefix + rel).replace(/\\/g, "/");
522
+ const target = sub(sourceTarget).replace(/\\/g, "/");
523
+ let content = sub(readFileSync3(join3(base, rel), "utf8"));
524
+ if (dir === "skills") content = withOptedInExtensions(mod, rel, content);
525
+ out.push({ target, content, disposition, shared: ownsSharedFile(mod, sourceTarget, target) });
526
+ }
527
+ }
528
+ return out;
529
+ }
530
+ function fragmentTargets(mod) {
531
+ const base = join3(mod.dir, "fragments");
532
+ if (!existsSync(base)) return [];
533
+ return walk(base).map((rel) => FRAGMENT_TARGET[rel]).filter((target) => Boolean(target));
534
+ }
535
+ function moduleTargets(mod, params, skillsDir, files = fileEmissions(mod, params, skillsDir)) {
536
+ return [
537
+ ...files.map((file) => ({ moduleName: mod.name, target: file.target, shared: file.shared })),
538
+ ...fragmentTargets(mod).map((target) => ({ moduleName: mod.name, target, shared: true, writeExisting: true })),
539
+ ...mod.gates.length ? [{ moduleName: mod.name, target: ".ai/gates.toml", shared: true, writeExisting: true }] : []
540
+ ];
541
+ }
542
+ function moduleEmissionCandidates(mods, params, skillsDir = ".claude/skills") {
543
+ return [
544
+ ...reservedSharedSinkCandidates(),
545
+ ...mods.flatMap((mod) => moduleTargets(mod, params, skillsDir))
546
+ ];
547
+ }
548
+ function preflightModuleEmissions(mods, repoRoot, params, skillsDir = ".claude/skills") {
549
+ preflightEmittedPaths(repoRoot, moduleEmissionCandidates(mods, params, skillsDir));
550
+ }
551
+ function prospectiveRuleEmissions(mods, params, skillsDir = ".claude/skills") {
552
+ return mods.flatMap(
553
+ (mod) => fileEmissions(mod, params, skillsDir).filter((file) => file.disposition === "rule").map((file) => ({ moduleName: mod.name, target: file.target, content: file.content }))
554
+ );
555
+ }
247
556
  function addModule(mod, repoRoot, params, opts = {}) {
248
557
  const actions = [];
558
+ const skillsDir = opts.skillsDir ?? ".claude/skills";
559
+ const files = fileEmissions(mod, params, skillsDir);
560
+ const targets = moduleTargets(mod, params, skillsDir, files);
561
+ const reservations = reservedSharedSinkCandidates();
562
+ const resolved = preflightEmittedPaths(repoRoot, [...reservations, ...targets]).slice(reservations.length);
563
+ const destinations = new Map(targets.map((candidate, index) => [candidate.target, resolved[index].absolute]));
249
564
  const write = (rel, content, disposition) => {
250
- const full = join3(repoRoot, rel);
565
+ const full = destinations.get(rel);
251
566
  if (existsSync(full)) {
252
567
  actions.push({ disposition: "skip-exists", target: rel, note: "already present \u2014 left alone" });
253
568
  return;
254
569
  }
255
570
  actions.push({ disposition, target: rel });
256
571
  if (opts.dryRun) return;
257
- mkdirSync(dirname(full), { recursive: true });
572
+ mkdirSync(dirname3(full), { recursive: true });
258
573
  writeFileSync(full, content);
259
574
  };
260
575
  const sub = (text) => substitute(text, mod.name, params);
261
576
  const has = (d) => existsSync(join3(mod.dir, d));
262
- if (has("files")) {
263
- const base = join3(mod.dir, "files");
264
- for (const rel of walk(base)) {
265
- write(sub(rel), sub(readFileSync2(join3(base, rel), "utf8")), "create");
266
- }
267
- }
268
- if (has("rules")) {
269
- const base = join3(mod.dir, "rules");
270
- for (const rel of walk(base)) {
271
- write(join3(".ai", "rules", rel).split("\\").join("/"), sub(readFileSync2(join3(base, rel), "utf8")), "rule");
272
- }
273
- }
274
- if (has("skills")) {
275
- const base = join3(mod.dir, "skills");
276
- const dir = opts.skillsDir ?? ".claude/skills";
277
- for (const rel of walk(base)) {
278
- write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync2(join3(base, rel), "utf8"))), "skill");
279
- }
280
- }
577
+ for (const file of files) write(file.target, file.content, file.disposition);
281
578
  if (has("fragments")) {
282
579
  const base = join3(mod.dir, "fragments");
283
580
  for (const rel of walk(base)) {
@@ -286,9 +583,9 @@ function addModule(mod, repoRoot, params, opts = {}) {
286
583
  actions.push({ disposition: "merge", target: rel, note: "unknown fragment target \u2014 skipped" });
287
584
  continue;
288
585
  }
289
- const full = join3(repoRoot, target);
290
- const existing = existsSync(full) ? readFileSync2(full, "utf8") : "";
291
- const fragment = sub(readFileSync2(join3(base, rel), "utf8"));
586
+ const full = destinations.get(target);
587
+ const existing = existsSync(full) ? readFileSync3(full, "utf8") : "";
588
+ const fragment = sub(readFileSync3(join3(base, rel), "utf8"));
292
589
  const merged = mergeBlock(existing, fragment, mod.name);
293
590
  actions.push({
294
591
  disposition: "merge",
@@ -296,7 +593,7 @@ function addModule(mod, repoRoot, params, opts = {}) {
296
593
  note: existing.includes(`rungs:begin ${mod.name}`) ? "block replaced" : "block appended"
297
594
  });
298
595
  if (!opts.dryRun) {
299
- mkdirSync(dirname(full), { recursive: true });
596
+ mkdirSync(dirname3(full), { recursive: true });
300
597
  writeFileSync(full, merged);
301
598
  }
302
599
  }
@@ -305,9 +602,14 @@ function addModule(mod, repoRoot, params, opts = {}) {
305
602
  }
306
603
  function registerGates(mods, repoRoot, dryRun = false, adopted = []) {
307
604
  const actions = [];
308
- const registry = join3(repoRoot, ".ai", "gates.toml");
605
+ const owners = [...adopted.length ? ["adopted"] : [], ...mods.filter((mod) => mod.gates.length).map((mod) => mod.name)];
606
+ const destinations = preflightEmittedPaths(
607
+ repoRoot,
608
+ owners.map((moduleName) => ({ moduleName, target: ".ai/gates.toml", shared: true, writeExisting: true }))
609
+ );
610
+ const registry = destinations[0]?.absolute ?? join3(repoRoot, ".ai", "gates.toml");
309
611
  if (adopted.length) {
310
- const existing = existsSync(registry) ? readFileSync2(registry, "utf8") : "";
612
+ const existing = existsSync(registry) ? readFileSync3(registry, "utf8") : "";
311
613
  const { begin, end } = markers("gates.toml", "adopted", "1.0.0");
312
614
  const body = [
313
615
  begin,
@@ -327,18 +629,18 @@ why = """Adopted from ${a.source}. Predates rungs and is owned by this repo.
327
629
  ].join("\n");
328
630
  actions.push({ disposition: "gate", target: ".ai/gates.toml", note: `adopted: ${adopted.length} entries` });
329
631
  if (!dryRun) {
330
- mkdirSync(dirname(registry), { recursive: true });
632
+ mkdirSync(dirname3(registry), { recursive: true });
331
633
  writeFileSync(registry, mergeBlock(existing, body, "adopted"));
332
634
  }
333
635
  }
334
636
  for (const mod of mods) {
335
637
  if (!mod.gates.length) continue;
336
- const existing = existsSync(registry) ? readFileSync2(registry, "utf8") : "";
638
+ const existing = existsSync(registry) ? readFileSync3(registry, "utf8") : "";
337
639
  const { begin, end } = markers("gates.toml", mod.name, mod.version);
338
640
  const body = [begin, ...mod.gates.map(gateEntry(mod)), end].join("\n");
339
641
  actions.push({ disposition: "gate", target: ".ai/gates.toml", note: `${mod.name}: ${mod.gates.length} entries` });
340
642
  if (dryRun) continue;
341
- mkdirSync(dirname(registry), { recursive: true });
643
+ mkdirSync(dirname3(registry), { recursive: true });
342
644
  writeFileSync(registry, mergeBlock(existing, body, mod.name));
343
645
  }
344
646
  return actions;
@@ -366,6 +668,29 @@ function blockedByParadigm(order, paradigms) {
366
668
  }
367
669
  return blocked;
368
670
  }
671
+ function blockedByConflict(order, present, all) {
672
+ const byName = new Map(all.map((m) => [m.name, m]));
673
+ const blocked = /* @__PURE__ */ new Map();
674
+ const partner = (mod) => {
675
+ const declared = mod.conflicts.find((other) => other !== mod.name && present.has(other));
676
+ if (declared) return declared;
677
+ for (const other of present) {
678
+ if (other === mod.name) continue;
679
+ if (byName.get(other)?.conflicts.includes(mod.name)) return other;
680
+ }
681
+ return void 0;
682
+ };
683
+ for (const mod of order) {
684
+ const against = partner(mod);
685
+ if (against) {
686
+ blocked.set(mod.name, { cause: mod.name, with: against });
687
+ continue;
688
+ }
689
+ const dep = mod.requires.find((d) => blocked.has(d));
690
+ if (dep) blocked.set(mod.name, blocked.get(dep));
691
+ }
692
+ return blocked;
693
+ }
369
694
  function resolveInstallOrder(requested, all) {
370
695
  const byName = new Map(all.map((m) => [m.name, m]));
371
696
  const order = [];
@@ -398,7 +723,6 @@ function resolveInstallOrder(requested, all) {
398
723
  return { order, missing };
399
724
  }
400
725
  var contentHash = (s) => createHash("sha256").update(s.replace(/\r\n/g, "\n")).digest("hex").slice(0, 12);
401
- var SHARED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes", ".ai/gates.toml"]);
402
726
  function withOptedInExtensions(mod, rel, content) {
403
727
  const name = rel.split(/[\\/]/)[0];
404
728
  const extensions = mod.skills?.[name]?.extensions;
@@ -414,21 +738,8 @@ ${added.join("\n")}
414
738
  }
415
739
  function emittedFiles(mod, params, skillsDir = ".claude/skills") {
416
740
  const out = /* @__PURE__ */ new Map();
417
- const sub = (t) => substitute(t, mod.name, params);
418
- for (const [dir, prefix] of [
419
- ["files", ""],
420
- ["rules", ".ai/rules/"],
421
- ["skills", `${skillsDir}/`]
422
- ]) {
423
- const base = join3(mod.dir, dir);
424
- if (!existsSync(base)) continue;
425
- for (const rel of walk(base)) {
426
- const target = sub(prefix + rel).split("\\").join("/");
427
- if (SHARED.has(target)) continue;
428
- let content = sub(readFileSync2(join3(base, rel), "utf8"));
429
- if (dir === "skills") content = withOptedInExtensions(mod, rel, content);
430
- out.set(target, content);
431
- }
741
+ for (const file of fileEmissions(mod, params, skillsDir)) {
742
+ if (!file.shared) out.set(file.target, file.content);
432
743
  }
433
744
  return out;
434
745
  }
@@ -457,8 +768,9 @@ function writeInstallRecord(repoRoot, mods, params, harnesses, stamp, skillsDir
457
768
  lines.push(`params = { ${Object.entries(p).map(([k, v]) => `${k} = ${JSON.stringify(v ?? "")}`).join(", ")} }`);
458
769
  }
459
770
  const emitted = emittedFiles(m, params, skillsDir);
460
- const created = [...emitted].filter(([rel]) => wroteByModule?.get(m.name)?.has(rel) ?? existsSync(join3(repoRoot, rel)));
461
- const kept = [...emitted].filter(([rel]) => !created.some(([c2]) => c2 === rel) && existsSync(join3(repoRoot, rel)));
771
+ const exists = (rel) => existsSync(resolveEmittedPath(repoRoot, m.name, rel).absolute);
772
+ const created = [...emitted].filter(([rel]) => wroteByModule?.get(m.name)?.has(rel) ?? exists(rel));
773
+ const kept = [...emitted].filter(([rel]) => !created.some(([c2]) => c2 === rel) && exists(rel));
462
774
  if (created.length) {
463
775
  lines.push(`[modules.${m.name}.hashes]`);
464
776
  for (const [rel, content] of created) lines.push(`"${rel}" = "${contentHash(content)}"`);
@@ -469,7 +781,10 @@ function writeInstallRecord(repoRoot, mods, params, harnesses, stamp, skillsDir
469
781
  }
470
782
  lines.push("");
471
783
  }
472
- writeFileSync(join3(repoRoot, ".ai", "rungs.toml"), lines.join("\n"));
784
+ const record = preflightEmittedPaths(repoRoot, [
785
+ { moduleName: "rungs", target: ".ai/rungs.toml", writeExisting: true }
786
+ ])[0];
787
+ writeFileSync(record.absolute, lines.join("\n"));
473
788
  }
474
789
  function adoptableGates(files, patterns, repoRoot) {
475
790
  const runner = { ".mjs": "node", ".js": "node", ".ps1": "pwsh -File", ".sh": "bash" };
@@ -519,7 +834,7 @@ function detect(mod, repoRoot, files, installed) {
519
834
  for (const rel of candidates) {
520
835
  let text;
521
836
  try {
522
- text = readFileSync3(join4(repoRoot, rel), "utf8");
837
+ text = readFileSync4(join4(repoRoot, rel), "utf8");
523
838
  } catch {
524
839
  continue;
525
840
  }
@@ -575,7 +890,7 @@ function infer(mod, repoRoot, files) {
575
890
  if (excluded.has(rel)) continue;
576
891
  let text;
577
892
  try {
578
- text = readFileSync3(join4(repoRoot, rel), "utf8");
893
+ text = readFileSync4(join4(repoRoot, rel), "utf8");
579
894
  } catch {
580
895
  continue;
581
896
  }
@@ -590,7 +905,7 @@ function infer(mod, repoRoot, files) {
590
905
  if (excluded.has(rel)) continue;
591
906
  let text;
592
907
  try {
593
- text = readFileSync3(join4(repoRoot, rel), "utf8");
908
+ text = readFileSync4(join4(repoRoot, rel), "utf8");
594
909
  } catch {
595
910
  continue;
596
911
  }
@@ -622,7 +937,9 @@ function scanRepo(repoRoot) {
622
937
  }
623
938
  function ownedState(mod, repoRoot, installed) {
624
939
  const params = installed.params_all ?? {};
625
- const emitted = emittedFiles(mod, params, installed.skillsDir ?? ".claude/skills");
940
+ const skillsDir = installed.skillsDir ?? ".claude/skills";
941
+ preflightModuleEmissions([mod], repoRoot, params, skillsDir);
942
+ const emitted = emittedFiles(mod, params, skillsDir);
626
943
  const kept = new Set(installed.kept?.files ?? []);
627
944
  const out = {
628
945
  version: installed.version,
@@ -637,12 +954,17 @@ function ownedState(mod, repoRoot, installed) {
637
954
  out.kept.push(rel);
638
955
  continue;
639
956
  }
640
- const full = join4(repoRoot, rel);
957
+ const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
958
+ const full = resolved.absolute;
959
+ if (resolved.leafAlias) {
960
+ out.diverged.push(rel);
961
+ continue;
962
+ }
641
963
  if (!existsSync2(full)) {
642
964
  out.missing.push(rel);
643
965
  continue;
644
966
  }
645
- const onDisk = contentHash(readFileSync3(full, "utf8"));
967
+ const onDisk = contentHash(readFileSync4(full, "utf8"));
646
968
  if (onDisk === contentHash(wouldEmit)) out.current.push(rel);
647
969
  else if (installed.hashes?.[rel] && onDisk === installed.hashes[rel]) out.stale.push(rel);
648
970
  else out.diverged.push(rel);
@@ -651,11 +973,18 @@ function ownedState(mod, repoRoot, installed) {
651
973
  }
652
974
 
653
975
  // src/render.ts
654
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
655
- import { dirname as dirname2, join as join5 } from "node:path";
976
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
977
+ import { dirname as dirname4, join as join5 } from "node:path";
978
+
979
+ // src/text.ts
980
+ function semanticText(text) {
981
+ return text.replace(/\r\n?/g, "\n");
982
+ }
983
+
984
+ // src/render.ts
656
985
  var DO_NOT_EDIT = (source) => `Generated by \`rungs render\` from ${source}. Do not edit \u2014 your changes are overwritten.`;
657
986
  function readRules(repoRoot) {
658
- const dir = join5(repoRoot, ".ai", "rules");
987
+ const dir = resolveEmittedPath(repoRoot, "render", ".ai/rules").absolute;
659
988
  const rules = [];
660
989
  let files;
661
990
  try {
@@ -664,20 +993,24 @@ function readRules(repoRoot) {
664
993
  return rules;
665
994
  }
666
995
  for (const rel of files) {
667
- const raw = readFileSync4(join5(dir, rel), "utf8");
668
- const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
669
- if (!m) continue;
670
- const [, fm, body] = m;
671
- rules.push({
672
- file: rel,
673
- description: scalar(fm, "description"),
674
- paths: list(fm, "paths"),
675
- enforcement: scalar(fm, "enforcement"),
676
- body: body.trim()
677
- });
996
+ const raw = semanticText(readFileSync5(join5(dir, rel), "utf8"));
997
+ const rule = parseRule(rel, raw);
998
+ if (rule) rules.push(rule);
678
999
  }
679
1000
  return rules;
680
1001
  }
1002
+ function parseRule(file, raw) {
1003
+ const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1004
+ if (!m) return null;
1005
+ const [, fm, body] = m;
1006
+ return {
1007
+ file,
1008
+ description: scalar(fm, "description"),
1009
+ paths: list(fm, "paths"),
1010
+ enforcement: scalar(fm, "enforcement"),
1011
+ body: body.trim()
1012
+ };
1013
+ }
681
1014
  function scalar(fm, key) {
682
1015
  const folded = fm.match(new RegExp(`^${key}:\\s*>-?\\s*\\n([\\s\\S]*?)(?=\\n\\S|$)`, "m"));
683
1016
  if (folded) return folded[1].split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
@@ -767,10 +1100,24 @@ function commonDirPrefix(paths) {
767
1100
  const first = dirs[0];
768
1101
  return dirs.every((d) => d === first) && first.includes("/") ? first : null;
769
1102
  }
770
- function render(repoRoot, harnesses) {
1103
+ function prepareRender(repoRoot, harnesses, prospective = [], preceding = []) {
771
1104
  const rules = readRules(repoRoot);
1105
+ const owners = new Map(rules.map((rule) => [rule.file, "render"]));
1106
+ const occupied = new Set(rules.map((rule) => `.ai/rules/${rule.file}`));
1107
+ for (const pending of prospective) {
1108
+ const source = resolveEmittedPath(repoRoot, pending.moduleName, pending.target);
1109
+ if (occupied.has(source.target) || existsSync3(source.absolute)) continue;
1110
+ occupied.add(source.target);
1111
+ if (!source.target.startsWith(".ai/rules/")) continue;
1112
+ const file = source.target.slice(".ai/rules/".length);
1113
+ const rule = parseRule(file, pending.content);
1114
+ if (!rule) continue;
1115
+ rules.push(rule);
1116
+ owners.set(file, pending.moduleName);
1117
+ }
772
1118
  const entries = [];
773
1119
  const routingOnly = [];
1120
+ const planned = [];
774
1121
  for (const rule of rules) {
775
1122
  for (const harness of harnesses) {
776
1123
  const out = renderRule(rule, harness);
@@ -779,18 +1126,45 @@ function render(repoRoot, harnesses) {
779
1126
  if (harness === "agents-md") routingOnly.push(rule);
780
1127
  continue;
781
1128
  }
782
- const full = join5(repoRoot, out.target);
783
- mkdirSync2(dirname2(full), { recursive: true });
784
- writeFileSync2(full, out.content);
785
- entries.push({ rule: rule.file, harness, target: out.target, dropped: out.dropped });
1129
+ const entry = { rule: rule.file, harness, target: out.target, dropped: out.dropped };
1130
+ entries.push(entry);
1131
+ planned.push({ entry, target: out.target, content: out.content, owner: owners.get(rule.file) ?? "render" });
786
1132
  }
787
1133
  }
788
- writeRoutingBlock(repoRoot, routingOnly, harnesses);
789
- return entries;
1134
+ const candidates = [
1135
+ ...preceding,
1136
+ ...planned.map((out) => ({ moduleName: out.owner, target: out.target, writeExisting: true })),
1137
+ ...harnesses.includes("agents-md") ? [{ moduleName: "render", target: "AGENTS.md", shared: true, writeExisting: true }] : [],
1138
+ { moduleName: "render", target: ".ai/render-report.md", writeExisting: true }
1139
+ ];
1140
+ const resolved = preflightEmittedPaths(repoRoot, candidates);
1141
+ return {
1142
+ entries,
1143
+ outputs: planned.map((out, index) => ({
1144
+ entry: out.entry,
1145
+ content: out.content,
1146
+ absolute: resolved[preceding.length + index].absolute
1147
+ })),
1148
+ routingOnly
1149
+ };
1150
+ }
1151
+ function preflightRender(repoRoot, harnesses, prospective = [], preceding = []) {
1152
+ prepareRender(repoRoot, harnesses, prospective, preceding);
1153
+ }
1154
+ function render(repoRoot, harnesses) {
1155
+ const prepared = prepareRender(repoRoot, harnesses);
1156
+ for (const output of prepared.outputs) {
1157
+ mkdirSync2(dirname4(output.absolute), { recursive: true });
1158
+ writeFileSync2(output.absolute, output.content);
1159
+ }
1160
+ writeRoutingBlock(repoRoot, prepared.routingOnly, harnesses);
1161
+ return prepared.entries;
790
1162
  }
791
1163
  function writeRoutingBlock(repoRoot, rules, harnesses) {
792
1164
  if (!harnesses.includes("agents-md")) return;
793
- const target = join5(repoRoot, "AGENTS.md");
1165
+ const target = preflightEmittedPaths(repoRoot, [
1166
+ { moduleName: "render", target: "AGENTS.md", shared: true, writeExisting: true }
1167
+ ])[0].absolute;
794
1168
  if (!existsSync3(target)) return;
795
1169
  const begin = "<!-- rungs:begin rules-routing -->";
796
1170
  const end = "<!-- rungs:end rules-routing -->";
@@ -804,7 +1178,7 @@ function writeRoutingBlock(repoRoot, rules, harnesses) {
804
1178
  ...rules.map((r) => `- \`${r.paths.join("`, `")}\` \u2192 [\`.ai/rules/${r.file}\`](.ai/rules/${r.file})`),
805
1179
  end
806
1180
  ].join("\n") : "";
807
- const existing = readFileSync4(target, "utf8");
1181
+ const existing = readFileSync5(target, "utf8");
808
1182
  const beginRe = /^[ \t]*<!--\s*rungs:begin rules-routing\s*-->[ \t]*$/m;
809
1183
  const endRe = /^[ \t]*<!--\s*rungs:end rules-routing\s*-->[ \t]*$/m;
810
1184
  const b = existing.match(beginRe);
@@ -844,27 +1218,31 @@ function writeReport(repoRoot, entries, harnesses, stamp) {
844
1218
  ""
845
1219
  );
846
1220
  const content = lines.join("\n");
847
- writeFileSync2(join5(repoRoot, ".ai", "render-report.md"), content);
1221
+ const target = preflightEmittedPaths(repoRoot, [
1222
+ { moduleName: "render", target: ".ai/render-report.md", writeExisting: true }
1223
+ ])[0].absolute;
1224
+ writeFileSync2(target, content);
848
1225
  return content;
849
1226
  }
850
1227
 
851
1228
  // src/check.ts
852
- import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
1229
+ import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync10, realpathSync as realpathSync2 } from "node:fs";
853
1230
  import { execSync } from "node:child_process";
854
- import { dirname as dirname5, join as join10 } from "node:path";
855
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1231
+ import { dirname as dirname7, join as join11, resolve as resolve4 } from "node:path";
1232
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
856
1233
  import { parse as parse2 } from "smol-toml";
857
1234
 
858
1235
  // src/engines.ts
859
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
860
- import { join as join9, dirname as dirname4, resolve as resolve2 } from "node:path";
861
- import { fileURLToPath } from "node:url";
862
- import { parse as parseToml } from "smol-toml";
1236
+ import { existsSync as existsSync6, readFileSync as readFileSync9 } from "node:fs";
1237
+ import { join as join10, dirname as dirname6, resolve as resolve3 } from "node:path";
1238
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1239
+ import { parse as parseToml2 } from "smol-toml";
863
1240
 
864
1241
  // src/selftest.ts
865
1242
  import { mkdirSync as mkdirSync3, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
1243
+ import { execFileSync } from "node:child_process";
866
1244
  import { tmpdir } from "node:os";
867
- import { dirname as dirname3, join as join6 } from "node:path";
1245
+ import { dirname as dirname5, join as join6 } from "node:path";
868
1246
  function targetPath(table) {
869
1247
  const t = Array.isArray(table) ? table[0] ?? {} : table ?? {};
870
1248
  const pattern = t.file ?? [t.scan ?? []].flat()[0] ?? "**/*.md";
@@ -874,27 +1252,90 @@ function targetPath(table) {
874
1252
  function build(root, table, fx, input) {
875
1253
  const write = (rel, body) => {
876
1254
  const full = join6(root, rel);
877
- mkdirSync3(dirname3(full), { recursive: true });
1255
+ mkdirSync3(dirname5(full), { recursive: true });
878
1256
  writeFileSync3(full, body);
879
1257
  return rel;
880
1258
  };
881
1259
  if (typeof input === "string") return [write(targetPath(table), `${input}
882
1260
  `)];
883
1261
  if (!fx || typeof fx !== "object") return null;
1262
+ if (Array.isArray(fx.changed) && Array.isArray(fx.fragments)) {
1263
+ const git2 = (...args2) => execFileSync("git", args2, { cwd: root, stdio: "pipe" }).toString().trim();
1264
+ const changed = fx.changed.map(String);
1265
+ git2("init", "-q", "-b", "main", ".");
1266
+ git2("config", "user.email", "selftest@rungs.local");
1267
+ git2("config", "user.name", "rungs-selftest");
1268
+ const written = [write(".fixture-base", "base\n")];
1269
+ if (changed.length && typeof fx.inherited_exempt === "string") {
1270
+ written.push(write(
1271
+ changed[0],
1272
+ `// ${fx.inherited_exempt}
1273
+ export const fixtureState = 'base';
1274
+ `
1275
+ ));
1276
+ }
1277
+ git2("add", "--all");
1278
+ git2("commit", "-q", "-m", "base");
1279
+ git2("switch", "-q", "-c", "fixture/change");
1280
+ for (const [index, rel] of changed.entries()) {
1281
+ const evidence = index === 0 && typeof fx.exempt === "string" ? fx.exempt : index === 0 && typeof fx.inherited_exempt === "string" ? fx.inherited_exempt : void 0;
1282
+ const body = evidence ? `// ${evidence}
1283
+ export const fixtureState = 'branch';
1284
+ ` : "fixture change\n";
1285
+ written.push(write(rel, body));
1286
+ }
1287
+ const changelogDir = fx.dir ?? "changelog.d";
1288
+ for (const rel of fx.fragments) {
1289
+ const concrete = String(rel).replace(/\{\{changelog_dir\}\}/g, changelogDir);
1290
+ written.push(write(concrete, "# fixture fragment\n"));
1291
+ }
1292
+ git2("add", "--all");
1293
+ git2("commit", "-q", "-m", "fixture change");
1294
+ return [...new Set(written)];
1295
+ }
884
1296
  if (fx.packages && typeof fx.packages === "object") {
885
1297
  return Object.entries(fx.packages).map(
886
1298
  ([rel, version]) => write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }))
887
1299
  );
888
1300
  }
1301
+ if (fx.versions && typeof fx.versions === "object") {
1302
+ return Object.entries(fx.versions).map(([rel, version]) => {
1303
+ const value = String(version).replace(/"/g, '\\"');
1304
+ if (rel.endsWith(".toml")) return write(rel, `[project]
1305
+ version = "${value}"
1306
+ `);
1307
+ if (rel.endsWith(".props")) {
1308
+ return write(rel, `<Project><PropertyGroup><Version>${String(version)}</Version></PropertyGroup></Project>
1309
+ `);
1310
+ }
1311
+ return write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }));
1312
+ });
1313
+ }
1314
+ if (fx.version_files && typeof fx.version_files === "object") {
1315
+ return Object.entries(fx.version_files).map(([rel, content]) => write(rel, String(content)));
1316
+ }
889
1317
  if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
890
1318
  const dir = fx.dir ?? "changelog.d";
891
1319
  const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
892
1320
  `));
893
- written.push(write("package.json", JSON.stringify({ version: fx.version })));
1321
+ if (fx.version_file === "Directory.Build.props") {
1322
+ written.push(write("Directory.Build.props", `<Project><PropertyGroup><Version>${fx.version}</Version></PropertyGroup></Project>
1323
+ `));
1324
+ } else if (fx.version_file === "pyproject.toml") {
1325
+ written.push(write("pyproject.toml", `[project]
1326
+ version = "${fx.version}"
1327
+ `));
1328
+ } else {
1329
+ written.push(write("package.json", JSON.stringify({ version: fx.version })));
1330
+ }
1331
+ if ("consumed_through" in fx) {
1332
+ written.push(write(`${dir}/CONSUMED_THROUGH`, `${fx.consumed_through}
1333
+ `));
1334
+ }
894
1335
  return written;
895
1336
  }
896
1337
  if (typeof fx.matching_files === "number") {
897
- const base = fx.location ?? dirname3(targetPath(table));
1338
+ const base = fx.location ?? dirname5(targetPath(table));
898
1339
  const marker = fx.exempt ? `<!-- ${fx.exempt} -->
899
1340
  ` : "";
900
1341
  return Array.from(
@@ -936,10 +1377,11 @@ var CONTEXT_FREE = /* @__PURE__ */ new Set([
936
1377
  "register-schema",
937
1378
  "file-population",
938
1379
  "changelog-freshness",
1380
+ "change-requires-file",
939
1381
  "computed-claim"
940
1382
  ]);
941
1383
  function deparam(spec, dir) {
942
- const walk3 = (v) => typeof v === "string" ? v.replace(/\{\{[^}]+\}\}/g, dir) : Array.isArray(v) ? v.map(walk3) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk3(x)])) : v;
1384
+ const walk3 = (v) => typeof v === "string" ? v.replace(/\{\{changelog_dir\}\}/g, dir) : Array.isArray(v) ? v.map(walk3) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk3(x)])) : v;
943
1385
  return walk3(spec);
944
1386
  }
945
1387
  function runSelfTests(gateId, engine, table, blocks) {
@@ -959,9 +1401,9 @@ function runSelfTests(gateId, engine, table, blocks) {
959
1401
  const files = build(root, table, b.fixture, b.input);
960
1402
  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;
961
1403
  if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
962
- if (Array.isArray(b.fixture?.exclude)) {
963
- const ex = b.fixture.exclude;
964
- spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
1404
+ if (Array.isArray(b.fixture?.changed)) {
1405
+ const base = b.fixture.base_branch ?? "main";
1406
+ spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, base_branch: base })) : { ...spec, base_branch: base };
965
1407
  }
966
1408
  if (!files) {
967
1409
  out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
@@ -986,12 +1428,121 @@ function runSelfTests(gateId, engine, table, blocks) {
986
1428
  }
987
1429
 
988
1430
  // src/engines2.ts
989
- import { readFileSync as readFileSync5 } from "node:fs";
990
- import { execFileSync } from "node:child_process";
991
- import { join as join7 } from "node:path";
1431
+ import { existsSync as existsSync4, lstatSync as lstatSync2, readFileSync as readFileSync7 } from "node:fs";
1432
+ import { execFileSync as execFileSync2 } from "node:child_process";
1433
+ import { join as join8 } from "node:path";
1434
+ import { TextDecoder } from "node:util";
1435
+
1436
+ // src/version-source.ts
1437
+ import { readFileSync as readFileSync6 } from "node:fs";
1438
+ import { extname, join as join7 } from "node:path";
1439
+ import { SaxesParser } from "saxes";
1440
+ import { parse as parseToml } from "smol-toml";
1441
+ var invalidScalar = (where) => ({
1442
+ ok: false,
1443
+ reason: `${where} is not a non-empty string or finite number`
1444
+ });
1445
+ function scalar2(value, where) {
1446
+ if (typeof value === "string") {
1447
+ const trimmed = value.trim();
1448
+ return trimmed ? { ok: true, value: trimmed } : invalidScalar(where);
1449
+ }
1450
+ if (typeof value === "number" && Number.isFinite(value)) {
1451
+ return { ok: true, value: String(value) };
1452
+ }
1453
+ return invalidScalar(where);
1454
+ }
1455
+ function dottedValue(parsed, path) {
1456
+ if (!path.trim()) return { found: false };
1457
+ let value = parsed;
1458
+ for (const key of path.split(".")) {
1459
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, key)) return { found: false };
1460
+ value = value[key];
1461
+ }
1462
+ return { found: true, value };
1463
+ }
1464
+ function errorMessage(error) {
1465
+ return error instanceof Error ? error.message : String(error);
1466
+ }
1467
+ function xmlElement(text, rel, xpath) {
1468
+ const match = /^\/\/([A-Za-z_][A-Za-z0-9_.:-]*)$/.exec(xpath);
1469
+ if (!match) return { ok: false, reason: `unsupported XML xpath '${xpath}'; expected //Element` };
1470
+ const element = match[1];
1471
+ const values = [];
1472
+ const active = [];
1473
+ try {
1474
+ const parser = new SaxesParser({ fragment: false, xmlns: false, fileName: rel });
1475
+ parser.on("doctype", () => {
1476
+ throw new Error("DOCTYPE declarations are not supported in version sources");
1477
+ });
1478
+ parser.on("opentag", (tag) => {
1479
+ for (const index of active) values[index].nested = true;
1480
+ if (tag.name === element) {
1481
+ values.push({ text: "", nested: false });
1482
+ active.push(values.length - 1);
1483
+ }
1484
+ });
1485
+ const append = (value) => {
1486
+ for (const index of active) values[index].text += value;
1487
+ };
1488
+ parser.on("text", append);
1489
+ parser.on("cdata", append);
1490
+ parser.on("closetag", (tag) => {
1491
+ if (tag.name === element) active.pop();
1492
+ });
1493
+ parser.write(text).close();
1494
+ } catch (error) {
1495
+ return { ok: false, reason: `contains invalid XML: ${errorMessage(error)}` };
1496
+ }
1497
+ if (!values.length) return { ok: false, reason: `does not contain configured element '${xpath}'` };
1498
+ if (values.length > 1) {
1499
+ return { ok: false, reason: `configured element '${xpath}' matched ${values.length} values; expected one` };
1500
+ }
1501
+ if (values[0].nested) {
1502
+ return { ok: false, reason: `configured element '${xpath}' contains nested XML; expected scalar text` };
1503
+ }
1504
+ return scalar2(values[0].text, `configured element '${xpath}'`);
1505
+ }
1506
+ function readVersionSource(root, rel, source) {
1507
+ let text;
1508
+ try {
1509
+ text = readFileSync6(join7(root, rel), "utf8");
1510
+ } catch (error) {
1511
+ return { ok: false, reason: `could not read version source: ${errorMessage(error)}` };
1512
+ }
1513
+ if (source.path && source.xpath) {
1514
+ return { ok: false, reason: "declares both `path` and `xpath`; choose one version lookup" };
1515
+ }
1516
+ if (source.path) {
1517
+ const extension = extname(rel).toLowerCase();
1518
+ let parsed;
1519
+ try {
1520
+ if (extension === ".json") parsed = JSON.parse(text);
1521
+ else if (extension === ".toml") parsed = parseToml(text);
1522
+ else {
1523
+ return {
1524
+ ok: false,
1525
+ reason: `cannot read dotted path '${source.path}' from '${extension || "(no extension)"}'; use JSON or TOML`
1526
+ };
1527
+ }
1528
+ } catch (error) {
1529
+ const format2 = extension === ".toml" ? "TOML" : "JSON";
1530
+ return { ok: false, reason: `contains invalid ${format2}: ${errorMessage(error)}` };
1531
+ }
1532
+ const found = dottedValue(parsed, source.path);
1533
+ if (!found.found) return { ok: false, reason: `does not contain configured path '${source.path}'` };
1534
+ return scalar2(found.value, `configured path '${source.path}'`);
1535
+ }
1536
+ if (source.xpath) {
1537
+ return xmlElement(text, rel, source.xpath);
1538
+ }
1539
+ return { ok: false, reason: "declares neither `path` nor `xpath` for its version value" };
1540
+ }
1541
+
1542
+ // src/engines2.ts
992
1543
  var read = (root, rel) => {
993
1544
  try {
994
- return readFileSync5(join7(root, rel), "utf8");
1545
+ return semanticText(readFileSync7(join8(root, rel), "utf8"));
995
1546
  } catch {
996
1547
  return "";
997
1548
  }
@@ -1104,6 +1655,7 @@ var registerSchema = (t, root, files) => {
1104
1655
  }
1105
1656
  for (const row of table.rows) {
1106
1657
  if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
1658
+ if (strip(Object.values(row)[0]) === "\u2014") continue;
1107
1659
  examined++;
1108
1660
  for (const [key, values] of Object.entries(t2.enum ?? {})) {
1109
1661
  const v = strip(row[key]);
@@ -1238,85 +1790,358 @@ var crossReference = (t, root, files) => {
1238
1790
  }
1239
1791
  return { findings, examined: skills.length };
1240
1792
  };
1241
- var gitArgs = (root, args2) => execFileSync("git", args2, { cwd: root, stdio: "pipe" }).toString().trim();
1242
- function landedWork(root, branch, base) {
1243
- const git2 = (...args2) => gitArgs(root, args2);
1793
+ var gitArgs = (root, args2) => execFileSync2("git", args2, { cwd: root, stdio: "pipe" }).toString().trim();
1794
+ var parseGitPathList = (output) => output.split("\0").filter(Boolean);
1795
+ var gitPaths = (root, args2) => parseGitPathList(execFileSync2("git", args2, { cwd: root, stdio: "pipe" }).toString());
1796
+ var gitRefExists = (root, ref) => {
1244
1797
  try {
1245
- const tip = git2("rev-parse", branch);
1246
- if (tip === git2("rev-parse", base)) return false;
1247
- return git2("log", base, "--merges", "--format=%P").split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
1248
- } catch {
1798
+ gitArgs(root, ["show-ref", "--verify", "--quiet", ref]);
1249
1799
  return true;
1250
- }
1251
- }
1252
- var gitStatusReconcile = (t, root, files) => {
1253
- const findings = [];
1254
- let merged;
1255
- try {
1256
- merged = new Set(
1257
- gitArgs(root, ["branch", "--merged", t.integration_branch ?? "main", "--format=%(refname:short)"]).split("\n").map((s) => s.trim()).filter(Boolean)
1258
- );
1259
1800
  } catch {
1260
- return { findings: [{ message: "cannot read git branches; status not reconciled" }], examined: 0 };
1801
+ return false;
1261
1802
  }
1262
- let examined = 0;
1263
- for (const rel of expand(files, ["docs/**/items/**/*.md"])) {
1264
- const text = read(root, rel);
1265
- if (exempted(text, t.exempt_marker)) continue;
1266
- const branch = text.match(new RegExp(`^${t.branch_field ?? "branch"}:\\s*(\\S+)`, "m"))?.[1];
1267
- const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
1268
- if (!branch || !status) continue;
1269
- examined++;
1270
- if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status) && landedWork(root, branch, t.integration_branch ?? "main")) {
1271
- findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
1803
+ };
1804
+ function resolveIntegrationRef(root, branch) {
1805
+ const local = `refs/heads/${branch}`;
1806
+ if (gitRefExists(root, local)) return { ref: local };
1807
+ const origin = `refs/remotes/origin/${branch}`;
1808
+ if (gitRefExists(root, origin)) return { ref: origin };
1809
+ const remotes = gitArgs(root, ["remote"]).split("\n").map((remote) => remote.trim()).filter((remote) => remote && remote !== "origin");
1810
+ const matches = remotes.map((remote) => `refs/remotes/${remote}/${branch}`).filter((ref) => gitRefExists(root, ref)).sort();
1811
+ if (matches.length === 1) return { ref: matches[0] };
1812
+ if (matches.length > 1) {
1813
+ return {
1814
+ finding: `integration branch '${branch}' is ambiguous across ${matches.join(", ")}`
1815
+ };
1816
+ }
1817
+ return {
1818
+ finding: `integration branch '${branch}' has no local or remote-tracking ref`
1819
+ };
1820
+ }
1821
+ var matchesAny = (rel, patterns) => (patterns ?? []).some((pattern) => matchAny([rel], pattern).length > 0);
1822
+ var patternList = (value, allowEmpty = false) => Array.isArray(value) && (allowEmpty || value.length > 0) && value.every((pattern) => typeof pattern === "string" && pattern.trim().length > 0);
1823
+ function wrapperCloseAt(reason, wrapper) {
1824
+ if (!wrapper.close) return -1;
1825
+ if (wrapper.kind !== "quote") return reason.indexOf(wrapper.close);
1826
+ for (let index = 0; index < reason.length; index++) {
1827
+ if (reason[index] === "\\") {
1828
+ index++;
1829
+ } else if (reason[index] === wrapper.close) {
1830
+ return index;
1272
1831
  }
1273
1832
  }
1274
- return { findings, examined };
1275
- };
1276
- var computedClaim = (t, root, files) => {
1277
- const specs = Array.isArray(t) ? t : [t];
1278
- const findings = [];
1279
- let examined = 0;
1280
- for (const spec of specs) {
1281
- const values = /* @__PURE__ */ new Map();
1282
- const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
1283
- for (const src of spec.sources ?? []) {
1284
- for (const rel of matchAny(files, src.file)) {
1285
- if (excluded(rel)) continue;
1286
- const text = read(root, rel);
1287
- let v;
1288
- if (src.path && rel.endsWith(".json")) {
1289
- try {
1290
- v = src.path.split(".").reduce((o, k) => o?.[k], JSON.parse(text));
1291
- } catch {
1292
- }
1293
- } else if (src.xpath) {
1294
- v = text.match(new RegExp(`<${src.xpath.split("//")[1]}>(.*?)<`))?.[1];
1295
- }
1296
- if (v) {
1297
- examined++;
1298
- values.set(rel, String(v));
1299
- }
1833
+ return -1;
1834
+ }
1835
+ function exemptionEvidence(text, marker) {
1836
+ const evidence = [];
1837
+ let wrapper = { kind: "plain" };
1838
+ for (let index = 0; index < text.length; index++) {
1839
+ if (text.startsWith(marker, index)) {
1840
+ const reasonAt = index + marker.length;
1841
+ const cr = text.indexOf("\r", reasonAt);
1842
+ const lf = text.indexOf("\n", reasonAt);
1843
+ const lineEnd = cr < 0 ? lf < 0 ? text.length : lf : lf < 0 ? cr : Math.min(cr, lf);
1844
+ const rawTail = text.slice(reasonAt, lineEnd);
1845
+ const leading = rawTail.match(/^[ \t]*/)?.[0].length ?? 0;
1846
+ if (/[\p{L}\p{N}]/u.test(rawTail[leading] ?? "")) {
1847
+ let reason = rawTail.slice(leading);
1848
+ const closeAt = wrapperCloseAt(reason, wrapper);
1849
+ if (closeAt >= 0) reason = reason.slice(0, closeAt);
1850
+ reason = reason.trimEnd();
1851
+ if (reason) evidence.push(reason);
1300
1852
  }
1301
1853
  }
1302
- const distinct = new Set(values.values());
1303
- if (spec.rule === "all-agree" && distinct.size > 1) {
1304
- const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
1305
- findings.push({
1306
- message: `${spec.id} disagrees across ${values.size} locations: ${where}` + (spec.autofix ? ` \u2014 run \`${spec.autofix}\`` : "") + (spec.exclude?.length ? "" : ". If one of these is versioned independently, list it in `exclude`.")
1307
- });
1854
+ if (wrapper.kind === "line") {
1855
+ if (text[index] === "\r" || text[index] === "\n") wrapper = { kind: "plain" };
1856
+ continue;
1308
1857
  }
1309
- }
1310
- return { findings, examined };
1311
- };
1312
-
1313
- // src/engines3.ts
1314
- import { readFileSync as readFileSync6 } from "node:fs";
1315
- import { execFileSync as execFileSync2 } from "node:child_process";
1316
- import { join as join8 } from "node:path";
1858
+ if (wrapper.kind === "block") {
1859
+ if (text.startsWith("*/", index)) {
1860
+ wrapper = { kind: "plain" };
1861
+ index++;
1862
+ }
1863
+ continue;
1864
+ }
1865
+ if (wrapper.kind === "html") {
1866
+ if (text.startsWith("-->", index)) {
1867
+ wrapper = { kind: "plain" };
1868
+ index += 2;
1869
+ }
1870
+ continue;
1871
+ }
1872
+ if (wrapper.kind === "quote") {
1873
+ if (text[index] === "\\") {
1874
+ index++;
1875
+ } else if (text[index] === wrapper.close) {
1876
+ wrapper = { kind: "plain" };
1877
+ }
1878
+ continue;
1879
+ }
1880
+ if (text.startsWith("//", index)) {
1881
+ wrapper = { kind: "line" };
1882
+ index++;
1883
+ } else if (text.startsWith("/*", index)) {
1884
+ wrapper = { kind: "block", close: "*/" };
1885
+ index++;
1886
+ } else if (text.startsWith("<!--", index)) {
1887
+ wrapper = { kind: "html", close: "-->" };
1888
+ index += 3;
1889
+ } else if (text[index] === '"' || text[index] === "'" || text[index] === "`") {
1890
+ wrapper = { kind: "quote", close: text[index] };
1891
+ }
1892
+ }
1893
+ return evidence;
1894
+ }
1895
+ var utf8 = new TextDecoder("utf-8", { fatal: true });
1896
+ function gitTreeEntry(root, treeish, rel) {
1897
+ const output = execFileSync2(
1898
+ "git",
1899
+ ["--literal-pathspecs", "ls-tree", "-z", treeish, "--", rel],
1900
+ { cwd: root, stdio: "pipe" }
1901
+ );
1902
+ if (!output.length) return void 0;
1903
+ const records = output.toString("utf8").split("\0").filter(Boolean);
1904
+ if (records.length !== 1) throw new Error(`unexpected tree entry count for ${rel}`);
1905
+ const match = records[0].match(/^([0-7]{6}) ([a-z]+) ([0-9a-f]+)\t/);
1906
+ if (!match) throw new Error(`cannot parse tree entry for ${rel}`);
1907
+ return { mode: match[1], type: match[2], oid: match[3] };
1908
+ }
1909
+ function candidateGitModesAreRegular(root, rel) {
1910
+ const output = execFileSync2(
1911
+ "git",
1912
+ ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", rel],
1913
+ { cwd: root, stdio: "pipe" }
1914
+ );
1915
+ const entries = output.toString("utf8").split("\0").filter(Boolean).map((record) => {
1916
+ const match = record.match(/^([0-7]{6}) [0-9a-f]+ ([0-3])\t/);
1917
+ if (!match) throw new Error(`cannot parse index entry for ${rel}`);
1918
+ return { mode: match[1], stage: match[2] };
1919
+ });
1920
+ return entries.length === 0 || entries.length === 1 && entries[0].stage === "0" && ["100644", "100755"].includes(entries[0].mode);
1921
+ }
1922
+ function candidateExemptionText(root, rel) {
1923
+ let resolved;
1924
+ try {
1925
+ resolved = resolveEmittedPath(root, "release exemption evidence", rel);
1926
+ if (resolved.leafAlias || !lstatSync2(resolved.absolute).isFile()) return void 0;
1927
+ } catch {
1928
+ return void 0;
1929
+ }
1930
+ if (!candidateGitModesAreRegular(root, rel)) return void 0;
1931
+ const attributes = execFileSync2(
1932
+ "git",
1933
+ ["check-attr", "-z", "diff", "text", "binary", "--", rel],
1934
+ { cwd: root, stdio: "pipe" }
1935
+ ).toString().split("\0");
1936
+ for (let index = 0; index + 2 < attributes.length; index += 3) {
1937
+ const attribute = attributes[index + 1];
1938
+ const value = attributes[index + 2];
1939
+ if (attribute === "diff" && value === "unset" || attribute === "binary" && value === "set") {
1940
+ return void 0;
1941
+ }
1942
+ }
1943
+ try {
1944
+ const bytes = readFileSync7(resolved.absolute);
1945
+ if (bytes.includes(0)) return void 0;
1946
+ return utf8.decode(bytes);
1947
+ } catch {
1948
+ return void 0;
1949
+ }
1950
+ }
1951
+ function inheritedExemptionEvidence(root, mergeBase, marker) {
1952
+ let paths = Buffer.alloc(0);
1953
+ try {
1954
+ paths = execFileSync2(
1955
+ "git",
1956
+ ["grep", "-I", "-l", "-z", "-F", "-e", marker, mergeBase, "--"],
1957
+ { cwd: root, stdio: "pipe" }
1958
+ );
1959
+ } catch (error) {
1960
+ if (error?.status !== 1) throw error;
1961
+ paths = Buffer.isBuffer(error?.stdout) ? error.stdout : Buffer.from(error?.stdout ?? "");
1962
+ }
1963
+ const prefix = `${mergeBase}:`;
1964
+ const inherited = /* @__PURE__ */ new Set();
1965
+ for (const named of utf8.decode(paths).split("\0").filter(Boolean)) {
1966
+ if (!named.startsWith(prefix)) throw new Error("cannot parse historical exemption path");
1967
+ const rel = named.slice(prefix.length);
1968
+ const entry = gitTreeEntry(root, mergeBase, rel);
1969
+ if (!entry || entry.type !== "blob") throw new Error(`cannot resolve historical exemption blob for ${rel}`);
1970
+ const bytes = execFileSync2("git", ["cat-file", "blob", entry.oid], { cwd: root, stdio: "pipe" });
1971
+ if (bytes.includes(0)) throw new Error(`historical exemption blob is not text: ${rel}`);
1972
+ for (const reason of exemptionEvidence(utf8.decode(bytes), marker)) inherited.add(reason);
1973
+ }
1974
+ return inherited;
1975
+ }
1976
+ function hasBranchLocalExemption(root, mergeBase, changed, marker) {
1977
+ const inherited = inheritedExemptionEvidence(root, mergeBase, marker);
1978
+ return changed.some((rel) => {
1979
+ const text = candidateExemptionText(root, rel);
1980
+ return text !== void 0 && exemptionEvidence(text, marker).some((reason) => !inherited.has(reason));
1981
+ });
1982
+ }
1983
+ var changeRequiresFile = (t, root) => {
1984
+ if (!patternList(t.require_when_changed) || !patternList(t.requires_one_of)) {
1985
+ return {
1986
+ findings: [{
1987
+ message: "change-requires-file requires non-empty 'require_when_changed' and 'requires_one_of' pattern arrays"
1988
+ }],
1989
+ examined: 0
1990
+ };
1991
+ }
1992
+ if (t.ignore_when_only !== void 0 && !patternList(t.ignore_when_only, true)) {
1993
+ return {
1994
+ findings: [{ message: "change-requires-file 'ignore_when_only' must be an array of non-empty patterns" }],
1995
+ examined: 0
1996
+ };
1997
+ }
1998
+ if (t.exempt_marker !== void 0 && (typeof t.exempt_marker !== "string" || !t.exempt_marker.trim())) {
1999
+ return {
2000
+ findings: [{ message: "change-requires-file 'exempt_marker' must be a non-empty string when configured" }],
2001
+ examined: 0
2002
+ };
2003
+ }
2004
+ const baseName = String(t.base_branch ?? "main");
2005
+ let changed;
2006
+ let mergeBase;
2007
+ let untracked;
2008
+ try {
2009
+ const resolved = resolveIntegrationRef(root, baseName);
2010
+ if (!resolved.ref) {
2011
+ return {
2012
+ findings: [{ message: `${resolved.finding}; required companion file not evaluated` }],
2013
+ examined: 0
2014
+ };
2015
+ }
2016
+ mergeBase = gitArgs(root, ["merge-base", "HEAD", resolved.ref]);
2017
+ untracked = gitPaths(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
2018
+ changed = [.../* @__PURE__ */ new Set([
2019
+ ...gitPaths(root, ["diff", "--name-only", "--no-renames", "-z", mergeBase, "HEAD"]),
2020
+ ...gitPaths(root, ["diff", "--cached", "--name-only", "--no-renames", "-z"]),
2021
+ ...gitPaths(root, ["diff", "--name-only", "--no-renames", "-z"]),
2022
+ ...untracked
2023
+ ])].sort();
2024
+ } catch {
2025
+ return {
2026
+ findings: [{ message: `cannot read git changes against '${baseName}'; required companion file not evaluated` }],
2027
+ examined: 0
2028
+ };
2029
+ }
2030
+ const examined = changed.length;
2031
+ const ignore = t.ignore_when_only;
2032
+ if (ignore?.length && changed.length && changed.every((rel) => matchesAny(rel, ignore))) {
2033
+ return { findings: [], examined };
2034
+ }
2035
+ if (!changed.some((rel) => matchesAny(rel, t.require_when_changed))) {
2036
+ return { findings: [], examined };
2037
+ }
2038
+ const companion = changed.find(
2039
+ (rel) => matchesAny(rel, t.requires_one_of) && existsSync4(join8(root, rel))
2040
+ );
2041
+ if (companion) return { findings: [], examined };
2042
+ if (t.exempt_marker) {
2043
+ try {
2044
+ if (hasBranchLocalExemption(root, mergeBase, changed, t.exempt_marker)) {
2045
+ return { findings: [], examined };
2046
+ }
2047
+ } catch {
2048
+ return {
2049
+ findings: [{ message: `cannot read git exemption provenance against '${baseName}'; required companion file not evaluated` }],
2050
+ examined: 0
2051
+ };
2052
+ }
2053
+ }
2054
+ return {
2055
+ findings: [{ message: String(t.message ?? "changed shipping code requires a companion file").trim() }],
2056
+ examined
2057
+ };
2058
+ };
2059
+ function landedWork(root, branch, base) {
2060
+ const git2 = (...args2) => gitArgs(root, args2);
2061
+ try {
2062
+ const tip = git2("rev-parse", branch);
2063
+ if (tip === git2("rev-parse", base)) return false;
2064
+ return git2("log", base, "--merges", "--format=%P").split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
2065
+ } catch {
2066
+ return true;
2067
+ }
2068
+ }
2069
+ var gitStatusReconcile = (t, root, files) => {
2070
+ const findings = [];
2071
+ let merged;
2072
+ let integrationRef;
2073
+ try {
2074
+ const integration = String(t.integration_branch ?? "main");
2075
+ const resolved = resolveIntegrationRef(root, integration);
2076
+ if (!resolved.ref) return { findings: [{ message: `${resolved.finding}; status not reconciled` }], examined: 0 };
2077
+ integrationRef = resolved.ref;
2078
+ merged = new Set(
2079
+ gitArgs(root, ["branch", "--merged", integrationRef, "--format=%(refname:short)"]).split("\n").map((s) => s.trim()).filter(Boolean)
2080
+ );
2081
+ } catch {
2082
+ return { findings: [{ message: "cannot read git branches; status not reconciled" }], examined: 0 };
2083
+ }
2084
+ let examined = 0;
2085
+ for (const rel of expand(files, ["docs/**/items/**/*.md"])) {
2086
+ const text = read(root, rel);
2087
+ if (exempted(text, t.exempt_marker)) continue;
2088
+ const branch = text.match(new RegExp(`^${t.branch_field ?? "branch"}:\\s*(\\S+)`, "m"))?.[1];
2089
+ const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
2090
+ if (!branch || !status) continue;
2091
+ examined++;
2092
+ if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status) && landedWork(root, branch, integrationRef)) {
2093
+ findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
2094
+ }
2095
+ }
2096
+ return { findings, examined };
2097
+ };
2098
+ var computedClaim = (t, root, files) => {
2099
+ const specs = Array.isArray(t) ? t : [t];
2100
+ const findings = [];
2101
+ let examined = 0;
2102
+ for (const spec of specs) {
2103
+ const values = /* @__PURE__ */ new Map();
2104
+ let specExamined = 0;
2105
+ const excludePatterns = (spec.exclude ?? []).filter(
2106
+ (pattern) => typeof pattern === "string" && pattern.trim().length > 0
2107
+ );
2108
+ const excluded = (rel) => excludePatterns.some((pattern) => matchAny([rel], pattern).length > 0);
2109
+ for (const src of spec.sources ?? []) {
2110
+ for (const rel of matchAny(files, src.file)) {
2111
+ if (excluded(rel)) continue;
2112
+ examined++;
2113
+ specExamined++;
2114
+ const result = readVersionSource(root, rel, src);
2115
+ if (!result.ok) {
2116
+ findings.push({ file: rel, message: `${spec.id} version source ${result.reason}` });
2117
+ continue;
2118
+ }
2119
+ values.set(rel, result.value);
2120
+ }
2121
+ }
2122
+ if (specExamined === 0) {
2123
+ findings.push({
2124
+ message: `${spec.id} found no configured version sources; check the source globs and exclusions`
2125
+ });
2126
+ }
2127
+ const distinct = new Set(values.values());
2128
+ if (spec.rule === "all-agree" && distinct.size > 1) {
2129
+ const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
2130
+ findings.push({
2131
+ message: `${spec.id} disagrees across ${values.size} locations: ${where}` + (spec.autofix ? ` \u2014 run \`${spec.autofix}\`` : "") + (excludePatterns.length ? "" : ". If one of these is versioned independently, set `release.version_exclude`.")
2132
+ });
2133
+ }
2134
+ }
2135
+ return { findings, examined };
2136
+ };
2137
+
2138
+ // src/engines3.ts
2139
+ import { existsSync as existsSync5, readFileSync as readFileSync8 } from "node:fs";
2140
+ import { execFileSync as execFileSync3 } from "node:child_process";
2141
+ import { join as join9 } from "node:path";
1317
2142
  var read2 = (root, rel) => {
1318
2143
  try {
1319
- return readFileSync6(join8(root, rel), "utf8");
2144
+ return semanticText(readFileSync8(join9(root, rel), "utf8"));
1320
2145
  } catch {
1321
2146
  return "";
1322
2147
  }
@@ -1335,26 +2160,89 @@ var changelogFreshness = (t, root, files) => {
1335
2160
  const findings = [];
1336
2161
  let examined = 0;
1337
2162
  for (const spec of specs) {
1338
- const src = spec.version ?? {};
2163
+ const sources = Array.isArray(spec.versions) ? spec.versions : [spec.version ?? { file: "package.json", path: "version" }];
1339
2164
  let current = null;
1340
- for (const rel of matchAny(files, src.file ?? "package.json")) {
1341
- try {
1342
- const raw = (src.path ?? "version").split(".").reduce((o, k) => o?.[k], JSON.parse(read2(root, rel)));
1343
- current = versionParts(String(raw ?? ""));
1344
- } catch {
2165
+ let currentSource = "";
2166
+ const versionProblems = [];
2167
+ for (const source of sources) {
2168
+ for (const rel of matchAny(files, source.file ?? "package.json")) {
2169
+ const result = readVersionSource(root, rel, source);
2170
+ if (!result.ok) {
2171
+ versionProblems.push({ file: rel, message: `release version source ${result.reason}` });
2172
+ continue;
2173
+ }
2174
+ const parsed = versionParts(result.value);
2175
+ if (!parsed) {
2176
+ versionProblems.push({
2177
+ file: rel,
2178
+ message: `release version source must contain a three-part numeric version; found ${JSON.stringify(result.value)}`
2179
+ });
2180
+ continue;
2181
+ }
2182
+ current = parsed;
2183
+ if (current) {
2184
+ currentSource = rel;
2185
+ break;
2186
+ }
1345
2187
  }
1346
2188
  if (current) break;
1347
2189
  }
1348
- if (!current) continue;
2190
+ const marker = spec.consumed_through ?? "changelog.d/CONSUMED_THROUGH";
2191
+ const markerExists = existsSync5(join9(root, marker));
2192
+ if (!markerExists) {
2193
+ findings.push({
2194
+ file: marker,
2195
+ message: `release consumption marker '${marker}' is missing \u2014 create it with 'none' if no release has consumed fragments, or the exact last consumed version`
2196
+ });
2197
+ continue;
2198
+ }
2199
+ examined++;
2200
+ const markerRaw = read2(root, marker);
2201
+ const markerValue = markerRaw.endsWith("\r\n") ? markerRaw.slice(0, -2) : markerRaw.endsWith("\n") ? markerRaw.slice(0, -1) : markerRaw;
2202
+ if (markerValue === "UNINITIALIZED") {
2203
+ findings.push({
2204
+ file: marker,
2205
+ message: `release consumption marker '${marker}' is UNINITIALIZED \u2014 replace it with 'none' if no release has consumed fragments, or the exact last consumed version`
2206
+ });
2207
+ continue;
2208
+ }
2209
+ const firstRelease = markerValue === "none";
2210
+ const consumed = firstRelease || !/^\d+\.\d+\.\d+$/.test(markerValue) ? null : versionParts(markerValue);
2211
+ if (!firstRelease && !consumed) {
2212
+ findings.push({
2213
+ file: marker,
2214
+ message: `release consumption marker '${marker}' must contain exactly 'none' or a three-part numeric version; found ${JSON.stringify(markerValue)}`
2215
+ });
2216
+ continue;
2217
+ }
2218
+ findings.push(...versionProblems);
2219
+ if (!current) {
2220
+ if (consumed) {
2221
+ findings.push({
2222
+ file: sources[0]?.file ?? "package.json",
2223
+ message: `cannot reconcile consumed-through ${markerValue} because none of the declared version sources contains a three-part numeric version`
2224
+ });
2225
+ }
2226
+ continue;
2227
+ }
2228
+ if (consumed && versionCmp(consumed, current) !== 0) {
2229
+ const relation = versionCmp(consumed, current) < 0 ? "below" : "above";
2230
+ findings.push({
2231
+ file: marker,
2232
+ message: `release consumption marker names ${markerValue}, ${relation} version ${current.join(".")} in ${currentSource} \u2014 they must match in a steady tree; advance both during reversible release preparation`
2233
+ });
2234
+ }
1349
2235
  for (const rel of expand2(files, spec.fragments, [])) {
1350
2236
  const name = rel.split("/").pop().replace(/\.md$/, "");
1351
2237
  const v = versionParts(name);
1352
2238
  if (!v) continue;
1353
2239
  examined++;
1354
- if (versionCmp(v, current) < 0) {
2240
+ const belowPackage = versionCmp(v, current) < 0;
2241
+ const alreadyConsumed = consumed ? versionCmp(v, consumed) <= 0 : false;
2242
+ if (belowPackage || alreadyConsumed) {
1355
2243
  findings.push({
1356
2244
  file: rel,
1357
- 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`
2245
+ message: spec.message?.trim() || (alreadyConsumed ? `fragment names ${name}, at or below consumed-through ${markerValue} \u2014 it was already assembled and should have been deleted` : `fragment names ${name}, below package version ${current.join(".")} \u2014 it belongs to an earlier release and should have been deleted`)
1358
2246
  });
1359
2247
  }
1360
2248
  }
@@ -1446,7 +2334,7 @@ var rulePropagation = (t, root, files) => {
1446
2334
  var gitState = (t, root) => {
1447
2335
  let out;
1448
2336
  try {
1449
- out = execFileSync2("git", ["worktree", "list", "--porcelain"], { cwd: root, stdio: "pipe" }).toString();
2337
+ out = execFileSync3("git", ["worktree", "list", "--porcelain"], { cwd: root, stdio: "pipe" }).toString();
1450
2338
  } catch {
1451
2339
  return { findings: [{ message: "cannot read git worktrees; checkout state unknown" }], examined: 0 };
1452
2340
  }
@@ -1471,7 +2359,7 @@ var mergeDriverCheck = (t, root) => {
1471
2359
  for (const driver of required) {
1472
2360
  let configured = "";
1473
2361
  try {
1474
- configured = execFileSync2("git", ["config", "--get", `merge.${driver}.driver`], { cwd: root, stdio: "pipe" }).toString().trim();
2362
+ configured = execFileSync3("git", ["config", "--get", `merge.${driver}.driver`], { cwd: root, stdio: "pipe" }).toString().trim();
1475
2363
  } catch {
1476
2364
  }
1477
2365
  if (!configured) {
@@ -1522,11 +2410,55 @@ var boardReconcile = (t, root, _files) => {
1522
2410
  return { findings, examined };
1523
2411
  };
1524
2412
 
2413
+ // src/engine-table.ts
2414
+ var WHOLE_TABLE = "__whole__";
2415
+ var ENGINE_TABLE_KEYS = Object.freeze({
2416
+ "file-budget": "file_budget",
2417
+ sections: "sections",
2418
+ "frontmatter-schema": "frontmatter_schema",
2419
+ "link-integrity": "link_integrity",
2420
+ "file-population": "file_population",
2421
+ "gate-meta": "gate_meta",
2422
+ "id-integrity": WHOLE_TABLE,
2423
+ "render-freshness": "render_freshness",
2424
+ "register-schema": "register_schema",
2425
+ "self-declared-closure": "self_declared_closure",
2426
+ "filename-schema": "filename_schema",
2427
+ "cross-reference": "cross_reference",
2428
+ "git-status-reconcile": "merged_status",
2429
+ "computed-claim": "computed_claim",
2430
+ "term-ownership": "term_ownership",
2431
+ "rule-propagation": "rule_propagation",
2432
+ "git-state": "git_state",
2433
+ "merge-driver-check": "merge_driver_check",
2434
+ "board-reconcile": "board_reconcile",
2435
+ "changelog-freshness": "changelog_freshness",
2436
+ "change-requires-file": "change_requires_file",
2437
+ "shell-safety": "shell_safety"
2438
+ });
2439
+ var entryMatches = (entry, gateId) => !!entry?.id && gateId.includes(String(entry.id));
2440
+ function selectEngineTable(raw, engine, gateId) {
2441
+ if (!Object.prototype.hasOwnProperty.call(ENGINE_TABLE_KEYS, engine)) {
2442
+ throw new Error(`engine '${engine}' has no table-section mapping`);
2443
+ }
2444
+ const key = ENGINE_TABLE_KEYS[engine];
2445
+ if (key === WHOLE_TABLE) return raw;
2446
+ if (!raw || typeof raw !== "object" || !(key in raw)) {
2447
+ throw new Error(`gate '${gateId}' requires table section '${key}' for engine '${engine}'`);
2448
+ }
2449
+ const section = raw[key];
2450
+ if (!Array.isArray(section)) return section;
2451
+ const identified = section.filter((entry) => entry?.id);
2452
+ if (!identified.length) return section;
2453
+ const matched = identified.filter((entry) => entryMatches(entry, gateId));
2454
+ return matched.length ? section.filter((entry) => !entry?.id || entryMatches(entry, gateId)) : section;
2455
+ }
2456
+
1525
2457
  // src/engines.ts
1526
- var CLI_MODULES = join9(dirname4(fileURLToPath(import.meta.url)), "..", "modules");
2458
+ var CLI_MODULES = join10(dirname6(fileURLToPath2(import.meta.url)), "..", "modules");
1527
2459
  var read3 = (root, rel) => {
1528
2460
  try {
1529
- return readFileSync7(join9(root, rel), "utf8");
2461
+ return semanticText(readFileSync9(join10(root, rel), "utf8"));
1530
2462
  } catch {
1531
2463
  return "";
1532
2464
  }
@@ -1545,7 +2477,7 @@ var fileBudget = (t, root, files) => {
1545
2477
  const findings = [];
1546
2478
  let examined = 0;
1547
2479
  for (const rel of targets) {
1548
- if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
2480
+ if (excluded.has(rel) || !existsSync6(join10(root, rel))) continue;
1549
2481
  examined++;
1550
2482
  const n = loadedLines(read3(root, rel));
1551
2483
  if (n > t.max_lines) {
@@ -1562,7 +2494,7 @@ var sections = (t, root, files) => {
1562
2494
  const targets = dropGenerated(root, spec.file ? [spec.file] : expand3(files, spec.scan));
1563
2495
  const excluded = new Set(expand3(files, spec.exclude, []));
1564
2496
  for (const rel of targets) {
1565
- if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
2497
+ if (excluded.has(rel) || !existsSync6(join10(root, rel))) continue;
1566
2498
  examined++;
1567
2499
  const text = read3(root, rel);
1568
2500
  const matches = [...text.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)];
@@ -1643,11 +2575,11 @@ var frontmatterSchema = (t, root, files) => {
1643
2575
  return { findings, examined };
1644
2576
  };
1645
2577
  function resolvesHere(root, rel, href) {
1646
- const from = dirname4(rel);
2578
+ const from = dirname6(rel);
1647
2579
  const decoded = decodeURIComponent(href);
1648
- if (existsSync6(resolve2(root, from, decoded))) return true;
2580
+ if (existsSync6(resolve3(root, from, decoded))) return true;
1649
2581
  const stripped = decoded.replace(/:\d+(?::\d+)?$/, "");
1650
- return stripped !== decoded && existsSync6(resolve2(root, from, stripped));
2582
+ return stripped !== decoded && existsSync6(resolve3(root, from, stripped));
1651
2583
  }
1652
2584
  function backtickedPaths(rel, text, root, hints) {
1653
2585
  const out = [];
@@ -1661,7 +2593,7 @@ function backtickedPaths(rel, text, root, hints) {
1661
2593
  if (!raw.includes("/")) continue;
1662
2594
  if (!/\.[a-z0-9]{1,5}$/i.test(raw)) continue;
1663
2595
  const bare = raw.replace(/^\.\//, "");
1664
- if (!existsSync6(join9(root, bare)) && !existsSync6(resolve2(root, dirname4(rel), bare))) {
2596
+ if (!existsSync6(join10(root, bare)) && !existsSync6(resolve3(root, dirname6(rel), bare))) {
1665
2597
  out.push({ message: `stale path in a code span \u2192 ${raw}` });
1666
2598
  }
1667
2599
  }
@@ -1714,9 +2646,9 @@ var filePopulation = (t, root, files) => {
1714
2646
  var gateMeta = (_t, root) => {
1715
2647
  const findings = [];
1716
2648
  let unrun = 0;
1717
- const registry = join9(root, ".ai", "gates.toml");
2649
+ const registry = join10(root, ".ai", "gates.toml");
1718
2650
  if (!existsSync6(registry)) return { findings, examined: 0 };
1719
- const text = readFileSync7(registry, "utf8");
2651
+ const text = semanticText(readFileSync9(registry, "utf8"));
1720
2652
  const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
1721
2653
  let examined = 0;
1722
2654
  for (const entry of entries) {
@@ -1725,8 +2657,8 @@ var gateMeta = (_t, root) => {
1725
2657
  const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
1726
2658
  if (!id || kind !== "declared" || !table) continue;
1727
2659
  examined++;
1728
- const tablePath = join9(CLI_MODULES, dirname4(table), "gates", table.split("/").pop());
1729
- const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
2660
+ const tablePath = join10(CLI_MODULES, dirname6(table), "gates", table.split("/").pop());
2661
+ const src = existsSync6(tablePath) ? semanticText(readFileSync9(tablePath, "utf8")) : "";
1730
2662
  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}"`));
1731
2663
  for (const direction of ["pass", "fail"]) {
1732
2664
  if (!forGate.some((b) => new RegExp(`expect\\s*=\\s*"${direction}"`).test(b))) {
@@ -1737,9 +2669,14 @@ var gateMeta = (_t, root) => {
1737
2669
  const parsed = parseTable(tablePath, table.split("/")[0]);
1738
2670
  if (engine && parsed) {
1739
2671
  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++;
2672
+ try {
2673
+ const section = selectEngineTable(parsed, engine, id);
2674
+ for (const r of runSelfTests(id, engine, section, blocks)) {
2675
+ if (r.outcome === "mismatch") findings.push({ message: `self-test for '${id}' ${r.detail}` });
2676
+ else if (r.outcome === "unrun") unrun++;
2677
+ }
2678
+ } catch (e) {
2679
+ findings.push({ message: `gate '${id}' table dispatch failed: ${e.message}` });
1743
2680
  }
1744
2681
  }
1745
2682
  }
@@ -1764,30 +2701,11 @@ function parseTable(path, module) {
1764
2701
  try {
1765
2702
  const mods = loadAllModules(CLI_MODULES);
1766
2703
  const params = resolveParams(mods, {}, ".");
1767
- return parseToml(substitute(readFileSync7(path, "utf8"), module, params));
2704
+ return parseToml2(substitute(semanticText(readFileSync9(path, "utf8")), module, params));
1768
2705
  } catch {
1769
2706
  return null;
1770
2707
  }
1771
2708
  }
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;
1791
2709
  var ENGINES = {
1792
2710
  "file-budget": fileBudget,
1793
2711
  sections,
@@ -1808,18 +2726,19 @@ var ENGINES = {
1808
2726
  "git-state": gitState,
1809
2727
  "merge-driver-check": mergeDriverCheck,
1810
2728
  "board-reconcile": boardReconcile,
1811
- "changelog-freshness": changelogFreshness
2729
+ "changelog-freshness": changelogFreshness,
2730
+ "change-requires-file": changeRequiresFile
1812
2731
  };
1813
2732
  function isImplemented(engine) {
1814
2733
  return engine in ENGINES;
1815
2734
  }
1816
2735
 
1817
2736
  // src/check.ts
1818
- var MODULES = join10(dirname5(fileURLToPath2(import.meta.url)), "..", "modules");
2737
+ var MODULES = join11(dirname7(fileURLToPath3(import.meta.url)), "..", "modules");
1819
2738
  function loadRegistry(repoRoot) {
1820
- const path = join10(repoRoot, ".ai", "gates.toml");
2739
+ const path = join11(repoRoot, ".ai", "gates.toml");
1821
2740
  if (!existsSync7(path)) return { runner: {}, gates: [] };
1822
- const raw = parse2(readFileSync8(path, "utf8"));
2741
+ const raw = parse2(readFileSync10(path, "utf8"));
1823
2742
  return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
1824
2743
  }
1825
2744
  function tierSelects(runnerTiers, requested, gateTier) {
@@ -1838,6 +2757,49 @@ var UnknownTierError = class extends Error {
1838
2757
  this.declared = declared;
1839
2758
  }
1840
2759
  };
2760
+ function commandText(value) {
2761
+ if (value === void 0 || value === null) return "";
2762
+ return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
2763
+ }
2764
+ function normalizeCommandText(value, repoRoot) {
2765
+ let text = commandText(value).replace(/\r\n?/g, "\n").trim();
2766
+ const absolute = resolve4(repoRoot);
2767
+ const roots = [absolute];
2768
+ try {
2769
+ roots.push(realpathSync2.native(absolute));
2770
+ } catch {
2771
+ }
2772
+ const variants = [...new Set(roots.flatMap((root) => [
2773
+ root,
2774
+ root.replaceAll("\\", "/"),
2775
+ root.replaceAll("/", "\\")
2776
+ ]))].sort((left, right) => right.length - left.length);
2777
+ for (const variant of variants) {
2778
+ const escaped = variant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2779
+ text = text.replace(new RegExp(escaped, process.platform === "win32" ? "gi" : "g"), "<repo>");
2780
+ }
2781
+ return text;
2782
+ }
2783
+ function commandFailure(error, repoRoot) {
2784
+ const stderr = normalizeCommandText(error?.stderr, repoRoot);
2785
+ const stdout = normalizeCommandText(error?.stdout, repoRoot);
2786
+ const fallback = normalizeCommandText(error?.message, repoRoot);
2787
+ const status = typeof error?.status === "number" ? String(error.status) : error?.signal ? `signal ${error.signal}` : "unknown";
2788
+ const streams = [
2789
+ ...stderr ? [`stderr:
2790
+ ${stderr}`] : [],
2791
+ ...stdout ? [`stdout:
2792
+ ${stdout}`] : []
2793
+ ];
2794
+ const detail = streams.length ? streams.join("\n") : fallback;
2795
+ const diagnostic = `command exited with status ${status}${detail ? `
2796
+ ${detail}` : ""}`;
2797
+ return {
2798
+ message: diagnostic,
2799
+ identity: `command:${status}${detail ? `
2800
+ ${detail}` : ""}`
2801
+ };
2802
+ }
1841
2803
  function runGates(repoRoot, tier, now = () => Date.now(), only) {
1842
2804
  const { runner, gates } = loadRegistry(repoRoot);
1843
2805
  const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
@@ -1859,7 +2821,7 @@ function runGates(repoRoot, tier, now = () => Date.now(), only) {
1859
2821
  execSync(g.command, { cwd: repoRoot, stdio: "pipe" });
1860
2822
  } catch (e) {
1861
2823
  status = "fail";
1862
- findings = [{ message: String(e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-3).join(" ") }];
2824
+ findings = [commandFailure(e, repoRoot)];
1863
2825
  }
1864
2826
  } else if (!g.engine || !isImplemented(g.engine)) {
1865
2827
  status = "unimplemented";
@@ -1871,12 +2833,7 @@ function runGates(repoRoot, tier, now = () => Date.now(), only) {
1871
2833
  findings = [{ message: `table '${g.table}' not found` }];
1872
2834
  } else {
1873
2835
  try {
1874
- const key = tableKey(g.engine);
1875
- let section = table[key] ?? table;
1876
- if (Array.isArray(section) && section.some((s) => s?.id)) {
1877
- const mine = section.filter((s) => !s.id || g.id.includes(s.id));
1878
- if (mine.length) section = mine;
1879
- }
2836
+ const section = selectEngineTable(table, g.engine, g.id);
1880
2837
  const r = ENGINES[g.engine](section, repoRoot, files);
1881
2838
  findings = r.findings;
1882
2839
  examined = r.examined;
@@ -1905,10 +2862,10 @@ function runGates(repoRoot, tier, now = () => Date.now(), only) {
1905
2862
  function loadTable(ref, repoRoot) {
1906
2863
  if (!ref) return null;
1907
2864
  const [mod, file] = ref.split("/");
1908
- const path = join10(MODULES, mod, "gates", file);
2865
+ const path = join11(MODULES, mod, "gates", file);
1909
2866
  if (!existsSync7(path)) return null;
1910
2867
  try {
1911
- return parse2(substitute(readFileSync8(path, "utf8"), mod, installedParams(repoRoot)));
2868
+ return parse2(substitute(readFileSync10(path, "utf8"), mod, installedParams(repoRoot)));
1912
2869
  } catch {
1913
2870
  return null;
1914
2871
  }
@@ -1917,10 +2874,10 @@ var paramCache = null;
1917
2874
  function installedParams(repoRoot) {
1918
2875
  if (paramCache?.root === repoRoot) return paramCache.params;
1919
2876
  const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
1920
- const recordPath = join10(repoRoot, ".ai", "rungs.toml");
2877
+ const recordPath = join11(repoRoot, ".ai", "rungs.toml");
1921
2878
  if (existsSync7(recordPath)) {
1922
2879
  try {
1923
- const rec = parse2(readFileSync8(recordPath, "utf8"));
2880
+ const rec = parse2(readFileSync10(recordPath, "utf8"));
1924
2881
  for (const [name, entry] of Object.entries(rec.modules ?? {})) {
1925
2882
  if (entry?.params) defaults[name] = { ...defaults[name] ?? {}, ...entry.params };
1926
2883
  }
@@ -1930,38 +2887,17 @@ function installedParams(repoRoot) {
1930
2887
  paramCache = { root: repoRoot, params: defaults };
1931
2888
  return defaults;
1932
2889
  }
1933
- var tableKey = (engine) => ({
1934
- "file-budget": "file_budget",
1935
- sections: "sections",
1936
- "frontmatter-schema": "frontmatter_schema",
1937
- "link-integrity": "link_integrity",
1938
- "file-population": "file_population",
1939
- "gate-meta": "gate_meta",
1940
- "id-integrity": "__whole__",
1941
- "render-freshness": "render_freshness",
1942
- "register-schema": "register_schema",
1943
- "self-declared-closure": "self_declared_closure",
1944
- "filename-schema": "filename_schema",
1945
- "cross-reference": "cross_reference",
1946
- "git-status-reconcile": "merged_status",
1947
- "computed-claim": "computed_claim",
1948
- "term-ownership": "term_ownership",
1949
- "rule-propagation": "rule_propagation",
1950
- "git-state": "git_state",
1951
- "merge-driver-check": "merge_driver_check",
1952
- "board-reconcile": "board_reconcile"
1953
- })[engine] ?? engine;
1954
2890
  function appendLedger(repoRoot, runs, stamp) {
1955
2891
  const { runner } = loadRegistry(repoRoot);
1956
2892
  if (runner.ledger === false) return;
1957
- const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
2893
+ const path = join11(repoRoot, ".ai", ".gate-ledger.jsonl");
1958
2894
  const lines = runs.map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined })).join("\n");
1959
2895
  appendFileSync(path, lines + "\n");
1960
2896
  }
1961
2897
  function ledgerQuestions(repoRoot, gates) {
1962
- const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
2898
+ const path = join11(repoRoot, ".ai", ".gate-ledger.jsonl");
1963
2899
  if (!existsSync7(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
1964
- const rows = readFileSync8(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
2900
+ const rows = readFileSync10(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
1965
2901
  const by = /* @__PURE__ */ new Map();
1966
2902
  for (const r of rows) {
1967
2903
  const e = by.get(r.id) ?? { total: 0, failed: 0 };
@@ -1976,12 +2912,12 @@ function ledgerQuestions(repoRoot, gates) {
1976
2912
  }
1977
2913
 
1978
2914
  // src/lifecycle.ts
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";
1981
- import { fileURLToPath as fileURLToPath3 } from "node:url";
1982
- import { execFileSync as execFileSync3 } from "node:child_process";
2915
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync11, writeFileSync as writeFileSync4 } from "node:fs";
2916
+ import { dirname as dirname8, join as join12 } from "node:path";
2917
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
2918
+ import { execFileSync as execFileSync4 } from "node:child_process";
1983
2919
  import { parse as parse3 } from "smol-toml";
1984
- var SRC = dirname6(fileURLToPath3(import.meta.url));
2920
+ var SRC = dirname8(fileURLToPath4(import.meta.url));
1985
2921
  var PROFILES = {
1986
2922
  minimal: ["instructions"],
1987
2923
  tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
@@ -1990,10 +2926,18 @@ var PROFILES = {
1990
2926
  fleet: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority", "concurrency", "design-sync"]
1991
2927
  };
1992
2928
  function readRecord(repoRoot) {
1993
- const p = join11(repoRoot, ".ai", "rungs.toml");
2929
+ const resolved = resolveEmittedPath(repoRoot, "rungs", ".ai/rungs.toml");
2930
+ if (resolved.leafAlias) {
2931
+ throw new UnsafeEmittedPathError(
2932
+ "rungs",
2933
+ ".ai/rungs.toml",
2934
+ "the install record is a symlink or junction leaf and will not be used as configuration"
2935
+ );
2936
+ }
2937
+ const p = resolved.absolute;
1994
2938
  if (!existsSync8(p)) return null;
1995
2939
  try {
1996
- const raw = parse3(readFileSync9(p, "utf8"));
2940
+ const raw = parse3(readFileSync11(p, "utf8"));
1997
2941
  return { harnesses: raw.repo?.harnesses ?? [], modules: raw.modules ?? {} };
1998
2942
  } catch {
1999
2943
  return null;
@@ -2003,20 +2947,26 @@ function planUpgrade(repoRoot, mods, record) {
2003
2947
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
2004
2948
  const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
2005
2949
  const items = [];
2006
- for (const mod of mods) {
2950
+ const installedMods = mods.filter((mod) => record.modules[mod.name]);
2951
+ preflightModuleEmissions(installedMods, repoRoot, params, skillsDir);
2952
+ for (const mod of installedMods) {
2007
2953
  const installed = record.modules[mod.name];
2008
- if (!installed) continue;
2009
2954
  const emitted = emittedFiles(mod, params, skillsDir);
2010
2955
  const files = [];
2011
2956
  const kept = new Set(installed.kept?.files ?? []);
2012
2957
  for (const [rel, wouldEmit] of emitted) {
2013
2958
  if (kept.has(rel)) continue;
2014
- const full = join11(repoRoot, rel);
2959
+ const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
2960
+ const full = resolved.absolute;
2961
+ if (resolved.leafAlias) {
2962
+ files.push({ rel, state: "diverged" });
2963
+ continue;
2964
+ }
2015
2965
  if (!existsSync8(full)) {
2016
2966
  files.push({ rel, state: "missing" });
2017
2967
  continue;
2018
2968
  }
2019
- const onDisk = contentHash(readFileSync9(full, "utf8"));
2969
+ const onDisk = contentHash(readFileSync11(full, "utf8"));
2020
2970
  const recorded = installed.hashes?.[rel];
2021
2971
  if (onDisk === contentHash(wouldEmit)) files.push({ rel, state: "current" });
2022
2972
  else if (recorded && onDisk === recorded) files.push({ rel, state: "stale" });
@@ -2029,23 +2979,51 @@ function planUpgrade(repoRoot, mods, record) {
2029
2979
  function applyUpgrade(repoRoot, mods, record, plan) {
2030
2980
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
2031
2981
  const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
2982
+ const prepared = plan.map((item) => {
2983
+ const mod = mods.find((candidate) => candidate.name === item.module);
2984
+ if (!mod) throw new Error(`upgrade plan names unknown module '${item.module}'`);
2985
+ const emitted = emittedFiles(mod, params, skillsDir);
2986
+ const files = item.files.map((file) => {
2987
+ const resolved = resolveEmittedPath(repoRoot, mod.name, file.rel);
2988
+ if ((file.state === "stale" || file.state === "missing") && resolved.leafAlias) {
2989
+ throw new UnsafeEmittedPathError(
2990
+ mod.name,
2991
+ file.rel,
2992
+ "the destination is a symlink or junction leaf and upgrade will not write through it"
2993
+ );
2994
+ }
2995
+ if ((file.state === "stale" || file.state === "missing") && !emitted.has(resolved.target)) {
2996
+ throw new Error(`module '${mod.name}' upgrade plan names target '${file.rel}' that the module does not emit`);
2997
+ }
2998
+ return { ...file, target: resolved.target, absolute: resolved.absolute };
2999
+ });
3000
+ return { item, mod, emitted, files };
3001
+ });
3002
+ const writable = new Set(
3003
+ prepared.flatMap(
3004
+ ({ mod, files }) => files.filter((file) => file.state === "stale" || file.state === "missing").map((file) => `${mod.name}\0${file.target}`)
3005
+ )
3006
+ );
3007
+ preflightEmittedPaths(repoRoot, [
3008
+ ...moduleEmissionCandidates(prepared.map(({ mod }) => mod), params, skillsDir).map(
3009
+ (candidate) => writable.has(`${candidate.moduleName}\0${candidate.target}`) ? { ...candidate, writeExisting: true } : candidate
3010
+ ),
3011
+ { moduleName: prepared[0]?.mod.name ?? "upgrade", target: ".ai/rungs.toml", writeExisting: true }
3012
+ ]);
2032
3013
  let written = 0;
2033
3014
  const rewritten = /* @__PURE__ */ new Map();
2034
- for (const item of plan) {
2035
- const mod = mods.find((m) => m.name === item.module);
2036
- const emitted = emittedFiles(mod, params, skillsDir);
2037
- for (const f of item.files) {
3015
+ for (const { mod, emitted, files } of prepared) {
3016
+ for (const f of files) {
2038
3017
  if (f.state !== "stale" && f.state !== "missing") continue;
2039
- const full = join11(repoRoot, f.rel);
2040
- const content = emitted.get(f.rel);
2041
- mkdirSync4(dirname6(full), { recursive: true });
2042
- writeFileSync4(full, content);
3018
+ const content = emitted.get(f.target);
3019
+ mkdirSync4(dirname8(f.absolute), { recursive: true });
3020
+ writeFileSync4(f.absolute, content);
2043
3021
  if (!rewritten.has(mod.name)) rewritten.set(mod.name, /* @__PURE__ */ new Map());
2044
- rewritten.get(mod.name).set(f.rel, contentHash(content));
3022
+ rewritten.get(mod.name).set(f.target, contentHash(content));
2045
3023
  written++;
2046
3024
  }
2047
3025
  }
2048
- const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)).filter(Boolean);
3026
+ const upgraded = prepared.map(({ mod }) => mod);
2049
3027
  const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
2050
3028
  const recorded = updateRecordAfterUpgrade(
2051
3029
  repoRoot,
@@ -2054,9 +3032,13 @@ function applyUpgrade(repoRoot, mods, record, plan) {
2054
3032
  return { written, gates: gateActions.length, recorded };
2055
3033
  }
2056
3034
  function updateRecordAfterUpgrade(repoRoot, updates) {
2057
- const path = join11(repoRoot, ".ai", "rungs.toml");
3035
+ const path = preflightEmittedPaths(repoRoot, [
3036
+ { moduleName: updates[0]?.module ?? "upgrade", target: ".ai/rungs.toml", writeExisting: true }
3037
+ ])[0].absolute;
2058
3038
  if (!existsSync8(path) || !updates.length) return 0;
2059
- const lines = readFileSync9(path, "utf8").split("\n");
3039
+ const original = readFileSync11(path, "utf8");
3040
+ const newline = original.match(/\r\n|\r|\n/)?.[0] ?? "\n";
3041
+ const lines = semanticText(original).split("\n");
2060
3042
  const byModule = new Map(updates.map((u) => [u.module, u]));
2061
3043
  let changed = 0;
2062
3044
  let current = null;
@@ -2078,14 +3060,16 @@ function updateRecordAfterUpgrade(repoRoot, updates) {
2078
3060
  const entry = /^"([^"]+)"\s*=/.exec(line);
2079
3061
  const replacement = entry && byModule.get(current.module).hashes.get(entry[1]);
2080
3062
  if (replacement) {
2081
- out.push(`"${entry[1]}" = "${replacement}"`);
2082
- changed++;
3063
+ const next = `"${entry[1]}" = "${replacement}"`;
3064
+ if (next !== line) changed++;
3065
+ out.push(next);
2083
3066
  continue;
2084
3067
  }
2085
3068
  }
2086
3069
  out.push(line);
2087
3070
  }
2088
- writeFileSync4(path, out.join("\n"));
3071
+ const updated = out.join(newline);
3072
+ if (changed > 0 && updated !== original) writeFileSync4(path, updated);
2089
3073
  return changed;
2090
3074
  }
2091
3075
  function paramsFrom(record) {
@@ -2096,8 +3080,8 @@ function paramsFrom(record) {
2096
3080
  return out;
2097
3081
  }
2098
3082
  function eject(repoRoot, mods, dryRun = false) {
2099
- const dest = join11(repoRoot, ".rungs");
2100
- const engines = ["glob.ts", "engines.ts", "engines2.ts"];
3083
+ const dest = join12(repoRoot, ".rungs");
3084
+ const engines = ["glob.ts", "text.ts", "engine-table.ts", "engines.ts", "engines2.ts"];
2101
3085
  const { gates } = loadRegistry(repoRoot);
2102
3086
  const declared = gates.filter((g) => g.kind === "declared" && g.table);
2103
3087
  const tables = [...new Set(declared.map((g) => g.table))];
@@ -2106,24 +3090,24 @@ function eject(repoRoot, mods, dryRun = false) {
2106
3090
  for (const t of tables) actions.push(`.rungs/tables/${t.replace("/", "-").replace(/.toml$/, ".json")}`);
2107
3091
  actions.push(".rungs/run-gate.mjs", ".ai/gates.toml (rewritten to command gates)");
2108
3092
  if (dryRun) return { actions, gates: declared.length };
2109
- mkdirSync4(join11(dest, "tables"), { recursive: true });
2110
- for (const f of engines) copyFileSync(join11(SRC, f), join11(dest, f));
3093
+ mkdirSync4(join12(dest, "tables"), { recursive: true });
3094
+ for (const f of engines) copyFileSync(join12(SRC, f), join12(dest, f));
2111
3095
  const record = readRecord(repoRoot);
2112
3096
  const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
2113
3097
  for (const t of tables) {
2114
3098
  const [mod, file] = t.split("/");
2115
- const src = join11(SRC, "..", "modules", mod, "gates", file);
3099
+ const src = join12(SRC, "..", "modules", mod, "gates", file);
2116
3100
  if (!existsSync8(src)) continue;
2117
3101
  try {
2118
- const parsed = parse3(substitute(readFileSync9(src, "utf8"), mod, params));
2119
- writeFileSync4(join11(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
3102
+ const parsed = parse3(substitute(readFileSync11(src, "utf8"), mod, params));
3103
+ writeFileSync4(join12(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
2120
3104
  } catch {
2121
3105
  }
2122
3106
  }
2123
- writeFileSync4(join11(dest, "run-gate.mjs"), RUNNER);
2124
- writeFileSync4(join11(dest, "README.md"), EJECT_README);
2125
- const registry = join11(repoRoot, ".ai", "gates.toml");
2126
- let text = readFileSync9(registry, "utf8");
3107
+ writeFileSync4(join12(dest, "run-gate.mjs"), RUNNER);
3108
+ writeFileSync4(join12(dest, "README.md"), EJECT_README);
3109
+ const registry = join12(repoRoot, ".ai", "gates.toml");
3110
+ let text = readFileSync11(registry, "utf8");
2127
3111
  for (const g of declared) {
2128
3112
  text = text.replace(
2129
3113
  new RegExp(`(id\\s*=\\s*"${g.id}"[\\s\\S]*?)kind\\s*=\\s*"declared"`),
@@ -2143,6 +3127,7 @@ import { readFileSync } from 'node:fs';
2143
3127
  import { join, dirname } from 'node:path';
2144
3128
  import { fileURLToPath } from 'node:url';
2145
3129
  import { ENGINES } from './engines.ts';
3130
+ import { selectEngineTable } from './engine-table.ts';
2146
3131
  import { walk } from './glob.ts';
2147
3132
 
2148
3133
  const here = dirname(fileURLToPath(import.meta.url));
@@ -2159,12 +3144,7 @@ if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engin
2159
3144
  // Tables were converted to JSON when this was ejected, so nothing here needs a
2160
3145
  // TOML parser \u2014 or any dependency at all beyond Node itself.
2161
3146
  const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
2162
- const KEYS = { 'file-budget': 'file_budget', 'frontmatter-schema': 'frontmatter_schema', 'link-integrity': 'link_integrity', 'file-population': 'file_population', 'gate-meta': 'gate_meta', 'render-freshness': 'render_freshness', 'register-schema': 'register_schema', 'self-declared-closure': 'self_declared_closure', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
2163
- let section = raw[KEYS[engine] ?? engine] ?? raw;
2164
- if (Array.isArray(section) && section.some((s) => s?.id)) {
2165
- const mine = section.filter((s) => !s.id || id.includes(s.id));
2166
- if (mine.length) section = mine;
2167
- }
3147
+ const section = selectEngineTable(raw, engine, id);
2168
3148
  const r = ENGINES[engine](section, root, walk(root));
2169
3149
  for (const f of r.findings) console.error(\` \${f.file ? f.file + ': ' : ''}\${f.message}\`);
2170
3150
  process.exit(r.findings.length ? 1 : 0);
@@ -2185,17 +3165,17 @@ itself to whoever finds it.
2185
3165
  To go back, delete this directory and re-run \`rungs add\`.
2186
3166
  `;
2187
3167
  function setupGit(repoRoot, dryRun = false) {
2188
- const attrs = join11(repoRoot, ".gitattributes");
3168
+ const attrs = join12(repoRoot, ".gitattributes");
2189
3169
  if (!existsSync8(attrs)) return { drivers: [], rerere: false };
2190
- const drivers = [...new Set([...readFileSync9(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
3170
+ const drivers = [...new Set([...readFileSync11(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
2191
3171
  const done = [];
2192
3172
  for (const d of drivers) {
2193
3173
  const cmd2 = d === "rungs-generated" ? `node -e "process.stderr.write('refusing to text-merge a generated artifact; regenerate it instead
2194
3174
  ');process.exit(1)"` : "git merge-file -L ours -L base -L theirs %A %O %B";
2195
3175
  if (!dryRun) {
2196
3176
  try {
2197
- execFileSync3("git", ["config", `merge.${d}.name`, `rungs ${d.replace("rungs-", "")} driver`], { cwd: repoRoot, stdio: "pipe" });
2198
- execFileSync3("git", ["config", `merge.${d}.driver`, cmd2], { cwd: repoRoot, stdio: "pipe" });
3177
+ execFileSync4("git", ["config", `merge.${d}.name`, `rungs ${d.replace("rungs-", "")} driver`], { cwd: repoRoot, stdio: "pipe" });
3178
+ execFileSync4("git", ["config", `merge.${d}.driver`, cmd2], { cwd: repoRoot, stdio: "pipe" });
2199
3179
  } catch {
2200
3180
  continue;
2201
3181
  }
@@ -2205,7 +3185,7 @@ function setupGit(repoRoot, dryRun = false) {
2205
3185
  let rerere = false;
2206
3186
  if (!dryRun) {
2207
3187
  try {
2208
- execFileSync3("git", ["config", "rerere.enabled", "true"], { cwd: repoRoot, stdio: "pipe" });
3188
+ execFileSync4("git", ["config", "rerere.enabled", "true"], { cwd: repoRoot, stdio: "pipe" });
2209
3189
  rerere = true;
2210
3190
  } catch {
2211
3191
  }
@@ -2252,12 +3232,7 @@ function explainWith(engines, mods, results, repoRoot, files) {
2252
3232
  continue;
2253
3233
  }
2254
3234
  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
- }
3235
+ const section = selectEngineTable(table, g.engine, g.id);
2261
3236
  const r = engines[g.engine](section, repoRoot, files);
2262
3237
  if (r.findings.length) {
2263
3238
  reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
@@ -2286,32 +3261,71 @@ function collapseDuplicates(reported) {
2286
3261
  }
2287
3262
 
2288
3263
  // 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";
3264
+ import {
3265
+ existsSync as existsSync9,
3266
+ lstatSync as lstatSync3,
3267
+ mkdirSync as mkdirSync5,
3268
+ readFileSync as readFileSync12,
3269
+ realpathSync as realpathSync3,
3270
+ renameSync,
3271
+ statSync as statSync5,
3272
+ writeFileSync as writeFileSync5
3273
+ } from "node:fs";
3274
+ import { dirname as dirname9, isAbsolute as isAbsolute2, join as join13, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
3275
+ var ARCHIVE_OPERATION = "backlog archive";
3276
+ var missingEntry2 = (error) => error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
3277
+ function existingDirectory(path) {
3278
+ try {
3279
+ if (!statSync5(path.absolute).isDirectory()) {
3280
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, "the existing archive tree entry is not a directory");
3281
+ }
3282
+ return true;
3283
+ } catch (error) {
3284
+ if (error instanceof UnsafeEmittedPathError) throw error;
3285
+ if (missingEntry2(error)) return false;
3286
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, "the archive tree entry cannot be inspected");
3287
+ }
3288
+ }
3289
+ function resolveArchiveTree(repoRoot, backlogRoot = "docs/backlog") {
3290
+ const [items, archive] = preflightEmittedPaths(repoRoot, [
3291
+ { moduleName: ARCHIVE_OPERATION, target: `${backlogRoot}/items` },
3292
+ { moduleName: ARCHIVE_OPERATION, target: `${backlogRoot}/archive` }
3293
+ ]);
3294
+ const suffix = "/items";
3295
+ const root = items.target.slice(0, -suffix.length);
3296
+ return {
3297
+ root,
3298
+ items,
3299
+ archive,
3300
+ itemsExists: existingDirectory(items),
3301
+ archiveExists: existingDirectory(archive)
3302
+ };
3303
+ }
2291
3304
  var FINISHED = /* @__PURE__ */ new Set(["done", "rejected"]);
2292
3305
  var field = (text, name) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, "m"))?.[1] ?? "";
2293
- var posix = (p) => p.split(sep2).join("/");
3306
+ var posix = (p) => p.split(sep3).join("/");
2294
3307
  var LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
2295
3308
  function planArchive(repoRoot, backlogRoot = "docs/backlog") {
2296
- const itemsDir = join12(repoRoot, ...backlogRoot.split("/"), "items");
2297
- const archiveDir = join12(repoRoot, ...backlogRoot.split("/"), "archive");
3309
+ const tree = resolveArchiveTree(repoRoot, backlogRoot);
2298
3310
  const moves = [];
2299
3311
  const held = [];
2300
- const files = walk(repoRoot);
2301
- const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
3312
+ if (!tree.itemsExists) return { root: tree.root, moves, rewrites: [], held };
3313
+ const beneath = (directory) => walk(directory.absolute).filter((file) => file.endsWith(".md")).map((file) => `${directory.target}/${posix(file)}`).sort();
3314
+ const items = beneath(tree.items);
3315
+ const archived = tree.archiveExists ? beneath(tree.archive) : [];
2302
3316
  for (const rel of items) {
2303
3317
  const base = posix(rel).split("/").pop();
2304
3318
  if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
2305
- const text = readFileSync10(join12(repoRoot, rel), "utf8");
3319
+ const source = resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, rel);
3320
+ const text = readFileSync12(source.absolute, "utf8");
2306
3321
  const status = field(text, "status");
2307
3322
  const id = field(text, "id");
2308
3323
  if (!FINISHED.has(status)) continue;
2309
3324
  if (field(text, "type") === "epic") {
2310
3325
  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
3326
  const unfinished = children.filter((c2) => {
2313
3327
  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"));
3328
+ return !f || !FINISHED.has(field(readFileSync12(resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, f).absolute, "utf8"), "status"));
2315
3329
  });
2316
3330
  if (unfinished.length) {
2317
3331
  held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(", ")}` });
@@ -2322,67 +3336,238 @@ function planArchive(repoRoot, backlogRoot = "docs/backlog") {
2322
3336
  id,
2323
3337
  status,
2324
3338
  from: rel,
2325
- to: posix(join12(relative2(repoRoot, archiveDir), posix(rel).split("/").pop()))
3339
+ to: `${tree.archive.target}/${posix(rel).split("/").pop()}`
2326
3340
  });
2327
3341
  }
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 };
3342
+ const provisional = { root: tree.root, moves, rewrites: [], held };
3343
+ const prepared = prepareArchive(repoRoot, provisional);
3344
+ return {
3345
+ ...provisional,
3346
+ rewrites: prepared.rewrites.map(({ file, links }) => ({ file, links }))
3347
+ };
2336
3348
  }
2337
3349
  function isRewritable(rel) {
2338
3350
  const p = posix(rel);
2339
3351
  if (!p.endsWith(".md")) return false;
2340
3352
  return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith("node_modules/");
2341
3353
  }
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);
3354
+ function retargets(repoRoot, rel, moved, source = readFileSync12(join13(repoRoot, ...rel.split("/")), "utf8")) {
3355
+ const oldDir = dirname9(resolve5(repoRoot, rel));
3356
+ const movedDestination = (path) => {
3357
+ const lexical = moved.get(path);
3358
+ if (lexical) return lexical;
3359
+ try {
3360
+ return moved.get(realpathSync3.native(path));
3361
+ } catch {
3362
+ return void 0;
3363
+ }
3364
+ };
3365
+ const selfMoved = movedDestination(resolve5(repoRoot, rel));
3366
+ const newDir = dirname9(resolve5(repoRoot, selfMoved ?? rel));
3367
+ const out = [];
3368
+ for (const m of source.matchAll(LINK)) {
3369
+ const href = m[1];
3370
+ if (href.includes("{{")) continue;
3371
+ const target = resolve5(oldDir, decodeURIComponent(href));
3372
+ const targetMoved = movedDestination(target);
2352
3373
  if (!targetMoved && !selfMoved) continue;
2353
3374
  if (!targetMoved && !existsSync9(target)) continue;
2354
- const targetNew = targetMoved ? resolve3(repoRoot, targetMoved) : target;
2355
- const to = posix(relative2(newDir, targetNew));
3375
+ const targetNew = targetMoved ? resolve5(repoRoot, targetMoved) : target;
3376
+ const to = posix(relative3(newDir, targetNew));
2356
3377
  if (to !== posix(href)) out.push({ href, to });
2357
3378
  }
2358
3379
  return out;
2359
3380
  }
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);
3381
+ function requireRegularFile(path, purpose) {
3382
+ if (path.leafAlias) {
3383
+ throw new UnsafeEmittedPathError(
3384
+ ARCHIVE_OPERATION,
3385
+ path.target,
3386
+ `the ${purpose} is a symlink or junction leaf`
3387
+ );
3388
+ }
3389
+ try {
3390
+ if (!lstatSync3(path.absolute).isFile()) {
3391
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} is not a regular file`);
3392
+ }
3393
+ } catch (error) {
3394
+ if (error instanceof UnsafeEmittedPathError) throw error;
3395
+ if (missingEntry2(error)) {
3396
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} no longer exists`);
3397
+ }
3398
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} cannot be inspected`);
3399
+ }
3400
+ }
3401
+ function requireMissingDestination(path) {
3402
+ if (path.leafAlias) {
3403
+ throw new UnsafeEmittedPathError(
3404
+ ARCHIVE_OPERATION,
3405
+ path.target,
3406
+ "the archive destination is an existing symlink or junction leaf"
3407
+ );
3408
+ }
3409
+ try {
3410
+ lstatSync3(path.absolute);
3411
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, "the archive destination already exists");
3412
+ } catch (error) {
3413
+ if (error instanceof UnsafeEmittedPathError) throw error;
3414
+ if (!missingEntry2(error)) {
3415
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, "the archive destination cannot be inspected");
3416
+ }
3417
+ }
3418
+ }
3419
+ function requireCanonicalDescendant(directory, path, purpose) {
3420
+ const fromDirectory = relative3(directory.absolute, path.absolute);
3421
+ if (!fromDirectory || fromDirectory === ".." || fromDirectory.startsWith(`..${sep3}`) || isAbsolute2(fromDirectory)) {
3422
+ throw new UnsafeEmittedPathError(
3423
+ ARCHIVE_OPERATION,
3424
+ path.target,
3425
+ `the ${purpose} resolves outside the canonical '${directory.target}' tree`
3426
+ );
3427
+ }
3428
+ }
3429
+ function prepareMoves(repoRoot, plan, tree) {
3430
+ if (plan.root !== tree.root) {
3431
+ throw new UnsafeEmittedPathError(
3432
+ ARCHIVE_OPERATION,
3433
+ plan.root,
3434
+ `the plan root does not match its normalized archive root '${tree.root}'`
3435
+ );
3436
+ }
3437
+ const itemsPrefix = `${tree.items.target}/`;
3438
+ const archivePrefix = `${tree.archive.target}/`;
3439
+ for (const move of plan.moves) {
3440
+ if (!move.from.startsWith(itemsPrefix)) {
3441
+ throw new UnsafeEmittedPathError(
3442
+ ARCHIVE_OPERATION,
3443
+ move.from,
3444
+ `an archive source must be below '${tree.items.target}'`
3445
+ );
3446
+ }
3447
+ const expected = `${archivePrefix}${move.from.split("/").pop()}`;
3448
+ if (move.to !== expected) {
3449
+ throw new UnsafeEmittedPathError(
3450
+ ARCHIVE_OPERATION,
3451
+ move.to,
3452
+ `the archive destination for '${move.from}' must be '${expected}'`
3453
+ );
3454
+ }
3455
+ }
3456
+ const resolved = preflightEmittedPaths(
3457
+ repoRoot,
3458
+ plan.moves.flatMap((move) => [
3459
+ { moduleName: ARCHIVE_OPERATION, target: move.from },
3460
+ { moduleName: ARCHIVE_OPERATION, target: move.to }
3461
+ ])
3462
+ );
3463
+ return plan.moves.map((move, index) => {
3464
+ const from = resolved[index * 2];
3465
+ const to = resolved[index * 2 + 1];
3466
+ requireCanonicalDescendant(tree.items, from, "archive source");
3467
+ requireCanonicalDescendant(tree.archive, to, "archive destination");
3468
+ requireRegularFile(from, "archive source");
3469
+ requireMissingDestination(to);
3470
+ const source = readFileSync12(from.absolute, "utf8");
3471
+ if (field(source, "id") !== move.id || field(source, "status") !== move.status || !FINISHED.has(move.status)) {
3472
+ throw new UnsafeEmittedPathError(
3473
+ ARCHIVE_OPERATION,
3474
+ move.from,
3475
+ "the archive plan is stale or does not match the source item frontmatter"
3476
+ );
3477
+ }
3478
+ return { move, from, to };
3479
+ });
3480
+ }
3481
+ function preparedText(source, edits) {
3482
+ return source.replace(LINK, (whole, href, anchor) => {
3483
+ const edit = edits.find((candidate) => candidate.href === href);
3484
+ return edit ? `](${edit.to}${anchor})` : whole;
3485
+ });
3486
+ }
3487
+ function rewriteSummary(rewrites) {
3488
+ return rewrites.map(({ file, links }) => ({ file, links })).sort((left, right) => left.file.localeCompare(right.file));
3489
+ }
3490
+ function prepareArchive(repoRoot, plan, verifyRecordedRewrites = false) {
3491
+ const tree = resolveArchiveTree(repoRoot, plan.root);
3492
+ const moves = prepareMoves(repoRoot, plan, tree);
3493
+ if (verifyRecordedRewrites) {
3494
+ const recorded = preflightEmittedPaths(
3495
+ repoRoot,
3496
+ plan.rewrites.map((rewrite) => ({
3497
+ moduleName: ARCHIVE_OPERATION,
3498
+ target: rewrite.file,
3499
+ writeExisting: true
3500
+ }))
3501
+ );
3502
+ recorded.forEach((path) => requireRegularFile(path, "recorded rewrite target"));
3503
+ }
3504
+ const moved = /* @__PURE__ */ new Map();
3505
+ for (const { move, from } of moves) {
3506
+ moved.set(resolve5(repoRoot, ...move.from.split("/")), move.to);
3507
+ moved.set(from.absolute, move.to);
3508
+ }
3509
+ const candidatePaths = [];
3510
+ const seenCanonical = /* @__PURE__ */ new Set();
3511
+ const addCandidate = (file) => {
3512
+ if (!isRewritable(file)) return;
3513
+ const path = resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, file);
3514
+ if (seenCanonical.has(path.absolute)) return;
3515
+ seenCanonical.add(path.absolute);
3516
+ candidatePaths.push({ file, path });
3517
+ };
3518
+ moves.forEach(({ move }) => addCandidate(move.from));
3519
+ if (verifyRecordedRewrites) plan.rewrites.forEach(({ file }) => addCandidate(file));
3520
+ walk(repoRoot).sort().forEach(addCandidate);
3521
+ const drafts = candidatePaths.flatMap(({ file, path }) => {
3522
+ const original = readFileSync12(path.absolute, "utf8");
3523
+ const edits = retargets(repoRoot, file, moved, original);
3524
+ if (!edits.length) return [];
3525
+ return [{ file, original, updated: preparedText(original, edits), links: edits.length }];
3526
+ });
3527
+ const paths = preflightEmittedPaths(
3528
+ repoRoot,
3529
+ drafts.map((rewrite) => ({
3530
+ moduleName: ARCHIVE_OPERATION,
3531
+ target: rewrite.file,
3532
+ writeExisting: true
3533
+ }))
3534
+ );
3535
+ const rewrites = drafts.map((rewrite, index) => ({ ...rewrite, path: paths[index] }));
3536
+ rewrites.forEach(({ path }) => requireRegularFile(path, "rewrite target"));
3537
+ if (verifyRecordedRewrites) {
3538
+ const expected = [...plan.rewrites].sort((left, right) => left.file.localeCompare(right.file));
3539
+ const actual = rewriteSummary(rewrites);
3540
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
3541
+ throw new UnsafeEmittedPathError(
3542
+ ARCHIVE_OPERATION,
3543
+ plan.root,
3544
+ "the archive plan is stale: its complete rewrite set no longer matches the repository"
3545
+ );
3546
+ }
2373
3547
  }
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);
3548
+ return { moves, rewrites };
3549
+ }
3550
+ function applyArchive(repoRoot, plan) {
3551
+ const prepared = prepareArchive(repoRoot, plan, true);
3552
+ for (const rewrite of prepared.rewrites) {
3553
+ if (readFileSync12(rewrite.path.absolute, "utf8") !== rewrite.original) {
3554
+ throw new UnsafeEmittedPathError(
3555
+ ARCHIVE_OPERATION,
3556
+ rewrite.file,
3557
+ "the rewrite target changed after archive preflight"
3558
+ );
3559
+ }
2378
3560
  }
3561
+ for (const move of prepared.moves) mkdirSync5(dirname9(move.to.absolute), { recursive: true });
3562
+ for (const rewrite of prepared.rewrites) writeFileSync5(rewrite.path.absolute, rewrite.updated);
3563
+ for (const move of prepared.moves) renameSync(move.from.absolute, move.to.absolute);
2379
3564
  }
2380
3565
 
2381
3566
  // src/concurrency.ts
2382
- import { existsSync as existsSync10, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync6, unlinkSync } from "node:fs";
2383
- import { execFileSync as execFileSync4 } from "node:child_process";
3567
+ import { existsSync as existsSync10, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readdirSync as readdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync6, unlinkSync } from "node:fs";
3568
+ import { execFileSync as execFileSync5 } from "node:child_process";
2384
3569
  import { hostname } from "node:os";
2385
- import { join as join13, resolve as resolve4, dirname as dirname8, basename as basename2 } from "node:path";
3570
+ import { join as join14, resolve as resolve6, dirname as dirname10, basename as basename3 } from "node:path";
2386
3571
  import { tmpdir as tmpdir2 } from "node:os";
2387
3572
  function loopParams(root) {
2388
3573
  const p = installedParams(root).concurrency ?? {};
@@ -2397,7 +3582,7 @@ function loopParams(root) {
2397
3582
  };
2398
3583
  }
2399
3584
  function git(root, args2) {
2400
- return execFileSync4("git", args2, { cwd: root, stdio: "pipe", encoding: "utf8" }).trim();
3585
+ return execFileSync5("git", args2, { cwd: root, stdio: "pipe", encoding: "utf8" }).trim();
2401
3586
  }
2402
3587
  function gitOk(root, args2) {
2403
3588
  try {
@@ -2414,6 +3599,199 @@ function revParse(root, ref) {
2414
3599
  return null;
2415
3600
  }
2416
3601
  }
3602
+ function parseGitRefFormatOutput(output) {
3603
+ if (output === void 0 || output === "--show-ref-format") return "files";
3604
+ if (output === "files" || output === "reftable") return output;
3605
+ throw new Error(`unsupported Git ref format '${output}'`);
3606
+ }
3607
+ function gitWorktrees(root) {
3608
+ const out = execFileSync5("git", ["worktree", "list", "--porcelain", "-z"], {
3609
+ cwd: root,
3610
+ stdio: "pipe",
3611
+ encoding: "utf8"
3612
+ });
3613
+ const rows = [];
3614
+ let row;
3615
+ for (const field2 of out.split("\0")) {
3616
+ if (!field2) {
3617
+ if (row) rows.push(row);
3618
+ row = void 0;
3619
+ } else if (field2.startsWith("worktree ")) {
3620
+ if (row) rows.push(row);
3621
+ row = { path: field2.slice("worktree ".length) };
3622
+ } else if (row && field2.startsWith("branch ")) {
3623
+ row.branch = field2.slice("branch ".length);
3624
+ }
3625
+ }
3626
+ if (row) rows.push(row);
3627
+ return rows;
3628
+ }
3629
+ function gitLocalBranchRefs(root) {
3630
+ const out = git(root, ["for-each-ref", "--format=%(refname)%09%(objectname)%09%(symref)", "refs/heads/"]);
3631
+ const refs = /* @__PURE__ */ new Map();
3632
+ if (out) {
3633
+ for (const line of out.split("\n")) {
3634
+ const [ref, oid, symref] = line.split(" ");
3635
+ refs.set(ref, { ref, oid, ...symref ? { symref } : {} });
3636
+ }
3637
+ }
3638
+ const common = resolve6(root, git(root, ["rev-parse", "--git-common-dir"]));
3639
+ const heads = join14(common, "refs", "heads");
3640
+ const visit = (directory, prefix) => {
3641
+ if (!existsSync10(directory)) return;
3642
+ for (const entry of readdirSync4(directory, { withFileTypes: true })) {
3643
+ const shortName = prefix ? `${prefix}/${entry.name}` : entry.name;
3644
+ const path = join14(directory, entry.name);
3645
+ if (entry.isDirectory()) {
3646
+ visit(path, shortName);
3647
+ continue;
3648
+ }
3649
+ const ref = `refs/heads/${shortName}`;
3650
+ if (entry.name.endsWith(".lock")) continue;
3651
+ if (!entry.isFile()) throw new Error(`branch ref '${ref}' is not a regular loose-ref file`);
3652
+ const value = readFileSync13(path, "utf8").replace(/[\r\n]+$/, "");
3653
+ if (value.startsWith("ref: ")) {
3654
+ refs.set(ref, { ref, oid: "", symref: value.slice("ref: ".length) });
3655
+ } else {
3656
+ const enumerated = refs.get(ref);
3657
+ if (!enumerated || enumerated.symref || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) || enumerated.oid.toLowerCase() !== value.toLowerCase()) throw new Error(`branch ref '${ref}' has an unreadable or unresolved loose-ref value`);
3658
+ }
3659
+ }
3660
+ };
3661
+ let refFormatOutput;
3662
+ try {
3663
+ refFormatOutput = git(root, ["rev-parse", "--show-ref-format"]);
3664
+ } catch {
3665
+ }
3666
+ const refFormat = parseGitRefFormatOutput(refFormatOutput);
3667
+ if (refFormat === "files") visit(heads, "");
3668
+ return [...refs.values()];
3669
+ }
3670
+ function symbolicRefTarget(root, ref) {
3671
+ try {
3672
+ return git(root, ["symbolic-ref", "--quiet", ref]);
3673
+ } catch {
3674
+ return null;
3675
+ }
3676
+ }
3677
+ function refStorageCollides(left, right) {
3678
+ const leftSegments = left.split("/");
3679
+ const rightSegments = right.split("/");
3680
+ const shared = Math.min(leftSegments.length, rightSegments.length);
3681
+ for (let index = 0; index < shared; index++) {
3682
+ if (!canonicalCaselessSegmentEqual(leftSegments[index], rightSegments[index])) return false;
3683
+ if (leftSegments[index] !== rightSegments[index] && index < leftSegments.length - 1 && index < rightSegments.length - 1) return true;
3684
+ }
3685
+ return true;
3686
+ }
3687
+ function exactDirectRef(root, stored, shortName, role, required) {
3688
+ const wanted = `refs/heads/${shortName}`;
3689
+ const label = role === "integration" ? `configured integration branch '${shortName}'` : `configured green ref '${shortName}'`;
3690
+ if (!gitOk(root, ["check-ref-format", wanted])) {
3691
+ return { error: `${label} is not a valid direct local branch ref; land is refused.` };
3692
+ }
3693
+ const exactSymbolicTarget = symbolicRefTarget(root, wanted);
3694
+ if (exactSymbolicTarget) {
3695
+ return { error: `${label} is symbolic (${wanted} -> ${exactSymbolicTarget}); land requires a direct local branch ref and is refused.` };
3696
+ }
3697
+ const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
3698
+ if (alias) {
3699
+ return { error: `${label} collides with case-aliased or directory/file-conflicting stored ref '${alias.ref}'; remove the ambiguity and retry.` };
3700
+ }
3701
+ const exact = stored.find((entry) => entry.ref === wanted);
3702
+ if (!exact) {
3703
+ if (required) return { error: `${label} has no exact stored local ref '${wanted}'; land is refused.` };
3704
+ return { value: { ref: wanted, oid: null } };
3705
+ }
3706
+ if (exact.symref) {
3707
+ return {
3708
+ error: `${label} is symbolic (${exact.ref} -> ${exact.symref}); land requires a direct local branch ref and is refused.`
3709
+ };
3710
+ }
3711
+ return { value: { ref: exact.ref, oid: exact.oid } };
3712
+ }
3713
+ function managedRefsState(root, integration, green) {
3714
+ try {
3715
+ const stored = gitLocalBranchRefs(root);
3716
+ const worktrees2 = gitWorktrees(root);
3717
+ const integrationRef = exactDirectRef(root, stored, integration, "integration", true);
3718
+ if (!integrationRef.value) return integrationRef;
3719
+ const greenRef = exactDirectRef(root, stored, green, "green", false);
3720
+ if (!greenRef.value) return greenRef;
3721
+ const withHolders = (ref) => ({
3722
+ ...ref,
3723
+ holders: worktrees2.filter((worktree) => worktree.branch !== void 0 && refStorageCollides(worktree.branch, ref.ref)).map((worktree) => worktree.path)
3724
+ });
3725
+ return {
3726
+ value: {
3727
+ integration: withHolders(integrationRef.value),
3728
+ green: withHolders(greenRef.value)
3729
+ }
3730
+ };
3731
+ } catch {
3732
+ return {
3733
+ error: "cannot enumerate local branch refs and worktrees; managed-ref identity or checkout state is unknown, so land is refused."
3734
+ };
3735
+ }
3736
+ }
3737
+ function createDirectRef(root, ref, oid) {
3738
+ const input = `option no-deref\0create ${ref}\0${oid}\0`;
3739
+ execFileSync5("git", ["update-ref", "--stdin", "-z", "-m", "rungs park verified merge"], {
3740
+ cwd: root,
3741
+ stdio: "pipe",
3742
+ input: Buffer.from(input, "utf8")
3743
+ });
3744
+ }
3745
+ function parkVerifiedMerge(root, preferred, merged, reserved) {
3746
+ const derived = `${preferred}-${merged}`;
3747
+ const flat = `rungs-park-${merged}`;
3748
+ for (let index = 0; index < 1e3; index++) {
3749
+ const candidate = index === 0 ? preferred : index === 1 ? derived : index === 2 ? flat : `${flat}-${index - 2}`;
3750
+ const wanted = `refs/heads/${candidate}`;
3751
+ const reservedCollision = [...reserved].some((name) => refStorageCollides(wanted, `refs/heads/${name}`));
3752
+ if (reservedCollision || !gitOk(root, ["check-ref-format", wanted])) continue;
3753
+ for (let inspection = 0; inspection < 2; inspection++) {
3754
+ let stored;
3755
+ let worktrees2;
3756
+ try {
3757
+ stored = gitLocalBranchRefs(root);
3758
+ worktrees2 = gitWorktrees(root);
3759
+ } catch {
3760
+ return { error: "cannot enumerate refs and worktrees, so no recovery ref can be created safely." };
3761
+ }
3762
+ const exact = stored.find((entry) => entry.ref === wanted);
3763
+ const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
3764
+ const exactSymbolicTarget = symbolicRefTarget(root, wanted);
3765
+ const held = worktrees2.some(
3766
+ (worktree) => worktree.branch !== void 0 && refStorageCollides(worktree.branch, wanted)
3767
+ );
3768
+ if (alias || exact?.symref || exactSymbolicTarget || held) break;
3769
+ if (exact) {
3770
+ if (exact.oid === merged) return { name: candidate };
3771
+ break;
3772
+ }
3773
+ try {
3774
+ createDirectRef(root, wanted, merged);
3775
+ return { name: candidate };
3776
+ } catch {
3777
+ }
3778
+ }
3779
+ }
3780
+ return { error: `could not allocate an unheld collision-free recovery ref below '${preferred}'.` };
3781
+ }
3782
+ function advanceVerifiedRefs(root, integration, green, merged) {
3783
+ const input = [
3784
+ "option no-deref\0",
3785
+ `update ${integration.ref}\0${merged}\0${integration.oid}\0`,
3786
+ "option no-deref\0",
3787
+ green.oid === null ? `create ${green.ref}\0${merged}\0` : `update ${green.ref}\0${merged}\0${green.oid}\0`
3788
+ ].join("");
3789
+ execFileSync5("git", ["update-ref", "--stdin", "-z", "-m", "rungs land verified merge"], {
3790
+ cwd: root,
3791
+ stdio: "pipe",
3792
+ input: Buffer.from(input, "utf8")
3793
+ });
3794
+ }
2417
3795
  function sessionStart(root, branch, at, dryRun = false) {
2418
3796
  const { integration, greenRef } = loopParams(root);
2419
3797
  const lines = [];
@@ -2431,7 +3809,7 @@ function sessionStart(root, branch, at, dryRun = false) {
2431
3809
  lines.push(`no ${greenRef} ref yet \u2014 cutting from the tip of ${integration} (${baseSha.slice(0, 8)}) instead.`);
2432
3810
  lines.push(`That tip has not been verified by a land. The first successful \`rungs land\` creates ${greenRef}.`);
2433
3811
  }
2434
- const path = resolve4(at ?? join13(dirname8(root), `${basename2(root)}-${branch.replace(/[^\w.-]+/g, "-")}`));
3812
+ const path = resolve6(at ?? join14(dirname10(root), `${basename3(root)}-${branch.replace(/[^\w.-]+/g, "-")}`));
2435
3813
  if (existsSync10(path)) return { ok: false, lines: [`${path} already exists \u2014 rungs never writes over a directory it did not create`] };
2436
3814
  lines.push(`worktree ${path}`);
2437
3815
  lines.push(`branch ${branch}`);
@@ -2476,7 +3854,7 @@ function preflight(root) {
2476
3854
  return { ok: true, lines };
2477
3855
  }
2478
3856
  function lockPath(root) {
2479
- return join13(git(root, ["rev-parse", "--git-common-dir"]).replace(/^\.git$/, join13(root, ".git")), "rungs-land.lock");
3857
+ return join14(git(root, ["rev-parse", "--git-common-dir"]).replace(/^\.git$/, join14(root, ".git")), "rungs-land.lock");
2480
3858
  }
2481
3859
  function alive(pid) {
2482
3860
  try {
@@ -2486,18 +3864,89 @@ function alive(pid) {
2486
3864
  return e?.code === "EPERM";
2487
3865
  }
2488
3866
  }
3867
+ var findingIdentity = (finding) => typeof finding === "string" ? finding : finding.identity;
3868
+ var findingDiagnostic = (finding) => typeof finding === "string" ? finding : finding.diagnostic;
3869
+ function failingIdentities(outcome) {
3870
+ return new Map(outcome.failing.map((failure) => [
3871
+ failure.id,
3872
+ new Set(failure.findings.map(findingIdentity))
3873
+ ]));
3874
+ }
3875
+ function coversGateIds(outcome, ids) {
3876
+ const failedIds = new Set(outcome.failing.map((failure) => failure.id));
3877
+ return [...failedIds].every((id) => ids.has(id)) && failedIds.size + outcome.pass >= ids.size;
3878
+ }
3879
+ function exactControlEligibility(root, integrationOid) {
3880
+ try {
3881
+ let attached = "";
3882
+ try {
3883
+ attached = execFileSync5("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], {
3884
+ cwd: root,
3885
+ stdio: "pipe",
3886
+ encoding: "utf8"
3887
+ }).trim();
3888
+ } catch (error) {
3889
+ if (error?.status !== 1) {
3890
+ return { ok: false, reason: "the invoking worktree HEAD attachment state could not be read" };
3891
+ }
3892
+ }
3893
+ if (attached) {
3894
+ return { ok: false, reason: `the invoking worktree is attached to '${attached}', not detached` };
3895
+ }
3896
+ const head = revParse(root, "HEAD");
3897
+ if (head !== integrationOid) {
3898
+ return {
3899
+ ok: false,
3900
+ reason: `the invoking worktree is at ${head?.slice(0, 8) ?? "an unreadable HEAD"}, not integration ${integrationOid.slice(0, 8)}`
3901
+ };
3902
+ }
3903
+ const status = execFileSync5(
3904
+ "git",
3905
+ ["--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
3906
+ { cwd: root, stdio: "pipe" }
3907
+ );
3908
+ if (status.length) {
3909
+ return { ok: false, reason: "the invoking worktree has tracked, staged or non-ignored untracked changes" };
3910
+ }
3911
+ return { ok: true };
3912
+ } catch {
3913
+ return { ok: false, reason: "the invoking worktree state could not be verified" };
3914
+ }
3915
+ }
2489
3916
  function land(root, branch, runner, dryRun = false) {
2490
3917
  const { integration, greenRef, integPrefix } = loopParams(root);
2491
3918
  const lines = [];
2492
3919
  if (!branch) return { ok: false, lines: ["a branch name is required: `rungs land <branch>`"] };
2493
3920
  const head = revParse(root, `refs/heads/${branch}`);
2494
3921
  if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
2495
- const before = revParse(root, `refs/heads/${integration}`);
3922
+ const preferredParked = `${integPrefix}${branch}`;
3923
+ const initialManaged = managedRefsState(root, integration, greenRef);
3924
+ if (!initialManaged.value) return { ok: false, lines: [initialManaged.error] };
3925
+ const { integration: initialIntegration, green: initialGreen } = initialManaged.value;
3926
+ if (initialIntegration.ref === initialGreen.ref) {
3927
+ return {
3928
+ ok: false,
3929
+ lines: [`configured integration branch '${integration}' and green ref '${greenRef}' resolve to the same direct ref; land requires two distinct managed refs.`]
3930
+ };
3931
+ }
3932
+ const before = initialIntegration.oid;
2496
3933
  if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
3934
+ const heldManagedRef = [initialIntegration, initialGreen].find((ref) => ref.holders.length);
3935
+ if (heldManagedRef) {
3936
+ const name = heldManagedRef.ref.slice("refs/heads/".length);
3937
+ return {
3938
+ ok: false,
3939
+ lines: [
3940
+ `'${name}' is checked out in ${heldManagedRef.holders.length} worktree(s), so land is refused:`,
3941
+ ...heldManagedRef.holders.map((path) => ` ${path}`),
3942
+ "Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry."
3943
+ ]
3944
+ };
3945
+ }
2497
3946
  const lp = lockPath(root);
2498
3947
  if (existsSync10(lp)) {
2499
3948
  try {
2500
- const held = JSON.parse(readFileSync11(lp, "utf8"));
3949
+ const held = JSON.parse(readFileSync13(lp, "utf8"));
2501
3950
  if (held.host === hostname() && alive(held.pid)) {
2502
3951
  return {
2503
3952
  ok: false,
@@ -2513,13 +3962,13 @@ function land(root, branch, runner, dryRun = false) {
2513
3962
  }
2514
3963
  }
2515
3964
  if (dryRun) {
2516
- lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
3965
+ lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${preferredParked}, verify, then atomically advance ${integration} and ${greenRef}.`);
2517
3966
  return { ok: true, lines };
2518
3967
  }
2519
3968
  const lock = { pid: process.pid, host: hostname(), started: (/* @__PURE__ */ new Date()).toISOString(), branch };
2520
3969
  writeFileSync6(lp, JSON.stringify(lock));
2521
- const scratch = mkdtempSync2(join13(tmpdir2(), "rungs-land-"));
2522
- const parked = `${integPrefix}${branch}`;
3970
+ const scratch = mkdtempSync2(join14(tmpdir2(), "rungs-land-"));
3971
+ let preserveScratch = false;
2523
3972
  try {
2524
3973
  git(root, ["worktree", "add", "--detach", scratch, before]);
2525
3974
  try {
@@ -2540,6 +3989,35 @@ function land(root, branch, runner, dryRun = false) {
2540
3989
  const merged = git(scratch, ["rev-parse", "HEAD"]);
2541
3990
  const res = runner(scratch);
2542
3991
  lines.push(`merged tree ${merged.slice(0, 8)} \u2014 ${res.pass} pass \xB7 ${res.failing.length} fail`);
3992
+ const refuseWithRecovery = (details, guidance) => {
3993
+ const recovery = parkVerifiedMerge(
3994
+ root,
3995
+ preferredParked,
3996
+ merged,
3997
+ /* @__PURE__ */ new Set([integration, greenRef, branch])
3998
+ );
3999
+ if (!recovery.name) {
4000
+ git(scratch, ["reset", "--hard", merged]);
4001
+ preserveScratch = true;
4002
+ return {
4003
+ ok: false,
4004
+ lines: [
4005
+ ...lines,
4006
+ ...details,
4007
+ `The verified merge ${merged} could not be parked safely: ${recovery.error}`,
4008
+ `Its detached scratch worktree is retained at ${scratch}. Resolve the ref/worktree state, create a recovery branch at that exact commit, then remove the scratch explicitly.`
4009
+ ]
4010
+ };
4011
+ }
4012
+ return {
4013
+ ok: false,
4014
+ lines: [
4015
+ ...lines,
4016
+ ...details,
4017
+ `Your verified merge is parked on '${recovery.name}'. ${guidance}`
4018
+ ]
4019
+ };
4020
+ };
2543
4021
  if (res.failing.length) {
2544
4022
  const ids = new Set(res.failing.map((f) => f.id));
2545
4023
  let base = null;
@@ -2549,65 +4027,157 @@ function land(root, branch, runner, dryRun = false) {
2549
4027
  } catch {
2550
4028
  base = null;
2551
4029
  }
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)]));
4030
+ const baseAttributable = base !== null && coversGateIds(base, ids);
4031
+ let control = null;
4032
+ let controlIssue = "";
4033
+ const eligibleBefore = exactControlEligibility(root, before);
4034
+ if (!eligibleBefore.ok) {
4035
+ controlIssue = eligibleBefore.reason;
4036
+ } else {
4037
+ try {
4038
+ control = runner(root, ids);
4039
+ const eligibleAfter = exactControlEligibility(root, before);
4040
+ if (!eligibleAfter.ok) {
4041
+ control = null;
4042
+ controlIssue = `the invoking control changed while gates ran: ${eligibleAfter.reason}`;
4043
+ }
4044
+ } catch {
4045
+ control = null;
4046
+ controlIssue = "the exact integration control could not be gated";
4047
+ }
4048
+ }
4049
+ if (control && !coversGateIds(control, ids)) {
4050
+ control = null;
4051
+ controlIssue = "some failing gates did not run in the exact integration control";
4052
+ }
4053
+ const controlAttributable = control !== null;
4054
+ const baseFindings = base ? failingIdentities(base) : /* @__PURE__ */ new Map();
4055
+ const controlFindings = control ? failingIdentities(control) : /* @__PURE__ */ new Map();
2554
4056
  const introduced = [];
2555
4057
  const inherited = [];
4058
+ const unverified = [];
4059
+ const addFinding = (target, id, finding) => {
4060
+ let failure = target.find((entry) => entry.id === id);
4061
+ if (!failure) {
4062
+ failure = { id, findings: [] };
4063
+ target.push(failure);
4064
+ }
4065
+ failure.findings.push(finding);
4066
+ };
2556
4067
  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);
4068
+ const atBase = baseAttributable ? baseFindings.get(f.id) ?? /* @__PURE__ */ new Set() : null;
4069
+ const inControl = controlAttributable ? controlFindings.get(f.id) ?? /* @__PURE__ */ new Set() : null;
4070
+ for (const finding of f.findings) {
4071
+ const identity = findingIdentity(finding);
4072
+ if (!atBase || !atBase.has(identity)) {
4073
+ addFinding(introduced, f.id, finding);
4074
+ } else if (!inControl || !inControl.has(identity)) {
4075
+ addFinding(unverified, f.id, finding);
4076
+ } else {
4077
+ addFinding(inherited, f.id, finding);
4078
+ }
4079
+ }
2561
4080
  }
2562
4081
  for (const f of inherited) {
2563
- lines.push(` inherited ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
4082
+ lines.push(` inherited ${f.id}${f.findings[0] ? ` \u2014 ${findingDiagnostic(f.findings[0])}` : ""}`);
2564
4083
  }
2565
4084
  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}`);
4085
+ lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` \u2014 ${findingDiagnostic(f.findings[0])}` : ""}`);
4086
+ for (const extra of f.findings.slice(1, 4)) lines.push(` ${findingDiagnostic(extra)}`);
2568
4087
  }
2569
4088
  if (base === null) {
2570
4089
  lines.push(" The merge base could not be gated, so nothing here is attributable and all of it blocks.");
2571
- } else if (!attributable) {
4090
+ } else if (!baseAttributable) {
2572
4091
  lines.push(" Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.");
2573
4092
  }
4093
+ if (controlIssue) {
4094
+ lines.push(` CONTROL UNAVAILABLE \u2014 ${controlIssue}; inherited failure cannot be established.`);
4095
+ }
4096
+ for (const f of unverified) {
4097
+ if (controlAttributable) {
4098
+ const mismatch = (controlFindings.get(f.id)?.size ?? 0) > 0 ? "the detached base scratch and exact integration control reported different findings" : "the exact integration control passed while the detached base scratch failed";
4099
+ lines.push(` UNVERIFIED ${f.id} \u2014 ${mismatch}; the scratch environment cannot establish inheritance.`);
4100
+ if (f.findings[0]) {
4101
+ lines.push(` ${findingDiagnostic(f.findings[0]).replace(/\n/g, "\n ")}`);
4102
+ }
4103
+ } else {
4104
+ lines.push(` UNVERIFIED ${f.id}${f.findings[0] ? ` \u2014 ${findingDiagnostic(f.findings[0])}` : ""}`);
4105
+ }
4106
+ }
2574
4107
  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.`
4108
+ return refuseWithRecovery(
4109
+ [`${introduced.length} introduced by this branch. ${integration} and ${greenRef} were not advanced.`],
4110
+ "Fix it there and land again; recovery-ref cleanup remains operator-owned."
4111
+ );
4112
+ }
4113
+ if (unverified.length || !baseAttributable || !controlAttributable) {
4114
+ const unverifiedCount = unverified.reduce((total, failure) => total + failure.findings.length, 0);
4115
+ return refuseWithRecovery(
4116
+ [
4117
+ `${unverifiedCount || res.failing.length} failure(s) could not be verified as inherited. ${integration} and ${greenRef} were not advanced.`
4118
+ ],
4119
+ `Run land from a clean detached checkout at ${integration} (${before.slice(0, 8)}) with the gate runtime available, then retry.`
2578
4120
  );
2579
- return { ok: false, lines };
2580
4121
  }
2581
4122
  lines.push(
2582
4123
  `${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
4124
  );
2584
4125
  git(scratch, ["reset", "--hard", merged]);
2585
4126
  }
4127
+ const lateManaged = managedRefsState(root, integration, greenRef);
4128
+ if (!lateManaged.value) {
4129
+ return refuseWithRecovery(
4130
+ [
4131
+ "managed-ref identity and checkout state could not be revalidated after verification, so the atomic advance is refused.",
4132
+ ` ${lateManaged.error}`
4133
+ ],
4134
+ `Re-run \`rungs land ${branch}\` after ref identity and checkout state can be verified.`
4135
+ );
4136
+ }
4137
+ const { integration: lateIntegration, green: lateGreen } = lateManaged.value;
4138
+ const lateHolder = [lateIntegration, lateGreen].find((ref) => ref.holders.length);
4139
+ if (lateHolder) {
4140
+ const name = lateHolder.ref.slice("refs/heads/".length);
4141
+ return refuseWithRecovery(
4142
+ [
4143
+ `'${name}' became checked out in ${lateHolder.holders.length} worktree(s) while this land was verifying, so the atomic ref advance is refused:`,
4144
+ ...lateHolder.holders.map((path) => ` ${path}`),
4145
+ "Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry."
4146
+ ],
4147
+ `Re-run \`rungs land ${branch}\` to rebuild the merge after releasing the branch.`
4148
+ );
4149
+ }
4150
+ if (lateIntegration.oid !== initialIntegration.oid || lateGreen.oid !== initialGreen.oid) {
4151
+ const moved = [
4152
+ ...lateIntegration.oid !== initialIntegration.oid ? [integration] : [],
4153
+ ...lateGreen.oid !== initialGreen.oid ? [greenRef] : []
4154
+ ];
4155
+ return refuseWithRecovery(
4156
+ [`${moved.join(" and ")} moved while this land was verifying, so the atomic advance was refused rather than overwriting concurrent work.`],
4157
+ `Re-run \`rungs land ${branch}\` to rebuild the merge on the new managed-ref state.`
4158
+ );
4159
+ }
2586
4160
  try {
2587
- git(root, ["update-ref", `refs/heads/${integration}`, merged, before]);
4161
+ advanceVerifiedRefs(root, initialIntegration, initialGreen, merged);
2588
4162
  } 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
- };
4163
+ return refuseWithRecovery(
4164
+ [`${integration} or ${greenRef} moved, became symbolic, or could not be locked while this land was verifying. The atomic managed-ref transaction was refused, so Rungs did not partially update either ref.`],
4165
+ `Re-run \`rungs land ${branch}\` to rebuild the merge after inspecting the competing ref state.`
4166
+ );
2598
4167
  }
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}`]);
4168
+ lines.push(`${integration} and ${greenRef} \u2192 ${merged.slice(0, 8)} in one atomic verified-ref transaction.`);
4169
+ lines.push("Existing recovery refs are retained; cleanup remains an explicit operator decision.");
2602
4170
  return { ok: true, lines };
2603
4171
  } finally {
2604
- try {
2605
- git(root, ["worktree", "remove", "--force", scratch]);
2606
- } catch {
2607
- rmSync2(scratch, { recursive: true, force: true });
4172
+ if (!preserveScratch) {
2608
4173
  try {
2609
- git(root, ["worktree", "prune"]);
4174
+ git(root, ["worktree", "remove", "--force", scratch]);
2610
4175
  } catch {
4176
+ rmSync2(scratch, { recursive: true, force: true });
4177
+ try {
4178
+ git(root, ["worktree", "prune"]);
4179
+ } catch {
4180
+ }
2611
4181
  }
2612
4182
  }
2613
4183
  try {
@@ -2636,10 +4206,40 @@ function worktrees(root) {
2636
4206
  return { rows, integration };
2637
4207
  }
2638
4208
 
4209
+ // src/help.ts
4210
+ var COMMANDS = [
4211
+ ["init [path] [profile]", "scaffold a repo \u2014 minimal \xB7 tracked \xB7 disciplined \xB7 hardened \xB7 fleet"],
4212
+ ["doctor [path]", "detect what a repo already has, installed or not"],
4213
+ ["add <module\u2026> [--into p]", "install modules, resolving dependencies and adopting what exists"],
4214
+ ["check [path] [tier]", "run the registered gates and record the ledger"],
4215
+ ["render [path]", "re-emit path-scoped rules per harness"],
4216
+ ["upgrade [path]", "move to newer module versions, never touching what you edited"],
4217
+ ["eject [path]", "materialise the engines; stop depending on rungs"],
4218
+ ["setup git [path]", "install the merge drivers .gitattributes names"],
4219
+ ["modules", "list the module set and audit the manifests"],
4220
+ ["backlog archive [path]", "move finished items to archive/, repointing every link"],
4221
+ ["session start <branch>", "cut a branch and worktree from the last verified merge"],
4222
+ ["preflight [path]", "did the integration branch change files you changed?"],
4223
+ ["land <branch>", "merge \u2192 verify the merged tree \u2192 advance, or refuse and park it"],
4224
+ ["worktrees [path]", "which worktrees are merged, prunable, or merged and still dirty"]
4225
+ ];
4226
+ var FLAGS = [
4227
+ ["--dry-run", "report what would happen, write nothing"],
4228
+ ["--explain", "doctor: also run the detectors over what this repo already has"],
4229
+ ["--confirm-paradigm", "add: install a module this repo already solves another way"],
4230
+ ["--confirm-conflict", "add: install a module that declares a conflict with one already here"],
4231
+ ["--into <path>", "add: install into this repo instead of the working directory"],
4232
+ ["--set m.param=value", "add/init: override a module parameter. Repeatable"],
4233
+ ["--confirm-threshold", "add: install a module whose rung is above this repo"],
4234
+ ["--apply", "upgrade: write the changes, rather than preview them"],
4235
+ ["--fast, --full", "check: pick the gate tier, as the positional also does"],
4236
+ ["--params", "modules: show every module parameter, its default and its allowed values"],
4237
+ ["--copilot", "also emit Copilot instruction files"]
4238
+ ];
4239
+
2639
4240
  // src/cli.ts
2640
- import { existsSync as existsSync11 } from "node:fs";
2641
- var HERE = dirname9(fileURLToPath4(import.meta.url));
2642
- var MODULES2 = join14(HERE, "..", "modules");
4241
+ var HERE = dirname11(fileURLToPath5(import.meta.url));
4242
+ var MODULES2 = join15(HERE, "..", "modules");
2643
4243
  var c = {
2644
4244
  dim: (s) => `\x1B[2m${s}\x1B[0m`,
2645
4245
  bold: (s) => `\x1B[1m${s}\x1B[0m`,
@@ -2648,6 +4248,13 @@ var c = {
2648
4248
  green: (s) => `\x1B[32m${s}\x1B[0m`,
2649
4249
  cyan: (s) => `\x1B[36m${s}\x1B[0m`
2650
4250
  };
4251
+ function pathRefusal(error) {
4252
+ if (!(error instanceof UnsafeEmittedPathError)) throw error;
4253
+ console.log(c.red(`
4254
+ refused: ${error.message}
4255
+ `) + c.dim(" Nothing was written. Fix the named path or repository alias and retry.\n"));
4256
+ return 1;
4257
+ }
2651
4258
  var STATE_LABEL = {
2652
4259
  absent: c.dim("absent"),
2653
4260
  "ours-current": c.green("ours"),
@@ -2663,8 +4270,12 @@ ${mods.length} modules
2663
4270
  `));
2664
4271
  for (const m of mods) {
2665
4272
  const deps = m.requires.length ? c.dim(` \u2190 ${m.requires.join(", ")}`) : "";
2666
- console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}`);
4273
+ const designed = m.provenance.kind === "designed" ? c.yellow(" designed") : "";
4274
+ console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}${designed}`);
2667
4275
  console.log(` ${" ".repeat(14)} ${c.dim(m.summary)}`);
4276
+ if (designed) {
4277
+ console.log(` ${" ".repeat(14)} ${c.dim(`not extracted \u2014 ${firstSentence(m.provenance.rationale ?? "")}`)}`);
4278
+ }
2668
4279
  if (!showParams) continue;
2669
4280
  for (const [name, spec] of Object.entries(m.params)) {
2670
4281
  const shown = spec.default === void 0 ? c.dim("(none)") : JSON.stringify(spec.default);
@@ -2697,13 +4308,18 @@ ${mods.length} modules
2697
4308
  return issues.length === 0 ? 0 : 1;
2698
4309
  }
2699
4310
  function cmdDoctor(target, doExplain = false) {
2700
- const root = resolve5(target);
4311
+ const root = resolve7(target);
2701
4312
  const mods = loadAllModules(MODULES2);
2702
4313
  console.log(c.bold(`
2703
4314
  rungs doctor \u2014 ${root}
2704
4315
  `));
2705
4316
  const files = scanRepo(root);
2706
- const record = readRecord(root);
4317
+ let record;
4318
+ try {
4319
+ record = readRecord(root);
4320
+ } catch (error) {
4321
+ return pathRefusal(error);
4322
+ }
2707
4323
  console.log(
2708
4324
  c.dim(` scanned ${files.length} files`) + (record ? c.dim(` \xB7 installed ${Object.keys(record.modules).length} module(s)`) : c.dim(" \xB7 not a rungs repo")) + "\n"
2709
4325
  );
@@ -2711,10 +4327,15 @@ rungs doctor \u2014 ${root}
2711
4327
  Object.entries(record?.modules ?? {}).flatMap(([n, e]) => e.params ? [[n, e.params]] : [])
2712
4328
  ), root);
2713
4329
  const skillsDir = record?.harnesses.includes("claude") === false ? ".agents/skills" : ".claude/skills";
2714
- const results = mods.map((m) => {
2715
- const installed = record?.modules[m.name];
2716
- return detect(m, root, files, installed ? { ...installed, skillsDir, params_all: params } : void 0);
2717
- });
4330
+ let results;
4331
+ try {
4332
+ results = mods.map((m) => {
4333
+ const installed = record?.modules[m.name];
4334
+ return detect(m, root, files, installed ? { ...installed, skillsDir, params_all: params } : void 0);
4335
+ });
4336
+ } catch (error) {
4337
+ return pathRefusal(error);
4338
+ }
2718
4339
  const byState = (s) => results.filter((r) => r.state === s);
2719
4340
  for (const r of results) {
2720
4341
  const mod = mods.find((m) => m.name === r.module);
@@ -2916,8 +4537,27 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
2916
4537
  }
2917
4538
  console.log(c.dim(" You will have two systems for one job. That is a choice, not a merge.\n"));
2918
4539
  }
4540
+ let record;
4541
+ try {
4542
+ record = readRecord(root);
4543
+ } catch (error) {
4544
+ return pathRefusal(error);
4545
+ }
4546
+ const present = /* @__PURE__ */ new Set([...Object.keys(record?.modules ?? {}), ...order.map((m) => m.name)]);
4547
+ const declared = blockedByConflict(order, present, mods);
4548
+ const conflictOverride = flags.has("--confirm-conflict");
4549
+ if (conflictOverride && declared.size) {
4550
+ for (const [name, clash] of declared) {
4551
+ if (clash.cause !== name) continue;
4552
+ console.log(
4553
+ c.yellow(` ${name}: installing alongside ${clash.with}, which it declares a conflict with`) + c.dim(" \u2014 --confirm-conflict")
4554
+ );
4555
+ }
4556
+ console.log(c.dim(" The author said these two do not coexist. You are overruling them, not merging them.\n"));
4557
+ }
4558
+ const conflicts = conflictOverride ? /* @__PURE__ */ new Map() : declared;
2919
4559
  let toInstall = order;
2920
- if (blocked.size) {
4560
+ if (blocked.size || conflicts.size) {
2921
4561
  for (const mod of order) {
2922
4562
  const cause = blocked.get(mod.name);
2923
4563
  if (!cause) continue;
@@ -2931,17 +4571,55 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
2931
4571
  console.log(c.yellow(` ${mod.name}: not installed \u2014 it requires ${cause}.`));
2932
4572
  }
2933
4573
  }
2934
- toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
2935
- const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
4574
+ for (const mod of order) {
4575
+ const clash = conflicts.get(mod.name);
4576
+ if (!clash || blocked.has(mod.name)) continue;
4577
+ if (clash.cause === mod.name) {
4578
+ const declarer = mod.conflicts.includes(clash.with) ? mod.name : clash.with;
4579
+ console.log(c.yellow(` ${mod.name}: conflicts with ${clash.with}`));
4580
+ console.log(
4581
+ c.dim(` declared by ${declarer}`) + c.dim(present.has(clash.with) && !order.some((m) => m.name === clash.with) ? ", which this repo already has" : ", and both were requested")
4582
+ );
4583
+ } else {
4584
+ console.log(c.yellow(` ${mod.name}: not installed \u2014 it requires ${clash.cause}.`));
4585
+ }
4586
+ }
4587
+ const refused = /* @__PURE__ */ new Set([...blocked.keys(), ...conflicts.keys()]);
4588
+ toInstall = resolveInstallOrder(names.filter((n) => !refused.has(n)), mods).order;
4589
+ const dropped = order.filter((m) => !toInstall.includes(m) && !refused.has(m.name));
2936
4590
  if (dropped.length) {
2937
4591
  console.log(c.dim(` ${dropped.map((m) => m.name).join(", ")} not written \u2014 pulled in only for the above`));
2938
4592
  }
4593
+ const escapes = [blocked.size ? "--confirm-paradigm" : "", conflicts.size ? "--confirm-conflict" : ""].filter(Boolean);
2939
4594
  console.log(
2940
4595
  c.dim(`
2941
- Pass --confirm-paradigm to install anyway.`) + (toInstall.length ? c.dim(" Continuing with the rest.\n") : c.dim(" Nothing was written.\n"))
4596
+ Pass ${escapes.join(" / ")} to install anyway.`) + (toInstall.length ? c.dim(" Continuing with the rest.\n") : c.dim(" Nothing was written.\n"))
2942
4597
  );
2943
4598
  if (!toInstall.length) return 1;
2944
4599
  }
4600
+ for (const mod of toInstall.filter((m) => m.provenance.kind === "designed")) {
4601
+ console.log(
4602
+ c.yellow(` ${mod.name}: designed, not extracted`) + c.dim(` \u2014 ${firstSentence(mod.provenance.rationale ?? "")}`)
4603
+ );
4604
+ }
4605
+ const actualInstall = toInstall.filter(
4606
+ (mod) => !(mod.threshold?.confirm && !dryRun && !flags.has("--confirm-threshold"))
4607
+ );
4608
+ try {
4609
+ preflightModuleEmissions(actualInstall, root, params, skillsDir);
4610
+ preflightRender(
4611
+ root,
4612
+ harnesses,
4613
+ prospectiveRuleEmissions(actualInstall, params, skillsDir),
4614
+ [
4615
+ ...moduleEmissionCandidates(actualInstall, params, skillsDir),
4616
+ { moduleName: "rungs", target: ".ai/gates.toml", shared: true, writeExisting: true },
4617
+ { moduleName: "rungs", target: ".ai/rungs.toml", writeExisting: true }
4618
+ ]
4619
+ );
4620
+ } catch (error) {
4621
+ return pathRefusal(error);
4622
+ }
2945
4623
  const installed = [];
2946
4624
  const wrote = /* @__PURE__ */ new Map();
2947
4625
  for (const mod of toInstall) {
@@ -2978,7 +4656,7 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
2978
4656
  registered ${gateActions.reduce((n, a) => n + Number(a.note.split(": ")[1].split(" ")[0]), 0)} gates from ${gateActions.length} module(s)`));
2979
4657
  }
2980
4658
  if (!dryRun) {
2981
- writeInstallRecord(root, order, params, harnesses, stamp, skillsDir, wrote);
4659
+ writeInstallRecord(root, installed, params, harnesses, stamp, skillsDir, wrote);
2982
4660
  const entries = render(root, harnesses);
2983
4661
  writeReport(root, entries, harnesses, stamp);
2984
4662
  console.log(
@@ -2990,6 +4668,11 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
2990
4668
  return 0;
2991
4669
  }
2992
4670
  function cmdRender(root, harnesses, stamp) {
4671
+ try {
4672
+ preflightRender(root, harnesses);
4673
+ } catch (error) {
4674
+ return pathRefusal(error);
4675
+ }
2993
4676
  const entries = render(root, harnesses);
2994
4677
  writeReport(root, entries, harnesses, stamp);
2995
4678
  console.log(c.bold(`
@@ -3020,11 +4703,18 @@ function landRunner(dir, only) {
3020
4703
  const failing = runs.filter((r) => r.status === "fail" || r.status === "error");
3021
4704
  return {
3022
4705
  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.
4706
+ // Keep a stable comparison identity separate from the operator-facing
4707
+ // diagnostic. `file: identity` makes the same broken link in the same file
4708
+ // the same finding across runs, while command diagnostics can stay complete.
3025
4709
  failing: failing.map((r) => ({
3026
4710
  id: r.id,
3027
- findings: r.findings.map((f) => `${f.file ? `${f.file}: ` : ""}${f.message}`)
4711
+ findings: r.findings.map((f) => {
4712
+ const prefix = f.file ? `${f.file}: ` : "";
4713
+ return {
4714
+ identity: `${prefix}${f.identity ?? f.message}`,
4715
+ diagnostic: `${prefix}${f.message}`
4716
+ };
4717
+ })
3028
4718
  }))
3029
4719
  };
3030
4720
  }
@@ -3085,7 +4775,7 @@ rungs check \u2014 ${root}${tier ? ` (${tier} tier)` : ""}
3085
4775
  ` ${mark[r.status]} ${r.id.padEnd(34)} ${c.dim(`${r.ms}ms`)}` + (r.examined ? c.dim(` ${r.examined} examined`) : "")
3086
4776
  );
3087
4777
  for (const f of r.findings.slice(0, 4)) {
3088
- console.log(` ${c.dim(f.file ? `${f.file}: ` : "")}${f.message}`);
4778
+ console.log(` ${c.dim(f.file ? `${f.file}: ` : "")}${f.message.replace(/\n/g, "\n ")}`);
3089
4779
  }
3090
4780
  if (r.findings.length > 4) console.log(c.dim(` \u2026and ${r.findings.length - 4} more`));
3091
4781
  }
@@ -3119,16 +4809,28 @@ function reportLedger(root) {
3119
4809
  console.log(c.dim(" directly, and CI runs, are not counted.\n"));
3120
4810
  }
3121
4811
  function cmdBacklogArchive(root, dryRun) {
3122
- const record = readRecord(root);
4812
+ let record;
4813
+ try {
4814
+ record = readRecord(root);
4815
+ } catch (error) {
4816
+ return pathRefusal(error);
4817
+ }
3123
4818
  const configured = record?.modules["backlog"]?.params?.root;
3124
4819
  const backlogRoot = `docs/${configured ?? "backlog"}`;
3125
- if (!existsSync11(join14(root, ...backlogRoot.split("/"), "items"))) {
4820
+ let tree;
4821
+ let plan;
4822
+ try {
4823
+ tree = resolveArchiveTree(root, backlogRoot);
4824
+ plan = planArchive(root, backlogRoot);
4825
+ } catch (error) {
4826
+ return pathRefusal(error);
4827
+ }
4828
+ if (!tree.itemsExists) {
3126
4829
  console.log(c.red(`
3127
4830
  no backlog at ${backlogRoot}/items
3128
4831
  `));
3129
4832
  return 1;
3130
4833
  }
3131
- const plan = planArchive(root, backlogRoot);
3132
4834
  console.log(c.bold(`
3133
4835
  rungs backlog archive \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
3134
4836
  `));
@@ -3155,14 +4857,24 @@ rungs backlog archive \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
3155
4857
  console.log(c.dim("\n Nothing written. Drop --dry-run to apply.\n"));
3156
4858
  return 0;
3157
4859
  }
3158
- applyArchive(root, plan);
4860
+ try {
4861
+ applyArchive(root, plan);
4862
+ } catch (error) {
4863
+ return pathRefusal(error);
4864
+ }
3159
4865
  console.log(c.green(`
3160
4866
  archived ${plan.moves.length} item(s)`) + c.dim(" \u2014 ids stay spent and every citation still resolves."));
3161
4867
  console.log(c.dim(" Run `rungs check` to confirm.\n"));
3162
4868
  return 0;
3163
4869
  }
3164
4870
  function cmdInit(root, profile, dryRun, harnesses, stamp) {
3165
- if (readRecord(root)) {
4871
+ let existing;
4872
+ try {
4873
+ existing = readRecord(root);
4874
+ } catch (error) {
4875
+ return pathRefusal(error);
4876
+ }
4877
+ if (existing) {
3166
4878
  console.log(
3167
4879
  c.yellow("\n this repo is already initialised.") + c.dim(" Use `rungs add <module>` to install more, or `rungs upgrade`.\n")
3168
4880
  );
@@ -3180,13 +4892,23 @@ function cmdInit(root, profile, dryRun, harnesses, stamp) {
3180
4892
  return cmdAdd(names, root, dryRun, harnesses, stamp);
3181
4893
  }
3182
4894
  function cmdUpgrade(root, apply) {
3183
- const record = readRecord(root);
4895
+ let record;
4896
+ try {
4897
+ record = readRecord(root);
4898
+ } catch (error) {
4899
+ return pathRefusal(error);
4900
+ }
3184
4901
  if (!record) {
3185
4902
  console.log(c.yellow("\n not a rungs repo \u2014 nothing to upgrade.\n"));
3186
4903
  return 1;
3187
4904
  }
3188
4905
  const mods = loadAllModules(MODULES2);
3189
- const plan = planUpgrade(root, mods, record);
4906
+ let plan;
4907
+ try {
4908
+ plan = planUpgrade(root, mods, record);
4909
+ } catch (error) {
4910
+ return pathRefusal(error);
4911
+ }
3190
4912
  console.log(c.bold(`
3191
4913
  rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
3192
4914
  `));
@@ -3203,7 +4925,13 @@ rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
3203
4925
  }
3204
4926
  }
3205
4927
  if (apply) {
3206
- const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
4928
+ let result;
4929
+ try {
4930
+ result = applyUpgrade(root, mods, record, plan);
4931
+ } catch (error) {
4932
+ return pathRefusal(error);
4933
+ }
4934
+ const { written, gates, recorded } = result;
3207
4935
  const parts = [
3208
4936
  written ? `${written} file(s)` : "",
3209
4937
  gates ? `${gates} gate registration(s)` : "",
@@ -3220,7 +4948,13 @@ rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
3220
4948
  return 0;
3221
4949
  }
3222
4950
  function cmdEject(root, dryRun) {
3223
- if (!readRecord(root)) {
4951
+ let record;
4952
+ try {
4953
+ record = readRecord(root);
4954
+ } catch (error) {
4955
+ return pathRefusal(error);
4956
+ }
4957
+ if (!record) {
3224
4958
  console.log(c.yellow("\n not a rungs repo \u2014 nothing to eject.\n"));
3225
4959
  return 1;
3226
4960
  }
@@ -3237,34 +4971,6 @@ rungs eject \u2014 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
3237
4971
  return 0;
3238
4972
  }
3239
4973
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["--set"]);
3240
- var COMMANDS = [
3241
- ["init [path] [profile]", "scaffold a repo \u2014 minimal \xB7 tracked \xB7 disciplined \xB7 hardened \xB7 fleet"],
3242
- ["doctor [path]", "detect what a repo already has, installed or not"],
3243
- ["add <module\u2026> [--into p]", "install modules, resolving dependencies and adopting what exists"],
3244
- ["check [path] [tier]", "run the registered gates and record the ledger"],
3245
- ["render [path]", "re-emit path-scoped rules per harness"],
3246
- ["upgrade [path]", "move to newer module versions, never touching what you edited"],
3247
- ["eject [path]", "materialise the engines; stop depending on rungs"],
3248
- ["setup git [path]", "install the merge drivers .gitattributes names"],
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"]
3255
- ];
3256
- var FLAGS = [
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"],
3260
- ["--into <path>", "add: install into this repo instead of the working directory"],
3261
- ["--set m.param=value", "add/init: override a module parameter. Repeatable"],
3262
- ["--confirm-threshold", "add: install a module whose rung is above this repo"],
3263
- ["--apply", "upgrade: write the changes, rather than preview them"],
3264
- ["--fast, --full", "check: pick the gate tier, as the positional also does"],
3265
- ["--params", "modules: show every module parameter, its default and its allowed values"],
3266
- ["--copilot", "also emit Copilot instruction files"]
3267
- ];
3268
4974
  function renderHelp() {
3269
4975
  const pad = Math.max(...COMMANDS.map(([u]) => u.length)) + 2;
3270
4976
  const fpad = Math.max(...FLAGS.map(([f]) => f.length)) + 2;
@@ -3330,20 +5036,20 @@ switch (cmd) {
3330
5036
  unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
3331
5037
  process.exit(1);
3332
5038
  }
3333
- process.exit(cmdBacklogArchive(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run")));
5039
+ process.exit(cmdBacklogArchive(resolve7(args[1] ?? process.cwd()), flags.has("--dry-run")));
3334
5040
  }
3335
5041
  case "check": {
3336
5042
  const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
3337
- process.exit(cmdCheck(resolve5(args[0] ?? process.cwd()), tier, STAMP));
5043
+ process.exit(cmdCheck(resolve7(args[0] ?? process.cwd()), tier, STAMP));
3338
5044
  }
3339
5045
  case "init": {
3340
5046
  const profile = args[1] ?? "tracked";
3341
- process.exit(cmdInit(resolve5(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
5047
+ process.exit(cmdInit(resolve7(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
3342
5048
  }
3343
5049
  case "upgrade":
3344
- process.exit(cmdUpgrade(resolve5(args[0] ?? process.cwd()), flags.has("--apply")));
5050
+ process.exit(cmdUpgrade(resolve7(args[0] ?? process.cwd()), flags.has("--apply")));
3345
5051
  case "eject":
3346
- process.exit(cmdEject(resolve5(args[0] ?? process.cwd()), flags.has("--dry-run")));
5052
+ process.exit(cmdEject(resolve7(args[0] ?? process.cwd()), flags.has("--dry-run")));
3347
5053
  case "setup": {
3348
5054
  if (args[0] !== "git") {
3349
5055
  console.log(
@@ -3352,7 +5058,7 @@ switch (cmd) {
3352
5058
  );
3353
5059
  process.exit(1);
3354
5060
  }
3355
- const r = setupGit(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run"));
5061
+ const r = setupGit(resolve7(args[1] ?? process.cwd()), flags.has("--dry-run"));
3356
5062
  console.log(
3357
5063
  r.drivers.length ? `
3358
5064
  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")
@@ -3360,7 +5066,7 @@ switch (cmd) {
3360
5066
  process.exit(0);
3361
5067
  }
3362
5068
  case "render":
3363
- process.exit(cmdRender(resolve5(args[0] ?? process.cwd()), HARNESSES, STAMP));
5069
+ process.exit(cmdRender(resolve7(args[0] ?? process.cwd()), HARNESSES, STAMP));
3364
5070
  case "session": {
3365
5071
  if (args[0] !== "start") {
3366
5072
  console.log(c.red(`
@@ -3370,15 +5076,15 @@ switch (cmd) {
3370
5076
  process.exit(report(sessionStart(process.cwd(), args[1], args[2], flags.has("--dry-run"))));
3371
5077
  }
3372
5078
  case "preflight":
3373
- process.exit(report(preflight(resolve5(args[0] ?? process.cwd()))));
5079
+ process.exit(report(preflight(resolve7(args[0] ?? process.cwd()))));
3374
5080
  case "land":
3375
5081
  process.exit(report(land(process.cwd(), args[0], landRunner, flags.has("--dry-run"))));
3376
5082
  case "worktrees":
3377
- process.exit(cmdWorktrees(resolve5(args[0] ?? process.cwd())));
5083
+ process.exit(cmdWorktrees(resolve7(args[0] ?? process.cwd())));
3378
5084
  case "add": {
3379
5085
  const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
3380
5086
  const names = flags.has("--into") ? args.slice(0, -1) : args;
3381
- process.exit(cmdAdd(names, resolve5(target), flags.has("--dry-run"), HARNESSES, STAMP));
5087
+ process.exit(cmdAdd(names, resolve7(target), flags.has("--dry-run"), HARNESSES, STAMP));
3382
5088
  }
3383
5089
  default: {
3384
5090
  const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";