@rungs/cli 0.1.3 → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  [module]
2
2
  name = "session"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  rung = 1
5
5
  summary = "A handoff document carrying state between sessions: resume point, active constraints, and a dated archive."
6
6
 
@@ -27,7 +27,8 @@ default = ".ai/archive"
27
27
  [[gates]]
28
28
  id = "session-sections-present"
29
29
  kind = "declared"
30
- engine = "frontmatter-schema"
30
+ engine = "sections"
31
+ applicability = "our-schema"
31
32
  table = "gates/session.toml"
32
33
  tier = "fast"
33
34
  why = """
@@ -39,6 +40,7 @@ reopens a settled question, which is the specific failure this document exists t
39
40
  id = "session-archive-named"
40
41
  kind = "declared"
41
42
  engine = "filename-schema"
43
+ applicability = "our-schema"
42
44
  table = "gates/session.toml"
43
45
  tier = "fast"
44
46
  why = "An archive whose filenames do not say what closed and what came next is a folder nobody opens."
@@ -30,6 +30,7 @@ consumed_by = "render"
30
30
  id = "skills-spec-pure"
31
31
  kind = "declared"
32
32
  engine = "frontmatter-schema"
33
+ applicability = "our-schema"
33
34
  table = "gates/skills.toml"
34
35
  tier = "fast"
35
36
  why = """
@@ -43,6 +44,7 @@ is stated at the point of the choice.
43
44
  id = "skills-description-routes"
44
45
  kind = "declared"
45
46
  engine = "frontmatter-schema"
47
+ applicability = "our-schema"
46
48
  table = "gates/skills.toml"
47
49
  tier = "fast"
48
50
  why = """
@@ -55,6 +57,7 @@ what the skill does and the phrases people actually say.
55
57
  id = "skills-names-neighbours"
56
58
  kind = "declared"
57
59
  engine = "cross-reference"
60
+ applicability = "our-schema"
58
61
  table = "gates/skills.toml"
59
62
  tier = "fast"
60
63
  why = """
@@ -37,6 +37,7 @@ default = 600
37
37
  id = "specs-status-evidence"
38
38
  kind = "declared"
39
39
  engine = "register-schema"
40
+ applicability = "our-schema"
40
41
  table = "gates/specs.toml"
41
42
  tier = "fast"
42
43
  why = """
@@ -49,6 +50,7 @@ integration is worse than no spec, because it is read as verified.
49
50
  id = "specs-scope-section"
50
51
  kind = "declared"
51
52
  engine = "sections"
53
+ applicability = "our-schema"
52
54
  table = "gates/specs.toml"
53
55
  tier = "fast"
54
56
  why = "Out-of-scope is what stops an agent silently expanding a surface while implementing an unrelated story."
@@ -57,6 +59,7 @@ why = "Out-of-scope is what stops an agent silently expanding a surface while
57
59
  id = "specs-ids-unique"
58
60
  kind = "declared"
59
61
  engine = "id-integrity"
62
+ applicability = "our-schema"
60
63
  table = "gates/specs.toml"
61
64
  tier = "fast"
62
65
  why = "Story ids are cited from commits, plans and work items; two stories sharing one make every citation ambiguous."
@@ -65,6 +68,7 @@ why = "Story ids are cited from commits, plans and work items; two stories sh
65
68
  id = "specs-split-threshold"
66
69
  kind = "declared"
67
70
  engine = "file-budget"
71
+ applicability = "repo-content"
68
72
  table = "gates/specs.toml"
69
73
  tier = "fast"
70
74
  why = "A spec past the threshold is one nobody reads to the end, and its later sections are where the unverified claims collect."
@@ -29,6 +29,7 @@ default = "docs/plans"
29
29
  id = "workflows-plan-tier-declared"
30
30
  kind = "declared"
31
31
  engine = "frontmatter-schema"
32
+ applicability = "our-schema"
32
33
  table = "gates/workflows.toml"
33
34
  tier = "fast"
34
35
  why = """
@@ -41,6 +42,7 @@ call the table exists to replace.
41
42
  id = "workflows-plan-sections"
42
43
  kind = "declared"
43
44
  engine = "sections"
45
+ applicability = "our-schema"
44
46
  table = "gates/workflows.toml"
45
47
  tier = "fast"
46
48
  why = "A tier-2 proposal missing its proof-of-reuse gate is a tier-1 plan wearing a heavier label."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rungs/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Installs and maintains a repository's agentic development system, composed from modules.",
5
5
  "author": "Antoine Dancre",
