@rungs/cli 0.3.1 → 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.
- package/README.md +6 -6
- package/dist/cli.js +2184 -478
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +18 -29
- package/src/engines2.ts +403 -20
- package/src/engines3.ts +111 -20
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +86 -27
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- 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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
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
|
|
355
|
-
const
|
|
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
|
-
|
|
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 {
|