@rungs/cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -31,13 +31,35 @@ const STATE_LABEL: Record<DetectResult['state'], string> = {
31
31
  unknown: c.red('unknown'),
32
32
  };
33
33
 
34
- function cmdModules() {
34
+ function cmdModules(showParams = false) {
35
35
  const mods = loadAllModules(MODULES);
36
36
  console.log(c.bold(`\n${mods.length} modules\n`));
37
37
  for (const m of mods) {
38
38
  const deps = m.requires.length ? c.dim(` ← ${m.requires.join(', ')}`) : '';
39
39
  console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}`);
40
40
  console.log(` ${' '.repeat(14)} ${c.dim(m.summary)}`);
41
+ // Rendered from the manifest at the moment it is asked for, never written down. A committed
42
+ // parameter table would be correct the day it was generated and silently wrong the day a
43
+ // default moved — which is the failure this flag exists to answer (WI-006).
44
+ if (!showParams) continue;
45
+ for (const [name, spec] of Object.entries(m.params)) {
46
+ const shown = spec.default === undefined ? c.dim('(none)') : JSON.stringify(spec.default);
47
+ const notes = [
48
+ spec.allowed ? `one of ${spec.allowed.map(String).join(' · ')}` : '',
49
+ // Behavioural parameters never appear as {{token}}, so a reader hunting for one in a
50
+ // template would conclude the parameter was dead. Say so where they meet it.
51
+ spec.consumed_by ? `behavioural — changes what \`${spec.consumed_by}\` does, not a template` : '',
52
+ spec.required ? 'required' : '',
53
+ ].filter(Boolean);
54
+ console.log(` ${' '.repeat(14)} ${c.cyan(`${m.name}.${name}`.padEnd(30))} ${c.dim('=')} ${shown}`);
55
+ if (spec.description) console.log(` ${' '.repeat(16)} ${c.dim(firstSentence(spec.description))}`);
56
+ for (const n of notes) console.log(` ${' '.repeat(16)} ${c.dim(n)}`);
57
+ }
58
+ if (Object.keys(m.params).length) console.log();
59
+ }
60
+ if (showParams) {
61
+ console.log(c.dim(' Set one with `--set module.param=value` on `add` or `init`; either spelling works.'));
62
+ console.log(c.dim(' Resolved values are recorded in `.ai/rungs.toml`. See docs/design/parameters.md.\n'));
41
63
  }
42
64
 
43
65
  const issues = auditModules(mods);
@@ -67,7 +89,7 @@ function cmdDoctor(target: string) {
67
89
 
68
90
  const params = resolveParams(mods, Object.fromEntries(
69
91
  Object.entries(record?.modules ?? {}).flatMap(([n, e]) => (e.params ? [[n, e.params]] : [])),
70
- ));
92
+ ), root);
71
93
  const skillsDir = record?.harnesses.includes('claude') === false ? '.agents/skills' : '.claude/skills';
