@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/cli.ts ADDED
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { auditModules, loadAllModules } from './manifest.ts';
5
+ import { detect, scanRepo } from './detect.ts';
6
+ import { addModule, adoptableGates, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
7
+ import { render, writeReport, type Harness } from './render.ts';
8
+ import { resolveParams } from './substitute.ts';
9
+ import { appendLedger, ledgerQuestions, loadRegistry, runGates } from './check.ts';
10
+ import { applyUpgrade, eject, planUpgrade, PROFILES, readRecord, setupGit } from './lifecycle.ts';
11
+ import type { DetectResult, Manifest } from './types.ts';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const MODULES = join(HERE, '..', 'modules');
15
+
16
+ const c = {
17
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
18
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
19
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
20
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
21
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
22
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
23
+ };
24
+
25
+ const STATE_LABEL: Record<DetectResult['state'], string> = {
26
+ absent: c.dim('absent'),
27
+ 'ours-current': c.green('ours'),
28
+ 'ours-diverged': c.yellow('diverged'),
29
+ theirs: c.cyan('theirs'),
30
+ paradigm: c.yellow('paradigm'),
31
+ unknown: c.red('unknown'),
32
+ };
33
+
34
+ function cmdModules() {
35
+ const mods = loadAllModules(MODULES);
36
+ console.log(c.bold(`\n${mods.length} modules\n`));
37
+ for (const m of mods) {
38
+ const deps = m.requires.length ? c.dim(` ← ${m.requires.join(', ')}`) : '';
39
+ console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}`);
40
+ console.log(` ${' '.repeat(14)} ${c.dim(m.summary)}`);
41
+ }
42
+
43
+ const issues = auditModules(mods);
44
+ console.log();
45
+ if (issues.length === 0) {
46
+ console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for, every gate has a table and a why'));
47
+ } else {
48
+ console.log(c.red(` ${issues.length} issue(s):`));
49
+ for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} — ${i.detail}`);
50
+ }
51
+ console.log();
52
+ return issues.length === 0 ? 0 : 1;
53
+ }
54
+
55
+ function cmdDoctor(target: string) {
56
+ const root = resolve(target);
57
+ const mods = loadAllModules(MODULES);
58
+ console.log(c.bold(`\nrungs doctor — ${root}\n`));
59
+
60
+ const files = scanRepo(root);
61
+ const record = readRecord(root);
62
+ console.log(
63
+ c.dim(` scanned ${files.length} files`) +
64
+ (record ? c.dim(` · installed ${Object.keys(record.modules).length} module(s)`) : c.dim(' · not a rungs repo')) +
65
+ '\n',
66
+ );
67
+
68
+ const params = resolveParams(mods, Object.fromEntries(
69
+ Object.entries(record?.modules ?? {}).flatMap(([n, e]) => (e.params ? [[n, e.params]] : [])),
70
+ ));
71
+ const skillsDir = record?.harnesses.includes('claude') === false ? '.agents/skills' : '.claude/skills';
72
+ const results = mods.map((m) => {
73
+ const installed = record?.modules[m.name];
74
+ return detect(m, root, files, installed ? { ...installed, skillsDir, params_all: params } : undefined);
75
+ });
76
+ const byState = (s: DetectResult['state']) => results.filter((r) => r.state === s);
77
+
78
+ for (const r of results) {
79
+ const mod = mods.find((m) => m.name === r.module)!;
80
+ const line = ` ${r.module.padEnd(14)} ${STATE_LABEL[r.state]}`;
81
+ if (r.state === 'absent') {
82
+ console.log(c.dim(line));
83
+ continue;
84
+ }
85
+ console.log(line);
86
+ if (r.ours) {
87
+ const parts = [`v${r.ours.version}`, `${r.ours.current.length} current`];
88
+ if (r.ours.stale.length) parts.push(c.cyan(`${r.ours.stale.length} stale`));
89
+ if (r.ours.missing.length) parts.push(c.yellow(`${r.ours.missing.length} missing`));
90
+ if (r.ours.kept.length) parts.push(c.dim(`${r.ours.kept.length} kept (yours from the start)`));
91
+ console.log(c.dim(` ${parts.join(' · ')}`));
92
+ for (const f of r.ours.diverged.slice(0, 3)) {
93
+ console.log(` ${c.yellow('diverged')} ${f} ${c.dim('— yours, never overwritten')}`);
94
+ }
95
+ if (r.ours.diverged.length > 3) console.log(c.dim(` …and ${r.ours.diverged.length - 3} more`));
96
+ if (r.ours.stale.length || r.ours.missing.length) {
97
+ console.log(c.dim(' run `rungs upgrade --apply`'));
98
+ }
99
+ continue;
100
+ }
101
+ for (const p of r.matchedPaths.slice(0, 2)) {
102
+ console.log(c.dim(` ${p.count}× ${p.pattern} e.g. ${p.sample[0]}`));
103
+ }
104
+ if (r.matchedMarkers.length) console.log(c.dim(` markers: ${r.matchedMarkers.join(', ')}`));
105
+ for (const prop of r.proposals) {
106
+ console.log(` ${c.cyan('proposes')} ${prop.param} = ${c.bold(prop.value)} ${c.dim(`(${prop.evidence})`)}`);
107
+ }
108
+ for (const a of r.adoptable) {
109
+ console.log(` ${c.cyan('adoptable')} ${a.count} as ${a.kind} ${c.dim(`e.g. ${a.sample[0]}`)}`);
110
+ }
111
+ if (r.paradigm) {
112
+ console.log(` ${c.yellow('different paradigm')}: ${r.paradigm.id} ${c.dim(`(${r.paradigm.matched[0]})`)}`);
113
+ if (r.paradigm.note) console.log(c.dim(` ${firstSentence(r.paradigm.note)}`));
114
+ }
115
+ if (mod.threshold?.confirm) {
116
+ console.log(c.yellow(` threshold: ${mod.threshold.minimum}+ ${mod.threshold.metric} — add requires confirmation`));
117
+ }
118
+ }
119
+
120
+ const ours = byState('ours-current').length + byState('ours-diverged').length;
121
+ console.log(
122
+ `\n ${ours ? `${ours} installed (${byState('ours-diverged').length} diverged) · ` : ''}` +
123
+ `${byState('theirs').length} present · ${byState('paradigm').length} different paradigm · ` +
124
+ `${byState('absent').length} absent\n`,
125
+ );
126
+
127
+ // ADR-0005: state what this does not cover, every time. A green read is not
128
+ // a verified one, and a low count may mean a narrow signature rather than a
129
+ // clean repo.
130
+ console.log(c.dim(' This reports presence, never quality. It cannot tell whether an adopted'));
131
+ console.log(c.dim(' system is good, complete, or working — only that files are where a'));
132
+ console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
133
+ return 0;
134
+ }
135
+
136
+ function firstSentence(s: string): string {
137
+ return s.trim().replace(/\s+/g, ' ').split(/(?<=\.)\s/)[0];
138
+ }
139
+
140
+ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
141
+ const mods = loadAllModules(MODULES);
142
+ const { order, missing } = resolveInstallOrder(names, mods);
143
+ if (missing.length) {
144
+ console.log(c.red(`\n unknown module(s): ${missing.join(', ')}\n`));
145
+ return 1;
146
+ }
147
+ const pulled = order.filter((m) => !names.includes(m.name));
148
+ const params = resolveParams(mods);
149
+ const skillsDir = harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
150
+
151
+ console.log(c.bold(`\nrungs add ${names.join(' ')} → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
152
+ if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(', ')}\n`));
153
+
154
+ const installed: Manifest[] = [];
155
+ const wrote = new Map<string, Set<string>>();
156
+ for (const mod of order) {
157
+ if (mod.threshold?.confirm && !dryRun && !flags.has('--confirm-threshold')) {
158
+ console.log(
159
+ c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) +
160
+ c.dim(' Skipped — pass --confirm-threshold to install it.\n'),
161
+ );
162
+ continue;
163
+ }
164
+ const actions = addModule(mod, root, params, { dryRun, skillsDir });
165
+ installed.push(mod);
166
+ wrote.set(mod.name, new Set(actions.filter((a) => a.disposition !== 'skip-exists' && a.disposition !== 'merge' && a.disposition !== 'gate').map((a) => a.target)));
167
+ const counts = new Map<string, number>();
168
+ for (const a of actions) counts.set(a.disposition, (counts.get(a.disposition) ?? 0) + 1);
169
+ console.log(` ${c.bold(mod.name.padEnd(14))} ${[...counts].map(([k, v]) => `${v} ${k}`).join(' · ')}`);
170
+ for (const a of actions.filter((x) => x.disposition === 'skip-exists')) {
171
+ console.log(c.dim(` kept ${a.target}`));
172
+ }
173
+ }
174
+
175
+ // Detect what the repo already has and register it alongside (ADR-0004).
176
+ const repoFiles = scanRepo(root);
177
+ const adopted = installed.flatMap((m) =>
178
+ (m.detect.adopt_as ?? [])
179
+ .filter((a) => a.kind === 'command')
180
+ .flatMap((a) => adoptableGates(repoFiles, a.paths ?? [], root)),
181
+ );
182
+ if (adopted.length) {
183
+ console.log(
184
+ '\n ' + c.cyan(`adopting ${adopted.length} existing validator(s)`) +
185
+ ' as command gates' + c.dim(' — their scripts are untouched'),
186
+ );
187
+ for (const a of adopted.slice(0, 3)) console.log(c.dim(` ${a.command}`));
188
+ if (adopted.length > 3) console.log(c.dim(` …and ${adopted.length - 3} more`));
189
+ }
190
+
191
+ // Phase two: the registry's owner has created it by now.
192
+ const gateActions = registerGates(installed, root, dryRun, adopted);
193
+ if (gateActions.length) {
194
+ console.log(c.dim(`\n registered ${gateActions.reduce((n, a) => n + Number(a.note!.split(': ')[1].split(' ')[0]), 0)} gates from ${gateActions.length} module(s)`));
195
+ }
196
+
197
+ if (!dryRun) {
198
+ writeInstallRecord(root, order, params, harnesses, stamp, skillsDir, wrote);
199
+ const entries = render(root, harnesses);
200
+ writeReport(root, entries, harnesses, stamp);
201
+ console.log(
202
+ `\n rendered ${entries.filter((e) => e.target).length} file(s) · ` +
203
+ `${entries.filter((e) => e.degraded).length} degraded ` +
204
+ c.dim('→ .ai/render-report.md'),
205
+ );
206
+ }
207
+ console.log();
208
+ return 0;
209
+ }
210
+
211
+ function cmdRender(root: string, harnesses: Harness[], stamp: string) {
212
+ const entries = render(root, harnesses);
213
+ writeReport(root, entries, harnesses, stamp);
214
+ console.log(c.bold(`\nrungs render — ${root}\n`));
215
+ for (const e of entries) {
216
+ const lost = e.degraded ?? (e.dropped?.length ? c.dim(` (dropped ${e.dropped.join(', ')})`) : '');
217
+ console.log(` ${e.rule.padEnd(24)} ${e.harness.padEnd(10)} ${e.target ?? c.yellow('not emitted')}${lost}`);
218
+ }
219
+ console.log(c.dim(`\n ${entries.length} rendering(s) → .ai/render-report.md\n`));
220
+ return 0;
221
+ }
222
+
223
+ function cmdCheck(root: string, tier: string | undefined, stamp: string) {
224
+ const runs = runGates(root, tier);
225
+ if (!runs.length) {
226
+ console.log(c.yellow('\n no gates registered — is this a rungs repo?\n'));
227
+ return 1;
228
+ }
229
+ appendLedger(root, runs, stamp);
230
+
231
+ console.log(c.bold(`\nrungs check — ${root}${tier ? ` (${tier} tier)` : ''}\n`));
232
+ const mark = { pass: c.green('pass'), fail: c.red('FAIL'), unimplemented: c.yellow('unimpl'), error: c.red('error') };
233
+ for (const r of runs) {
234
+ console.log(
235
+ ` ${mark[r.status]} ${r.id.padEnd(34)} ${c.dim(`${r.ms}ms`)}` +
236
+ (r.examined ? c.dim(` ${r.examined} examined`) : ''),
237
+ );
238
+ for (const f of r.findings.slice(0, 4)) {
239
+ console.log(` ${c.dim(f.file ? `${f.file}: ` : '')}${f.message}`);
240
+ }
241
+ if (r.findings.length > 4) console.log(c.dim(` …and ${r.findings.length - 4} more`));
242
+ }
243
+
244
+ const n = (s: string) => runs.filter((r) => r.status === s).length;
245
+ console.log(
246
+ `\n ${c.green(`${n('pass')} pass`)} · ${c.red(`${n('fail')} fail`)} · ` +
247
+ `${c.yellow(`${n('unimplemented')} unimplemented`)} · ${n('error')} error` +
248
+ c.dim(` (${runs.reduce((t, r) => t + r.ms, 0)}ms total)`),
249
+ );
250
+
251
+ if (n('unimplemented')) {
252
+ console.log(
253
+ c.yellow('\n Unimplemented gates are not passes.') +
254
+ c.dim(' A registry reporting green because most of its\n gates do nothing is the worst failure this tool could have, so they block.'),
255
+ );
256
+ }
257
+
258
+ const { gates } = loadRegistry(root);
259
+ const q = ledgerQuestions(root, gates);
260
+ if (q.neverFired.length || q.alwaysFires.length) {
261
+ console.log(c.bold(`\n Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
262
+ for (const g of q.neverFired.slice(0, 3)) {
263
+ console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ''))}`);
264
+ console.log(c.dim(' Is that still a risk here, or is the gate scoped too narrowly?'));
265
+ }
266
+ for (const g of q.alwaysFires.slice(0, 3)) {
267
+ console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
268
+ }
269
+ console.log(
270
+ c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'),
271
+ );
272
+ console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
273
+ console.log(c.dim(' directly, and CI runs, are not counted.'));
274
+ }
275
+ console.log();
276
+ return n('fail') + n('unimplemented') + n('error') > 0 ? 1 : 0;
277
+ }
278
+
279
+ function cmdInit(root: string, profile: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
280
+ if (readRecord(root)) {
281
+ console.log(
282
+ c.yellow('\n this repo is already initialised.') +
283
+ c.dim(' Use `rungs add <module>` to install more, or `rungs upgrade`.\n'),
284
+ );
285
+ return 1;
286
+ }
287
+ const names = PROFILES[profile];
288
+ if (!names) {
289
+ console.log(c.red(`\n unknown profile '${profile}'.`) + c.dim(` Known: ${Object.keys(PROFILES).join(', ')}\n`));
290
+ return 1;
291
+ }
292
+ console.log(c.dim(`\n profile '${profile}' — ${names.length} modules`));
293
+ return cmdAdd(names, root, dryRun, harnesses, stamp);
294
+ }
295
+
296
+ function cmdUpgrade(root: string, apply: boolean) {
297
+ const record = readRecord(root);
298
+ if (!record) {
299
+ console.log(c.yellow('\n not a rungs repo — nothing to upgrade.\n'));
300
+ return 1;
301
+ }
302
+ const mods = loadAllModules(MODULES);
303
+ const plan = planUpgrade(root, mods, record);
304
+ console.log(c.bold(`\nrungs upgrade — ${root}${apply ? '' : c.yellow(' (preview)')}\n`));
305
+
306
+ let stale = 0;
307
+ let diverged = 0;
308
+ for (const item of plan) {
309
+ const counts = item.files.reduce<Record<string, number>>((a, f) => ({ ...a, [f.state]: (a[f.state] ?? 0) + 1 }), {});
310
+ stale += (counts.stale ?? 0) + (counts.missing ?? 0);
311
+ diverged += counts.diverged ?? 0;
312
+ const moved = item.from === item.to ? c.dim(item.to) : `${item.from} → ${c.bold(item.to)}`;
313
+ console.log(` ${item.module.padEnd(14)} ${moved} ${c.dim(Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · '))}`);
314
+ for (const f of item.files.filter((x) => x.state === 'diverged')) {
315
+ console.log(` ${c.yellow('diverged')} ${f.rel} ${c.dim('— yours, left alone')}`);
316
+ }
317
+ }
318
+
319
+ if (apply && stale) {
320
+ const written = applyUpgrade(root, mods, record, plan);
321
+ console.log(c.green(`\n updated ${written} file(s)`));
322
+ }
323
+ console.log(
324
+ `\n ${stale} to update · ${diverged} diverged\n` +
325
+ c.dim(' Divergence is a decision, not an error: a file you edited is never overwritten.\n') +
326
+ (apply ? '' : c.dim(' Run with --apply to write.\n')),
327
+ );
328
+ return 0;
329
+ }
330
+
331
+ function cmdEject(root: string, dryRun: boolean) {
332
+ if (!readRecord(root)) {
333
+ console.log(c.yellow('\n not a rungs repo — nothing to eject.\n'));
334
+ return 1;
335
+ }
336
+ const result = eject(root, loadAllModules(MODULES), dryRun);
337
+ console.log(c.bold(`\nrungs eject — ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
338
+ for (const a of result.actions.slice(0, 6)) console.log(c.dim(` ${a}`));
339
+ if (result.actions.length > 6) console.log(c.dim(` …and ${result.actions.length - 6} more`));
340
+ console.log(
341
+ `\n ${result.gates} declared gate(s) rewritten as commands.` +
342
+ c.dim('\n This repo no longer needs rungs installed to run its checks.\n') +
343
+ c.dim(' Engine fixes stop arriving with a version bump — these files are yours now.\n'),
344
+ );
345
+ return 0;
346
+ }
347
+
348
+ const [, , cmd, ...rest] = process.argv;
349
+ const flags = new Set(rest.filter((r) => r.startsWith('--')));
350
+ const args = rest.filter((r) => !r.startsWith('--'));
351
+ // Dates come from the caller, never from inside a render: a timestamp baked
352
+ // into generated output makes every run a diff.
353
+ const STAMP = process.env.RUNGS_DATE ?? new Date().toISOString().slice(0, 10);
354
+ const HARNESSES: Harness[] = flags.has('--copilot')
355
+ ? ['claude', 'copilot', 'agents-md']
356
+ : (['claude', 'agents-md'] as Harness[]);
357
+
358
+ switch (cmd) {
359
+ case 'modules':
360
+ process.exit(cmdModules());
361
+ case 'doctor':
362
+ process.exit(cmdDoctor(args[0] ?? process.cwd()));
363
+ case 'check': {
364
+ const tier = args[1] ?? (flags.has('--full') ? 'full' : flags.has('--fast') ? 'fast' : undefined);
365
+ process.exit(cmdCheck(resolve(args[0] ?? process.cwd()), tier, STAMP));
366
+ }
367
+ case 'init': {
368
+ const profile = args[1] ?? 'tracked';
369
+ process.exit(cmdInit(resolve(args[0] ?? process.cwd()), profile, flags.has('--dry-run'), HARNESSES, STAMP));
370
+ }
371
+ case 'upgrade':
372
+ process.exit(cmdUpgrade(resolve(args[0] ?? process.cwd()), flags.has('--apply')));
373
+ case 'eject':
374
+ process.exit(cmdEject(resolve(args[0] ?? process.cwd()), flags.has('--dry-run')));
375
+ case 'setup': {
376
+ const r = setupGit(resolve(args[1] ?? process.cwd()), flags.has('--dry-run'));
377
+ console.log(
378
+ r.drivers.length
379
+ ? `\n installed ${r.drivers.length} merge driver(s): ${r.drivers.join(', ')}` +
380
+ (r.rerere ? c.dim(' · rerere on') : '') +
381
+ c.dim('\n Declared drivers were inert until now — a fresh clone needs this once.\n')
382
+ : c.dim('\n no rungs merge drivers declared in .gitattributes\n'),
383
+ );
384
+ process.exit(0);
385
+ }
386
+ case 'render':
387
+ process.exit(cmdRender(resolve(args[0] ?? process.cwd()), HARNESSES, STAMP));
388
+ case 'add': {
389
+ const target = flags.has('--into') ? args[args.length - 1] : process.cwd();
390
+ const names = flags.has('--into') ? args.slice(0, -1) : args;
391
+ process.exit(cmdAdd(names, resolve(target), flags.has('--dry-run'), HARNESSES, STAMP));
392
+ }
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);
411
+ }
package/src/detect.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { DetectResult, Manifest } from './types.ts';
4
+ import { matchAny, walk } from './glob.ts';
5
+ import { contentHash, emittedFiles } from './add.ts';
6
+ import type { Params } from './substitute.ts';
7
+
8
+ const SAMPLE = 3;
9
+
10
+ /**
11
+ * ADR-0004. Presence is decided by `paths` and `markers` only; `infer` merely
12
+ * *proposes* parameters, and never concludes presence — hexguard-templates has
13
+ * 207 well-formed `FOUND-US-###` matches and no backlog, because those are spec
14
+ * story ids.
15
+ *
16
+ * Signatures are biased toward false negatives throughout: a false negative
17
+ * creates something visible in git, while a false positive makes the CLI
18
+ * believe wrong things about a repo and act on that belief later.
19
+ */
20
+ export function detect(mod: Manifest, repoRoot: string, files: string[], installed?: InstalledModule): DetectResult {
21
+ const result: DetectResult = {
22
+ module: mod.name,
23
+ state: 'absent',
24
+ matchedPaths: [],
25
+ matchedMarkers: [],
26
+ proposals: [],
27
+ adoptable: [],
28
+ };
29
+
30
+ // A module the repo installed is answered from the record, not from
31
+ // signatures. Signatures exist to recognise somebody *else's* structure;
32
+ // running them over our own would report a healthy install as "theirs" and
33
+ // lose the one thing the record knows and detection cannot — which files we
34
+ // wrote, and whether they still say what we wrote.
35
+ if (installed) {
36
+ result.ours = ownedState(mod, repoRoot, installed);
37
+ result.state = result.ours.diverged.length ? 'ours-diverged' : 'ours-current';
38
+ return result;
39
+ }
40
+
41
+ for (const pattern of mod.detect.paths ?? []) {
42
+ const hits = matchAny(files, pattern);
43
+ if (hits.length) {
44
+ result.matchedPaths.push({ pattern, count: hits.length, sample: hits.slice(0, SAMPLE) });
45
+ }
46
+ }
47
+
48
+ const markers = mod.detect.markers ?? [];
49
+ if (markers.length) {
50
+ // Only files a marker could plausibly live in, and only ones we already
51
+ // have a reason to read. Scanning a whole repo for a marker string is both
52
+ // slow and a way to match prose that mentions one.
53
+ //
54
+ // `marker_paths` exists for the case where a file's *existence* is not
55
+ // discriminating but its *content* is: nearly every repo has a
56
+ // `.gitattributes`, and only one of the four declares a custom merge
57
+ // driver in it — 21 declarations against 0, 0, 0.
58
+ const scanPatterns = mod.detect.marker_paths ?? result.matchedPaths.map((m) => m.pattern);
59
+ const candidates = new Set(scanPatterns.flatMap((p) => matchAny(files, p)));
60
+ for (const rel of candidates) {
61
+ let text: string;
62
+ try {
63
+ text = readFileSync(join(repoRoot, rel), 'utf8');
64
+ } catch {
65
+ continue;
66
+ }
67
+ for (const marker of markers) {
68
+ if (text.includes(marker) && !result.matchedMarkers.includes(marker)) {
69
+ result.matchedMarkers.push(marker);
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ for (const adopt of mod.detect.adopt_as ?? []) {
76
+ const hits = (adopt.paths ?? []).flatMap((p) => matchAny(files, p));
77
+ if (hits.length) {
78
+ result.adoptable.push({ kind: adopt.kind, count: hits.length, sample: hits.slice(0, SAMPLE), note: adopt.note });
79
+ }
80
+ }
81
+
82
+ // A paradigm is only consulted when nothing else matched. Checking it
83
+ // unconditionally reported rift-forge's pulled design mirror as *both* an
84
+ // external authority and an in-repo design system, on a theme.ts the pattern
85
+ // was never meant to reach.
86
+ if (result.matchedPaths.length === 0 && result.adoptable.length === 0) {
87
+ for (const para of mod.detect.paradigm ?? []) {
88
+ const matched = (para.paths ?? []).flatMap((p) => matchAny(files, p));
89
+ if (matched.length) {
90
+ result.paradigm = { id: para.id, note: para.note, compare: para.compare, matched: matched.slice(0, SAMPLE) };
91
+ break;
92
+ }
93
+ }
94
+ }
95
+
96
+ // State. `ours-current` / `ours-diverged` require a rungs.toml recording a
97
+ // prior install; a repo without one can only be absent, theirs, or paradigm.
98
+ //
99
+ // An `adopt_as` match is ADR-0004 state 4 — "theirs, equivalent": the
100
+ // module's function exists in a shape we can map, even though our own
101
+ // structure is absent. Treating it as absent hid the single highest-value
102
+ // adoption in the catalogue, rift-forge's 82 registered gates.
103
+ if (result.matchedPaths.length > 0 || result.adoptable.length > 0 || result.matchedMarkers.length > 0) {
104
+ result.state = 'theirs';
105
+ } else if (result.paradigm) {
106
+ result.state = 'paradigm';
107
+ } else {
108
+ result.state = 'absent';
109
+ }
110
+
111
+ // Proposals run only once presence is established, and are reported as
112
+ // proposals — never applied, never used to decide state.
113
+ if (result.state === 'theirs') {
114
+ result.proposals = infer(mod, repoRoot, files);
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ function infer(mod: Manifest, repoRoot: string, files: string[]) {
121
+ const proposals: DetectResult['proposals'] = [];
122
+
123
+ for (const rule of mod.detect.infer ?? []) {
124
+ if (rule.paths) {
125
+ // Directory-presence inference (e.g. which harnesses exist).
126
+ const present = Object.entries(rule.paths)
127
+ .filter(([, p]) => files.some((f) => f.startsWith(p.replace(/\/$/, '/'))))
128
+ .map(([key]) => key);
129
+ if (present.length) {
130
+ proposals.push({ param: rule.param, value: present.join(', '), evidence: 'directory present' });
131
+ }
132
+ continue;
133
+ }
134
+ if (!rule.pattern) continue;
135
+
136
+ const scope = (rule.scope ?? ['**/*.md']).flatMap((p) => matchAny(files, p));
137
+ const excluded = new Set((rule.exclude ?? []).flatMap((p) => matchAny(files, p)));
138
+ const counts = new Map<string, number>();
139
+
140
+ for (const rel of scope) {
141
+ if (excluded.has(rel)) continue;
142
+ let text: string;
143
+ try {
144
+ text = readFileSync(join(repoRoot, rel), 'utf8');
145
+ } catch {
146
+ continue;
147
+ }
148
+ for (const m of text.matchAll(new RegExp(rule.pattern, 'gm'))) {
149
+ const key = m[1];
150
+ if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
151
+ }
152
+ }
153
+
154
+ // An anchor wins outright over frequency. Counting raw occurrences made
155
+ // `findings` propose the *backlog's* prefix, because a findings register is
156
+ // full of citations to work items — more of them than of its own ids.
157
+ // The register's own NEXT-ID marker settles it without judgement.
158
+ if (rule.anchor) {
159
+ const anchored = new Map<string, number>();
160
+ for (const rel of scope) {
161
+ if (excluded.has(rel)) continue;
162
+ let text: string;
163
+ try {
164
+ text = readFileSync(join(repoRoot, rel), 'utf8');
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const m of text.matchAll(new RegExp(rule.anchor, 'gm'))) {
169
+ if (m[1]) anchored.set(m[1], (anchored.get(m[1]) ?? 0) + 1);
170
+ }
171
+ }
172
+ const [best] = [...anchored].sort((a, b) => b[1] - a[1]);
173
+ if (best) {
174
+ proposals.push({ param: rule.param, value: best[0], evidence: `anchored on ${rule.anchor_name ?? 'marker'}` });
175
+ continue;
176
+ }
177
+ }
178
+
179
+ const banned = new Set(rule.exclude_values ?? []);
180
+ const ranked = [...counts].filter(([k]) => !banned.has(k)).sort((a, b) => b[1] - a[1]);
181
+ const [top] = ranked;
182
+ if (top && top[1] >= (rule.min ?? 1)) {
183
+ proposals.push({
184
+ param: rule.param,
185
+ value: top[0],
186
+ evidence: `${top[1]} matches${ranked.length > 1 ? ` (next: ${ranked[1][0]} at ${ranked[1][1]})` : ''}`,
187
+ });
188
+ }
189
+ }
190
+ return proposals;
191
+ }
192
+
193
+ export function scanRepo(repoRoot: string): string[] {
194
+ return walk(repoRoot);
195
+ }
196
+
197
+ export interface InstalledModule {
198
+ version: string;
199
+ params?: Record<string, unknown>;
200
+ hashes?: Record<string, string>;
201
+ kept?: { files: string[] };
202
+ skillsDir?: string;
203
+ params_all?: Params;
204
+ }
205
+
206
+ /**
207
+ * The state of files this repo installed from a module.
208
+ *
209
+ * Three comparisons, and each answers a different question:
210
+ *
211
+ * absent from disk → missing, an upgrade restores it
212
+ * matches what we'd emit now → current
213
+ * matches the recorded hash → stale; ours to replace on upgrade
214
+ * matches neither → diverged; theirs, and never touched
215
+ */
216
+ export function ownedState(mod: Manifest, repoRoot: string, installed: InstalledModule) {
217
+ const params = installed.params_all ?? {};
218
+ const emitted = emittedFiles(mod, params, installed.skillsDir ?? '.claude/skills');
219
+ const kept = new Set(installed.kept?.files ?? []);
220
+ const out = {
221
+ version: installed.version,
222
+ current: [] as string[],
223
+ stale: [] as string[],
224
+ diverged: [] as string[],
225
+ missing: [] as string[],
226
+ kept: [] as string[],
227
+ };
228
+ for (const [rel, wouldEmit] of emitted) {
229
+ // A file that already existed at install was never ours. Calling it
230
+ // "diverged" implies the user broke something they never touched.
231
+ if (kept.has(rel)) {
232
+ out.kept.push(rel);
233
+ continue;
234
+ }
235
+ const full = join(repoRoot, rel);
236
+ if (!existsSync(full)) {
237
+ out.missing.push(rel);
238
+ continue;
239
+ }
240
+ const onDisk = contentHash(readFileSync(full, 'utf8'));
241
+ if (onDisk === contentHash(wouldEmit)) out.current.push(rel);
242
+ else if (installed.hashes?.[rel] && onDisk === installed.hashes[rel]) out.stale.push(rel);
243
+ else out.diverged.push(rel);
244
+ }
245
+ return out;
246
+ }