@rungs/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2194 -488
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +40 -32
  62. package/src/engines2.ts +424 -29
  63. package/src/engines3.ts +115 -23
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +95 -31
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/src/add.ts CHANGED
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import type { Manifest } from './types.ts';
5
5
  import { matchAny, walk } from './glob.ts';
6
6
  import { markers, mergeBlock, substitute, type Params } from './substitute.ts';
7
+ import { preflightEmittedPaths, resolveEmittedPath, type EmittedPathCandidate } from './emitted-path.ts';
7
8
 
8
9
  export interface AddAction {
9
10
  disposition: 'create' | 'skip-exists' | 'rule' | 'skill' | 'merge' | 'gate';
@@ -18,6 +19,127 @@ const FRAGMENT_TARGET: Record<string, string> = {
18
19
  gitattributes: '.gitattributes',
19
20
  };
20
21
 
22
+ const RESERVED_SHARED_SINKS = new Set([
23
+ 'AGENTS.md',
24
+ 'CLAUDE.md',
25
+ '.gitignore',
26
+ '.gitattributes',
27
+ '.ai/gates.toml',
28
+ ]);
29
+
30
+ /**
31
+ * Whole-file sources that establish a shared sink before other phases merge
32
+ * managed blocks into it. The source role is deliberately checked before
33
+ * substitution: a parameterised ordinary file does not become a co-owner just
34
+ * because its chosen value happens to spell `AGENTS.md` or `.ai/gates.toml`.
35
+ */
36
+ const SHARED_FILE_OWNERS = new Map<string, ReadonlySet<string>>([
37
+ ['instructions', new Set(['AGENTS.md', 'CLAUDE.md'])],
38
+ ['gates', new Set(['.ai/gates.toml'])],
39
+ ]);
40
+
41
+ interface FileEmission {
42
+ target: string;
43
+ content: string;
44
+ disposition: AddAction['disposition'];
45
+ shared: boolean;
46
+ }
47
+
48
+ function ownsSharedFile(mod: Manifest, sourceTarget: string, target: string): boolean {
49
+ return sourceTarget === target && (SHARED_FILE_OWNERS.get(mod.name)?.has(sourceTarget) ?? false);
50
+ }
51
+
52
+ function reservedSharedSinkCandidates(): EmittedPathCandidate[] {
53
+ return [...RESERVED_SHARED_SINKS].map((target) => ({
54
+ moduleName: 'rungs reserved shared sink',
55
+ target,
56
+ shared: true,
57
+ }));
58
+ }
59
+
60
+ /** One source of truth for the files, rules and skills both add and upgrade emit. */
61
+ function fileEmissions(mod: Manifest, params: Params, skillsDir = '.claude/skills'): FileEmission[] {
62
+ const out: FileEmission[] = [];
63
+ const sub = (text: string) => substitute(text, mod.name, params);
64
+ for (const [dir, prefix, disposition] of [
65
+ ['files', '', 'create'],
66
+ ['rules', '.ai/rules/', 'rule'],
67
+ ['skills', `${skillsDir}/`, 'skill'],
68
+ ] as const) {
69
+ const base = join(mod.dir, dir);
70
+ if (!existsSync(base)) continue;
71
+ for (const rel of walk(base)) {
72
+ const sourceTarget = (prefix + rel).replace(/\\/g, '/');
73
+ const target = sub(sourceTarget).replace(/\\/g, '/');
74
+ let content = sub(readFileSync(join(base, rel), 'utf8'));
75
+ if (dir === 'skills') content = withOptedInExtensions(mod, rel, content);
76
+ out.push({ target, content, disposition, shared: ownsSharedFile(mod, sourceTarget, target) });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function fragmentTargets(mod: Manifest): string[] {
83
+ const base = join(mod.dir, 'fragments');
84
+ if (!existsSync(base)) return [];
85
+ return walk(base).map((rel) => FRAGMENT_TARGET[rel]).filter((target): target is string => Boolean(target));
86
+ }
87
+
88
+ function moduleTargets(
89
+ mod: Manifest,
90
+ params: Params,
91
+ skillsDir: string,
92
+ files = fileEmissions(mod, params, skillsDir),
93
+ ): EmittedPathCandidate[] {
94
+ return [
95
+ ...files.map((file) => ({ moduleName: mod.name, target: file.target, shared: file.shared })),
96
+ ...fragmentTargets(mod).map((target) => ({ moduleName: mod.name, target, shared: true, writeExisting: true })),
97
+ ...(mod.gates.length
98
+ ? [{ moduleName: mod.name, target: '.ai/gates.toml', shared: true, writeExisting: true }]
99
+ : []),
100
+ ];
101
+ }
102
+
103
+ /** Validate a whole install set before the first module is allowed to write. */
104
+ export function moduleEmissionCandidates(
105
+ mods: Manifest[],
106
+ params: Params,
107
+ skillsDir = '.claude/skills',
108
+ ): EmittedPathCandidate[] {
109
+ return [
110
+ ...reservedSharedSinkCandidates(),
111
+ ...mods.flatMap((mod) => moduleTargets(mod, params, skillsDir)),
112
+ ];
113
+ }
114
+
115
+ export function preflightModuleEmissions(
116
+ mods: Manifest[],
117
+ repoRoot: string,
118
+ params: Params,
119
+ skillsDir = '.claude/skills',
120
+ ): void {
121
+ preflightEmittedPaths(repoRoot, moduleEmissionCandidates(mods, params, skillsDir));
122
+ }
123
+
124
+ export interface ProspectiveRuleEmission {
125
+ moduleName: string;
126
+ target: string;
127
+ content: string;
128
+ }
129
+
130
+ /** Rule sources an install would create, in installation order. */
131
+ export function prospectiveRuleEmissions(
132
+ mods: Manifest[],
133
+ params: Params,
134
+ skillsDir = '.claude/skills',
135
+ ): ProspectiveRuleEmission[] {
136
+ return mods.flatMap((mod) =>
137
+ fileEmissions(mod, params, skillsDir)
138
+ .filter((file) => file.disposition === 'rule')
139
+ .map((file) => ({ moduleName: mod.name, target: file.target, content: file.content })),
140
+ );
141
+ }
142
+
21
143
  /**
22
144
  * Install one module. Disposition is decided by which subdirectory a file is
23
145
  * in — never by per-file configuration (ADR-0003), which is why this function
@@ -33,8 +155,14 @@ export function addModule(
33
155
  opts: { dryRun?: boolean; skillsDir?: string } = {},
34
156
  ): AddAction[] {
35
157
  const actions: AddAction[] = [];
158
+ const skillsDir = opts.skillsDir ?? '.claude/skills';
159
+ const files = fileEmissions(mod, params, skillsDir);
160
+ const targets = moduleTargets(mod, params, skillsDir, files);
161
+ const reservations = reservedSharedSinkCandidates();
162
+ const resolved = preflightEmittedPaths(repoRoot, [...reservations, ...targets]).slice(reservations.length);
163
+ const destinations = new Map(targets.map((candidate, index) => [candidate.target, resolved[index].absolute]));
36
164
  const write = (rel: string, content: string, disposition: AddAction['disposition']) => {
37
- const full = join(repoRoot, rel);
165
+ const full = destinations.get(rel)!;
38
166
  if (existsSync(full)) {
39
167
  actions.push({ disposition: 'skip-exists', target: rel, note: 'already present — left alone' });
40
168
  return;
@@ -48,31 +176,7 @@ export function addModule(
48
176
  const sub = (text: string) => substitute(text, mod.name, params);
49
177
  const has = (d: string) => existsSync(join(mod.dir, d));
50
178
 
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
- // Through the same helper `emittedFiles` uses. These two paths both emit
70
- // skills and are easy to change apart — patching only `emittedFiles` for
71
- // F-019 left `add` still writing the un-extended file, so an install and
72
- // an upgrade would have produced different content for the same skill.
73
- write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync(join(base, rel), 'utf8'))), 'skill');
74
- }
75
- }
179
+ for (const file of files) write(file.target, file.content, file.disposition);
76
180
 
77
181
  if (has('fragments')) {
78
182
  const base = join(mod.dir, 'fragments');
@@ -82,7 +186,7 @@ export function addModule(
82
186
  actions.push({ disposition: 'merge', target: rel, note: 'unknown fragment target — skipped' });
83
187
  continue;
84
188
  }
85
- const full = join(repoRoot, target);
189
+ const full = destinations.get(target)!;
86
190
  const existing = existsSync(full) ? readFileSync(full, 'utf8') : '';
87
191
  const fragment = sub(readFileSync(join(base, rel), 'utf8'));
88
192
  const merged = mergeBlock(existing, fragment, mod.name);
@@ -114,7 +218,12 @@ export function addModule(
114
218
  */
115
219
  export function registerGates(mods: Manifest[], repoRoot: string, dryRun = false, adopted: AdoptedGate[] = []): AddAction[] {
116
220
  const actions: AddAction[] = [];
117
- const registry = join(repoRoot, '.ai', 'gates.toml');
221
+ const owners = [...(adopted.length ? ['adopted'] : []), ...mods.filter((mod) => mod.gates.length).map((mod) => mod.name)];
222
+ const destinations = preflightEmittedPaths(
223
+ repoRoot,
224
+ owners.map((moduleName) => ({ moduleName, target: '.ai/gates.toml', shared: true, writeExisting: true })),
225
+ );
226
+ const registry = destinations[0]?.absolute ?? join(repoRoot, '.ai', 'gates.toml');
118
227
 
119
228
  // Adoption, in the only form ADR-0004 permits: the repo's existing validators
120
229
  // are registered as `command` gates so they gain the runner, the ledger and
@@ -194,6 +303,64 @@ export function blockedByParadigm(order: Manifest[], paradigms: ReadonlySet<stri
194
303
  return blocked;
195
304
  }
196
305
 
306
+ /** Why one module could not be installed: which conflict, and whose. */
307
+ export interface ConflictBlock {
308
+ /** The module in `order` whose own conflict caused this — itself, if direct. */
309
+ cause: string;
310
+ /** The module it conflicts with. */
311
+ with: string;
312
+ }
313
+
314
+ /**
315
+ * Every module in `order` that cannot be installed because it conflicts with a
316
+ * module the repo already has, with another module in the same install set, or
317
+ * because something it depends on does.
318
+ *
319
+ * `[conflicts]` was parsed into the manifest and read by nothing (F-038), so a
320
+ * module declaring an incompatibility got silence — the same family as the
321
+ * skill extensions of F-019 and the paradigm state of F-014, and the third time
322
+ * a manifest field was documented to contributors and enforced nowhere.
323
+ *
324
+ * **The relation is symmetric even though the declaration is not.** Only one
325
+ * side can realistically know: a module authored outside this package can name
326
+ * `backlog`, and `backlog` will never name it. Requiring both to declare would
327
+ * make the field useless for exactly the case it exists for.
328
+ *
329
+ * Blocking travels up the dependency edges for the reason `blockedByParadigm`
330
+ * does — a dependency is only ever pulled in *for* something, and installing
331
+ * the dependent of a refused module ships half a system.
332
+ */
333
+ export function blockedByConflict(
334
+ order: Manifest[],
335
+ present: ReadonlySet<string>,
336
+ all: Manifest[],
337
+ ): Map<string, ConflictBlock> {
338
+ const byName = new Map(all.map((m) => [m.name, m]));
339
+ const blocked = new Map<string, ConflictBlock>();
340
+
341
+ const partner = (mod: Manifest): string | undefined => {
342
+ const declared = mod.conflicts.find((other) => other !== mod.name && present.has(other));
343
+ if (declared) return declared;
344
+ for (const other of present) {
345
+ if (other === mod.name) continue;
346
+ if (byName.get(other)?.conflicts.includes(mod.name)) return other;
347
+ }
348
+ return undefined;
349
+ };
350
+
351
+ // `order` is already dependency-first, so one forward pass settles it.
352
+ for (const mod of order) {
353
+ const against = partner(mod);
354
+ if (against) {
355
+ blocked.set(mod.name, { cause: mod.name, with: against });
356
+ continue;
357
+ }
358
+ const dep = mod.requires.find((d) => blocked.has(d));
359
+ if (dep) blocked.set(mod.name, blocked.get(dep)!);
360
+ }
361
+ return blocked;
362
+ }
363
+
197
364
  export function resolveInstallOrder(requested: string[], all: Manifest[]): { order: Manifest[]; missing: string[] } {
198
365
  const byName = new Map(all.map((m) => [m.name, m]));
199
366
  const order: Manifest[] = [];
@@ -259,8 +426,6 @@ export const contentHash = (s: string) => createHash('sha256').update(s.replace(
259
426
  * managed blocks is never whole-file upgraded — its **blocks** are, through the
260
427
  * merge path.
261
428
  */
262
- const SHARED = new Set(['AGENTS.md', 'CLAUDE.md', '.gitignore', '.gitattributes', '.ai/gates.toml']);
263
-
264
429
  /**
265
430
  * Add the harness extensions a module opted this skill into.
266
431
  *
@@ -294,21 +459,8 @@ function withOptedInExtensions(mod: Manifest, rel: string, content: string): str
294
459
 
295
460
  export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude/skills'): Map<string, string> {
296
461
  const out = new Map<string, string>();
297
- const sub = (t: string) => substitute(t, mod.name, params);
298
- for (const [dir, prefix] of [
299
- ['files', ''],
300
- ['rules', '.ai/rules/'],
301
- ['skills', `${skillsDir}/`],
302
- ] as const) {
303
- const base = join(mod.dir, dir);
304
- if (!existsSync(base)) continue;
305
- for (const rel of walk(base)) {
306
- const target = sub(prefix + rel).split('\\').join('/');
307
- if (SHARED.has(target)) continue;
308
- let content = sub(readFileSync(join(base, rel), 'utf8'));
309
- if (dir === 'skills') content = withOptedInExtensions(mod, rel, content);
310
- out.set(target, content);
311
- }
462
+ for (const file of fileEmissions(mod, params, skillsDir)) {
463
+ if (!file.shared) out.set(file.target, file.content);
312
464
  }
313
465
  return out;
314
466
  }
@@ -351,8 +503,9 @@ export function writeInstallRecord(
351
503
  // divergence the user caused — implying they broke something they never
352
504
  // touched. Kept files are listed separately and stay theirs forever.
353
505
  const emitted = emittedFiles(m, params, skillsDir);
354
- const created = [...emitted].filter(([rel]) => (wroteByModule?.get(m.name)?.has(rel) ?? existsSync(join(repoRoot, rel))));
355
- const kept = [...emitted].filter(([rel]) => !created.some(([c]) => c === rel) && existsSync(join(repoRoot, rel)));
506
+ const exists = (rel: string) => existsSync(resolveEmittedPath(repoRoot, m.name, rel).absolute);
507
+ const created = [...emitted].filter(([rel]) => (wroteByModule?.get(m.name)?.has(rel) ?? exists(rel)));
508
+ const kept = [...emitted].filter(([rel]) => !created.some(([c]) => c === rel) && exists(rel));
356
509
  if (created.length) {
357
510
  lines.push(`[modules.${m.name}.hashes]`);
358
511
  for (const [rel, content] of created) lines.push(`"${rel}" = "${contentHash(content)}"`);
@@ -363,7 +516,10 @@ export function writeInstallRecord(
363
516
  }
364
517
  lines.push('');
365
518
  }
366
- writeFileSync(join(repoRoot, '.ai', 'rungs.toml'), lines.join('\n'));
519
+ const record = preflightEmittedPaths(repoRoot, [
520
+ { moduleName: 'rungs', target: '.ai/rungs.toml', writeExisting: true },
521
+ ])[0];
522
+ writeFileSync(record.absolute, lines.join('\n'));
367
523
  }
368
524
 
369
525
  export interface AdoptedGate {