@rungs/cli 0.1.3 → 0.3.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 +61 -11
- package/dist/cli.js +1250 -129
- package/dist/cli.js.map +4 -4
- package/modules/README.md +13 -0
- package/modules/adr/gates/adr.toml +14 -2
- package/modules/adr/module.toml +19 -1
- package/modules/audit/module.toml +1 -0
- package/modules/backlog/gates/ids.toml +33 -0
- package/modules/backlog/module.toml +61 -1
- package/modules/ci/files/{{workflow_path}} +9 -1
- package/modules/ci/module.toml +2 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
- package/modules/concurrency/module.toml +3 -1
- package/modules/design-sync/module.toml +2 -0
- package/modules/doc-authority/module.toml +4 -0
- package/modules/findings/module.toml +3 -0
- package/modules/gates/gates/structural.toml +61 -17
- package/modules/gates/module.toml +5 -0
- package/modules/instructions/module.toml +4 -0
- package/modules/release/gates/release.toml +72 -4
- package/modules/release/module.toml +16 -1
- package/modules/release/skills/cut-release/SKILL.md +8 -1
- package/modules/session/module.toml +4 -2
- package/modules/skills/module.toml +3 -0
- package/modules/specs/module.toml +4 -0
- package/modules/workflows/module.toml +2 -0
- package/package.json +1 -1
- package/src/add.ts +64 -2
- package/src/backlog.ts +197 -0
- package/src/check.ts +56 -6
- package/src/cli.ts +406 -27
- package/src/concurrency.ts +412 -0
- package/src/engines.ts +261 -13
- package/src/engines2.ts +89 -4
- package/src/engines3.ts +147 -0
- package/src/explain.ts +189 -0
- package/src/lifecycle.ts +90 -3
- package/src/manifest.ts +13 -1
- package/src/selftest.ts +237 -0
- package/src/types.ts +34 -0
package/src/engines3.ts
CHANGED
|
@@ -15,6 +15,72 @@ const expand = (files: string[], p: string[] | undefined, f: string[] = []) =>
|
|
|
15
15
|
[...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
16
16
|
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
17
17
|
|
|
18
|
+
/** `1.2.3` → [1,2,3]; anything else → null. Deliberately not a semver parser. */
|
|
19
|
+
export function versionParts(s: string): number[] | null {
|
|
20
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
|
|
21
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function versionCmp(a: number[], b: number[]): number {
|
|
25
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `changelog-freshness` — a consumed fragment that was never deleted.
|
|
30
|
+
*
|
|
31
|
+
* Fragments are consumed at release time, not archived: one left behind appears
|
|
32
|
+
* in the next release too, where it reads as unreleased work. `cut-release` §3
|
|
33
|
+
* has said so in prose since it was written, and prose did not hold it —
|
|
34
|
+
* `changelog.d/0.1.1.md` survived two releases and was still there at 0.2.0
|
|
35
|
+
* preparation (F-022). So the rule becomes mechanical.
|
|
36
|
+
*
|
|
37
|
+
* A fragment is stale when its filename names a version **below** the version
|
|
38
|
+
* being prepared. Files whose names are not versions are ignored rather than
|
|
39
|
+
* reported: the module's own fixtures use `42.feature.md`, and a gate that
|
|
40
|
+
* refuses a naming convention it was not asked about is a gate people disable.
|
|
41
|
+
*/
|
|
42
|
+
export const changelogFreshness: Engine = (t, root, files) => {
|
|
43
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
44
|
+
const findings: Finding[] = [];
|
|
45
|
+
let examined = 0;
|
|
46
|
+
|
|
47
|
+
for (const spec of specs) {
|
|
48
|
+
const src = spec.version ?? {};
|
|
49
|
+
let current: number[] | null = null;
|
|
50
|
+
for (const rel of matchAny(files, src.file ?? 'package.json')) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = (src.path ?? 'version')
|
|
53
|
+
.split('.')
|
|
54
|
+
.reduce((o: any, k: string) => o?.[k], JSON.parse(read(root, rel)));
|
|
55
|
+
current = versionParts(String(raw ?? ''));
|
|
56
|
+
} catch {
|
|
57
|
+
/* unparseable is not a stale fragment */
|
|
58
|
+
}
|
|
59
|
+
if (current) break;
|
|
60
|
+
}
|
|
61
|
+
// Without a version to compare against there is no claim to make. Saying
|
|
62
|
+
// nothing is right; passing loudly would not be.
|
|
63
|
+
if (!current) continue;
|
|
64
|
+
|
|
65
|
+
for (const rel of expand(files, spec.fragments, [])) {
|
|
66
|
+
const name = rel.split('/').pop()!.replace(/\.md$/, '');
|
|
67
|
+
const v = versionParts(name);
|
|
68
|
+
if (!v) continue;
|
|
69
|
+
examined++;
|
|
70
|
+
if (versionCmp(v, current) < 0) {
|
|
71
|
+
findings.push({
|
|
72
|
+
file: rel,
|
|
73
|
+
message:
|
|
74
|
+
spec.message?.trim() ||
|
|
75
|
+
`fragment names ${name}, below the ${current.join('.')} being prepared — it was consumed by an earlier release and should have been deleted`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { findings, examined };
|
|
82
|
+
};
|
|
83
|
+
|
|
18
84
|
/** An exemption marker is ignored unless it states a reason. */
|
|
19
85
|
const exempted = (text: string, marker?: string) =>
|
|
20
86
|
!!marker && new RegExp(`${escapeRe(marker)}\\s*\\S`).test(text);
|
|
@@ -192,3 +258,84 @@ export const mergeDriverCheck: Engine = (t, root) => {
|
|
|
192
258
|
}
|
|
193
259
|
return { findings, examined: declared.length };
|
|
194
260
|
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The board's grouping must agree with each item's own `status` field.
|
|
264
|
+
*
|
|
265
|
+
* `git-status-reconcile` already reconciles a **branch** against that field, and
|
|
266
|
+
* this repo cites it constantly as proof that typed bookkeeping decays. Nothing
|
|
267
|
+
* reconciled the **board** — so on 2026-08-16 `BACKLOG.md` filed fourteen items
|
|
268
|
+
* under `Proposed` and `Planned` whose files all read `status: done`, nine of
|
|
269
|
+
* them linking into `archive/`. The board said *proposed* about a document in
|
|
270
|
+
* the directory for work that can no longer change.
|
|
271
|
+
*
|
|
272
|
+
* It was found by an outside reviewer asserting the framework research was done.
|
|
273
|
+
* They were right; the board would have told them otherwise. That is the same
|
|
274
|
+
* failure the whole module exists to prevent, one layer up, in the file every
|
|
275
|
+
* session opens first.
|
|
276
|
+
*
|
|
277
|
+
* Only table rows are read. The board's prose deliberately discusses finished
|
|
278
|
+
* work, and a paragraph is not a claim about status.
|
|
279
|
+
*/
|
|
280
|
+
export const boardReconcile: Engine = (t, root, _files) => {
|
|
281
|
+
const rel = t.file as string;
|
|
282
|
+
const text = read(root, rel);
|
|
283
|
+
if (!text) return { findings: [{ message: `board not found at ${rel}` }], examined: 0 };
|
|
284
|
+
if (exempted(text, t.exempt_marker)) return { findings: [], examined: 0 };
|
|
285
|
+
|
|
286
|
+
const groups: Record<string, string[]> = t.groups ?? {};
|
|
287
|
+
const dir = rel.split('/').slice(0, -1).join('/');
|
|
288
|
+
const findings: Finding[] = [];
|
|
289
|
+
let heading = '';
|
|
290
|
+
let examined = 0;
|
|
291
|
+
|
|
292
|
+
for (const line of text.split('\n')) {
|
|
293
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
294
|
+
if (h) {
|
|
295
|
+
heading = h[1];
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!line.startsWith('|')) continue;
|
|
299
|
+
|
|
300
|
+
const link = /^\|\s*\[[^\]]+\]\(([^)]+)\)/.exec(line);
|
|
301
|
+
if (!link) continue; // separator, header, or an empty `| — |` placeholder
|
|
302
|
+
|
|
303
|
+
// An undeclared heading is narrative, not a status group. The board's later
|
|
304
|
+
// sections are prose with their own tables — "The first-user path", closed
|
|
305
|
+
// 2026-08-15, tabulates seven finished items and says so in the heading.
|
|
306
|
+
//
|
|
307
|
+
// Reporting those was this gate's first behaviour and it was wrong: measured
|
|
308
|
+
// 2026-08-16, it produced seven findings against a document that is correct.
|
|
309
|
+
// The plan's requirement that every undeclared heading be reported was aimed
|
|
310
|
+
// at a *typo* hiding rows from the check, and it caught legitimate prose
|
|
311
|
+
// instead. That case is covered exactly, below, by requiring each declared
|
|
312
|
+
// group to appear — a misspelled `Propsed` makes `Proposed` go missing.
|
|
313
|
+
if (!Object.hasOwn(groups, heading)) continue;
|
|
314
|
+
|
|
315
|
+
examined++;
|
|
316
|
+
const target = `${dir}/${link[1]}`.replace(/[^/]+\/\.\.\//g, '');
|
|
317
|
+
const item = read(root, target);
|
|
318
|
+
if (!item) {
|
|
319
|
+
findings.push({ file: rel, message: `row under '${heading}' links to a missing file: ${link[1]}` });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const status = /^status:\s*(\S+)/m.exec(item)?.[1] ?? '';
|
|
323
|
+
if (!groups[heading].includes(status)) {
|
|
324
|
+
findings.push({
|
|
325
|
+
file: rel,
|
|
326
|
+
message: `${link[1]} is under '${heading}' but its status is '${status}' (expected ${groups[heading].join(' | ')})`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Every declared group must actually appear. This is the typo check: a board
|
|
332
|
+
// whose `Proposed` heading is misspelled would otherwise drop those rows
|
|
333
|
+
// silently, which is exactly what the group map exists to prevent.
|
|
334
|
+
const seen = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((m) => m[1]));
|
|
335
|
+
for (const g of Object.keys(groups)) {
|
|
336
|
+
if (!seen.has(g)) findings.push({ file: rel, message: `declared group '${g}' has no heading in the board` });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return { findings, examined };
|
|
340
|
+
};
|
|
341
|
+
|
package/src/explain.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ENGINES, type Finding } from './engines.ts';
|
|
2
|
+
import { loadTable, tableKey } from './check.ts';
|
|
3
|
+
import type { DetectResult, Manifest } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `doctor` answers a *presence* question — which of our modules does this repo
|
|
7
|
+
* already have an equivalent of. The question a repo actually arrives with is a
|
|
8
|
+
* *defect* question: which of my agent rules say MUST and have nothing checking
|
|
9
|
+
* them, how many near-identical CI workflows do I have, which topics have two
|
|
10
|
+
* documents claiming authority.
|
|
11
|
+
*
|
|
12
|
+
* Those detectors were already written. They ship as gates inside modules and
|
|
13
|
+
* ran only after installation, over rungs-managed content — so the analysis was
|
|
14
|
+
* gated behind installing the thing the analysis exists to justify, which is
|
|
15
|
+
* backwards for a tool whose primary case is retrofit (WI-038).
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is new detection. It is the same `ENGINES` table the runner
|
|
18
|
+
* uses, over a registry synthesized in memory from the module manifests rather
|
|
19
|
+
* than read from `.ai/gates.toml`, which an unmanaged repo does not have.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface DetectorFinding {
|
|
23
|
+
module: string;
|
|
24
|
+
gate: string;
|
|
25
|
+
/** The extracted incident behind the gate, from the manifest. */
|
|
26
|
+
why?: string;
|
|
27
|
+
findings: Finding[];
|
|
28
|
+
examined: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExplainResult {
|
|
32
|
+
reported: DetectorFinding[];
|
|
33
|
+
/** Gates skipped, by reason — printed, because a silent skip reads as a pass. */
|
|
34
|
+
skipped: { command: number; unimplemented: string[]; undeclared: string[]; errored: { gate: string; message: string }[] };
|
|
35
|
+
/** Modules whose detectors ran at all. */
|
|
36
|
+
scope: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type EngineTable = Record<string, (t: any, root: string, files: string[]) => { findings: Finding[]; examined: number }>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A `command` gate runs a shell command the *repo* owns. On a repo that never
|
|
43
|
+
* installed rungs there is no registry to own one, but a module can still
|
|
44
|
+
* declare one — and executing an arbitrary command against somebody else's
|
|
45
|
+
* checkout because they typed a read-only-sounding flag is not a thing this
|
|
46
|
+
* tool gets to do. Skipped, and counted, never run.
|
|
47
|
+
*/
|
|
48
|
+
const isRunnable = (g: Manifest['gates'][number]) => g.kind !== 'command' && !g.trigger && !!g.engine;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which modules' detectors are allowed to run.
|
|
52
|
+
*
|
|
53
|
+
* **Only what the repo already has.** ADR-0004 biased detection signatures
|
|
54
|
+
* toward false negatives; the same bias applies here for a stronger reason —
|
|
55
|
+
* these engines read rungs-shaped inputs, so on a foreign repo a
|
|
56
|
+
* technically-correct finding can still be framed against a convention the repo
|
|
57
|
+
* never adopted. A module the repo has no equivalent of has nothing to check,
|
|
58
|
+
* and running it anyway produces exactly the confident noise that loses an
|
|
59
|
+
* adoption wedge.
|
|
60
|
+
*
|
|
61
|
+
* `paradigm` is excluded too: the repo solves that problem a different way, and
|
|
62
|
+
* measuring their solution against our shape is the same error with a nastier
|
|
63
|
+
* tone.
|
|
64
|
+
*/
|
|
65
|
+
export const IN_SCOPE: ReadonlySet<string> = new Set(['theirs', 'ours-current', 'ours-diverged']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Which declared applicability may run against a repo that is not ours.
|
|
69
|
+
*
|
|
70
|
+
* This was two hard-coded sets of **engine names** in this file, and the
|
|
71
|
+
* knowledge lived nowhere near the gates it governed: adding a gate on
|
|
72
|
+
* `file-population` silently made it foreign-safe, and adding one on a new
|
|
73
|
+
* engine silently made it not, with nothing at either declaration saying so.
|
|
74
|
+
* It is now a required field on each gate — see `Applicability` in `types.ts`
|
|
75
|
+
* for what the three cases mean and which measurement produced them.
|
|
76
|
+
*/
|
|
77
|
+
const FOREIGN_SAFE: ReadonlySet<string> = new Set(['repo-content']);
|
|
78
|
+
|
|
79
|
+
export function explain(
|
|
80
|
+
mods: Manifest[],
|
|
81
|
+
results: DetectResult[],
|
|
82
|
+
repoRoot: string,
|
|
83
|
+
files: string[],
|
|
84
|
+
): ExplainResult {
|
|
85
|
+
return explainWith(ENGINES, mods, results, repoRoot, files);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `explain` with the engine table injected, so the scope rules above can be
|
|
90
|
+
* tested without a repo on disk. Those rules are the whole safety argument of
|
|
91
|
+
* this pass; testing them through fifteen real manifests would test the
|
|
92
|
+
* manifests instead.
|
|
93
|
+
*/
|
|
94
|
+
export function explainWith(
|
|
95
|
+
engines: EngineTable,
|
|
96
|
+
mods: Manifest[],
|
|
97
|
+
results: DetectResult[],
|
|
98
|
+
repoRoot: string,
|
|
99
|
+
files: string[],
|
|
100
|
+
): ExplainResult {
|
|
101
|
+
const inScope = results.filter((r) => IN_SCOPE.has(r.state));
|
|
102
|
+
const scope = inScope.map((r) => r.module);
|
|
103
|
+
const stateOf = new Map(inScope.map((r) => [r.module, r.state]));
|
|
104
|
+
const reported: DetectorFinding[] = [];
|
|
105
|
+
const skipped: ExplainResult['skipped'] = { command: 0, unimplemented: [], undeclared: [], errored: [] };
|
|
106
|
+
|
|
107
|
+
for (const name of scope) {
|
|
108
|
+
const mod = mods.find((m) => m.name === name);
|
|
109
|
+
if (!mod) continue;
|
|
110
|
+
const isOurs = stateOf.get(name) !== 'theirs';
|
|
111
|
+
|
|
112
|
+
for (const g of mod.gates) {
|
|
113
|
+
if (!isRunnable(g)) {
|
|
114
|
+
if (g.kind === 'command') skipped.command++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// No default. A gate that has not said whether it can read a foreign repo
|
|
118
|
+
// does not read one, and is named — silence resolving to "safe" is how the
|
|
119
|
+
// 71 mis-framed findings of WI-038's first version happened.
|
|
120
|
+
if (!isOurs) {
|
|
121
|
+
if (!g.applicability) {
|
|
122
|
+
skipped.undeclared.push(g.id);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!FOREIGN_SAFE.has(g.applicability)) continue;
|
|
126
|
+
}
|
|
127
|
+
if (!(g.engine! in engines)) {
|
|
128
|
+
// Same rule as the runner: an engine named and missing is an unknown,
|
|
129
|
+
// and an unknown is never reported as clean.
|
|
130
|
+
skipped.unimplemented.push(g.id);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const table = loadTable(g.table ? `${mod.name}/${g.table.replace(/^gates\//, '')}` : undefined, repoRoot);
|
|
135
|
+
if (!table) {
|
|
136
|
+
skipped.errored.push({ gate: g.id, message: `table '${g.table ?? '(none)'}' not found` });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const key = tableKey(g.engine!);
|
|
142
|
+
let section = table[key] ?? table;
|
|
143
|
+
if (Array.isArray(section) && section.some((s: any) => s?.id)) {
|
|
144
|
+
const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
|
|
145
|
+
if (mine.length) section = mine;
|
|
146
|
+
}
|
|
147
|
+
const r = engines[g.engine!](section, repoRoot, files);
|
|
148
|
+
if (r.findings.length) {
|
|
149
|
+
reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
|
|
150
|
+
}
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
// An engine that throws on a foreign repo's shapes is a fact about this
|
|
153
|
+
// pass, not about the repo. Reported as ours, not as their finding.
|
|
154
|
+
skipped.errored.push({ gate: g.id, message: e.message });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { reported: collapseDuplicates(reported), skipped, scope };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Two gate ids that produce the identical finding set are one check reported
|
|
164
|
+
* twice, and on a repo with 112 broken links that is 224 lines of the same
|
|
165
|
+
* thing. `gates-links-resolve` and `gates-paths-exist` currently run the same
|
|
166
|
+
* markdown-link scan — F-007 in `docs/backlog/FINDINGS.md`, open before this
|
|
167
|
+
* pass existed and unchanged by it.
|
|
168
|
+
*
|
|
169
|
+
* Collapsed here rather than fixed there on purpose: the duplication is a defect
|
|
170
|
+
* in the gate set, this is a defect in *reading* the gate set, and a reporting
|
|
171
|
+
* layer that hides a registry problem is how the registry problem survives. The
|
|
172
|
+
* merged row names both ids, so the duplication stays visible to anyone who
|
|
173
|
+
* looks at the output — which is the point.
|
|
174
|
+
*/
|
|
175
|
+
export function collapseDuplicates(reported: DetectorFinding[]): DetectorFinding[] {
|
|
176
|
+
const out: DetectorFinding[] = [];
|
|
177
|
+
const seen = new Map<string, DetectorFinding>();
|
|
178
|
+
for (const r of reported) {
|
|
179
|
+
const key = `${r.module} ${r.findings.map((f) => `${f.file ?? ''}|${f.message}`).join('')}`;
|
|
180
|
+
const prior = seen.get(key);
|
|
181
|
+
if (prior) {
|
|
182
|
+
prior.gate = `${prior.gate} + ${r.gate}`;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
seen.set(key, r);
|
|
186
|
+
out.push(r);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
import { parse } from 'smol-toml';
|
|
6
6
|
import type { Manifest } from './types.ts';
|
|
7
|
-
import { contentHash, emittedFiles } from './add.ts';
|
|
7
|
+
import { contentHash, emittedFiles, registerGates } from './add.ts';
|
|
8
8
|
import { resolveParams, substitute, type Params } from './substitute.ts';
|
|
9
9
|
import { loadRegistry } from './check.ts';
|
|
10
10
|
|
|
@@ -89,18 +89,105 @@ export function applyUpgrade(repoRoot: string, mods: Manifest[], record: Install
|
|
|
89
89
|
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
90
90
|
const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
91
91
|
let written = 0;
|
|
92
|
+
// Only files this run rewrote. A diverged file is not in here, which is what
|
|
93
|
+
// keeps its recorded hash — and therefore its protection — intact (F-017).
|
|
94
|
+
const rewritten = new Map<string, Map<string, string>>();
|
|
92
95
|
for (const item of plan) {
|
|
93
96
|
const mod = mods.find((m) => m.name === item.module)!;
|
|
94
97
|
const emitted = emittedFiles(mod, params, skillsDir);
|
|
95
98
|
for (const f of item.files) {
|
|
96
99
|
if (f.state !== 'stale' && f.state !== 'missing') continue;
|
|
97
100
|
const full = join(repoRoot, f.rel);
|
|
101
|
+
const content = emitted.get(f.rel)!;
|
|
98
102
|
mkdirSync(dirname(full), { recursive: true });
|
|
99
|
-
writeFileSync(full,
|
|
103
|
+
writeFileSync(full, content);
|
|
104
|
+
if (!rewritten.has(mod.name)) rewritten.set(mod.name, new Map());
|
|
105
|
+
rewritten.get(mod.name)!.set(f.rel, contentHash(content));
|
|
100
106
|
written++;
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
|
-
|
|
109
|
+
|
|
110
|
+
// F-016. Upgrading rewrote a module's **files** and never its **gates**, so a
|
|
111
|
+
// module version that added, removed or renamed one left the registry on the
|
|
112
|
+
// old block and told the user the upgrade succeeded. Reproduced 2026-08-16
|
|
113
|
+
// against a scratch consumer: `session` 1.1.0 → 1.2.0 with a new gate, and
|
|
114
|
+
// `.ai/gates.toml` kept `rungs:begin session@1.1.0` and 20 entries.
|
|
115
|
+
//
|
|
116
|
+
// Registration is by whole merge block, so this fixes removal too — a gate
|
|
117
|
+
// dropped from a manifest leaves the registry with the block that replaces it.
|
|
118
|
+
// Idempotent, and cheap enough to run for every module in the plan rather than
|
|
119
|
+
// only the ones whose files happened to be stale.
|
|
120
|
+
const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)!).filter(Boolean);
|
|
121
|
+
const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
|
|
122
|
+
|
|
123
|
+
const recorded = updateRecordAfterUpgrade(
|
|
124
|
+
repoRoot,
|
|
125
|
+
upgraded.map((m) => ({ module: m.name, version: m.version, hashes: rewritten.get(m.name) ?? new Map() })),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { written, gates: gateActions.length, recorded };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Update `.ai/rungs.toml` in place after an upgrade: the version each module
|
|
133
|
+
* moved to, and a new hash for each file this run actually rewrote.
|
|
134
|
+
*
|
|
135
|
+
* **Surgical, and text-level, on purpose.** F-017: `upgrade` left the record
|
|
136
|
+
* naming the old version, so a repo on 1.2.0 described itself to its owner as
|
|
137
|
+
* 1.1.0 and `planUpgrade` offered the same move forever. The obvious fix —
|
|
138
|
+
* calling `writeInstallRecord` — is worse than the bug: it re-derives the whole
|
|
139
|
+
* record and hashes **every emitted file that exists**, which would stamp our
|
|
140
|
+
* hash onto a file the user had diverged. That file would then match its record
|
|
141
|
+
* and be silently reclassified from `diverged` to `current`, so the next upgrade
|
|
142
|
+
* would overwrite the edit rungs promises never to touch.
|
|
143
|
+
*
|
|
144
|
+
* So: only the lines that must change, and only for files we wrote. Everything
|
|
145
|
+
* else — the header comment, kept-file lists, and the hash of every file we did
|
|
146
|
+
* not touch — is left exactly as it was.
|
|
147
|
+
*/
|
|
148
|
+
export function updateRecordAfterUpgrade(
|
|
149
|
+
repoRoot: string,
|
|
150
|
+
updates: { module: string; version: string; hashes: Map<string, string> }[],
|
|
151
|
+
): number {
|
|
152
|
+
const path = join(repoRoot, '.ai', 'rungs.toml');
|
|
153
|
+
if (!existsSync(path) || !updates.length) return 0;
|
|
154
|
+
|
|
155
|
+
const lines = readFileSync(path, 'utf8').split('\n');
|
|
156
|
+
const byModule = new Map(updates.map((u) => [u.module, u]));
|
|
157
|
+
let changed = 0;
|
|
158
|
+
let current: { module: string; hashes: boolean } | null = null;
|
|
159
|
+
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const header = /^\[modules\.([^\].]+)(\.[^\]]+)?\]/.exec(line);
|
|
163
|
+
if (header) {
|
|
164
|
+
current = byModule.has(header[1]) ? { module: header[1], hashes: header[2] === '.hashes' } : null;
|
|
165
|
+
out.push(line);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (current && !current.hashes && /^version\s*=/.test(line)) {
|
|
170
|
+
const next = `version = "${byModule.get(current.module)!.version}"`;
|
|
171
|
+
if (next !== line) changed++;
|
|
172
|
+
out.push(next);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (current?.hashes) {
|
|
177
|
+
const entry = /^"([^"]+)"\s*=/.exec(line);
|
|
178
|
+
const replacement = entry && byModule.get(current.module)!.hashes.get(entry[1]);
|
|
179
|
+
if (replacement) {
|
|
180
|
+
out.push(`"${entry[1]}" = "${replacement}"`);
|
|
181
|
+
changed++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
out.push(line);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
writeFileSync(path, out.join('\n'));
|
|
190
|
+
return changed;
|
|
104
191
|
}
|
|
105
192
|
|
|
106
193
|
function paramsFrom(record: InstallRecord): Params {
|
package/src/manifest.ts
CHANGED
|
@@ -21,6 +21,7 @@ export function loadManifest(dir: string): Manifest {
|
|
|
21
21
|
params: (raw.params ?? {}) as Record<string, ParamSpec>,
|
|
22
22
|
gates: raw.gates ?? [],
|
|
23
23
|
detect: raw.detect ?? {},
|
|
24
|
+
skills: raw.skills ?? {},
|
|
24
25
|
provenance: raw.provenance,
|
|
25
26
|
threshold: raw.threshold,
|
|
26
27
|
dir,
|
|
@@ -61,7 +62,7 @@ export function usedParams(dir: string): Set<string> {
|
|
|
61
62
|
|
|
62
63
|
export interface ManifestIssue {
|
|
63
64
|
module: string;
|
|
64
|
-
kind: 'dead-param' | 'undeclared-param' | 'dep-missing' | 'gate-no-table' | 'gate-no-why';
|
|
65
|
+
kind: 'dead-param' | 'undeclared-param' | 'dep-missing' | 'gate-no-table' | 'gate-no-why' | 'gate-no-applicability';
|
|
65
66
|
detail: string;
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -102,6 +103,17 @@ export function auditModules(mods: Manifest[]): ManifestIssue[] {
|
|
|
102
103
|
if (!g.why?.trim()) {
|
|
103
104
|
issues.push({ module: mod.name, kind: 'gate-no-why', detail: `gate '${g.id}' has no 'why'` });
|
|
104
105
|
}
|
|
106
|
+
// WI-052. `doctor --explain` will not run an undeclared gate against a repo
|
|
107
|
+
// that is not ours, so an author who forgets this silently loses their gate
|
|
108
|
+
// on exactly the repos the analysis exists for. Caught here, where the
|
|
109
|
+
// module is written, rather than as a skip line nobody reads.
|
|
110
|
+
if (g.kind === 'declared' && !g.applicability) {
|
|
111
|
+
issues.push({
|
|
112
|
+
module: mod.name,
|
|
113
|
+
kind: 'gate-no-applicability',
|
|
114
|
+
detail: `gate '${g.id}' does not declare applicability (repo-content | our-artifacts | our-schema)`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
return issues;
|