@rungs/cli 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +196 -0
  3. package/modules/README.md +116 -0
  4. package/modules/adr/files/{{path}}/README.md +52 -0
  5. package/modules/adr/files/{{path}}/TEMPLATE.md +55 -0
  6. package/modules/adr/fragments/AGENTS.md +8 -0
  7. package/modules/adr/gates/adr.toml +65 -0
  8. package/modules/adr/module.toml +67 -0
  9. package/modules/audit/files/{{criteria_path}} +43 -0
  10. package/modules/audit/fragments/AGENTS.md +8 -0
  11. package/modules/audit/gates/audit.toml +48 -0
  12. package/modules/audit/module.toml +72 -0
  13. package/modules/audit/skills/assess/SKILL.md +62 -0
  14. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +52 -0
  15. package/modules/backlog/files/docs/{{root}}/README.md +110 -0
  16. package/modules/backlog/files/docs/{{root}}/TEMPLATE.md +56 -0
  17. package/modules/backlog/files/docs/{{root}}/archive/README.md +13 -0
  18. package/modules/backlog/files/docs/{{root}}/items/README.md +13 -0
  19. package/modules/backlog/fragments/AGENTS.md +9 -0
  20. package/modules/backlog/gates/ids.toml +106 -0
  21. package/modules/backlog/module.toml +150 -0
  22. package/modules/backlog/rules/work-items.md +44 -0
  23. package/modules/backlog/skills/backlog-summary/SKILL.md +65 -0
  24. package/modules/backlog/skills/work-item/SKILL.md +96 -0
  25. package/modules/ci/files/{{workflow_path}} +43 -0
  26. package/modules/ci/gates/ci.toml +44 -0
  27. package/modules/ci/module.toml +74 -0
  28. package/modules/concurrency/files/docs/concurrent-sessions.md +87 -0
  29. package/modules/concurrency/fragments/AGENTS.md +10 -0
  30. package/modules/concurrency/fragments/gitattributes +15 -0
  31. package/modules/concurrency/gates/concurrency.toml +54 -0
  32. package/modules/concurrency/module.toml +113 -0
  33. package/modules/design-sync/fragments/AGENTS.md +8 -0
  34. package/modules/design-sync/gates/design.toml +70 -0
  35. package/modules/design-sync/module.toml +82 -0
  36. package/modules/design-sync/skills/design-align/SKILL.md +51 -0
  37. package/modules/design-sync/skills/design-pull/SKILL.md +50 -0
  38. package/modules/doc-authority/files/{{registry_path}} +59 -0
  39. package/modules/doc-authority/files/{{rules_path}} +51 -0
  40. package/modules/doc-authority/fragments/AGENTS.md +9 -0
  41. package/modules/doc-authority/gates/authority.toml +127 -0
  42. package/modules/doc-authority/module.toml +111 -0
  43. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +56 -0
  44. package/modules/findings/fragments/AGENTS.md +7 -0
  45. package/modules/findings/gates/findings.toml +68 -0
  46. package/modules/findings/module.toml +100 -0
  47. package/modules/findings/skills/record-finding/SKILL.md +63 -0
  48. package/modules/gates/files/.ai/gates.toml +49 -0
  49. package/modules/gates/fragments/AGENTS.md +8 -0
  50. package/modules/gates/fragments/gitignore +4 -0
  51. package/modules/gates/gates/structural.toml +127 -0
  52. package/modules/gates/module.toml +143 -0
  53. package/modules/gates/skills/harden-rule/SKILL.md +82 -0
  54. package/modules/instructions/files/.ai/rules/README.md +53 -0
  55. package/modules/instructions/files/AGENTS.md +104 -0
  56. package/modules/instructions/files/CLAUDE.md +11 -0
  57. package/modules/instructions/gates/core.toml +114 -0
  58. package/modules/instructions/module.toml +127 -0
  59. package/modules/release/fragments/AGENTS.md +8 -0
  60. package/modules/release/gates/release.toml +85 -0
  61. package/modules/release/module.toml +97 -0
  62. package/modules/release/skills/cut-release/SKILL.md +81 -0
  63. package/modules/session/files/{{archive}}/README.md +28 -0
  64. package/modules/session/files/{{path}} +56 -0
  65. package/modules/session/fragments/AGENTS.md +6 -0
  66. package/modules/session/gates/session.toml +63 -0
  67. package/modules/session/module.toml +72 -0
  68. package/modules/session/skills/close-session/SKILL.md +59 -0
  69. package/modules/skills/fragments/AGENTS.md +8 -0
  70. package/modules/skills/gates/skills.toml +94 -0
  71. package/modules/skills/module.toml +89 -0
  72. package/modules/skills/rules/skill-authoring.md +71 -0
  73. package/modules/specs/files/{{path}}/README.md +63 -0
  74. package/modules/specs/files/{{path}}/TEMPLATE.md +62 -0
  75. package/modules/specs/fragments/AGENTS.md +8 -0
  76. package/modules/specs/gates/specs.toml +119 -0
  77. package/modules/specs/module.toml +107 -0
  78. package/modules/workflows/fragments/AGENTS.md +8 -0
  79. package/modules/workflows/gates/workflows.toml +65 -0
  80. package/modules/workflows/module.toml +75 -0
  81. package/modules/workflows/rules/planning-tiers.md +46 -0
  82. package/modules/workflows/rules/reuse-decision.md +50 -0
  83. package/modules/workflows/skills/decompose/SKILL.md +75 -0
  84. package/package.json +42 -0
  85. package/src/add.ts +331 -0
  86. package/src/check.ts +216 -0
  87. package/src/cli.ts +411 -0
  88. package/src/detect.ts +246 -0
  89. package/src/engines.ts +260 -0
  90. package/src/engines2.ts +297 -0
  91. package/src/engines3.ts +194 -0
  92. package/src/glob.ts +101 -0
  93. package/src/lifecycle.ts +268 -0
  94. package/src/manifest.ts +108 -0
  95. package/src/render.ts +222 -0
  96. package/src/substitute.ts +78 -0
  97. package/src/types.ts +110 -0
