@rungs/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -19
- package/dist/cli.js +2270 -0
- package/dist/cli.js.map +7 -0
- package/modules/README.md +30 -5
- package/modules/ci/files/{{workflow_path}} +1 -1
- package/modules/findings/gates/findings.toml +28 -0
- package/modules/findings/module.toml +9 -1
- package/modules/gates/gates/structural.toml +14 -0
- package/modules/instructions/files/AGENTS.md +0 -6
- package/modules/instructions/fragments/AGENTS.md +12 -0
- package/modules/instructions/module.toml +11 -3
- package/modules/session/files/{{path}} +2 -1
- package/modules/session/fragments/AGENTS.md +4 -1
- package/modules/session/module.toml +6 -3
- package/modules/session/skills/close-session/SKILL.md +4 -0
- package/modules/skills/fragments/AGENTS.md +1 -1
- package/modules/skills/module.toml +6 -3
- package/modules/skills/rules/skill-authoring.md +8 -0
- package/modules/workflows/fragments/AGENTS.md +1 -1
- package/modules/workflows/module.toml +13 -2
- package/modules/workflows/rules/bounded-invocation.md +21 -0
- package/modules/workflows/rules/invocation-boundaries.md +27 -0
- package/modules/workflows/skills/decompose/SKILL.md +9 -1
- package/package.json +13 -4
- package/src/add.ts +8 -1
- package/src/check.ts +2 -1
- package/src/cli.ts +203 -24
- package/src/engines.ts +14 -6
- package/src/engines2.ts +70 -0
- package/src/lifecycle.ts +4 -4
- package/src/substitute.ts +41 -10
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
|
-
|
|
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
|
-
|
|
350
|
-
const
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
|