72
94
  const results = mods.map((m) => {
73
95
  const installed = record?.modules[m.name];
@@ -130,6 +152,33 @@ function cmdDoctor(target: string) {
130
152
  console.log(c.dim(' This reports presence, never quality. It cannot tell whether an adopted'));
131
153
  console.log(c.dim(' system is good, complete, or working — only that files are where a'));
132
154
  console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
155
+
156
+ // `doctor` is the command the README makes the entry point, and it used to stop on the sentence
157
+ // above — fifteen `absent` lines and nothing to do next. The recommendation is deliberately a
158
+ // **single** command, and never the maximal one: the brief names selling rung 5 to a rung-1 repo
159
+ // as the most likely way this tool does harm, so a repo with nothing is pointed at `tracked`
160
+ // rather than at the fifteen things it could install (WI-005).
161
+ const theirs = byState('theirs');
162
+ console.log(c.bold(' Next\n'));
163
+ if (ours) {
164
+ const behind = results.some((r) => r.ours?.stale.length || r.ours?.missing.length);
165
+ console.log(
166
+ behind
167
+ ? ` ${c.cyan('rungs upgrade --apply')} ${c.dim('— bring the stale and missing files up to date')}`
168
+ : ` ${c.cyan('rungs check')} ${c.dim('— run the gates this repo already registered')}`,
169
+ );
170
+ console.log(c.dim(` Add more with \`rungs add <module>\`; \`rungs modules\` lists the set.\n`));
171
+ } else if (theirs.length) {
172
+ const names = theirs.map((r) => r.module).slice(0, 3).join(' ');
173
+ console.log(` ${c.cyan(`rungs add ${names}`)} ${c.dim('— adopt what you already built, in place')}`);
174
+ console.log(c.dim(' Nothing is overwritten. Files you already have are kept and reported as'));
175
+ console.log(c.dim(' yours; only what is missing gets written.\n'));
176
+ } else {
177
+ console.log(` ${c.cyan('rungs init . tracked')} ${c.dim('— instructions · gates · backlog · findings · adr · session')}`);
178
+ console.log(c.dim(' `tracked` is the rung for more than one thing in flight. `minimal` is just'));
179
+ console.log(c.dim(' the entry document; higher profiles cost more than they return until the'));
180
+ console.log(c.dim(' problem they answer actually exists. `rungs modules` lists all fifteen.\n'));
181
+ }
133
182
  return 0;
134
183
  }
135
184
 
@@ -145,7 +194,29 @@ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harne
145
194
  return 1;
146
195
  }
147
196
  const pulled = order.filter((m) => !names.includes(m.name));
148
- const params = resolveParams(mods);
197
+
198
+ // `--set module.param=value`. Without it the first real install into a repo
199
+ // that already had a backlog would have created a second one beside it —
200
+ // `docs/backlog/` next to `docs/.ai/backlog/` — which is the "two places to
201
+ // look" failure this whole tool is against, arriving through the installer.
202
+ //
203
+ // Values arrive already split from their flag, in either spelling. A malformed
204
+ // key is refused rather than skipped: `--set root=x` used to be dropped in
205
+ // silence, so the install proceeded with the default and looked successful.
206
+ const overrides: Record<string, Record<string, unknown>> = {};
207
+ for (const raw of flagValues['--set'] ?? []) {
208
+ const [key, ...rhs] = raw.split('=');
209
+ const [modName, param] = key.split('.');
210
+ if (!modName || !param || !rhs.length) {
211
+ console.log(c.red(`\n --set expects module.param=value, got: ${raw}\n`));
212
+ return 1;
213
+ }
214
+ (overrides[modName] ??= {})[param] = rhs.join('=');
215
+ }
216
+ const params = resolveParams(mods, overrides, root);
217
+ for (const [m, vals] of Object.entries(overrides)) {
218
+ for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
219
+ }
149
220
  const skillsDir = harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
150
221
 
151
222
  console.log(c.bold(`\nrungs add ${names.join(' ')} → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
@@ -217,6 +288,14 @@ function cmdRender(root: string, harnesses: Harness[], stamp: string) {
217
288
  console.log(` ${e.rule.padEnd(24)} ${e.harness.padEnd(10)} ${e.target ?? c.yellow('not emitted')}${lost}`);
218
289
  }
219
290
  console.log(c.dim(`\n ${entries.length} rendering(s) → .ai/render-report.md\n`));
291
+ // A bare `0 rendering(s)` reads as a completed edit. It is the answer a user gets after editing a
292
+ // parameter in `.ai/rungs.toml` and running this — the thing the record's header used to tell
293
+ // them to do — so the zero case has to say what it did not do, not just how much of it (WI-003).
294
+ if (entries.length === 0) {
295
+ console.log(c.yellow(' Nothing to render.') + c.dim(' This command re-emits path-scoped rules from `.ai/rules/`.'));
296
+ console.log(c.dim(' It does not re-substitute parameters — a changed value in `.ai/rungs.toml`'));
297
+ console.log(c.dim(' does not rewrite a file that already exists.\n'));
298
+ }
220
299
  return 0;
221
300
  }
222
301
 
@@ -345,9 +424,103 @@ function cmdEject(root: string, dryRun: boolean) {
345
424
  return 0;
346
425
  }
347
426
 
427
+ /**
428
+ * Flags that carry a value, and therefore consume the token after them unless it is attached with
429
+ * `=`. Everything else is a bare switch.
430
+ *
431
+ * This exists because the split below used to be two filters — `startsWith('--')` into flags,
432
+ * everything else into positionals — which has no concept of a value. `--set backlog.root=x` then
433
+ * left `backlog.root=x` sitting in the positionals, `--into` took the last positional as its
434
+ * target, and the user's actual path was reported back to them as an unknown module. Both
435
+ * spellings now work, and the value never reaches `args` (WI-002).
436
+ */
437
+ const VALUE_FLAGS = new Set(['--set']);
438
+
439
+ /**
440
+ * The command surface, defined once and rendered into `--help`.
441
+ *
442
+ * It was a template literal listing eight of the nine commands — `setup git` was missing entirely —
443
+ * beside a README table listing all nine, which is two hand-kept inventories of one fact. They had
444
+ * already drifted, in both directions: help omitted a real command, and three real flags appeared
445
+ * in neither. Keep this table beside the switch it describes, and add a row when you add a `case`.
446
+ *
447
+ * The README's table is still hand-kept and still a second inventory. That is a known cost, not an
448
+ * oversight — see WI-004.
449
+ */
450
+ const COMMANDS: [usage: string, blurb: string][] = [
451
+ ['init [path] [profile]', 'scaffold a repo — minimal · tracked · disciplined · hardened · fleet'],
452
+ ['doctor [path]', 'detect what a repo already has, installed or not'],
453
+ ['add <module…> [--into p]', 'install modules, resolving dependencies and adopting what exists'],
454
+ ['check [path] [tier]', 'run the registered gates and record the ledger'],
455
+ ['render [path]', 're-emit path-scoped rules per harness'],
456
+ ['upgrade [path]', 'move to newer module versions, never touching what you edited'],
457
+ ['eject [path]', 'materialise the engines; stop depending on rungs'],
458
+ ['setup git [path]', 'install the merge drivers .gitattributes names'],
459
+ ['modules', 'list the module set and audit the manifests'],
460
+ ];
461
+
462
+ /** Every flag the parser honours. A flag absent here is a flag nobody can find. */
463
+ const FLAGS: [flag: string, blurb: string][] = [
464
+ ['--dry-run', 'report what would happen, write nothing'],
465
+ ['--into <path>', 'add: install into this repo instead of the working directory'],
466
+ ['--set m.param=value', 'add/init: override a module parameter. Repeatable'],
467
+ ['--confirm-threshold', 'add: install a module whose rung is above this repo'],
468
+ ['--apply', 'upgrade: write the changes, rather than preview them'],
469
+ ['--fast, --full', 'check: pick the gate tier, as the positional also does'],
470
+ ['--params', 'modules: show every module parameter, its default and its allowed values'],
471
+ ['--copilot', 'also emit Copilot instruction files'],
472
+ ];
473
+
474
+ function renderHelp(): string {
475
+ const pad = Math.max(...COMMANDS.map(([u]) => u.length)) + 2;
476
+ const fpad = Math.max(...FLAGS.map(([f]) => f.length)) + 2;
477
+ return [
478
+ ``,
479
+ `${c.bold('rungs')} — installs and maintains a repository's agentic development system`,
480
+ ``,
481
+ ...COMMANDS.map(([u, b]) => ` ${c.bold(`rungs ${u.split(' ')[0]}`)}${u.slice(u.split(' ')[0].length).padEnd(pad - u.split(' ')[0].length)} ${c.dim(b)}`),
482
+ ``,
483
+ ...FLAGS.map(([f, b]) => ` ${c.dim(f.padEnd(fpad))} ${c.dim(b)}`),
484
+ ``,
485
+ ].join('\n');
486
+ }
487
+
348
488
  const [, , cmd, ...rest] = process.argv;
349
- const flags = new Set(rest.filter((r) => r.startsWith('--')));
350
- const args = rest.filter((r) => !r.startsWith('--'));
489
+
490
+ const flags = new Set<string>();
491
+ const args: string[] = [];
492
+ const flagValues: Record<string, string[]> = {};
493
+ /** A value-flag left without a value. Reported by the command, so `--help` still works. */
494
+ let missingValue: string | null = null;
495
+
496
+ for (let i = 0; i < rest.length; i++) {
497
+ const token = rest[i];
498
+ if (!token.startsWith('--')) {
499
+ args.push(token);
500
+ continue;
501
+ }
502
+ const eq = token.indexOf('=');
503
+ const name = eq === -1 ? token : token.slice(0, eq);
504
+ if (!VALUE_FLAGS.has(name)) {
505
+ // The bare name, not the raw token, so `--copilot=yes` still answers `flags.has('--copilot')`.
506
+ flags.add(name);
507
+ continue;
508
+ }
509
+ // Attached form first; otherwise the next token, unless that is itself a flag — `--set --dry-run`
510
+ // is a missing value, not a value of `--dry-run`.
511
+ const next = rest[i + 1];
512
+ const value = eq === -1 ? (next === undefined || next.startsWith('--') ? undefined : rest[++i]) : token.slice(eq + 1);
513
+ if (value === undefined) missingValue = name;
514
+ else (flagValues[name] ??= []).push(value);
515
+ }
516
+
517
+ /**
518
+ * A positional shaped like `module.param=value` was meant to be an override and was not claimed by
519
+ * `--set`. No path, module, profile or tier has that shape, so it is unambiguously a mistake —
520
+ * refuse it by name rather than letting a command interpret it as something else.
521
+ */
522
+ const strayOverride = args.find((a) => /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_]*=/.test(a));
523
+
351
524
  // Dates come from the caller, never from inside a render: a timestamp baked