package/src/add.ts ADDED
@@ -0,0 +1,331 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import type { Manifest } from './types.ts';
5
+ import { matchAny, walk } from './glob.ts';
6
+ import { markers, mergeBlock, substitute, type Params } from './substitute.ts';
7
+
8
+ export interface AddAction {
9
+ disposition: 'create' | 'skip-exists' | 'rule' | 'skill' | 'merge' | 'gate';
10
+ target: string;
11
+ note?: string;
12
+ }
13
+
14
+ /** Where a fragment file merges to. The name is the target, not a path. */
15
+ const FRAGMENT_TARGET: Record<string, string> = {
16
+ 'AGENTS.md': 'AGENTS.md',
17
+ gitignore: '.gitignore',
18
+ gitattributes: '.gitattributes',
19
+ };
20
+
21
+ /**
22
+ * Install one module. Disposition is decided by which subdirectory a file is
23
+ * in — never by per-file configuration (ADR-0003), which is why this function
24
+ * is a switch over five directory names and nothing else.
25
+ *
26
+ * Never overwrites an existing file. ADR-0004: `add` on existing structure
27
+ * reports the delta; the dangerous operation is removed rather than guarded.
28
+ */
29
+ export function addModule(
30
+ mod: Manifest,
31
+ repoRoot: string,
32
+ params: Params,
33
+ opts: { dryRun?: boolean; skillsDir?: string } = {},
34
+ ): AddAction[] {
35
+ const actions: AddAction[] = [];
36
+ const write = (rel: string, content: string, disposition: AddAction['disposition']) => {
37
+ const full = join(repoRoot, rel);
38
+ if (existsSync(full)) {
39
+ actions.push({ disposition: 'skip-exists', target: rel, note: 'already present — left alone' });
40
+ return;
41
+ }
42
+ actions.push({ disposition, target: rel });
43
+ if (opts.dryRun) return;
44
+ mkdirSync(dirname(full), { recursive: true });
45
+ writeFileSync(full, content);
46
+ };
47
+
48
+ const sub = (text: string) => substitute(text, mod.name, params);
49
+ const has = (d: string) => existsSync(join(mod.dir, d));
50
+
51
+ if (has('files')) {
52
+ const base = join(mod.dir, 'files');
53
+ for (const rel of walk(base)) {
54
+ write(sub(rel), sub(readFileSync(join(base, rel), 'utf8')), 'create');
55
+ }
56
+ }
57
+
58
+ if (has('rules')) {
59
+ const base = join(mod.dir, 'rules');
60
+ for (const rel of walk(base)) {
61
+ write(join('.ai', 'rules', rel).split('\\').join('/'), sub(readFileSync(join(base, rel), 'utf8')), 'rule');
62
+ }
63
+ }
64
+
65
+ if (has('skills')) {
66
+ const base = join(mod.dir, 'skills');
67
+ const dir = opts.skillsDir ?? '.claude/skills';
68
+ for (const rel of walk(base)) {
69
+ write(`${dir}/${rel}`, sub(readFileSync(join(base, rel), 'utf8')), 'skill');
70
+ }
71
+ }
72
+
73
+ if (has('fragments')) {
74
+ const base = join(mod.dir, 'fragments');
75
+ for (const rel of walk(base)) {
76
+ const target = FRAGMENT_TARGET[rel];
77
+ if (!target) {
78
+ actions.push({ disposition: 'merge', target: rel, note: 'unknown fragment target — skipped' });
79
+ continue;
80
+ }
81
+ const full = join(repoRoot, target);
82
+ const existing = existsSync(full) ? readFileSync(full, 'utf8') : '';
83
+ const fragment = sub(readFileSync(join(base, rel), 'utf8'));
84
+ const merged = mergeBlock(existing, fragment, mod.name);
85
+ actions.push({
86
+ disposition: 'merge',
87
+ target,
88
+ note: existing.includes(`rungs:begin ${mod.name}`) ? 'block replaced' : 'block appended',
89
+ });
90
+ if (!opts.dryRun) {
91
+ mkdirSync(dirname(full), { recursive: true });
92
+ writeFileSync(full, merged);
93
+ }
94
+ }
95
+ }
96
+
97
+ return actions;
98
+ }
99
+
100
+ /**
101
+ * Gate registration is a **second phase**, run after every module's files exist.
102
+ *
103
+ * Done inside `addModule` it raced the `gates` module's own registry file:
104
+ * whichever module merged an entry first created `.ai/gates.toml`, and the
105
+ * owner then hit the never-overwrite rule and was skipped — leaving a registry
106
+ * with entries and no `[runner]` block. Reordering the install did not fix it,
107
+ * because `gates` depends on `instructions`, which itself ships gates. The
108
+ * ordering was never the problem: **the owner of a shared file must create it
109
+ * before anything merges into it, which is a phase, not a position.**
110
+ */
111
+ export function registerGates(mods: Manifest[], repoRoot: string, dryRun = false, adopted: AdoptedGate[] = []): AddAction[] {
112
+ const actions: AddAction[] = [];
113
+ const registry = join(repoRoot, '.ai', 'gates.toml');
114
+
115
+ // Adoption, in the only form ADR-0004 permits: the repo's existing validators
116
+ // are registered as `command` gates so they gain the runner, the ledger and
117
+ // attribution — **without a line of them being rewritten**. This is the claim
118
+ // the whole product rests on, and it was missing: `add` created a registry of
119
+ // rungs' own gates and left the repo's sixteen where they were.
120
+ if (adopted.length) {
121
+ const existing = existsSync(registry) ? readFileSync(registry, 'utf8') : '';
122
+ const { begin, end } = markers('gates.toml', 'adopted', '1.0.0');
123
+ const body = [
124
+ begin,
125
+ '# Registered from validators this repo already had. Their scripts are untouched and',
126
+ '# stay yours; rungs only runs them and records what it observes.',
127
+ ...adopted.map(
128
+ (a) => `\n[[gates]]\nid = "${a.id}"\nkind = "command"\nmodule = "adopted"\ntier = "${a.tier}"\ncommand = "${a.command}"\nwhy = """Adopted from ${a.source}. Predates rungs and is owned by this repo."""`,
129
+ ),
130
+ end,
131
+ ].join('\n');
132
+ actions.push({ disposition: 'gate', target: '.ai/gates.toml', note: `adopted: ${adopted.length} entries` });
133
+ if (!dryRun) {
134
+ mkdirSync(dirname(registry), { recursive: true });
135
+ writeFileSync(registry, mergeBlock(existing, body, 'adopted'));
136
+ }
137
+ }
138
+
139
+ for (const mod of mods) {
140
+ if (!mod.gates.length) continue;
141
+ const existing = existsSync(registry) ? readFileSync(registry, 'utf8') : '';
142
+ const { begin, end } = markers('gates.toml', mod.name, mod.version);
143
+ const body = [begin, ...mod.gates.map(gateEntry(mod)), end].join('\n');
144
+ actions.push({ disposition: 'gate', target: '.ai/gates.toml', note: `${mod.name}: ${mod.gates.length} entries` });
145
+ if (dryRun) continue;
146
+ mkdirSync(dirname(registry), { recursive: true });
147
+ writeFileSync(registry, mergeBlock(existing, body, mod.name));
148
+ }
149
+ return actions;
150
+ }
151
+
152
+ const gateEntry = (mod: Manifest) => (g: Manifest['gates'][number]) => {
153
+ const lines = ['', '[[gates]]', `id = "${g.id}"`, `kind = "${g.kind}"`, `module = "${mod.name}"`];
154
+ if (g.engine) lines.push(`engine = "${g.engine}"`);
155
+ if (g.table) lines.push(`table = "${mod.name}/${g.table.replace(/^gates\//, '')}"`);
156
+ if (g.command) lines.push(`command = "${g.command}"`);
157
+ if (g.tier) lines.push(`tier = "${g.tier}"`);
158
+ if (g.trigger) lines.push(`trigger = "${g.trigger}"`);
159
+ if (g.matcher) lines.push(`matcher = "${g.matcher}"`);
160
+ // `why` is carried into the repo because ADR-0005 tier B quotes it back when
161
+ // a gate has never fired. A gate whose reason lives only in this CLI cannot
162
+ // be asked about by a repo that has it installed.
163
+ if (g.why) lines.push(`why = """${g.why.trim()}"""`);
164
+ return lines.join('\n');
165
+ };
166
+
167
+ /** Dependency order, refusing anything unmet — naming the incident (ADR-0003). */
168
+ export function resolveInstallOrder(requested: string[], all: Manifest[]): { order: Manifest[]; missing: string[] } {
169
+ const byName = new Map(all.map((m) => [m.name, m]));
170
+ const order: Manifest[] = [];
171
+ const missing: string[] = [];
172
+ const seen = new Set<string>();
173
+ const visit = (name: string) => {
174
+ if (seen.has(name)) return;
175
+ const mod = byName.get(name);
176
+ if (!mod) {
177
+ missing.push(name);
178
+ return;
179
+ }
180
+ seen.add(name);
181
+ for (const dep of mod.requires) visit(dep);
182
+ order.push(mod);
183
+ };
184
+ // A module's gates are inert without the runner that executes them. Installing
185
+ // `backlog` alone produced a `.ai/gates.toml` holding three entries and no
186
+ // `[runner]` block — a registry nothing reads. Rather than making every
187
+ // gate-shipping module declare the dependency (and `instructions`, which ships
188
+ // gates and is what `gates` itself requires, could not), the runner is pulled
189
+ // in whenever anything registers with it.
190
+ //
191
+ // It is visited **first**, not appended: installed last it arrived after other
192
+ // modules had already merged entries into the registry, so its own file — the
193
+ // one carrying `[runner]` — hit the never-overwrite rule and was skipped. The
194
+ // owner of a shared file has to create it before anyone merges into it.
195
+ const closure = new Set<string>();
196
+ const collect = (n: string) => {
197
+ if (closure.has(n)) return;
198
+ const m = byName.get(n);
199
+ if (!m) return;
200
+ closure.add(n);
201
+ m.requires.forEach(collect);
202
+ };
203
+ requested.forEach(collect);
204
+ if ([...closure].some((n) => byName.get(n)!.gates.length) && byName.has('gates')) {
205
+ visit('gates');
206
+ }
207
+
208
+ for (const r of requested) visit(r);
209
+ return { order, missing };
210
+ }
211
+
212
+ /**
213
+ * Content hash of what a module emitted, recorded at install.
214
+ *
215
+ * Without it `upgrade` cannot tell a file the user edited from one an older
216
+ * module version wrote — and those want opposite treatment: the first is a
217
+ * decision to respect, the second is the thing upgrade exists to replace.
218
+ * It is also what makes ADR-0004's `ours-current` and `ours-diverged` states
219
+ * decidable at all.
220
+ */
221
+ export const contentHash = (s: string) => createHash('sha256').update(s.replace(/\r\n/g, '\n')).digest('hex').slice(0, 12);
222
+
223
+ /**
224
+ * Files a module owns **outright** — not the shared ones it merges into.
225
+ *
226
+ * `AGENTS.md` and `.ai/gates.toml` are co-owned: a module creates them and then
227
+ * every other module merges a block in, so their content differs from what any
228
+ * single module emitted the moment the second module installs. Hashing them
229
+ * reported both as diverged on a completely untouched repo. A file carrying
230
+ * managed blocks is never whole-file upgraded — its **blocks** are, through the
231
+ * merge path.
232
+ */
233
+ const SHARED = new Set(['AGENTS.md', 'CLAUDE.md', '.gitignore', '.gitattributes', '.ai/gates.toml']);
234
+
235
+ export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude/skills'): Map<string, string> {
236
+ const out = new Map<string, string>();
237
+ const sub = (t: string) => substitute(t, mod.name, params);
238
+ for (const [dir, prefix] of [
239
+ ['files', ''],
240
+ ['rules', '.ai/rules/'],
241
+ ['skills', `${skillsDir}/`],
242
+ ] as const) {
243
+ const base = join(mod.dir, dir);
244
+ if (!existsSync(base)) continue;
245
+ for (const rel of walk(base)) {
246
+ const target = sub(prefix + rel).split('\\').join('/');
247
+ if (SHARED.has(target)) continue;
248
+ out.set(target, sub(readFileSync(join(base, rel), 'utf8')));
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ export function writeInstallRecord(
255
+ repoRoot: string,
256
+ mods: Manifest[],
257
+ params: Params,
258
+ harnesses: string[],
259
+ stamp: string,
260
+ skillsDir = '.claude/skills',
261
+ /** Per module, the files rungs actually created — as opposed to kept. */
262
+ wroteByModule?: Map<string, Set<string>>,
263
+ ) {
264
+ const lines = [
265
+ '# Installed by `rungs`. Edit parameters here and re-run `rungs render`.',
266
+ '# Hashes are what rungs emitted; a file whose hash no longer matches is a',
267
+ '# divergence rungs reports and never overwrites.',
268
+ '',
269
+ '[repo]',
270
+ `harnesses = ${JSON.stringify(harnesses)}`,
271
+ `installed = "${stamp}"`,
272
+ '',
273
+ ];
274
+ for (const m of mods) {
275
+ lines.push(`[modules.${m.name}]`, `version = "${m.version}"`, 'state = "managed"');
276
+ const p = params[m.name] ?? {};
277
+ if (Object.keys(p).length) {
278
+ lines.push(`params = { ${Object.entries(p).map(([k, v]) => `${k} = ${JSON.stringify(v ?? '')}`).join(', ')} }`);
279
+ }
280
+ // Only files rungs actually **wrote** get a hash. A file that already
281
+ // existed was kept, and hashing it with our content would later read as a
282
+ // divergence the user caused — implying they broke something they never
283
+ // touched. Kept files are listed separately and stay theirs forever.
284
+ const emitted = emittedFiles(m, params, skillsDir);
285
+ const created = [...emitted].filter(([rel]) => (wroteByModule?.get(m.name)?.has(rel) ?? existsSync(join(repoRoot, rel))));
286
+ const kept = [...emitted].filter(([rel]) => !created.some(([c]) => c === rel) && existsSync(join(repoRoot, rel)));
287
+ if (created.length) {
288
+ lines.push(`[modules.${m.name}.hashes]`);
289
+ for (const [rel, content] of created) lines.push(`"${rel}" = "${contentHash(content)}"`);
290
+ }
291
+ if (kept.length) {
292
+ lines.push('', `[modules.${m.name}]`.replace(']', '.kept]'));
293
+ lines.push(`files = ${JSON.stringify(kept.map(([rel]) => rel))}`);
294
+ }
295
+ lines.push('');
296
+ }
297
+ writeFileSync(join(repoRoot, '.ai', 'rungs.toml'), lines.join('\n'));
298
+ }
299
+
300
+ export interface AdoptedGate {
301
+ id: string;
302
+ command: string;
303
+ tier: string;
304
+ source: string;
305
+ }
306
+
307
+ /**
308
+ * Turn detected `adopt_as` matches into `command` gate entries.
309
+ *
310
+ * The interpreter is chosen from the extension, and an unknown one is skipped
311
+ * rather than guessed at — a registry entry that cannot run is worse than one
312
+ * that is absent, because it reports as a failure the owner did not cause.
313
+ */
314
+ export function adoptableGates(files: string[], patterns: string[], repoRoot: string): AdoptedGate[] {
315
+ const runner: Record<string, string> = { '.mjs': 'node', '.js': 'node', '.ps1': 'pwsh -File', '.sh': 'bash' };
316
+ const out: AdoptedGate[] = [];
317
+ for (const pattern of patterns) {
318
+ for (const rel of matchAny(files, pattern)) {
319
+ const ext = rel.slice(rel.lastIndexOf('.'));
320
+ const exec = runner[ext];
321
+ if (!exec) continue;
322
+ out.push({
323
+ id: `adopted-${rel.split('/').pop()!.replace(/\.[^.]+$/, '')}`,
324
+ command: `${exec} ${rel}`,
325
+ tier: 'fast',
326
+ source: rel,
327
+ });
328
+ }
329
+ }
330
+ return out;
331
+ }
package/src/check.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { execSync } from 'node:child_process';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parse } from 'smol-toml';
6
+ import { ENGINES, isImplemented, type Finding } from './engines.ts';
7
+ import { walk } from './glob.ts';
8
+ import { resolveParams, substitute, type Params } from './substitute.ts';
9
+ import { loadAllModules } from './manifest.ts';
10
+
11
+ const MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules');
12
+
13
+ export type Status = 'pass' | 'fail' | 'unimplemented' | 'error';
14
+
15
+ export interface GateRun {
16
+ id: string;
17
+ module?: string;
18
+ kind: string;
19
+ engine?: string;
20
+ tier: string;
21
+ status: Status;
22
+ ms: number;
23
+ examined: number;
24
+ findings: Finding[];
25
+ why?: string;
26
+ }
27
+
28
+ interface RegistryGate {
29
+ id: string;
30
+ kind: string;
31
+ module?: string;
32
+ engine?: string;
33
+ table?: string;
34
+ command?: string;
35
+ tier?: string;
36
+ trigger?: string;
37
+ why?: string;
38
+ }
39
+
40
+ export function loadRegistry(repoRoot: string): { runner: any; gates: RegistryGate[] } {
41
+ const path = join(repoRoot, '.ai', 'gates.toml');
42
+ if (!existsSync(path)) return { runner: {}, gates: [] };
43
+ const raw = parse(readFileSync(path, 'utf8')) as any;
44
+ return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
45
+ }
46
+
47
+ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()): GateRun[] {
48
+ const { gates } = loadRegistry(repoRoot);
49
+ const files = walk(repoRoot);
50
+ const runs: GateRun[] = [];
51
+
52
+ for (const g of gates) {
53
+ // A hook fires on a tool call, not in the runner. Skipping it here is
54
+ // correct; counting it as a pass would not be.
55
+ if (g.trigger) continue;
56
+ if (tier && g.tier && g.tier !== tier) continue;
57
+
58
+ const started = now();
59
+ let status: Status = 'pass';
60
+ let findings: Finding[] = [];
61
+ let examined = 0;
62
+
63
+ if (g.kind === 'command' && g.command) {
64
+ try {
65
+ execSync(g.command, { cwd: repoRoot, stdio: 'pipe' });
66
+ } catch (e: any) {
67
+ status = 'fail';
68
+ findings = [{ message: String(e.stderr ?? e.stdout ?? e.message).trim().split('\n').slice(-3).join(' ') }];
69
+ }
70
+ } else if (!g.engine || !isImplemented(g.engine)) {
71
+ // Never green. An engine named in a table and missing from the CLI is an
72
+ // unknown, and a registry reporting green because most of its gates do
73
+ // nothing is the worst failure this tool could have.
74
+ status = 'unimplemented';
75
+ findings = [{ message: `engine '${g.engine ?? '(none)'}' is not implemented` }];
76
+ } else {
77
+ const table = loadTable(g.table, repoRoot);
78
+ if (!table) {
79
+ status = 'error';
80
+ findings = [{ message: `table '${g.table}' not found` }];
81
+ } else {
82
+ try {
83
+ const key = tableKey(g.engine);
84
+ let section = table[key] ?? table;
85
+ // An array table holds one entry per gate; select by trailing id.
86
+ if (Array.isArray(section) && section.some((s: any) => s?.id)) {
87
+ const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
88
+ if (mine.length) section = mine;
89
+ }
90
+ const r = ENGINES[g.engine](section, repoRoot, files);
91
+ findings = r.findings;
92
+ examined = r.examined;
93
+ status = r.findings.length ? 'fail' : 'pass';
94
+ } catch (e: any) {
95
+ status = 'error';
96
+ findings = [{ message: e.message }];
97
+ }
98
+ }
99
+ }
100
+
101
+ runs.push({
102
+ id: g.id,
103
+ module: g.module,
104
+ kind: g.kind,
105
+ engine: g.engine,
106
+ tier: g.tier ?? 'fast',
107
+ status,
108
+ ms: now() - started,
109
+ examined,
110
+ findings,
111
+ why: g.why,
112
+ });
113
+ }
114
+ return runs;
115
+ }
116
+
117
+ /**
118
+ * A table lives in the CLI's module set, not in the repo (ADR-0002) — but it is
119
+ * authored with `{{param}}` placeholders, so it is **not valid TOML until
120
+ * substituted**: `max_lines = {{core_budget}}` parses as nothing.
121
+ *
122
+ * Found by running the runner, which reported `table not found` for a file
123
+ * plainly on disk. Tables are substituted against the repo's own installed
124
+ * parameters before parsing — which is also what makes a gate honour the
125
+ * prefix, root and budget that repo actually chose.
126
+ */
127
+ function loadTable(ref: string | undefined, repoRoot: string): any | null {
128
+ if (!ref) return null;
129
+ const [mod, file] = ref.split('/');
130
+ const path = join(MODULES, mod, 'gates', file);
131
+ if (!existsSync(path)) return null;
132
+ try {
133
+ return parse(substitute(readFileSync(path, 'utf8'), mod, installedParams(repoRoot)));
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ let paramCache: { root: string; params: Params } | null = null;
140
+
141
+ /** Parameters as the repo installed them, falling back to module defaults. */
142
+ function installedParams(repoRoot: string): Params {
143
+ if (paramCache?.root === repoRoot) return paramCache.params;
144
+ const defaults = resolveParams(loadAllModules(MODULES));
145
+ const recordPath = join(repoRoot, '.ai', 'rungs.toml');
146
+ if (existsSync(recordPath)) {
147
+ try {
148
+ const rec = parse(readFileSync(recordPath, 'utf8')) as any;
149
+ for (const [name, entry] of Object.entries<any>(rec.modules ?? {})) {
150
+ if (entry?.params) defaults[name] = { ...(defaults[name] ?? {}), ...entry.params };
151
+ }
152
+ } catch {
153
+ /* a malformed record falls back to defaults rather than failing every gate */
154
+ }
155
+ }
156
+ paramCache = { root: repoRoot, params: defaults };
157
+ return defaults;
158
+ }
159
+
160
+ const tableKey = (engine: string) =>
161
+ ({
162
+ 'file-budget': 'file_budget',
163
+ sections: 'sections',
164
+ 'frontmatter-schema': 'frontmatter_schema',
165
+ 'link-integrity': 'link_integrity',
166
+ 'file-population': 'file_population',
167
+ 'gate-meta': 'gate_meta',
168
+ 'id-integrity': '__whole__',
169
+ 'render-freshness': 'render_freshness',
170
+ 'register-schema': 'register_schema',
171
+ 'filename-schema': 'filename_schema',
172
+ 'cross-reference': 'cross_reference',
173
+ 'git-status-reconcile': 'merged_status',
174
+ 'computed-claim': 'computed_claim',
175
+ 'term-ownership': 'term_ownership',
176
+ 'rule-propagation': 'rule_propagation',
177
+ 'git-state': 'git_state',
178
+ 'merge-driver-check': 'merge_driver_check',
179
+ })[engine] ?? engine;
180
+
181
+ /**
182
+ * ADR-0005 tier A. One line per gate per run: what the runner directly observes
183
+ * and nothing that needs interpretation. Local, gitignored, never transmitted.
184
+ */
185
+ export function appendLedger(repoRoot: string, runs: GateRun[], stamp: string) {
186
+ const { runner } = loadRegistry(repoRoot);
187
+ if (runner.ledger === false) return;
188
+ const path = join(repoRoot, '.ai', '.gate-ledger.jsonl');
189
+ const lines = runs
190
+ .map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined }))
191
+ .join('\n');
192
+ appendFileSync(path, lines + '\n');
193
+ }
194
+
195
+ /** The two questions ADR-0005 tier B allows, both binary facts. */
196
+ export function ledgerQuestions(repoRoot: string, gates: RegistryGate[]) {
197
+ const path = join(repoRoot, '.ai', '.gate-ledger.jsonl');
198
+ if (!existsSync(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
199
+ const rows = readFileSync(path, 'utf8')
200
+ .split('\n')
201
+ .filter(Boolean)
202
+ .map((l) => JSON.parse(l) as { id: string; status: Status });
203
+ const by = new Map<string, { total: number; failed: number }>();
204
+ for (const r of rows) {
205
+ const e = by.get(r.id) ?? { total: 0, failed: 0 };
206
+ e.total++;
207
+ if (r.status === 'fail') e.failed++;
208
+ by.set(r.id, e);
209
+ }
210
+ const whyOf = (id: string) => gates.find((g) => g.id === id)?.why;
211
+ const neverFired = [...by].filter(([, e]) => e.total >= 3 && e.failed === 0).map(([id]) => ({ id, why: whyOf(id) }));
212
+ const alwaysFires = [...by]
213
+ .filter(([, e]) => e.total >= 3 && e.failed / e.total > 0.9)
214
+ .map(([id, e]) => ({ id, why: whyOf(id), rate: `${e.failed}/${e.total}` }));
215
+ return { neverFired, alwaysFires, runs: rows.length };
216
+ }