6
6
  "repository": {
package/src/add.ts CHANGED
@@ -66,7 +66,11 @@ export function addModule(
66
66
  const base = join(mod.dir, 'skills');
67
67
  const dir = opts.skillsDir ?? '.claude/skills';
68
68
  for (const rel of walk(base)) {
69
- write(`${dir}/${rel}`, sub(readFileSync(join(base, rel), 'utf8')), 'skill');
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');
70
74
  }
71
75
  }
72
76
 
@@ -165,6 +169,31 @@ const gateEntry = (mod: Manifest) => (g: Manifest['gates'][number]) => {
165
169
  };
166
170
 
167
171
  /** Dependency order, refusing anything unmet — naming the incident (ADR-0003). */
172
+ /**
173
+ * Every module in `order` that cannot be installed because one of the modules
174
+ * it needs — or itself — is a different paradigm.
175
+ *
176
+ * A refusal has to travel *up* the dependency edges, not just stop at the
177
+ * module that matched. `add audit` pulls `findings` which pulls `backlog`; if
178
+ * the repo's work lives in an issue tracker, refusing `backlog` and installing
179
+ * `audit` anyway would ship an audit procedure whose findings have nowhere to
180
+ * close — which is the exact incident (268 audit documents, no register) that
181
+ * made `audit → findings → backlog` a declared dependency in the first place.
182
+ */
183
+ export function blockedByParadigm(order: Manifest[], paradigms: ReadonlySet<string>): Map<string, string> {
184
+ const blocked = new Map<string, string>();
185
+ // `order` is already dependency-first, so one forward pass settles it.
186
+ for (const mod of order) {
187
+ if (paradigms.has(mod.name)) {
188
+ blocked.set(mod.name, mod.name);
189
+ continue;
190
+ }
191
+ const dep = mod.requires.find((d) => blocked.has(d));
192
+ if (dep) blocked.set(mod.name, blocked.get(dep)!);
193
+ }
194
+ return blocked;
195
+ }
196
+
168
197
  export function resolveInstallOrder(requested: string[], all: Manifest[]): { order: Manifest[]; missing: string[] } {
169
198
  const byName = new Map(all.map((m) => [m.name, m]));
170
199
  const order: Manifest[] = [];
@@ -232,6 +261,37 @@ export const contentHash = (s: string) => createHash('sha256').update(s.replace(
232
261
  */
233
262
  const SHARED = new Set(['AGENTS.md', 'CLAUDE.md', '.gitignore', '.gitattributes', '.ai/gates.toml']);
234
263
 
264
+ /**
265
+ * Add the harness extensions a module opted this skill into.
266
+ *
267
+ * F-019. `[skills.work-item] extensions = { disable-model-invocation = true }`
268
+ * was declared in the `backlog` manifest, documented in `modules/README.md`, and
269
+ * **implemented at no layer**: `grep -n extensions src/*.ts` returned nothing, so
270
+ * the key never reached the emitted `SKILL.md`, and the gate that is supposed to
271
+ * police it could not see the opt-in either. `work-item` creates branches and
272
+ * merges, and the manifest's stated reason for opting it out of model invocation
273
+ * had been inert since it was written.
274
+ *
275
+ * Injected here rather than written into the source skill because that is the
276
+ * point of the opt-in: the file stays spec-pure and portable
277
+ * ([ADR-0001](../docs/decisions/ADR-0001-multi-harness-rendering.md)), and the
278
+ * extension — with its portability cost — stays attached to the module's
279
+ * decision to take it.
280
+ */
281
+ function withOptedInExtensions(mod: Manifest, rel: string, content: string): string {
282
+ const name = rel.split(/[\\/]/)[0];
283
+ const extensions = mod.skills?.[name]?.extensions;
284
+ if (!extensions || !Object.keys(extensions).length) return content;
285
+
286
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
287
+ if (!m) return content; // no frontmatter to extend; `skills-spec-pure` reports it
288
+ const added = Object.entries(extensions)
289
+ .filter(([k]) => !new RegExp(`^${k}:`, 'm').test(m[1]))
290
+ .map(([k, v]) => `${k}: ${v}`);
291
+ if (!added.length) return content;
292
+ return content.replace(/^---\n[\s\S]*?\n---/, `---\n${m[1]}\n${added.join('\n')}\n---`);
293
+ }
294
+
235
295
  export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude/skills'): Map<string, string> {
236
296
  const out = new Map<string, string>();
237
297
  const sub = (t: string) => substitute(t, mod.name, params);
@@ -245,7 +305,9 @@ export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude
245
305
  for (const rel of walk(base)) {
246
306
  const target = sub(prefix + rel).split('\\').join('/');
247
307
  if (SHARED.has(target)) continue;
248
- out.set(target, sub(readFileSync(join(base, rel), 'utf8')));
308
+ let content = sub(readFileSync(join(base, rel), 'utf8'));
309
+ if (dir === 'skills') content = withOptedInExtensions(mod, rel, content);
310
+ out.set(target, content);
249
311
  }
250
312
  }
251
313
  return out;
package/src/backlog.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { walk } from './glob.ts';
4
+
5
+ /**
6
+ * `rungs backlog archive` — move finished items out of `items/` and repoint
7
+ * every link in the repo at their new home.
8
+ *
9
+ * F-015. Three files shipped into **every** consumer repo named this command,
10
+ * two of them saying "never by hand", and it did not exist: `rungs backlog`
11
+ * answered *"unknown command"*. So the instruction was unfollowable everywhere
12
+ * rungs had ever been installed, and the reason it says *never by hand* is
13
+ * exactly why it could not be worked around — moving 39 files and rewriting
14
+ * every citation of them is the kind of repo-wide edit that fails silently.
15
+ *
16
+ * The link rewrite is the whole substance of the command. It resolves each
17
+ * link from the citing file's **own** directory rather than pattern-matching
18
+ * text, because the same target is written `items/WI-001-x.md`,
19
+ * `../items/WI-001-x.md` and `WI-001-x.md` depending on who is citing it, and a
20
+ * regex over any one of those spellings silently misses the others.
21
+ */
22
+
23
+ export interface ArchiveMove {
24
+ id: string;
25
+ status: string;
26
+ from: string;
27
+ to: string;
28
+ }
29
+
30
+ export interface ArchivePlan {
31
+ root: string;
32
+ moves: ArchiveMove[];
33
+ /** Files whose links change, with how many links move in each. */
34
+ rewrites: { file: string; links: number }[];
35
+ /** Items that look finished but are not eligible, with the reason. */
36
+ held: { file: string; reason: string }[];
37
+ }
38
+
39
+ /** Statuses whose work can no longer change. Mirrors backlog README §8. */
40
+ const FINISHED = new Set(['done', 'rejected']);
41
+
42
+ const field = (text: string, name: string) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, 'm'))?.[1] ?? '';
43
+
44
+ const posix = (p: string) => p.split(sep).join('/');
45
+
46
+ /** A relative markdown link that could point at a repo file. */
47
+ const LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
48
+
49
+ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): ArchivePlan {
50
+ const itemsDir = join(repoRoot, ...backlogRoot.split('/'), 'items');
51
+ const archiveDir = join(repoRoot, ...backlogRoot.split('/'), 'archive');
52
+ const moves: ArchiveMove[] = [];
53
+ const held: ArchivePlan['held'] = [];
54
+
55
+ const files = walk(repoRoot);
56
+ const items = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, itemsDir)) + '/') && f.endsWith('.md'));
57
+
58
+ for (const rel of items) {
59
+ if (/README\.md$/i.test(rel) || /TEMPLATE\.md$/i.test(rel)) continue;
60
+ const text = readFileSync(join(repoRoot, rel), 'utf8');
61
+ const status = field(text, 'status');
62
+ const id = field(text, 'id');
63
+ if (!FINISHED.has(status)) continue;
64
+
65
+ // An epic whose children are not all finished is still live bookkeeping: it
66
+ // is the thing that says what remains. Moving it would file the index of
67
+ // open work under "cannot change any more".
68
+ if (field(text, 'type') === 'epic') {
69
+ const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? '')
70
+ .split(',')
71
+ .map((s) => s.trim())
72
+ .filter(Boolean);
73
+ const unfinished = children.filter((c) => {
74
+ const f = items.find((i) => i.includes(`${c}-`));
75
+ return !f || !FINISHED.has(field(readFileSync(join(repoRoot, f), 'utf8'), 'status'));
76
+ });
77
+ if (unfinished.length) {
78
+ held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(', ')}` });
79
+ continue;
80
+ }
81
+ }
82
+
83
+ // `posix(rel)` first: `walk` yields `/`-separated paths, so splitting on the
84
+ // platform `sep` on Windows never splits and the basename came back as the
85
+ // whole path — producing `archive/docs/backlog/items/WI-001-….md`.
86
+ moves.push({
87
+ id,
88
+ status,
89
+ from: rel,
90
+ to: posix(join(relative(repoRoot, archiveDir), posix(rel).split('/').pop()!)),
91
+ });
92
+ }
93
+
94
+ // Where each moved file ends up, keyed by its absolute old path, so a link can
95
+ // be looked up by what it resolves to rather than by how it was spelled.
96
+ const moved = new Map(moves.map((m) => [resolve(repoRoot, m.from), m.to]));
97
+ const rewrites: ArchivePlan['rewrites'] = [];
98
+
99
+ for (const rel of files) {
100
+ if (!isRewritable(rel)) continue;
101
+ const links = retargets(repoRoot, rel, moved).length;
102
+ if (links || moved.has(resolve(repoRoot, rel))) rewrites.push({ file: rel, links });
103
+ }
104
+
105
+ return { root: backlogRoot, moves, rewrites, held };
106
+ }
107
+
108
+ /**
109
+ * A module's `files/` and `fragments/` are **templates**, not repo content.
110
+ * Their links are relative to wherever the fragment merges into, they carry
111
+ * `{{param}}` tokens, and resolving them here reports every one as broken —
112
+ * which is why `link_integrity.exclude` already skips them. Rewriting them
113
+ * would be worse than reporting them: it would bake this repo's paths into what
114
+ * every consumer repo gets installed.
115
+ */
116
+ function isRewritable(rel: string): boolean {
117
+ const p = posix(rel);
118
+ if (!p.endsWith('.md')) return false;
119
+ return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith('node_modules/');
120
+ }
121
+
122
+ /**
123
+ * The links in one file that this archive run has to change, and what to.
124
+ *
125
+ * Deliberately **only** links whose target moved, plus — when the citing file is
126
+ * itself moving — links that would otherwise break from the new location. The
127
+ * first version compared every link's written form against a freshly computed
128
+ * relative path and counted a difference as a change, which claimed 334 links
129
+ * across 58 files including `AGENTS.md`, `README.md` and module templates. Most
130
+ * of those were equivalent spellings of an unmoved target. Rewriting them would
131
+ * have been a repo-wide reflow disguised as an archive.
132
+ */
133
+ function retargets(repoRoot: string, rel: string, moved: Map<string, string>): { href: string; to: string }[] {
134
+ const oldDir = dirname(resolve(repoRoot, rel));
135
+ const selfMoved = moved.get(resolve(repoRoot, rel));
136
+ const newDir = dirname(resolve(repoRoot, selfMoved ?? rel));
137
+ const out: { href: string; to: string }[] = [];
138
+
139
+ for (const m of readFileSync(join(repoRoot, rel), 'utf8').matchAll(LINK)) {
140
+ const href = m[1];
141
+ if (href.includes('{{')) continue; // a template link, resolved at install
142
+ const target = resolve(oldDir, decodeURIComponent(href));
143
+ const targetMoved = moved.get(target);
144
+ if (!targetMoved && !selfMoved) continue;
145
+ if (!targetMoved && !existsSync(target)) continue; // already broken; not this command's to fix
146
+ const targetNew = targetMoved ? resolve(repoRoot, targetMoved) : target;
147
+ // No `./` prefix. It is never required for a relative markdown link, and
148
+ // adding it rewrites the spelling of paths whose *target* is what changed —
149
+ // turning a one-word diff into a whole-line one across 37 files.
150
+ const to = posix(relative(newDir, targetNew));
151
+ if (to !== posix(href)) out.push({ href, to });
152
+ }
153
+ return out;
154
+ }
155
+
156
+ export function applyArchive(repoRoot: string, plan: ArchivePlan): void {
157
+ const moved = new Map(plan.moves.map((m) => [resolve(repoRoot, m.from), m.to]));
158
+
159
+ // Rewrite before moving. Every path is computed from the plan rather than from
160
+ // the filesystem, so the order is a choice — and this order means a crash
161
+ // halfway leaves the files still where the links say they are.
162
+ for (const rel of walk(repoRoot)) {
163
+ if (!isRewritable(rel)) continue;
164
+ const edits = retargets(repoRoot, rel, moved);
165
+ if (!edits.length) continue;
166
+ const path = join(repoRoot, rel);
167
+ let text = readFileSync(path, 'utf8');
168
+ // Replace through the same matcher that found them, so a href appearing in
169
+ // prose as well as in a link cannot be hit by a bare string replace.
170
+ text = text.replace(LINK, (whole, href: string, anchor: string) => {
171
+ const edit = edits.find((e) => e.href === href);
172
+ return edit ? `](${edit.to}${anchor})` : whole;
173
+ });
174
+ writeFileSync(path, text);
175
+ }
176
+
177
+ for (const m of plan.moves) {
178
+ const to = join(repoRoot, ...m.to.split('/'));
179
+ mkdirSync(dirname(to), { recursive: true });
180
+ renameSync(join(repoRoot, m.from), to);
181
+ }
182
+ }
package/src/check.ts CHANGED
@@ -44,8 +44,50 @@ export function loadRegistry(repoRoot: string): { runner: any; gates: RegistryGa
44
44
  return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
45
45
  }
46
46
 
47
+ /**
48
+ * ADR-0008: a tier is an ordered **level**, not a tag. `[runner] tiers` declares
49
+ * the order, and asking for one runs every gate at that level or below it.
50
+ *
51
+ * This was string equality, so `full` selected only gates labelled `full` — zero
52
+ * of them on a registry where everything is `fast`, which is this repo. The run
53
+ * then reported no gates and exited as though the release had been gated, and
54
+ * `cut-release` told every consumer to gate on exactly that command (F-020).
55
+ */
56
+ export function tierSelects(runnerTiers: string[], requested: string, gateTier?: string): boolean {
57
+ if (!gateTier) return true; // untiered gates run in every tier
58
+ const at = runnerTiers.indexOf(requested);
59
+ const of = runnerTiers.indexOf(gateTier);
60
+ // An undeclared tier on either side cannot be ordered. Fall back to equality
61
+ // rather than guessing a position — silently including it would be worse.
62
+ if (at < 0 || of < 0) return gateTier === requested;
63
+ return of <= at;
64
+ }
65
+
66
+ /**
67
+ * No parameter properties: Node's strip-only TypeScript mode rejects them, and
68
+ * `dist/` is built from these sources for a runtime that has no compiler. The
69
+ * same constraint is what v0.1.1 shipped broken (ERR_UNSUPPORTED_NODE_MODULES_
70
+ * TYPE_STRIPPING), so it is worth the four extra lines.
71
+ */
72
+ export class UnknownTierError extends Error {
73
+ requested: string;
74
+ declared: string[];
75
+ constructor(requested: string, declared: string[]) {
76
+ super(`unknown tier "${requested}"`);
77
+ this.requested = requested;
78
+ this.declared = declared;
79
+ }
80
+ }
81
+
47
82
  export function runGates(repoRoot: string, tier?: string, now = () => Date.now()): GateRun[] {
48
- const { gates } = loadRegistry(repoRoot);
83
+ const { runner, gates } = loadRegistry(repoRoot);
84
+ const runnerTiers: string[] = Array.isArray(runner?.tiers) ? runner.tiers : [];
85
+ // A tier nobody declared selects nothing, and "selected nothing" is
86
+ // indistinguishable from "everything passed" at the exit code. Refuse it here
87
+ // rather than let a typo read as a green release gate.
88
+ if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
89
+ throw new UnknownTierError(tier, runnerTiers);
90
+ }
49
91
  const files = walk(repoRoot);
50
92
  const runs: GateRun[] = [];
51
93
 
@@ -53,7 +95,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
53
95
  // A hook fires on a tool call, not in the runner. Skipping it here is
54
96
  // correct; counting it as a pass would not be.
55
97
  if (g.trigger) continue;
56
- if (tier && g.tier && g.tier !== tier) continue;
98
+ if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
57
99
 
58
100
  const started = now();
59
101
  let status: Status = 'pass';
@@ -124,7 +166,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
124
166
  * parameters before parsing — which is also what makes a gate honour the
125
167
  * prefix, root and budget that repo actually chose.
126
168
  */
127
- function loadTable(ref: string | undefined, repoRoot: string): any | null {
169
+ export function loadTable(ref: string | undefined, repoRoot: string): any | null {
128
170
  if (!ref) return null;
129
171
  const [mod, file] = ref.split('/');
130
172
  const path = join(MODULES, mod, 'gates', file);
@@ -157,7 +199,7 @@ function installedParams(repoRoot: string): Params {
157
199
  return defaults;
158
200
  }
159
201
 
160
- const tableKey = (engine: string) =>
202
+ export const tableKey = (engine: string) =>
161
203
  ({
162
204
  'file-budget': 'file_budget',
163
205
  sections: 'sections',
@@ -177,6 +219,7 @@ const tableKey = (engine: string) =>
177
219
  'rule-propagation': 'rule_propagation',
178
220
  'git-state': 'git_state',
179
221
  'merge-driver-check': 'merge_driver_check',
222
+ 'board-reconcile': 'board_reconcile',
180
223
  })[engine] ?? engine;
181
224
 
182
225
  /**