352
525
  // into generated output makes every run a diff.
353
526
  const STAMP = process.env.RUNGS_DATE ?? new Date().toISOString().slice(0, 10);
@@ -355,9 +528,24 @@ const HARNESSES: Harness[] = flags.has('--copilot')
355
528
  ? ['claude', 'copilot', 'agents-md']
356
529
  : (['claude', 'agents-md'] as Harness[]);
357
530
 
531
+ // Both refusals run before dispatch, because either one means the argv the user typed is not the
532
+ // argv any command would act on. Silently proceeding is what made the original failure so opaque.
533
+ if (missingValue) {
534
+ console.log(c.red(`\n ${missingValue} expects a value — ${missingValue} module.param=value\n`));
535
+ process.exit(1);
536
+ }
537
+ if (strayOverride) {
538
+ console.log(
539
+ c.red(`\n stray override: ${strayOverride}`) +
540
+ c.dim(`\n Nothing claimed it, so it would be read as a path or a module name.`) +
541
+ c.dim(`\n Did you mean: --set ${strayOverride}\n`),
542
+ );
543
+ process.exit(1);
544
+ }
545
+
358
546
  switch (cmd) {
359
547
  case 'modules':
360
- process.exit(cmdModules());
548
+ process.exit(cmdModules(flags.has('--params')));
361
549
  case 'doctor':
362
550
  process.exit(cmdDoctor(args[0] ?? process.cwd()));
363
551
  case 'check': {
@@ -390,22 +578,13 @@ switch (cmd) {
390
578
  const names = flags.has('--into') ? args.slice(0, -1) : args;
391
579
  process.exit(cmdAdd(names, resolve(target), flags.has('--dry-run'), HARNESSES, STAMP));
392
580
  }
393
- default:
394
- console.log(`
395
- ${c.bold('rungs')}installs and maintains a repository's agentic development system
396
-
397
- ${c.bold('rungs init')} [path] [profile] scaffold a repo minimal · tracked · disciplined · hardened · fleet
398
- ${c.bold('rungs doctor')} [path] detect what a repo already has, installed or not
399
- ${c.bold('rungs add')} <module…> [--into p] install modules, resolving dependencies and adopting what exists
400
- ${c.bold('rungs check')} [path] [tier] run the registered gates and record the ledger
401
- ${c.bold('rungs render')} [path] re-emit path-scoped rules per harness
402
- ${c.bold('rungs upgrade')} [path] move to newer module versions, never touching what you edited
403
- ${c.bold('rungs eject')} [path] materialise the engines; stop depending on rungs
404
- ${c.bold('rungs modules')} list the module set and audit the manifests
405
-
406
- ${c.dim('--dry-run report what would happen, write nothing')}
407
- ${c.dim('--apply upgrade only: write the changes')}
408
- ${c.dim('--copilot also emit Copilot instruction files')}
409
- `);
410
- process.exit(cmd ? 1 : 0);
581
+ default: {
582
+ // Help is a success, and an unknown command is not. Both used to land here and exit on
583
+ // `cmd ? 1 : 0`, which made `rungs --help` a command that did exactly what was asked —
584
+ // report failure to anything checking the status (WI-004).
585
+ const wantedHelp = cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h';
586
+ if (!wantedHelp) console.log(c.red(`\n unknown command: ${cmd}`));
587
+ console.log(renderHelp());
588
+ process.exit(wantedHelp ? 0 : 1);
589
+ }
411
590
  }
package/src/engines.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  idIntegrity,
10
10
  registerSchema,
11
11
  renderFreshness,
12
+ selfDeclaredClosure,
12
13
  } from './engines2.ts';
13
14
  import { gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
14
15
 
@@ -148,14 +149,20 @@ const linkIntegrity: Engine = (t, root, files) => {
148
149
  for (const rel of scan) {
149
150
  if (excluded.has(rel)) continue;
150
151
  const text = read(root, rel);
151
- // A file with unsubstituted placeholders is a **template**, and its links
152
- // resolve only once installed. Found by running this against the rungs repo
153
- // itself, where `modules/*/fragments/AGENTS.md` was reported for a broken
154
- // link to `{{path}}/README.md` — a path that is not meant to exist here.
155
- if (/\{\{[a-z_.]+\}\}/.test(text)) continue;
156
152
  examined++;
157
153
  if (/path-ok:\s*\S/.test(text)) continue;
158
- for (const m of text.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
154
+ // A link written inside a code span is prose *quoting* a link — most often a document
155
+ // explaining that some link is wrong. Blanked rather than removed, so every offset after it
156
+ // is unchanged and the reported text still matches what the author sees.
157
+ const scannable = text.replace(/`+[^`\n]*`+/g, (s) => ' '.repeat(s.length));
158
+ for (const m of scannable.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
159
+ // An unsubstituted placeholder makes a **template** link, which resolves only once
160
+ // installed. This test used to sit on the whole file, and one token anywhere in a document
161
+ // exempted every link in it: 16 non-excluded files, including eight that ship to consumer
162
+ // repos, silently stopped being checked. A green gate and a skipped file looked identical.
163
+ // The case the file-level skip was written for — `modules/*/fragments/AGENTS.md` linking
164
+ // `{{path}}/README.md` — is already excluded by path in `link_integrity.exclude` (WI-008).
165
+ if (/\{\{[a-z_.]+\}\}/.test(m[1])) continue;
159
166
  const target = resolve(root, dirname(rel), decodeURIComponent(m[1]));
160
167
  if (!existsSync(target)) findings.push({ file: rel, message: `broken link → ${m[1]}` });
161
168
  }
@@ -237,6 +244,7 @@ export const ENGINES: Record<string, Engine> = {
237
244
  'id-integrity': idIntegrity,
238
245
  'render-freshness': renderFreshness,
239
246
  'register-schema': registerSchema,
247
+ 'self-declared-closure': selfDeclaredClosure,
240
248
  'filename-schema': filenameSchema,
241
249
  'cross-reference': crossReference,
242
250
  'git-status-reconcile': gitStatusReconcile,
package/src/engines2.ts CHANGED
@@ -167,6 +167,76 @@ export const registerSchema: Engine = (t, root, files) => {
167
167
  return { findings, examined };
168
168
  };
169
169
 
170
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
171
+
172
+ /**
173
+ * An open finding must not declare itself fixed in its own detail section.
174
+ *
175
+ * This is deliberately a text-only contradiction check. It does not inspect
176
+ * code or infer that a fix really shipped; those questions are repository-
177
+ * specific and a guessed probe would be confidently wrong. A section may
178
+ * contain a reasoned `closure-ok:` marker when only a part of the observation
179
+ * was addressed. The table owns the headings, id shape, and verdict phrases so
180
+ * the engine remains useful for registers that use a different prefix or
181
+ * detail heading.
182
+ */
183
+ export const selfDeclaredClosure: Engine = (t, root, files) => {
184
+ const findings: Finding[] = [];
185
+ let examined = 0;
186
+ const targets = t.file ? [t.file] : expand(files, t.scan ?? ['docs/**/FINDINGS.md']);
187
+ const idPattern = t.id_pattern ?? '[A-Z]{1,6}-\\d{1,4}';
188
+ const openRow = new RegExp(t.open_row_pattern ?? `^\\|\\s*\\[?(${idPattern})\\]`, 'gmu');
189
+ const detailHeading = new RegExp(t.detail_heading_pattern ?? `^###\\s+(${idPattern})\\s+—\\s+`, 'gmu');
190
+ const verdicts = (t.declares_fixed ?? [
191
+ '\\*\\*Fixed[.,)*]',
192
+ '\\*\\*Fixed\\s+(?:in|by|the\\s+same\\s+day|\\d{4}-\\d{2}-\\d{2})',
193
+ '\\*\\*Implemented in this change\\.?\\*\\*',
194
+ '\\*\\*fixed in the pass that found it\\*\\*',
195
+ ]).map((p: string) => new RegExp(p, 'iu'));
196
+
197
+ for (const rel of targets) {
198
+ const text = read(root, rel);
199
+ if (!text) continue;
200
+ const openStart = headingIndex(text, t.open_heading ?? 'Open');
201
+ const closedStart = headingIndex(text, t.closed_heading ?? 'Closed');
202
+ const detailStart = headingIndex(text, t.detail_heading ?? 'Detail');
203
+ if (openStart < 0 || closedStart < 0 || detailStart < 0 || closedStart <= openStart || detailStart < closedStart) continue;
204
+
205
+ const open = new Set<string>();
206
+ for (const match of text.slice(openStart, closedStart).matchAll(openRow)) open.add(match[1]);
207
+ if (!open.size) continue;
208
+
209
+ const detail = text.slice(detailStart);
210
+ const headings = [...detail.matchAll(detailHeading)];
211
+ for (let i = 0; i < headings.length; i++) {
212
+ const id = headings[i][1];
213
+ if (!open.has(id)) continue;
214
+ examined++;
215
+ const start = headings[i].index ?? 0;
216
+ const end = headings[i + 1]?.index ?? detail.length;
217
+ const section = detail.slice(start, end);
218
+ const marker = t.exempt_marker ?? 'closure-ok:';
219
+ if (new RegExp(`<!--\\s*${escapeRe(marker)}\\s*\\S`, 'u').test(section)) continue;
220
+ const body = section.slice(section.indexOf('\n') + 1);
221
+ for (const verdict of verdicts) {
222
+ const match = verdict.exec(body);
223
+ if (!match) continue;
224
+ const before = body.slice(Math.max(0, match.index - (t.citation_window ?? 120)), match.index);
225
+ const cited = [...before.matchAll(new RegExp(`(${idPattern})[^.]{0,${t.citation_window ?? 120}}$`, 'gu'))].at(-1)?.[1];
226
+ if (cited && cited !== id) continue;
227
+ findings.push({ file: rel, message: `${id} is open but its detail declares it fixed: ${body.slice(match.index, match.index + 60).split('\n')[0].trim()}` });
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ return { findings, examined };
233
+ };
234
+
235
+ function headingIndex(text: string, heading: string): number {
236
+ const re = new RegExp(`^#{1,6}\\s+${escapeRe(heading)}\\s*$`, 'imu');
237
+ return text.search(re);
238
+ }
239
+
170
240
  const strip = (v?: string) => (v ?? '').replace(/[`*\[\]]/g, '').split('(')[0].trim();
171
241
  const firstCell = (row: Record<string, string>) => strip(Object.values(row)[0]) || '?';
172
242
 
package/src/lifecycle.ts CHANGED
@@ -56,7 +56,7 @@ export interface UpgradeItem {
56
56
  * clobber deliberate edits or refuse to move anything.
57
57
  */
58
58
  export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord): UpgradeItem[] {
59
- const params = resolveParams(mods, paramsFrom(record));
59
+ const params = resolveParams(mods, paramsFrom(record), repoRoot);
60
60
  const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
61
61
  const items: UpgradeItem[] = [];
62
62
 
@@ -86,7 +86,7 @@ export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallR
86
86
 
87
87
  /** Applies only `stale` and `missing`. Divergence is a decision, not an error. */
88
88
  export function applyUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord, plan: UpgradeItem[]) {
89
- const params = resolveParams(mods, paramsFrom(record));
89
+ const params = resolveParams(mods, paramsFrom(record), repoRoot);
90
90
  const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
91
91
  let written = 0;
92
92
  for (const item of plan) {
@@ -145,7 +145,7 @@ export function eject(repoRoot: string, mods: Manifest[], dryRun = false) {
145
145
  // — which is the same promise ADR-0002 makes about installation, kept on the
146
146
  // way out. Parameters are substituted now, for the same reason.
147
147
  const record = readRecord(repoRoot);
148
- const params = resolveParams(mods, record ? paramsFrom(record) : {});
148
+ const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
149
149
  for (const t of tables) {
150
150
  const [mod, file] = t.split('/');
151
151
  const src = join(SRC, '..', 'modules', mod, 'gates', file);
@@ -196,7 +196,7 @@ if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engin
196
196
  // Tables were converted to JSON when this was ejected, so nothing here needs a
197
197
  // TOML parser — or any dependency at all beyond Node itself.
198
198
  const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
199
- 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', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
199
+ 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' };
200
200
  let section = raw[KEYS[engine] ?? engine] ?? raw;
201
201
  if (Array.isArray(section) && section.some((s) => s?.id)) {
202
202
  const mine = section.filter((s) => !s.id || id.includes(s.id));
package/src/substitute.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { basename, resolve } from 'node:path';
1
2
  import type { Manifest } from './types.ts';
2
3
 
3
4
  export type Params = Record<string, Record<string, unknown>>;
@@ -26,25 +27,55 @@ function format(v: unknown): string {
26
27
  return String(v);
27
28
  }
28
29
 
29
- /** Defaults from every manifest, with explicit overrides applied on top. */
30
- export function resolveParams(mods: Manifest[], overrides: Params = {}): Params {
31
- const out: Params = {};
30
+ /**
31
+ * Facts about the target repository, addressable from a default as `{{repo.<key>}}`.
32
+ *
33
+ * `repo` is a **reserved namespace, not a module**, which is what keeps it clear of
34
+ * `modules/README.md` rule 9b — referencing a module you have not declared is an undeclared
35
+ * coupling, but every module already sits in a repository, so there is nothing to declare.
36
+ *
37
+ * Deliberately one key. `git_remote` and `branch` were considered and left out: nothing consumes
38
+ * them, and rule 9e is about the knob wired to nothing that stays invisible until someone compares
39
+ * every module at once.
40
+ */
41
+ function repoFacts(repoRoot?: string): Record<string, unknown> {
42
+ return repoRoot ? { dirname: basename(resolve(repoRoot)) } : {};
43
+ }
44
+
45
+ /**
46
+ * Defaults from every manifest, with explicit overrides applied on top.
47
+ *
48
+ * `repoRoot` is optional only so a caller with no repository in hand can still read defaults. When
49
+ * it is absent `{{repo.dirname}}` does not resolve, and `substitute` leaves the token visible
50
+ * rather than emitting an empty string — the same bias as every other unresolved reference, and
51
+ * the reason a missing root shows up as a wrong-looking file instead of a silently blank heading.
52
+ */
53
+ export function resolveParams(mods: Manifest[], overrides: Params = {}, repoRoot?: string): Params {
54
+ const out: Params = { repo: repoFacts(repoRoot) };
32
55
  for (const m of mods) {
33
56
  out[m.name] = {};
34
57
  for (const [k, spec] of Object.entries(m.params)) out[m.name][k] = spec.default;
35
58
  }
36
- // A default may itself reference another module's parameter, e.g. findings'
37
- // register living at `docs/{{backlog.root}}/FINDINGS.md`. Resolve after all
38
- // defaults are in place, and only one level — a chain would be a template
39
- // language arriving through the back door.
59
+
60
+ // **Overrides go on before cross-module references resolve.** They were
61
+ // applied last, which meant a default referencing another module's parameter
62
+ // had already baked in that module's *default* — so installing into hexguard
63
+ // with `--set backlog.root=.ai/backlog` put the findings register at
64
+ // `docs/.ai/backlog/FINDINGS.md` and left every link to it pointing at
65
+ // `docs/backlog/FINDINGS.md`. The gate caught it on the first real install;
66
+ // nothing in a scratch repo could have, because nothing there overrides.
67
+ for (const [mod, vals] of Object.entries(overrides)) {
68
+ out[mod] = { ...(out[mod] ?? {}), ...vals };
69
+ }
70
+
71
+ // A default may reference another module's parameter, e.g. findings' register
72
+ // living at `docs/{{backlog.root}}/FINDINGS.md`. One level only — a chain
73
+ // would be a template language arriving through the back door.
40
74
  for (const m of mods) {
41
75
  for (const [k, v] of Object.entries(out[m.name])) {
42
76
  if (typeof v === 'string' && v.includes('{{')) out[m.name][k] = substitute(v, m.name, out);
43
77
  }
44
78
  }
45
- for (const [mod, vals] of Object.entries(overrides)) {
46
- out[mod] = { ...(out[mod] ?? {}), ...vals };
47
- }
48
79
  return out;
49
80
  }
50
81