@rungs/cli 0.1.0 → 0.1.2
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 +43 -21
- package/dist/cli.js +2270 -0
- package/dist/cli.js.map +7 -0
- package/modules/README.md +30 -5
- package/modules/ci/files/{{workflow_path}} +1 -1
- package/modules/findings/gates/findings.toml +28 -0
- package/modules/findings/module.toml +9 -1
- package/modules/gates/gates/structural.toml +14 -0
- package/modules/instructions/files/AGENTS.md +0 -6
- package/modules/instructions/fragments/AGENTS.md +12 -0
- package/modules/instructions/module.toml +11 -3
- package/modules/session/files/{{path}} +2 -1
- package/modules/session/fragments/AGENTS.md +4 -1
- package/modules/session/module.toml +6 -3
- package/modules/session/skills/close-session/SKILL.md +4 -0
- package/modules/skills/fragments/AGENTS.md +1 -1
- package/modules/skills/module.toml +6 -3
- package/modules/skills/rules/skill-authoring.md +8 -0
- package/modules/workflows/fragments/AGENTS.md +1 -1
- package/modules/workflows/module.toml +13 -2
- package/modules/workflows/rules/bounded-invocation.md +21 -0
- package/modules/workflows/rules/invocation-boundaries.md +27 -0
- package/modules/workflows/skills/decompose/SKILL.md +9 -1
- package/package.json +14 -5
- package/src/add.ts +8 -1
- package/src/check.ts +2 -1
- package/src/cli.ts +203 -24
- package/src/engines.ts +14 -6
- package/src/engines2.ts +70 -0
- package/src/lifecycle.ts +4 -4
- package/src/substitute.ts +41 -10
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
+
import { dirname as dirname6, join as join11, resolve as resolve3 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/manifest.ts
|
|
8
|
+
import { readdirSync as readdirSync2, readFileSync, statSync as statSync2 } from "node:fs";
|
|
9
|
+
import { join as join2 } from "node:path";
|
|
10
|
+
import { parse } from "smol-toml";
|
|
11
|
+
|
|
12
|
+
// src/glob.ts
|
|
13
|
+
import { readdirSync, statSync } from "node:fs";
|
|
14
|
+
import { join, relative, sep } from "node:path";
|
|
15
|
+
function globToRegExp(pattern) {
|
|
16
|
+
let out = "";
|
|
17
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
18
|
+
const c2 = pattern[i];
|
|
19
|
+
if (c2 === "*") {
|
|
20
|
+
if (pattern[i + 1] === "*") {
|
|
21
|
+
if (pattern[i + 2] === "/") {
|
|
22
|
+
out += "(?:[^/]+/)*";
|
|
23
|
+
i += 2;
|
|
24
|
+
} else {
|
|
25
|
+
out += ".*";
|
|
26
|
+
i += 1;
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
out += "[^/]*";
|
|
30
|
+
}
|
|
31
|
+
} else if (c2 === "?") {
|
|
32
|
+
out += "[^/]";
|
|
33
|
+
} else if (c2 === "{") {
|
|
34
|
+
const end = pattern.indexOf("}", i);
|
|
35
|
+
if (end === -1) {
|
|
36
|
+
out += "\\{";
|
|
37
|
+
} else {
|
|
38
|
+
const alts = pattern.slice(i + 1, end).split(",");
|
|
39
|
+
out += `(?:${alts.map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`;
|
|
40
|
+
i = end;
|
|
41
|
+
}
|
|
42
|
+
} else if (".+^$()|[]\\".includes(c2)) {
|
|
43
|
+
out += `\\${c2}`;
|
|
44
|
+
} else {
|
|
45
|
+
out += c2;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return new RegExp(`^${out}$`);
|
|
49
|
+
}
|
|
50
|
+
var SKIP = /* @__PURE__ */ new Set([
|
|
51
|
+
".git",
|
|
52
|
+
"node_modules",
|
|
53
|
+
"dist",
|
|
54
|
+
"build",
|
|
55
|
+
"out",
|
|
56
|
+
"bin",
|
|
57
|
+
"obj",
|
|
58
|
+
".vs",
|
|
59
|
+
".angular",
|
|
60
|
+
".next",
|
|
61
|
+
"coverage",
|
|
62
|
+
"TestResults",
|
|
63
|
+
"BenchmarkDotNet.Artifacts"
|
|
64
|
+
]);
|
|
65
|
+
function walk(root, maxEntries = 2e5) {
|
|
66
|
+
const files = [];
|
|
67
|
+
const stack = [root];
|
|
68
|
+
while (stack.length && files.length < maxEntries) {
|
|
69
|
+
const dir = stack.pop();
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (SKIP.has(e.name)) continue;
|
|
78
|
+
const full = join(dir, e.name);
|
|
79
|
+
if (e.isDirectory()) {
|
|
80
|
+
stack.push(full);
|
|
81
|
+
} else if (e.isFile()) {
|
|
82
|
+
files.push(relative(root, full).split(sep).join("/"));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return files;
|
|
87
|
+
}
|
|
88
|
+
function matchAny(files, pattern) {
|
|
89
|
+
const re = globToRegExp(pattern);
|
|
90
|
+
return files.filter((f) => re.test(f));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/manifest.ts
|
|
94
|
+
function loadManifest(dir) {
|
|
95
|
+
const raw = parse(readFileSync(join2(dir, "module.toml"), "utf8"));
|
|
96
|
+
const m = raw.module ?? {};
|
|
97
|
+
const name = m.name;
|
|
98
|
+
if (!name) throw new Error(`${dir}: [module].name is required`);
|
|
99
|
+
const manifest = {
|
|
100
|
+
name,
|
|
101
|
+
version: m.version ?? "0.0.0",
|
|
102
|
+
rung: m.rung ?? 0,
|
|
103
|
+
summary: m.summary ?? "",
|
|
104
|
+
requires: raw.requires?.modules ?? [],
|
|
105
|
+
conflicts: raw.conflicts?.modules ?? [],
|
|
106
|
+
params: raw.params ?? {},
|
|
107
|
+
gates: raw.gates ?? [],
|
|
108
|
+
detect: raw.detect ?? {},
|
|
109
|
+
provenance: raw.provenance,
|
|
110
|
+
threshold: raw.threshold,
|
|
111
|
+
dir
|
|
112
|
+
};
|
|
113
|
+
const p = manifest.provenance;
|
|
114
|
+
if (!p?.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
|
|
115
|
+
if (!p?.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
|
|
116
|
+
if (!p?.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
|
|
117
|
+
return manifest;
|
|
118
|
+
}
|
|
119
|
+
function loadAllModules(modulesRoot) {
|
|
120
|
+
return readdirSync2(modulesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && statSync2(join2(modulesRoot, e.name, "module.toml"), { throwIfNoEntry: false })).map((e) => loadManifest(join2(modulesRoot, e.name))).sort((a, b) => a.rung - b.rung || a.name.localeCompare(b.name));
|
|
121
|
+
}
|
|
122
|
+
function usedParams(dir) {
|
|
123
|
+
const used = /* @__PURE__ */ new Set();
|
|
124
|
+
const add = (text) => {
|
|
125
|
+
for (const match of text.matchAll(/(^|[^$])\{\{([a-z_.]+)\}\}/g)) used.add(match[2]);
|
|
126
|
+
};
|
|
127
|
+
for (const rel of walk(dir)) {
|
|
128
|
+
add(rel);
|
|
129
|
+
add(readFileSync(join2(dir, rel), "utf8"));
|
|
130
|
+
}
|
|
131
|
+
return used;
|
|
132
|
+
}
|
|
133
|
+
function auditModules(mods) {
|
|
134
|
+
const issues = [];
|
|
135
|
+
const names = new Set(mods.map((m) => m.name));
|
|
136
|
+
for (const mod of mods) {
|
|
137
|
+
for (const dep of mod.requires) {
|
|
138
|
+
if (!names.has(dep)) {
|
|
139
|
+
issues.push({ module: mod.name, kind: "dep-missing", detail: `requires unknown module '${dep}'` });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const used = usedParams(mod.dir);
|
|
143
|
+
for (const [param, spec] of Object.entries(mod.params)) {
|
|
144
|
+
if (used.has(param) || spec.consumed_by) continue;
|
|
145
|
+
issues.push({
|
|
146
|
+
module: mod.name,
|
|
147
|
+
kind: "dead-param",
|
|
148
|
+
detail: `'${param}' is declared, never substituted, and not marked consumed_by`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const u of used) {
|
|
152
|
+
if (u.includes(".")) continue;
|
|
153
|
+
if (!(u in mod.params)) {
|
|
154
|
+
issues.push({ module: mod.name, kind: "undeclared-param", detail: `uses {{${u}}} but does not declare it` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const g of mod.gates) {
|
|
158
|
+
if (g.kind === "declared" && !g.table) {
|
|
159
|
+
issues.push({ module: mod.name, kind: "gate-no-table", detail: `gate '${g.id}' is declared with no table` });
|
|
160
|
+
}
|
|
161
|
+
if (!g.why?.trim()) {
|
|
162
|
+
issues.push({ module: mod.name, kind: "gate-no-why", detail: `gate '${g.id}' has no 'why'` });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return issues;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/detect.ts
|
|
170
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
171
|
+
import { join as join4 } from "node:path";
|
|
172
|
+
|
|
173
|
+
// src/add.ts
|
|
174
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
175
|
+
import { dirname, join as join3 } from "node:path";
|
|
176
|
+
import { createHash } from "node:crypto";
|
|
177
|
+
|
|
178
|
+
// src/substitute.ts
|
|
179
|
+
import { basename, resolve } from "node:path";
|
|
180
|
+
function substitute(text, module, params) {
|
|
181
|
+
return text.replace(/(^|[^$])\{\{([a-z_.]+)\}\}/g, (whole, lead, ref) => {
|
|
182
|
+
const [a, b] = ref.includes(".") ? ref.split(".") : [module, ref];
|
|
183
|
+
const value = params[a]?.[b];
|
|
184
|
+
if (value === void 0) return whole;
|
|
185
|
+
return lead + format(value);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function format(v) {
|
|
189
|
+
if (Array.isArray(v)) return `[${v.map((x) => JSON.stringify(x)).join(", ")}]`;
|
|
190
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
191
|
+
return String(v);
|
|
192
|
+
}
|
|
193
|
+
function repoFacts(repoRoot) {
|
|
194
|
+
return repoRoot ? { dirname: basename(resolve(repoRoot)) } : {};
|
|
195
|
+
}
|
|
196
|
+
function resolveParams(mods, overrides = {}, repoRoot) {
|
|
197
|
+
const out = { repo: repoFacts(repoRoot) };
|
|
198
|
+
for (const m of mods) {
|
|
199
|
+
out[m.name] = {};
|
|
200
|
+
for (const [k, spec] of Object.entries(m.params)) out[m.name][k] = spec.default;
|
|
201
|
+
}
|
|
202
|
+
for (const [mod, vals] of Object.entries(overrides)) {
|
|
203
|
+
out[mod] = { ...out[mod] ?? {}, ...vals };
|
|
204
|
+
}
|
|
205
|
+
for (const m of mods) {
|
|
206
|
+
for (const [k, v] of Object.entries(out[m.name])) {
|
|
207
|
+
if (typeof v === "string" && v.includes("{{")) out[m.name][k] = substitute(v, m.name, out);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function markers(targetPath, module, version) {
|
|
213
|
+
const hash = /\.(toml|ya?ml|gitignore|gitattributes|sh|ps1|conf|properties)$|(^|\/)\.(gitignore|gitattributes)$/.test(
|
|
214
|
+
targetPath
|
|
215
|
+
);
|
|
216
|
+
return hash ? { begin: `# rungs:begin ${module}@${version}`, end: `# rungs:end ${module}` } : { begin: `<!-- rungs:begin ${module}@${version} -->`, end: `<!-- rungs:end ${module} -->` };
|
|
217
|
+
}
|
|
218
|
+
function mergeBlock(existing, fragment, module) {
|
|
219
|
+
const beginRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:begin ${module}(?:@[\\w.\\-]+)?\\s*(?:-->)?[ \\t]*$`, "m");
|
|
220
|
+
const endRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:end ${module}\\s*(?:-->)?[ \\t]*$`, "m");
|
|
221
|
+
const b = existing.match(beginRe);
|
|
222
|
+
const e = existing.match(endRe);
|
|
223
|
+
if (b && e && b.index !== void 0 && e.index !== void 0 && e.index > b.index) {
|
|
224
|
+
const before = existing.slice(0, b.index);
|
|
225
|
+
const after = existing.slice(e.index + e[0].length);
|
|
226
|
+
return `${before}${fragment.trim()}${after}`;
|
|
227
|
+
}
|
|
228
|
+
const sep2 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
229
|
+
return `${existing}${sep2}${fragment.trim()}
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/add.ts
|
|
234
|
+
var FRAGMENT_TARGET = {
|
|
235
|
+
"AGENTS.md": "AGENTS.md",
|
|
236
|
+
gitignore: ".gitignore",
|
|
237
|
+
gitattributes: ".gitattributes"
|
|
238
|
+
};
|
|
239
|
+
function addModule(mod, repoRoot, params, opts = {}) {
|
|
240
|
+
const actions = [];
|
|
241
|
+
const write = (rel, content, disposition) => {
|
|
242
|
+
const full = join3(repoRoot, rel);
|
|
243
|
+
if (existsSync(full)) {
|
|
244
|
+
actions.push({ disposition: "skip-exists", target: rel, note: "already present \u2014 left alone" });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
actions.push({ disposition, target: rel });
|
|
248
|
+
if (opts.dryRun) return;
|
|
249
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
250
|
+
writeFileSync(full, content);
|
|
251
|
+
};
|
|
252
|
+
const sub = (text) => substitute(text, mod.name, params);
|
|
253
|
+
const has = (d) => existsSync(join3(mod.dir, d));
|
|
254
|
+
if (has("files")) {
|
|
255
|
+
const base = join3(mod.dir, "files");
|
|
256
|
+
for (const rel of walk(base)) {
|
|
257
|
+
write(sub(rel), sub(readFileSync2(join3(base, rel), "utf8")), "create");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (has("rules")) {
|
|
261
|
+
const base = join3(mod.dir, "rules");
|
|
262
|
+
for (const rel of walk(base)) {
|
|
263
|
+
write(join3(".ai", "rules", rel).split("\\").join("/"), sub(readFileSync2(join3(base, rel), "utf8")), "rule");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (has("skills")) {
|
|
267
|
+
const base = join3(mod.dir, "skills");
|
|
268
|
+
const dir = opts.skillsDir ?? ".claude/skills";
|
|
269
|
+
for (const rel of walk(base)) {
|
|
270
|
+
write(`${dir}/${rel}`, sub(readFileSync2(join3(base, rel), "utf8")), "skill");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (has("fragments")) {
|
|
274
|
+
const base = join3(mod.dir, "fragments");
|
|
275
|
+
for (const rel of walk(base)) {
|
|
276
|
+
const target = FRAGMENT_TARGET[rel];
|
|
277
|
+
if (!target) {
|
|
278
|
+
actions.push({ disposition: "merge", target: rel, note: "unknown fragment target \u2014 skipped" });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const full = join3(repoRoot, target);
|
|
282
|
+
const existing = existsSync(full) ? readFileSync2(full, "utf8") : "";
|
|
283
|
+
const fragment = sub(readFileSync2(join3(base, rel), "utf8"));
|
|
284
|
+
const merged = mergeBlock(existing, fragment, mod.name);
|
|
285
|
+
actions.push({
|
|
286
|
+
disposition: "merge",
|
|
287
|
+
target,
|
|
288
|
+
note: existing.includes(`rungs:begin ${mod.name}`) ? "block replaced" : "block appended"
|
|
289
|
+
});
|
|
290
|
+
if (!opts.dryRun) {
|
|
291
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
292
|
+
writeFileSync(full, merged);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return actions;
|
|
297
|
+
}
|
|
298
|
+
function registerGates(mods, repoRoot, dryRun = false, adopted = []) {
|
|
299
|
+
const actions = [];
|
|
300
|
+
const registry = join3(repoRoot, ".ai", "gates.toml");
|
|
301
|
+
if (adopted.length) {
|
|
302
|
+
const existing = existsSync(registry) ? readFileSync2(registry, "utf8") : "";
|
|
303
|
+
const { begin, end } = markers("gates.toml", "adopted", "1.0.0");
|
|
304
|
+
const body = [
|
|
305
|
+
begin,
|
|
306
|
+
"# Registered from validators this repo already had. Their scripts are untouched and",
|
|
307
|
+
"# stay yours; rungs only runs them and records what it observes.",
|
|
308
|
+
...adopted.map(
|
|
309
|
+
(a) => `
|
|
310
|
+
[[gates]]
|
|
311
|
+
id = "${a.id}"
|
|
312
|
+
kind = "command"
|
|
313
|
+
module = "adopted"
|
|
314
|
+
tier = "${a.tier}"
|
|
315
|
+
command = "${a.command}"
|
|
316
|
+
why = """Adopted from ${a.source}. Predates rungs and is owned by this repo."""`
|
|
317
|
+
),
|
|
318
|
+
end
|
|
319
|
+
].join("\n");
|
|
320
|
+
actions.push({ disposition: "gate", target: ".ai/gates.toml", note: `adopted: ${adopted.length} entries` });
|
|
321
|
+
if (!dryRun) {
|
|
322
|
+
mkdirSync(dirname(registry), { recursive: true });
|
|
323
|
+
writeFileSync(registry, mergeBlock(existing, body, "adopted"));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const mod of mods) {
|
|
327
|
+
if (!mod.gates.length) continue;
|
|
328
|
+
const existing = existsSync(registry) ? readFileSync2(registry, "utf8") : "";
|
|
329
|
+
const { begin, end } = markers("gates.toml", mod.name, mod.version);
|
|
330
|
+
const body = [begin, ...mod.gates.map(gateEntry(mod)), end].join("\n");
|
|
331
|
+
actions.push({ disposition: "gate", target: ".ai/gates.toml", note: `${mod.name}: ${mod.gates.length} entries` });
|
|
332
|
+
if (dryRun) continue;
|
|
333
|
+
mkdirSync(dirname(registry), { recursive: true });
|
|
334
|
+
writeFileSync(registry, mergeBlock(existing, body, mod.name));
|
|
335
|
+
}
|
|
336
|
+
return actions;
|
|
337
|
+
}
|
|
338
|
+
var gateEntry = (mod) => (g) => {
|
|
339
|
+
const lines = ["", "[[gates]]", `id = "${g.id}"`, `kind = "${g.kind}"`, `module = "${mod.name}"`];
|
|
340
|
+
if (g.engine) lines.push(`engine = "${g.engine}"`);
|
|
341
|
+
if (g.table) lines.push(`table = "${mod.name}/${g.table.replace(/^gates\//, "")}"`);
|
|
342
|
+
if (g.command) lines.push(`command = "${g.command}"`);
|
|
343
|
+
if (g.tier) lines.push(`tier = "${g.tier}"`);
|
|
344
|
+
if (g.trigger) lines.push(`trigger = "${g.trigger}"`);
|
|
345
|
+
if (g.matcher) lines.push(`matcher = "${g.matcher}"`);
|
|
346
|
+
if (g.why) lines.push(`why = """${g.why.trim()}"""`);
|
|
347
|
+
return lines.join("\n");
|
|
348
|
+
};
|
|
349
|
+
function resolveInstallOrder(requested, all) {
|
|
350
|
+
const byName = new Map(all.map((m) => [m.name, m]));
|
|
351
|
+
const order = [];
|
|
352
|
+
const missing = [];
|
|
353
|
+
const seen = /* @__PURE__ */ new Set();
|
|
354
|
+
const visit = (name) => {
|
|
355
|
+
if (seen.has(name)) return;
|
|
356
|
+
const mod = byName.get(name);
|
|
357
|
+
if (!mod) {
|
|
358
|
+
missing.push(name);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
seen.add(name);
|
|
362
|
+
for (const dep of mod.requires) visit(dep);
|
|
363
|
+
order.push(mod);
|
|
364
|
+
};
|
|
365
|
+
const closure = /* @__PURE__ */ new Set();
|
|
366
|
+
const collect = (n) => {
|
|
367
|
+
if (closure.has(n)) return;
|
|
368
|
+
const m = byName.get(n);
|
|
369
|
+
if (!m) return;
|
|
370
|
+
closure.add(n);
|
|
371
|
+
m.requires.forEach(collect);
|
|
372
|
+
};
|
|
373
|
+
requested.forEach(collect);
|
|
374
|
+
if ([...closure].some((n) => byName.get(n).gates.length) && byName.has("gates")) {
|
|
375
|
+
visit("gates");
|
|
376
|
+
}
|
|
377
|
+
for (const r of requested) visit(r);
|
|
378
|
+
return { order, missing };
|
|
379
|
+
}
|
|
380
|
+
var contentHash = (s) => createHash("sha256").update(s.replace(/\r\n/g, "\n")).digest("hex").slice(0, 12);
|
|
381
|
+
var SHARED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes", ".ai/gates.toml"]);
|
|
382
|
+
function emittedFiles(mod, params, skillsDir = ".claude/skills") {
|
|
383
|
+
const out = /* @__PURE__ */ new Map();
|
|
384
|
+
const sub = (t) => substitute(t, mod.name, params);
|
|
385
|
+
for (const [dir, prefix] of [
|
|
386
|
+
["files", ""],
|
|
387
|
+
["rules", ".ai/rules/"],
|
|
388
|
+
["skills", `${skillsDir}/`]
|
|
389
|
+
]) {
|
|
390
|
+
const base = join3(mod.dir, dir);
|
|
391
|
+
if (!existsSync(base)) continue;
|
|
392
|
+
for (const rel of walk(base)) {
|
|
393
|
+
const target = sub(prefix + rel).split("\\").join("/");
|
|
394
|
+
if (SHARED.has(target)) continue;
|
|
395
|
+
out.set(target, sub(readFileSync2(join3(base, rel), "utf8")));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
function writeInstallRecord(repoRoot, mods, params, harnesses, stamp, skillsDir = ".claude/skills", wroteByModule) {
|
|
401
|
+
const lines = [
|
|
402
|
+
"# Installed by `rungs`. This is a record of what was written, not a control panel:",
|
|
403
|
+
"# editing a parameter here does not rewrite a file that already exists. `rungs render`",
|
|
404
|
+
"# re-emits path-scoped rules from `.ai/rules/`, and `rungs upgrade --apply` replaces",
|
|
405
|
+
"# module files you have not edited \u2014 neither re-substitutes parameters. AGENTS.md,",
|
|
406
|
+
"# CLAUDE.md, .gitignore, .gitattributes and .ai/gates.toml are shared between modules,",
|
|
407
|
+
"# so only their `rungs:begin`/`rungs:end` blocks are ever updated; anything outside a",
|
|
408
|
+
"# block, including the entry document's title, is yours to edit directly.",
|
|
409
|
+
"#",
|
|
410
|
+
"# Hashes are what rungs emitted; a file whose hash no longer matches is a",
|
|
411
|
+
"# divergence rungs reports and never overwrites.",
|
|
412
|
+
"",
|
|
413
|
+
"[repo]",
|
|
414
|
+
`harnesses = ${JSON.stringify(harnesses)}`,
|
|
415
|
+
`installed = "${stamp}"`,
|
|
416
|
+
""
|
|
417
|
+
];
|
|
418
|
+
for (const m of mods) {
|
|
419
|
+
lines.push(`[modules.${m.name}]`, `version = "${m.version}"`, 'state = "managed"');
|
|
420
|
+
const p = params[m.name] ?? {};
|
|
421
|
+
if (Object.keys(p).length) {
|
|
422
|
+
lines.push(`params = { ${Object.entries(p).map(([k, v]) => `${k} = ${JSON.stringify(v ?? "")}`).join(", ")} }`);
|
|
423
|
+
}
|
|
424
|
+
const emitted = emittedFiles(m, params, skillsDir);
|
|
425
|
+
const created = [...emitted].filter(([rel]) => wroteByModule?.get(m.name)?.has(rel) ?? existsSync(join3(repoRoot, rel)));
|
|
426
|
+
const kept = [...emitted].filter(([rel]) => !created.some(([c2]) => c2 === rel) && existsSync(join3(repoRoot, rel)));
|
|
427
|
+
if (created.length) {
|
|
428
|
+
lines.push(`[modules.${m.name}.hashes]`);
|
|
429
|
+
for (const [rel, content] of created) lines.push(`"${rel}" = "${contentHash(content)}"`);
|
|
430
|
+
}
|
|
431
|
+
if (kept.length) {
|
|
432
|
+
lines.push("", `[modules.${m.name}]`.replace("]", ".kept]"));
|
|
433
|
+
lines.push(`files = ${JSON.stringify(kept.map(([rel]) => rel))}`);
|
|
434
|
+
}
|
|
435
|
+
lines.push("");
|
|
436
|
+
}
|
|
437
|
+
writeFileSync(join3(repoRoot, ".ai", "rungs.toml"), lines.join("\n"));
|
|
438
|
+
}
|
|
439
|
+
function adoptableGates(files, patterns, repoRoot) {
|
|
440
|
+
const runner = { ".mjs": "node", ".js": "node", ".ps1": "pwsh -File", ".sh": "bash" };
|
|
441
|
+
const out = [];
|
|
442
|
+
for (const pattern of patterns) {
|
|
443
|
+
for (const rel of matchAny(files, pattern)) {
|
|
444
|
+
const ext = rel.slice(rel.lastIndexOf("."));
|
|
445
|
+
const exec = runner[ext];
|
|
446
|
+
if (!exec) continue;
|
|
447
|
+
out.push({
|
|
448
|
+
id: `adopted-${rel.split("/").pop().replace(/\.[^.]+$/, "")}`,
|
|
449
|
+
command: `${exec} ${rel}`,
|
|
450
|
+
tier: "fast",
|
|
451
|
+
source: rel
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/detect.ts
|
|
459
|
+
var SAMPLE = 3;
|
|
460
|
+
function detect(mod, repoRoot, files, installed) {
|
|
461
|
+
const result = {
|
|
462
|
+
module: mod.name,
|
|
463
|
+
state: "absent",
|
|
464
|
+
matchedPaths: [],
|
|
465
|
+
matchedMarkers: [],
|
|
466
|
+
proposals: [],
|
|
467
|
+
adoptable: []
|
|
468
|
+
};
|
|
469
|
+
if (installed) {
|
|
470
|
+
result.ours = ownedState(mod, repoRoot, installed);
|
|
471
|
+
result.state = result.ours.diverged.length ? "ours-diverged" : "ours-current";
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
for (const pattern of mod.detect.paths ?? []) {
|
|
475
|
+
const hits = matchAny(files, pattern);
|
|
476
|
+
if (hits.length) {
|
|
477
|
+
result.matchedPaths.push({ pattern, count: hits.length, sample: hits.slice(0, SAMPLE) });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const markers2 = mod.detect.markers ?? [];
|
|
481
|
+
if (markers2.length) {
|
|
482
|
+
const scanPatterns = mod.detect.marker_paths ?? result.matchedPaths.map((m) => m.pattern);
|
|
483
|
+
const candidates = new Set(scanPatterns.flatMap((p) => matchAny(files, p)));
|
|
484
|
+
for (const rel of candidates) {
|
|
485
|
+
let text;
|
|
486
|
+
try {
|
|
487
|
+
text = readFileSync3(join4(repoRoot, rel), "utf8");
|
|
488
|
+
} catch {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
for (const marker of markers2) {
|
|
492
|
+
if (text.includes(marker) && !result.matchedMarkers.includes(marker)) {
|
|
493
|
+
result.matchedMarkers.push(marker);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const adopt of mod.detect.adopt_as ?? []) {
|
|
499
|
+
const hits = (adopt.paths ?? []).flatMap((p) => matchAny(files, p));
|
|
500
|
+
if (hits.length) {
|
|
501
|
+
result.adoptable.push({ kind: adopt.kind, count: hits.length, sample: hits.slice(0, SAMPLE), note: adopt.note });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (result.matchedPaths.length === 0 && result.adoptable.length === 0) {
|
|
505
|
+
for (const para of mod.detect.paradigm ?? []) {
|
|
506
|
+
const matched = (para.paths ?? []).flatMap((p) => matchAny(files, p));
|
|
507
|
+
if (matched.length) {
|
|
508
|
+
result.paradigm = { id: para.id, note: para.note, compare: para.compare, matched: matched.slice(0, SAMPLE) };
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (result.matchedPaths.length > 0 || result.adoptable.length > 0 || result.matchedMarkers.length > 0) {
|
|
514
|
+
result.state = "theirs";
|
|
515
|
+
} else if (result.paradigm) {
|
|
516
|
+
result.state = "paradigm";
|
|
517
|
+
} else {
|
|
518
|
+
result.state = "absent";
|
|
519
|
+
}
|
|
520
|
+
if (result.state === "theirs") {
|
|
521
|
+
result.proposals = infer(mod, repoRoot, files);
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
function infer(mod, repoRoot, files) {
|
|
526
|
+
const proposals = [];
|
|
527
|
+
for (const rule of mod.detect.infer ?? []) {
|
|
528
|
+
if (rule.paths) {
|
|
529
|
+
const present = Object.entries(rule.paths).filter(([, p]) => files.some((f) => f.startsWith(p.replace(/\/$/, "/")))).map(([key]) => key);
|
|
530
|
+
if (present.length) {
|
|
531
|
+
proposals.push({ param: rule.param, value: present.join(", "), evidence: "directory present" });
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (!rule.pattern) continue;
|
|
536
|
+
const scope = (rule.scope ?? ["**/*.md"]).flatMap((p) => matchAny(files, p));
|
|
537
|
+
const excluded = new Set((rule.exclude ?? []).flatMap((p) => matchAny(files, p)));
|
|
538
|
+
const counts = /* @__PURE__ */ new Map();
|
|
539
|
+
for (const rel of scope) {
|
|
540
|
+
if (excluded.has(rel)) continue;
|
|
541
|
+
let text;
|
|
542
|
+
try {
|
|
543
|
+
text = readFileSync3(join4(repoRoot, rel), "utf8");
|
|
544
|
+
} catch {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
for (const m of text.matchAll(new RegExp(rule.pattern, "gm"))) {
|
|
548
|
+
const key = m[1];
|
|
549
|
+
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (rule.anchor) {
|
|
553
|
+
const anchored = /* @__PURE__ */ new Map();
|
|
554
|
+
for (const rel of scope) {
|
|
555
|
+
if (excluded.has(rel)) continue;
|
|
556
|
+
let text;
|
|
557
|
+
try {
|
|
558
|
+
text = readFileSync3(join4(repoRoot, rel), "utf8");
|
|
559
|
+
} catch {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
for (const m of text.matchAll(new RegExp(rule.anchor, "gm"))) {
|
|
563
|
+
if (m[1]) anchored.set(m[1], (anchored.get(m[1]) ?? 0) + 1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const [best] = [...anchored].sort((a, b) => b[1] - a[1]);
|
|
567
|
+
if (best) {
|
|
568
|
+
proposals.push({ param: rule.param, value: best[0], evidence: `anchored on ${rule.anchor_name ?? "marker"}` });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const banned = new Set(rule.exclude_values ?? []);
|
|
573
|
+
const ranked = [...counts].filter(([k]) => !banned.has(k)).sort((a, b) => b[1] - a[1]);
|
|
574
|
+
const [top] = ranked;
|
|
575
|
+
if (top && top[1] >= (rule.min ?? 1)) {
|
|
576
|
+
proposals.push({
|
|
577
|
+
param: rule.param,
|
|
578
|
+
value: top[0],
|
|
579
|
+
evidence: `${top[1]} matches${ranked.length > 1 ? ` (next: ${ranked[1][0]} at ${ranked[1][1]})` : ""}`
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return proposals;
|
|
584
|
+
}
|
|
585
|
+
function scanRepo(repoRoot) {
|
|
586
|
+
return walk(repoRoot);
|
|
587
|
+
}
|
|
588
|
+
function ownedState(mod, repoRoot, installed) {
|
|
589
|
+
const params = installed.params_all ?? {};
|
|
590
|
+
const emitted = emittedFiles(mod, params, installed.skillsDir ?? ".claude/skills");
|
|
591
|
+
const kept = new Set(installed.kept?.files ?? []);
|
|
592
|
+
const out = {
|
|
593
|
+
version: installed.version,
|
|
594
|
+
current: [],
|
|
595
|
+
stale: [],
|
|
596
|
+
diverged: [],
|
|
597
|
+
missing: [],
|
|
598
|
+
kept: []
|
|
599
|
+
};
|
|
600
|
+
for (const [rel, wouldEmit] of emitted) {
|
|
601
|
+
if (kept.has(rel)) {
|
|
602
|
+
out.kept.push(rel);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const full = join4(repoRoot, rel);
|
|
606
|
+
if (!existsSync2(full)) {
|
|
607
|
+
out.missing.push(rel);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const onDisk = contentHash(readFileSync3(full, "utf8"));
|
|
611
|
+
if (onDisk === contentHash(wouldEmit)) out.current.push(rel);
|
|
612
|
+
else if (installed.hashes?.[rel] && onDisk === installed.hashes[rel]) out.stale.push(rel);
|
|
613
|
+
else out.diverged.push(rel);
|
|
614
|
+
}
|
|
615
|
+
return out;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/render.ts
|
|
619
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
620
|
+
import { dirname as dirname2, join as join5 } from "node:path";
|
|
621
|
+
var DO_NOT_EDIT = (source) => `Generated by \`rungs render\` from ${source}. Do not edit \u2014 your changes are overwritten.`;
|
|
622
|
+
function readRules(repoRoot) {
|
|
623
|
+
const dir = join5(repoRoot, ".ai", "rules");
|
|
624
|
+
const rules = [];
|
|
625
|
+
let files;
|
|
626
|
+
try {
|
|
627
|
+
files = walk(dir).filter((f) => f.endsWith(".md") && f !== "README.md");
|
|
628
|
+
} catch {
|
|
629
|
+
return rules;
|
|
630
|
+
}
|
|
631
|
+
for (const rel of files) {
|
|
632
|
+
const raw = readFileSync4(join5(dir, rel), "utf8");
|
|
633
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
634
|
+
if (!m) continue;
|
|
635
|
+
const [, fm, body] = m;
|
|
636
|
+
rules.push({
|
|
637
|
+
file: rel,
|
|
638
|
+
description: scalar(fm, "description"),
|
|
639
|
+
paths: list(fm, "paths"),
|
|
640
|
+
enforcement: scalar(fm, "enforcement"),
|
|
641
|
+
body: body.trim()
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return rules;
|
|
645
|
+
}
|
|
646
|
+
function scalar(fm, key) {
|
|
647
|
+
const folded = fm.match(new RegExp(`^${key}:\\s*>-?\\s*\\n([\\s\\S]*?)(?=\\n\\S|$)`, "m"));
|
|
648
|
+
if (folded) return folded[1].split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
|
|
649
|
+
const plain = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
650
|
+
return plain?.[1].trim().replace(/^["']|["']$/g, "");
|
|
651
|
+
}
|
|
652
|
+
function list(fm, key) {
|
|
653
|
+
const block = fm.match(new RegExp(`^${key}:\\s*\\n((?:\\s*-\\s*.+\\n?)+)`, "m"));
|
|
654
|
+
if (!block) return [];
|
|
655
|
+
return [...block[1].matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim().replace(/^["']|["']$/g, ""));
|
|
656
|
+
}
|
|
657
|
+
function renderRule(rule, harness) {
|
|
658
|
+
const stem = rule.file.replace(/\.md$/, "");
|
|
659
|
+
const source = `.ai/rules/${rule.file}`;
|
|
660
|
+
const dropped = [];
|
|
661
|
+
if (harness === "claude") {
|
|
662
|
+
if (rule.description) dropped.push("description");
|
|
663
|
+
const fm = rule.paths.length ? `paths:
|
|
664
|
+
${rule.paths.map((p) => ` - "${p}"`).join("\n")}
|
|
665
|
+
` : "";
|
|
666
|
+
return {
|
|
667
|
+
target: `.claude/rules/${stem}.md`,
|
|
668
|
+
content: `---
|
|
669
|
+
${fm}---
|
|
670
|
+
|
|
671
|
+
<!-- ${DO_NOT_EDIT(source)} -->
|
|
672
|
+
|
|
673
|
+
${rule.body}
|
|
674
|
+
`,
|
|
675
|
+
dropped
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (harness === "copilot") {
|
|
679
|
+
const applyTo = rule.paths.length ? rule.paths.join(", ") : "**/*";
|
|
680
|
+
const desc = rule.description ? `description: '${rule.description.replace(/'/g, "''")}'
|
|
681
|
+
` : "";
|
|
682
|
+
return {
|
|
683
|
+
target: `.github/instructions/${stem}.instructions.md`,
|
|
684
|
+
content: `---
|
|
685
|
+
${desc}applyTo: '${applyTo}'
|
|
686
|
+
---
|
|
687
|
+
|
|
688
|
+
<!-- ${DO_NOT_EDIT(source)} -->
|
|
689
|
+
|
|
690
|
+
${rule.body}
|
|
691
|
+
`,
|
|
692
|
+
dropped
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
if (harness === "cursor") {
|
|
696
|
+
const desc = rule.description ? `description: ${rule.description}
|
|
697
|
+
` : "";
|
|
698
|
+
const globs = rule.paths.length ? `globs: ${rule.paths.join(",")}
|
|
699
|
+
` : "";
|
|
700
|
+
return {
|
|
701
|
+
target: `.cursor/rules/${stem}.mdc`,
|
|
702
|
+
content: `---
|
|
703
|
+
${desc}${globs}alwaysApply: ${rule.paths.length === 0}
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
<!-- ${DO_NOT_EDIT(source)} -->
|
|
707
|
+
|
|
708
|
+
${rule.body}
|
|
709
|
+
`,
|
|
710
|
+
dropped
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const prefix = commonDirPrefix(rule.paths);
|
|
714
|
+
if (prefix) {
|
|
715
|
+
return {
|
|
716
|
+
target: `${prefix}/AGENTS.md`,
|
|
717
|
+
content: `<!-- ${DO_NOT_EDIT(source)} -->
|
|
718
|
+
|
|
719
|
+
${rule.body}
|
|
720
|
+
`,
|
|
721
|
+
dropped: ["description", "paths (directory-scoped instead of glob)"]
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
degraded: `routing-only: globs do not share a directory prefix, so root AGENTS.md gets a pointer to ${source}`
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function commonDirPrefix(paths) {
|
|
729
|
+
if (!paths.length) return null;
|
|
730
|
+
const dirs = paths.map((p) => p.split("/").filter((s) => !s.includes("*")).join("/")).filter(Boolean);
|
|
731
|
+
if (dirs.length !== paths.length) return null;
|
|
732
|
+
const first = dirs[0];
|
|
733
|
+
return dirs.every((d) => d === first) && first.includes("/") ? first : null;
|
|
734
|
+
}
|
|
735
|
+
function render(repoRoot, harnesses) {
|
|
736
|
+
const rules = readRules(repoRoot);
|
|
737
|
+
const entries = [];
|
|
738
|
+
const routingOnly = [];
|
|
739
|
+
for (const rule of rules) {
|
|
740
|
+
for (const harness of harnesses) {
|
|
741
|
+
const out = renderRule(rule, harness);
|
|
742
|
+
if ("degraded" in out) {
|
|
743
|
+
entries.push({ rule: rule.file, harness, degraded: out.degraded });
|
|
744
|
+
if (harness === "agents-md") routingOnly.push(rule);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const full = join5(repoRoot, out.target);
|
|
748
|
+
mkdirSync2(dirname2(full), { recursive: true });
|
|
749
|
+
writeFileSync2(full, out.content);
|
|
750
|
+
entries.push({ rule: rule.file, harness, target: out.target, dropped: out.dropped });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
writeRoutingBlock(repoRoot, routingOnly, harnesses);
|
|
754
|
+
return entries;
|
|
755
|
+
}
|
|
756
|
+
function writeRoutingBlock(repoRoot, rules, harnesses) {
|
|
757
|
+
if (!harnesses.includes("agents-md")) return;
|
|
758
|
+
const target = join5(repoRoot, "AGENTS.md");
|
|
759
|
+
if (!existsSync3(target)) return;
|
|
760
|
+
const begin = "<!-- rungs:begin rules-routing -->";
|
|
761
|
+
const end = "<!-- rungs:end rules-routing -->";
|
|
762
|
+
const body = rules.length ? [
|
|
763
|
+
begin,
|
|
764
|
+
"## Rules for specific paths",
|
|
765
|
+
"",
|
|
766
|
+
"This harness has no glob scoping, so these load only if you open them. **Read the one that",
|
|
767
|
+
"matches what you are editing before editing broadly.**",
|
|
768
|
+
"",
|
|
769
|
+
...rules.map((r) => `- \`${r.paths.join("`, `")}\` \u2192 [\`.ai/rules/${r.file}\`](.ai/rules/${r.file})`),
|
|
770
|
+
end
|
|
771
|
+
].join("\n") : "";
|
|
772
|
+
const existing = readFileSync4(target, "utf8");
|
|
773
|
+
const beginRe = /^[ \t]*<!--\s*rungs:begin rules-routing\s*-->[ \t]*$/m;
|
|
774
|
+
const endRe = /^[ \t]*<!--\s*rungs:end rules-routing\s*-->[ \t]*$/m;
|
|
775
|
+
const b = existing.match(beginRe);
|
|
776
|
+
const e = existing.match(endRe);
|
|
777
|
+
if (b && e && b.index !== void 0 && e.index !== void 0) {
|
|
778
|
+
const next = existing.slice(0, b.index) + body.trim() + existing.slice(e.index + e[0].length);
|
|
779
|
+
writeFileSync2(target, body ? next : next.replace(/\n{3,}/g, "\n\n"));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (body) writeFileSync2(target, `${existing.replace(/\n+$/, "\n")}
|
|
783
|
+
${body}
|
|
784
|
+
`);
|
|
785
|
+
}
|
|
786
|
+
function writeReport(repoRoot, entries, harnesses, stamp) {
|
|
787
|
+
const lines = [
|
|
788
|
+
"# Render report",
|
|
789
|
+
"",
|
|
790
|
+
`> Generated by \`rungs render\` on ${stamp}. Do not edit.`,
|
|
791
|
+
"",
|
|
792
|
+
`Harnesses: ${harnesses.join(", ")}`,
|
|
793
|
+
"",
|
|
794
|
+
"| Rule | Harness | Emitted | Dropped / degraded |",
|
|
795
|
+
"| --- | --- | --- | --- |"
|
|
796
|
+
];
|
|
797
|
+
for (const e of entries) {
|
|
798
|
+
const lost = e.degraded ?? (e.dropped?.length ? e.dropped.join(", ") : "\u2014");
|
|
799
|
+
lines.push(`| \`${e.rule}\` | ${e.harness} | ${e.target ? `\`${e.target}\`` : "**not emitted**"} | ${lost} |`);
|
|
800
|
+
}
|
|
801
|
+
const degraded = entries.filter((e) => e.degraded).length;
|
|
802
|
+
const lossy = entries.filter((e) => e.dropped?.length).length;
|
|
803
|
+
lines.push(
|
|
804
|
+
"",
|
|
805
|
+
`${entries.length} renderings \xB7 ${lossy} lost a field \xB7 ${degraded} degraded.`,
|
|
806
|
+
"",
|
|
807
|
+
"A field listed as dropped is one the target harness has no way to express. It is recorded",
|
|
808
|
+
"here rather than silently discarded, so a repo can see what its harness choice costs it.",
|
|
809
|
+
""
|
|
810
|
+
);
|
|
811
|
+
const content = lines.join("\n");
|
|
812
|
+
writeFileSync2(join5(repoRoot, ".ai", "render-report.md"), content);
|
|
813
|
+
return content;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/check.ts
|
|
817
|
+
import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
|
|
818
|
+
import { execSync as execSync3 } from "node:child_process";
|
|
819
|
+
import { dirname as dirname4, join as join9 } from "node:path";
|
|
820
|
+
import { fileURLToPath } from "node:url";
|
|
821
|
+
import { parse as parse2 } from "smol-toml";
|
|
822
|
+
|
|
823
|
+
// src/engines.ts
|
|
824
|
+
import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
|
|
825
|
+
import { join as join8, dirname as dirname3, resolve as resolve2 } from "node:path";
|
|
826
|
+
|
|
827
|
+
// src/engines2.ts
|
|
828
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
829
|
+
import { execSync } from "node:child_process";
|
|
830
|
+
import { join as join6 } from "node:path";
|
|
831
|
+
var read = (root, rel) => {
|
|
832
|
+
try {
|
|
833
|
+
return readFileSync5(join6(root, rel), "utf8");
|
|
834
|
+
} catch {
|
|
835
|
+
return "";
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
var expand = (files, p, f = []) => [...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
839
|
+
var exempted = (text, marker) => !!marker && new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\S`).test(text);
|
|
840
|
+
var idIntegrity = (t, root, files) => {
|
|
841
|
+
const findings = [];
|
|
842
|
+
let examined = 0;
|
|
843
|
+
const known = /* @__PURE__ */ new Set();
|
|
844
|
+
for (const [, kind] of Object.entries(t.kinds ?? {})) {
|
|
845
|
+
const re = new RegExp(`^\\s*id:\\s*(${kind.format})`, "m");
|
|
846
|
+
const seen = /* @__PURE__ */ new Map();
|
|
847
|
+
for (const rel of expand(files, kind.sources)) {
|
|
848
|
+
examined++;
|
|
849
|
+
const text = read(root, rel);
|
|
850
|
+
const id = text.match(re)?.[1] ?? rel.match(new RegExp(kind.format))?.[0];
|
|
851
|
+
if (!id) continue;
|
|
852
|
+
known.add(id);
|
|
853
|
+
const prior = seen.get(id);
|
|
854
|
+
if (prior) findings.push({ file: rel, message: `id ${id} also claimed by ${prior}` });
|
|
855
|
+
else seen.set(id, rel);
|
|
856
|
+
}
|
|
857
|
+
if (kind.marker?.file) {
|
|
858
|
+
const m = read(root, kind.marker.file).match(new RegExp(kind.marker.pattern));
|
|
859
|
+
if (m?.[1] && seen.has(m[1])) {
|
|
860
|
+
findings.push({ file: kind.marker.file, message: `NEXT marker points at ${m[1]}, already taken` });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const sb = t.stale_blocker;
|
|
865
|
+
if (sb?.phrases?.length && known.size) {
|
|
866
|
+
const scope = expand(files, ["docs/**/*.md", "AGENTS.md", "CLAUDE.md"]).filter(
|
|
867
|
+
(f) => !expand(files, sb.scope_exclude, []).includes(f)
|
|
868
|
+
);
|
|
869
|
+
const past = (sb.past_tense_ok ?? []).map((p) => p.toLowerCase());
|
|
870
|
+
for (const rel of scope) {
|
|
871
|
+
const text = read(root, rel);
|
|
872
|
+
if (exempted(text, sb.exempt_marker)) continue;
|
|
873
|
+
for (const phrase of sb.phrases) {
|
|
874
|
+
const re = new RegExp(`(.{0,${sb.negation_window ?? 60}})\\b${phrase}\\b\\s+([A-Z]{1,6}-\\d{1,4})`, "gi");
|
|
875
|
+
for (const m of text.matchAll(re)) {
|
|
876
|
+
const lead = m[1].toLowerCase();
|
|
877
|
+
if (past.some((p) => lead.includes(p.split(" ")[0]) && lead.includes("was"))) continue;
|
|
878
|
+
if (/\bnot\b|\bnever\b|\bno longer\b/.test(lead.slice(-30))) continue;
|
|
879
|
+
if (isDone(root, m[2], files)) {
|
|
880
|
+
findings.push({ file: rel, message: `claims to be ${phrase} ${m[2]}, which is done` });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return { findings, examined };
|
|
887
|
+
};
|
|
888
|
+
function isDone(root, id, files) {
|
|
889
|
+
const hit = files.find((f) => f.includes(id) && f.endsWith(".md"));
|
|
890
|
+
if (!hit) return false;
|
|
891
|
+
const s = read(root, hit).match(/^status:\s*(\S+)/m)?.[1];
|
|
892
|
+
return s === "done" || hit.includes("/archive/");
|
|
893
|
+
}
|
|
894
|
+
var renderFreshness = (t, root, files) => {
|
|
895
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
896
|
+
const findings = [];
|
|
897
|
+
let examined = 0;
|
|
898
|
+
for (const spec of specs) {
|
|
899
|
+
if (spec.block?.file) {
|
|
900
|
+
examined++;
|
|
901
|
+
const text = read(root, spec.block.file);
|
|
902
|
+
const re = new RegExp(`rungs:begin ${spec.block.marker}[\\s\\S]*?rungs:end ${spec.block.marker}`);
|
|
903
|
+
if (!re.test(text)) {
|
|
904
|
+
findings.push({ file: spec.block.file, message: `no '${spec.block.marker}' block \u2014 run \`${spec.command}\`` });
|
|
905
|
+
}
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const excluded = new Set(expand(files, spec.exclude, []));
|
|
909
|
+
const sources = expand(files, spec.sources).filter((s) => !excluded.has(s));
|
|
910
|
+
const targets = expand(files, spec.targets);
|
|
911
|
+
const live = new Set(targets.map((x) => x.split("/")[0]));
|
|
912
|
+
for (const src of sources) {
|
|
913
|
+
examined++;
|
|
914
|
+
const stem = src.split("/").pop().replace(/\.md$/, "");
|
|
915
|
+
for (const dir of live) {
|
|
916
|
+
if (!targets.some((x) => x.startsWith(dir) && x.includes(stem))) {
|
|
917
|
+
findings.push({ file: src, message: `no rendering under ${dir}/ \u2014 run \`${spec.command}\`` });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return { findings, examined };
|
|
923
|
+
};
|
|
924
|
+
var registerSchema = (t, root, files) => {
|
|
925
|
+
const findings = [];
|
|
926
|
+
let examined = 0;
|
|
927
|
+
const targets = t.file ? [t.file] : expand(files, t.scan);
|
|
928
|
+
for (const rel of targets) {
|
|
929
|
+
const text = read(root, rel);
|
|
930
|
+
if (!text) continue;
|
|
931
|
+
for (const table of parseTables(text)) {
|
|
932
|
+
if (t.table && !sectionOf(text, table.headerLine).toLowerCase().includes(String(t.table).toLowerCase())) continue;
|
|
933
|
+
const cols = t.required_cols ?? t.table_columns ?? [];
|
|
934
|
+
const present = cols.filter(
|
|
935
|
+
(c2) => table.headers.some((h) => h.toLowerCase() === String(c2).toLowerCase())
|
|
936
|
+
);
|
|
937
|
+
if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
|
|
938
|
+
for (const c2 of cols) {
|
|
939
|
+
if (!present.includes(c2)) findings.push({ file: rel, message: `register table missing column '${c2}'` });
|
|
940
|
+
}
|
|
941
|
+
for (const row of table.rows) {
|
|
942
|
+
if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
|
|
943
|
+
examined++;
|
|
944
|
+
for (const [key, values] of Object.entries(t.enum ?? {})) {
|
|
945
|
+
const v = strip(row[key]);
|
|
946
|
+
if (v && !values.map(String).includes(v)) {
|
|
947
|
+
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
for (const c2 of t.non_empty ?? []) {
|
|
951
|
+
if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
|
|
952
|
+
}
|
|
953
|
+
for (const cond of t.conditional ?? []) {
|
|
954
|
+
const matches = Object.entries(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
|
|
955
|
+
if (!matches) continue;
|
|
956
|
+
for (const c2 of cond.non_empty ?? []) {
|
|
957
|
+
const v = strip(row[c2]);
|
|
958
|
+
if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' required when ${JSON.stringify(cond.when)}` });
|
|
959
|
+
else if (cond.min_words?.[c2] && v.split(/\s+/).length < cond.min_words[c2]) {
|
|
960
|
+
findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is too thin to be a reason` });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return { findings, examined };
|
|
968
|
+
};
|
|
969
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
970
|
+
var selfDeclaredClosure = (t, root, files) => {
|
|
971
|
+
const findings = [];
|
|
972
|
+
let examined = 0;
|
|
973
|
+
const targets = t.file ? [t.file] : expand(files, t.scan ?? ["docs/**/FINDINGS.md"]);
|
|
974
|
+
const idPattern = t.id_pattern ?? "[A-Z]{1,6}-\\d{1,4}";
|
|
975
|
+
const openRow = new RegExp(t.open_row_pattern ?? `^\\|\\s*\\[?(${idPattern})\\]`, "gmu");
|
|
976
|
+
const detailHeading = new RegExp(t.detail_heading_pattern ?? `^###\\s+(${idPattern})\\s+\u2014\\s+`, "gmu");
|
|
977
|
+
const verdicts = (t.declares_fixed ?? [
|
|
978
|
+
"\\*\\*Fixed[.,)*]",
|
|
979
|
+
"\\*\\*Fixed\\s+(?:in|by|the\\s+same\\s+day|\\d{4}-\\d{2}-\\d{2})",
|
|
980
|
+
"\\*\\*Implemented in this change\\.?\\*\\*",
|
|
981
|
+
"\\*\\*fixed in the pass that found it\\*\\*"
|
|
982
|
+
]).map((p) => new RegExp(p, "iu"));
|
|
983
|
+
for (const rel of targets) {
|
|
984
|
+
const text = read(root, rel);
|
|
985
|
+
if (!text) continue;
|
|
986
|
+
const openStart = headingIndex(text, t.open_heading ?? "Open");
|
|
987
|
+
const closedStart = headingIndex(text, t.closed_heading ?? "Closed");
|
|
988
|
+
const detailStart = headingIndex(text, t.detail_heading ?? "Detail");
|
|
989
|
+
if (openStart < 0 || closedStart < 0 || detailStart < 0 || closedStart <= openStart || detailStart < closedStart) continue;
|
|
990
|
+
const open = /* @__PURE__ */ new Set();
|
|
991
|
+
for (const match of text.slice(openStart, closedStart).matchAll(openRow)) open.add(match[1]);
|
|
992
|
+
if (!open.size) continue;
|
|
993
|
+
const detail = text.slice(detailStart);
|
|
994
|
+
const headings = [...detail.matchAll(detailHeading)];
|
|
995
|
+
for (let i = 0; i < headings.length; i++) {
|
|
996
|
+
const id = headings[i][1];
|
|
997
|
+
if (!open.has(id)) continue;
|
|
998
|
+
examined++;
|
|
999
|
+
const start = headings[i].index ?? 0;
|
|
1000
|
+
const end = headings[i + 1]?.index ?? detail.length;
|
|
1001
|
+
const section = detail.slice(start, end);
|
|
1002
|
+
const marker = t.exempt_marker ?? "closure-ok:";
|
|
1003
|
+
if (new RegExp(`<!--\\s*${escapeRe(marker)}\\s*\\S`, "u").test(section)) continue;
|
|
1004
|
+
const body = section.slice(section.indexOf("\n") + 1);
|
|
1005
|
+
for (const verdict of verdicts) {
|
|
1006
|
+
const match = verdict.exec(body);
|
|
1007
|
+
if (!match) continue;
|
|
1008
|
+
const before = body.slice(Math.max(0, match.index - (t.citation_window ?? 120)), match.index);
|
|
1009
|
+
const cited = [...before.matchAll(new RegExp(`(${idPattern})[^.]{0,${t.citation_window ?? 120}}$`, "gu"))].at(-1)?.[1];
|
|
1010
|
+
if (cited && cited !== id) continue;
|
|
1011
|
+
findings.push({ file: rel, message: `${id} is open but its detail declares it fixed: ${body.slice(match.index, match.index + 60).split("\n")[0].trim()}` });
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return { findings, examined };
|
|
1017
|
+
};
|
|
1018
|
+
function headingIndex(text, heading) {
|
|
1019
|
+
const re = new RegExp(`^#{1,6}\\s+${escapeRe(heading)}\\s*$`, "imu");
|
|
1020
|
+
return text.search(re);
|
|
1021
|
+
}
|
|
1022
|
+
var strip = (v) => (v ?? "").replace(/[`*\[\]]/g, "").split("(")[0].trim();
|
|
1023
|
+
var firstCell = (row) => strip(Object.values(row)[0]) || "?";
|
|
1024
|
+
function parseTables(text) {
|
|
1025
|
+
const out = [];
|
|
1026
|
+
const lines = text.split("\n");
|
|
1027
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1028
|
+
if (!/^\s*\|/.test(lines[i]) || !/^\s*\|[\s:|-]+\|/.test(lines[i + 1] ?? "")) continue;
|
|
1029
|
+
const headers = cells(lines[i]);
|
|
1030
|
+
const rows = [];
|
|
1031
|
+
let j = i + 2;
|
|
1032
|
+
for (; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
|
|
1033
|
+
const c2 = cells(lines[j]);
|
|
1034
|
+
rows.push(Object.fromEntries(headers.map((h, k) => [h, c2[k] ?? ""])));
|
|
1035
|
+
}
|
|
1036
|
+
out.push({ headers, rows, headerLine: i });
|
|
1037
|
+
i = j;
|
|
1038
|
+
}
|
|
1039
|
+
return out;
|
|
1040
|
+
}
|
|
1041
|
+
var cells = (line) => line.trim().replace(/^\||\|$/g, "").split("|").map((s) => s.trim());
|
|
1042
|
+
var sectionOf = (text, line) => {
|
|
1043
|
+
const before = text.split("\n").slice(0, line);
|
|
1044
|
+
for (let i = before.length - 1; i >= 0; i--) if (/^#{1,6}\s/.test(before[i])) return before[i];
|
|
1045
|
+
return "";
|
|
1046
|
+
};
|
|
1047
|
+
var filenameSchema = (t, root, files) => {
|
|
1048
|
+
const re = new RegExp(t.pattern);
|
|
1049
|
+
const excluded = new Set(expand(files, t.exclude, []));
|
|
1050
|
+
const findings = [];
|
|
1051
|
+
let examined = 0;
|
|
1052
|
+
for (const rel of expand(files, t.scan)) {
|
|
1053
|
+
if (excluded.has(rel)) continue;
|
|
1054
|
+
examined++;
|
|
1055
|
+
const base = rel.split("/").pop();
|
|
1056
|
+
if (!re.test(base)) findings.push({ file: rel, message: "filename does not say what closed and what came next" });
|
|
1057
|
+
}
|
|
1058
|
+
return { findings, examined };
|
|
1059
|
+
};
|
|
1060
|
+
var crossReference = (t, root, files) => {
|
|
1061
|
+
const skills = expand(files, t.scan);
|
|
1062
|
+
if (skills.length < (t.min_skills ?? 6)) return { findings: [], examined: skills.length };
|
|
1063
|
+
const names = skills.map((s) => s.split("/").slice(-2)[0]);
|
|
1064
|
+
const findings = [];
|
|
1065
|
+
for (const rel of skills) {
|
|
1066
|
+
const text = read(root, rel);
|
|
1067
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
1068
|
+
const desc = text.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
|
|
1069
|
+
const self = rel.split("/").slice(-2)[0];
|
|
1070
|
+
if (!names.some((n) => n !== self && desc.includes(n))) {
|
|
1071
|
+
findings.push({ file: rel, message: `names no neighbouring skill (${skills.length} in this repo)` });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { findings, examined: skills.length };
|
|
1075
|
+
};
|
|
1076
|
+
var gitStatusReconcile = (t, root, files) => {
|
|
1077
|
+
const findings = [];
|
|
1078
|
+
let merged;
|
|
1079
|
+
try {
|
|
1080
|
+
merged = new Set(
|
|
1081
|
+
execSync(`git branch --merged ${t.integration_branch ?? "main"} --format=%(refname:short)`, {
|
|
1082
|
+
cwd: root,
|
|
1083
|
+
stdio: "pipe"
|
|
1084
|
+
}).toString().split("\n").map((s) => s.trim()).filter(Boolean)
|
|
1085
|
+
);
|
|
1086
|
+
} catch {
|
|
1087
|
+
return { findings: [{ message: "cannot read git branches; status not reconciled" }], examined: 0 };
|
|
1088
|
+
}
|
|
1089
|
+
let examined = 0;
|
|
1090
|
+
for (const rel of expand(files, ["docs/**/items/**/*.md"])) {
|
|
1091
|
+
const text = read(root, rel);
|
|
1092
|
+
if (exempted(text, t.exempt_marker)) continue;
|
|
1093
|
+
const branch = text.match(new RegExp(`^${t.branch_field ?? "branch"}:\\s*(\\S+)`, "m"))?.[1];
|
|
1094
|
+
const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
|
|
1095
|
+
if (!branch || !status) continue;
|
|
1096
|
+
examined++;
|
|
1097
|
+
if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
|
|
1098
|
+
findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return { findings, examined };
|
|
1102
|
+
};
|
|
1103
|
+
var computedClaim = (t, root, files) => {
|
|
1104
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
1105
|
+
const findings = [];
|
|
1106
|
+
let examined = 0;
|
|
1107
|
+
for (const spec of specs) {
|
|
1108
|
+
const values = /* @__PURE__ */ new Map();
|
|
1109
|
+
for (const src of spec.sources ?? []) {
|
|
1110
|
+
for (const rel of matchAny(files, src.file)) {
|
|
1111
|
+
const text = read(root, rel);
|
|
1112
|
+
let v;
|
|
1113
|
+
if (src.path && rel.endsWith(".json")) {
|
|
1114
|
+
try {
|
|
1115
|
+
v = src.path.split(".").reduce((o, k) => o?.[k], JSON.parse(text));
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
} else if (src.xpath) {
|
|
1119
|
+
v = text.match(new RegExp(`<${src.xpath.split("//")[1]}>(.*?)<`))?.[1];
|
|
1120
|
+
}
|
|
1121
|
+
if (v) {
|
|
1122
|
+
examined++;
|
|
1123
|
+
values.set(rel, String(v));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const distinct = new Set(values.values());
|
|
1128
|
+
if (spec.rule === "all-agree" && distinct.size > 1) {
|
|
1129
|
+
findings.push({
|
|
1130
|
+
message: `${spec.id} disagrees across ${values.size} locations: ${[...distinct].join(", ")} \u2014 run \`${spec.autofix}\``
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return { findings, examined };
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
// src/engines3.ts
|
|
1138
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1139
|
+
import { execSync as execSync2 } from "node:child_process";
|
|
1140
|
+
import { join as join7 } from "node:path";
|
|
1141
|
+
var read2 = (root, rel) => {
|
|
1142
|
+
try {
|
|
1143
|
+
return readFileSync6(join7(root, rel), "utf8");
|
|
1144
|
+
} catch {
|
|
1145
|
+
return "";
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
var expand2 = (files, p, f = []) => [...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
1149
|
+
var escapeRe2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1150
|
+
var exempted2 = (text, marker) => !!marker && new RegExp(`${escapeRe2(marker)}\\s*\\S`).test(text);
|
|
1151
|
+
function tableRows(text, near) {
|
|
1152
|
+
const lines = text.split("\n");
|
|
1153
|
+
const rows = [];
|
|
1154
|
+
let heading = "";
|
|
1155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1156
|
+
if (/^#{1,6}\s/.test(lines[i])) heading = lines[i];
|
|
1157
|
+
if (!/^\s*\|/.test(lines[i]) || !/^\s*\|[\s:|-]+\|/.test(lines[i + 1] ?? "")) continue;
|
|
1158
|
+
if (near && !heading.toLowerCase().includes(near.toLowerCase())) continue;
|
|
1159
|
+
const cells2 = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((s) => s.trim());
|
|
1160
|
+
const headers = cells2(lines[i]);
|
|
1161
|
+
for (let j = i + 2; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
|
|
1162
|
+
const c2 = cells2(lines[j]);
|
|
1163
|
+
rows.push(Object.fromEntries(headers.map((h, k) => [h, c2[k] ?? ""])));
|
|
1164
|
+
}
|
|
1165
|
+
i = lines.length;
|
|
1166
|
+
}
|
|
1167
|
+
return rows;
|
|
1168
|
+
}
|
|
1169
|
+
var clean = (v = "") => v.replace(/[`*\[\]]/g, "").trim();
|
|
1170
|
+
function terms(topic) {
|
|
1171
|
+
const stop = /* @__PURE__ */ new Set(["the", "and", "for", "with", "per", "its", "a", "an", "of", "to", "in", "on", "is", "are"]);
|
|
1172
|
+
return clean(topic).toLowerCase().split(/[^a-z0-9_-]+/).filter((w) => w.length > 3 && !stop.has(w));
|
|
1173
|
+
}
|
|
1174
|
+
var termOwnership = (t, root, files) => {
|
|
1175
|
+
const registry = read2(root, t.registry ?? "docs/doc-ownership.md");
|
|
1176
|
+
if (!registry) return { findings: [{ message: `ownership registry '${t.registry}' not found` }], examined: 0 };
|
|
1177
|
+
const cols = t.columns ?? {};
|
|
1178
|
+
const findings = [];
|
|
1179
|
+
let examined = 0;
|
|
1180
|
+
for (const row of tableRows(registry)) {
|
|
1181
|
+
const topic = clean(row[cols.topic ?? "Topic"]);
|
|
1182
|
+
const owner = clean(row[cols.owner ?? "Owner"]);
|
|
1183
|
+
const forbidden = clean(row[cols.forbidden ?? "Must NOT appear in"]);
|
|
1184
|
+
if (!topic || !forbidden || forbidden === "\u2014" || topic.startsWith("(example)")) continue;
|
|
1185
|
+
const want = terms(topic);
|
|
1186
|
+
if (want.length < 2) continue;
|
|
1187
|
+
const patterns = forbidden.split(/[,·]/).map((s) => s.trim()).filter(Boolean);
|
|
1188
|
+
for (const rel of expand2(files, patterns)) {
|
|
1189
|
+
if (rel === owner) continue;
|
|
1190
|
+
const text = read2(root, rel);
|
|
1191
|
+
if (exempted2(text, t.exempt_marker)) continue;
|
|
1192
|
+
examined++;
|
|
1193
|
+
for (const section of text.split(/^#{1,6}\s+/m)) {
|
|
1194
|
+
const lower = section.toLowerCase();
|
|
1195
|
+
const hits = want.filter((w) => lower.includes(w));
|
|
1196
|
+
if (hits.length >= (t.engage_min_terms ?? 3)) {
|
|
1197
|
+
findings.push({ file: rel, message: `restates "${topic}", owned by ${owner} (${hits.length} terms)` });
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return { findings, examined };
|
|
1204
|
+
};
|
|
1205
|
+
var rulePropagation = (t, root, files) => {
|
|
1206
|
+
const registry = read2(root, t.registry ?? "docs/working-rules.md");
|
|
1207
|
+
if (!registry) return { findings: [{ message: `rules registry '${t.registry}' not found` }], examined: 0 };
|
|
1208
|
+
const cols = t.columns ?? {};
|
|
1209
|
+
const findings = [];
|
|
1210
|
+
let examined = 0;
|
|
1211
|
+
const window = t.negation_window ?? 60;
|
|
1212
|
+
for (const row of tableRows(registry)) {
|
|
1213
|
+
const rule = clean(row[cols.rule ?? "Rule"]);
|
|
1214
|
+
const retired = clean(row[cols.retired ?? "Retired wording"]);
|
|
1215
|
+
const surfaces = clean(row[cols.surfaces ?? "Surfaces that restate it"]);
|
|
1216
|
+
if (!rule || !retired || retired === "\u2014" || rule.startsWith("(example)")) continue;
|
|
1217
|
+
for (const rel of expand2(files, surfaces.split(/[,·]/).map((s) => s.trim()).filter(Boolean))) {
|
|
1218
|
+
const text = read2(root, rel);
|
|
1219
|
+
if (exempted2(text, t.exempt_marker)) continue;
|
|
1220
|
+
examined++;
|
|
1221
|
+
const re = new RegExp(`(.{0,${window}})${escapeRe2(retired)}`, "gis");
|
|
1222
|
+
for (const m of text.matchAll(re)) {
|
|
1223
|
+
const lead = m[1].toLowerCase();
|
|
1224
|
+
if (/\bnot\b|\bnever\b|\bno longer\b|\bused to\b|\bformerly\b|\bretired\b/.test(lead)) continue;
|
|
1225
|
+
findings.push({ file: rel, message: `carries the retired wording for "${rule}"` });
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return { findings, examined };
|
|
1231
|
+
};
|
|
1232
|
+
var gitState = (t, root) => {
|
|
1233
|
+
let out;
|
|
1234
|
+
try {
|
|
1235
|
+
out = execSync2("git worktree list --porcelain", { cwd: root, stdio: "pipe" }).toString();
|
|
1236
|
+
} catch {
|
|
1237
|
+
return { findings: [{ message: "cannot read git worktrees; checkout state unknown" }], examined: 0 };
|
|
1238
|
+
}
|
|
1239
|
+
const findings = [];
|
|
1240
|
+
const blocks = out.split("\n\n").filter(Boolean);
|
|
1241
|
+
for (const b of blocks) {
|
|
1242
|
+
const dir = b.match(/^worktree (.+)$/m)?.[1];
|
|
1243
|
+
const branch = b.match(/^branch refs\/heads\/(.+)$/m)?.[1];
|
|
1244
|
+
if (branch && (t.refuse_checked_out ?? []).includes(branch)) {
|
|
1245
|
+
findings.push({ message: `'${branch}' is checked out in ${dir} \u2014 nothing should hold it` });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return { findings, examined: blocks.length };
|
|
1249
|
+
};
|
|
1250
|
+
var mergeDriverCheck = (t, root) => {
|
|
1251
|
+
const attrs = read2(root, t.attributes_file ?? ".gitattributes");
|
|
1252
|
+
if (!attrs) return { findings: [], examined: 0 };
|
|
1253
|
+
const declared = [...new Set([...attrs.matchAll(/merge=([\w-]+)/g)].map((m) => m[1]))];
|
|
1254
|
+
const required = (t.required_drivers ?? []).filter((d) => declared.includes(d));
|
|
1255
|
+
if (!required.length) return { findings: [], examined: declared.length };
|
|
1256
|
+
const findings = [];
|
|
1257
|
+
for (const driver of required) {
|
|
1258
|
+
let configured = "";
|
|
1259
|
+
try {
|
|
1260
|
+
configured = execSync2(`git config --get merge.${driver}.driver`, { cwd: root, stdio: "pipe" }).toString().trim();
|
|
1261
|
+
} catch {
|
|
1262
|
+
}
|
|
1263
|
+
if (!configured) {
|
|
1264
|
+
findings.push({ message: `driver '${driver}' is declared but not installed \u2014 run \`${t.install_command}\`` });
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return { findings, examined: declared.length };
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
// src/engines.ts
|
|
1271
|
+
var read3 = (root, rel) => {
|
|
1272
|
+
try {
|
|
1273
|
+
return readFileSync7(join8(root, rel), "utf8");
|
|
1274
|
+
} catch {
|
|
1275
|
+
return "";
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
var GENERATED = "Generated by `rungs";
|
|
1279
|
+
var expand3 = (files, patterns, fallback = []) => [...new Set((patterns ?? fallback).flatMap((p) => matchAny(files, p)))];
|
|
1280
|
+
function dropGenerated(root, rels) {
|
|
1281
|
+
return rels.filter((rel) => !read3(root, rel).slice(0, 600).includes(GENERATED));
|
|
1282
|
+
}
|
|
1283
|
+
function loadedLines(text) {
|
|
1284
|
+
return text.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/<!--[\s\S]*?-->/g, "").split("\n").filter((l) => l.trim()).length;
|
|
1285
|
+
}
|
|
1286
|
+
var fileBudget = (t, root, files) => {
|
|
1287
|
+
const targets = dropGenerated(root, t.file ? [t.file] : expand3(files, t.scan));
|
|
1288
|
+
const excluded = new Set(expand3(files, t.exclude, []));
|
|
1289
|
+
const findings = [];
|
|
1290
|
+
let examined = 0;
|
|
1291
|
+
for (const rel of targets) {
|
|
1292
|
+
if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
|
|
1293
|
+
examined++;
|
|
1294
|
+
const n = loadedLines(read3(root, rel));
|
|
1295
|
+
if (n > t.max_lines) findings.push({ file: rel, message: `${n} lines, budget ${t.max_lines}` });
|
|
1296
|
+
}
|
|
1297
|
+
return { findings, examined };
|
|
1298
|
+
};
|
|
1299
|
+
var sections = (t, root, files) => {
|
|
1300
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
1301
|
+
const findings = [];
|
|
1302
|
+
let examined = 0;
|
|
1303
|
+
for (const spec of specs) {
|
|
1304
|
+
const targets = dropGenerated(root, spec.file ? [spec.file] : expand3(files, spec.scan));
|
|
1305
|
+
const excluded = new Set(expand3(files, spec.exclude, []));
|
|
1306
|
+
for (const rel of targets) {
|
|
1307
|
+
if (excluded.has(rel) || !existsSync6(join8(root, rel))) continue;
|
|
1308
|
+
examined++;
|
|
1309
|
+
const text = read3(root, rel);
|
|
1310
|
+
const heads = [...text.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)].map((m) => m[1]);
|
|
1311
|
+
for (const want of spec.required ?? []) {
|
|
1312
|
+
const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
|
|
1313
|
+
if (idx === -1) {
|
|
1314
|
+
findings.push({ file: rel, message: `missing section '${want}'` });
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (spec.non_empty) {
|
|
1318
|
+
const after = text.split(new RegExp(`^#{1,6}\\s+${escapeRe3(heads[idx])}\\s*$`, "m"))[1] ?? "";
|
|
1319
|
+
const body = after.split(/^#{1,6}\s+/m)[0].replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
1320
|
+
if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
for (const open of spec.requires_opening ?? []) {
|
|
1324
|
+
if (!text.slice(0, 400).includes(open)) findings.push({ file: rel, message: `does not open with ${open}` });
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return { findings, examined };
|
|
1329
|
+
};
|
|
1330
|
+
var frontmatterSchema = (t, root, files) => {
|
|
1331
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
1332
|
+
const findings = [];
|
|
1333
|
+
let examined = 0;
|
|
1334
|
+
for (const spec of specs) {
|
|
1335
|
+
for (const rel of dropGenerated(root, expand3(files, spec.scan))) {
|
|
1336
|
+
if (new Set(expand3(files, spec.exclude, [])).has(rel)) continue;
|
|
1337
|
+
const text = read3(root, rel);
|
|
1338
|
+
const m = text.match(/^---\n([\s\S]*?)\n---/);
|
|
1339
|
+
if (!m) {
|
|
1340
|
+
findings.push({ file: rel, message: "no frontmatter" });
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
examined++;
|
|
1344
|
+
const keys = [...m[1].matchAll(/^([a-zA-Z0-9_-]+):/gm)].map((k) => k[1]);
|
|
1345
|
+
for (const req of spec.required ?? []) {
|
|
1346
|
+
if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
|
|
1347
|
+
}
|
|
1348
|
+
if (spec.allowed) {
|
|
1349
|
+
for (const k of keys) {
|
|
1350
|
+
if (!spec.allowed.includes(k)) findings.push({ file: rel, message: `non-spec key '${k}'` });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
for (const [key, values] of Object.entries(spec.enum ?? {})) {
|
|
1354
|
+
const v = m[1].match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
|
|
1355
|
+
if (v && !values.map(String).includes(v)) {
|
|
1356
|
+
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return { findings, examined };
|
|
1362
|
+
};
|
|
1363
|
+
var linkIntegrity = (t, root, files) => {
|
|
1364
|
+
const scan = expand3(files, t.scan, ["**/*.md"]);
|
|
1365
|
+
const excluded = new Set(expand3(files, t.exclude, []));
|
|
1366
|
+
const findings = [];
|
|
1367
|
+
let examined = 0;
|
|
1368
|
+
for (const rel of scan) {
|
|
1369
|
+
if (excluded.has(rel)) continue;
|
|
1370
|
+
const text = read3(root, rel);
|
|
1371
|
+
examined++;
|
|
1372
|
+
if (/path-ok:\s*\S/.test(text)) continue;
|
|
1373
|
+
const scannable = text.replace(/`+[^`\n]*`+/g, (s) => " ".repeat(s.length));
|
|
1374
|
+
for (const m of scannable.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
|
|
1375
|
+
if (/\{\{[a-z_.]+\}\}/.test(m[1])) continue;
|
|
1376
|
+
const target = resolve2(root, dirname3(rel), decodeURIComponent(m[1]));
|
|
1377
|
+
if (!existsSync6(target)) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return { findings, examined };
|
|
1381
|
+
};
|
|
1382
|
+
var filePopulation = (t, root, files) => {
|
|
1383
|
+
let hits = dropGenerated(root, expand3(files, t.scan)).filter((f) => !new Set(expand3(files, t.exclude, [])).has(f));
|
|
1384
|
+
if (t.detect === "body_is_only_a_pointer") {
|
|
1385
|
+
hits = hits.filter((rel) => {
|
|
1386
|
+
const body = read3(root, rel).replace(/^---\n[\s\S]*?\n---\n/, "").replace(/<!--[\s\S]*?-->/g, "").replace(/^#.*$/gm, "").trim();
|
|
1387
|
+
const words = body.split(/\s+/).filter(Boolean).length;
|
|
1388
|
+
const links = (body.match(/\]\(/g) ?? []).length;
|
|
1389
|
+
return words > 0 && words <= (t.max_body_words ?? 40) && links >= 1;
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
const findings = [];
|
|
1393
|
+
const failAt = t.fail_at ?? Infinity;
|
|
1394
|
+
if (hits.length >= failAt) {
|
|
1395
|
+
findings.push({ message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` });
|
|
1396
|
+
}
|
|
1397
|
+
return { findings, examined: hits.length };
|
|
1398
|
+
};
|
|
1399
|
+
var gateMeta = (_t, root) => {
|
|
1400
|
+
const findings = [];
|
|
1401
|
+
const registry = join8(root, ".ai", "gates.toml");
|
|
1402
|
+
if (!existsSync6(registry)) return { findings, examined: 0 };
|
|
1403
|
+
const text = readFileSync7(registry, "utf8");
|
|
1404
|
+
const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
|
|
1405
|
+
let examined = 0;
|
|
1406
|
+
for (const entry of entries) {
|
|
1407
|
+
const id = entry.match(/^id\s*=\s*"(.+)"/m)?.[1];
|
|
1408
|
+
const kind = entry.match(/^kind\s*=\s*"(.+)"/m)?.[1];
|
|
1409
|
+
const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
|
|
1410
|
+
if (!id || kind !== "declared" || !table) continue;
|
|
1411
|
+
examined++;
|
|
1412
|
+
const tablePath = join8(dirname3(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname3(table), "gates", table.split("/").pop());
|
|
1413
|
+
const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
|
|
1414
|
+
const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)].map((m) => m[0]).filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
|
|
1415
|
+
for (const direction of ["pass", "fail"]) {
|
|
1416
|
+
if (!forGate.some((b) => new RegExp(`expect\\s*=\\s*"${direction}"`).test(b))) {
|
|
1417
|
+
findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return { findings, examined };
|
|
1422
|
+
};
|
|
1423
|
+
var escapeRe3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1424
|
+
var ENGINES = {
|
|
1425
|
+
"file-budget": fileBudget,
|
|
1426
|
+
sections,
|
|
1427
|
+
"frontmatter-schema": frontmatterSchema,
|
|
1428
|
+
"link-integrity": linkIntegrity,
|
|
1429
|
+
"file-population": filePopulation,
|
|
1430
|
+
"gate-meta": gateMeta,
|
|
1431
|
+
"id-integrity": idIntegrity,
|
|
1432
|
+
"render-freshness": renderFreshness,
|
|
1433
|
+
"register-schema": registerSchema,
|
|
1434
|
+
"self-declared-closure": selfDeclaredClosure,
|
|
1435
|
+
"filename-schema": filenameSchema,
|
|
1436
|
+
"cross-reference": crossReference,
|
|
1437
|
+
"git-status-reconcile": gitStatusReconcile,
|
|
1438
|
+
"computed-claim": computedClaim,
|
|
1439
|
+
"term-ownership": termOwnership,
|
|
1440
|
+
"rule-propagation": rulePropagation,
|
|
1441
|
+
"git-state": gitState,
|
|
1442
|
+
"merge-driver-check": mergeDriverCheck
|
|
1443
|
+
};
|
|
1444
|
+
function isImplemented(engine) {
|
|
1445
|
+
return engine in ENGINES;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// src/check.ts
|
|
1449
|
+
var MODULES = join9(dirname4(fileURLToPath(import.meta.url)), "..", "modules");
|
|
1450
|
+
function loadRegistry(repoRoot) {
|
|
1451
|
+
const path = join9(repoRoot, ".ai", "gates.toml");
|
|
1452
|
+
if (!existsSync7(path)) return { runner: {}, gates: [] };
|
|
1453
|
+
const raw = parse2(readFileSync8(path, "utf8"));
|
|
1454
|
+
return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
|
|
1455
|
+
}
|
|
1456
|
+
function runGates(repoRoot, tier, now = () => Date.now()) {
|
|
1457
|
+
const { gates } = loadRegistry(repoRoot);
|
|
1458
|
+
const files = walk(repoRoot);
|
|
1459
|
+
const runs = [];
|
|
1460
|
+
for (const g of gates) {
|
|
1461
|
+
if (g.trigger) continue;
|
|
1462
|
+
if (tier && g.tier && g.tier !== tier) continue;
|
|
1463
|
+
const started = now();
|
|
1464
|
+
let status = "pass";
|
|
1465
|
+
let findings = [];
|
|
1466
|
+
let examined = 0;
|
|
1467
|
+
if (g.kind === "command" && g.command) {
|
|
1468
|
+
try {
|
|
1469
|
+
execSync3(g.command, { cwd: repoRoot, stdio: "pipe" });
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
status = "fail";
|
|
1472
|
+
findings = [{ message: String(e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-3).join(" ") }];
|
|
1473
|
+
}
|
|
1474
|
+
} else if (!g.engine || !isImplemented(g.engine)) {
|
|
1475
|
+
status = "unimplemented";
|
|
1476
|
+
findings = [{ message: `engine '${g.engine ?? "(none)"}' is not implemented` }];
|
|
1477
|
+
} else {
|
|
1478
|
+
const table = loadTable(g.table, repoRoot);
|
|
1479
|
+
if (!table) {
|
|
1480
|
+
status = "error";
|
|
1481
|
+
findings = [{ message: `table '${g.table}' not found` }];
|
|
1482
|
+
} else {
|
|
1483
|
+
try {
|
|
1484
|
+
const key = tableKey(g.engine);
|
|
1485
|
+
let section = table[key] ?? table;
|
|
1486
|
+
if (Array.isArray(section) && section.some((s) => s?.id)) {
|
|
1487
|
+
const mine = section.filter((s) => !s.id || g.id.includes(s.id));
|
|
1488
|
+
if (mine.length) section = mine;
|
|
1489
|
+
}
|
|
1490
|
+
const r = ENGINES[g.engine](section, repoRoot, files);
|
|
1491
|
+
findings = r.findings;
|
|
1492
|
+
examined = r.examined;
|
|
1493
|
+
status = r.findings.length ? "fail" : "pass";
|
|
1494
|
+
} catch (e) {
|
|
1495
|
+
status = "error";
|
|
1496
|
+
findings = [{ message: e.message }];
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
runs.push({
|
|
1501
|
+
id: g.id,
|
|
1502
|
+
module: g.module,
|
|
1503
|
+
kind: g.kind,
|
|
1504
|
+
engine: g.engine,
|
|
1505
|
+
tier: g.tier ?? "fast",
|
|
1506
|
+
status,
|
|
1507
|
+
ms: now() - started,
|
|
1508
|
+
examined,
|
|
1509
|
+
findings,
|
|
1510
|
+
why: g.why
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
return runs;
|
|
1514
|
+
}
|
|
1515
|
+
function loadTable(ref, repoRoot) {
|
|
1516
|
+
if (!ref) return null;
|
|
1517
|
+
const [mod, file] = ref.split("/");
|
|
1518
|
+
const path = join9(MODULES, mod, "gates", file);
|
|
1519
|
+
if (!existsSync7(path)) return null;
|
|
1520
|
+
try {
|
|
1521
|
+
return parse2(substitute(readFileSync8(path, "utf8"), mod, installedParams(repoRoot)));
|
|
1522
|
+
} catch {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
var paramCache = null;
|
|
1527
|
+
function installedParams(repoRoot) {
|
|
1528
|
+
if (paramCache?.root === repoRoot) return paramCache.params;
|
|
1529
|
+
const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
|
|
1530
|
+
const recordPath = join9(repoRoot, ".ai", "rungs.toml");
|
|
1531
|
+
if (existsSync7(recordPath)) {
|
|
1532
|
+
try {
|
|
1533
|
+
const rec = parse2(readFileSync8(recordPath, "utf8"));
|
|
1534
|
+
for (const [name, entry] of Object.entries(rec.modules ?? {})) {
|
|
1535
|
+
if (entry?.params) defaults[name] = { ...defaults[name] ?? {}, ...entry.params };
|
|
1536
|
+
}
|
|
1537
|
+
} catch {
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
paramCache = { root: repoRoot, params: defaults };
|
|
1541
|
+
return defaults;
|
|
1542
|
+
}
|
|
1543
|
+
var tableKey = (engine) => ({
|
|
1544
|
+
"file-budget": "file_budget",
|
|
1545
|
+
sections: "sections",
|
|
1546
|
+
"frontmatter-schema": "frontmatter_schema",
|
|
1547
|
+
"link-integrity": "link_integrity",
|
|
1548
|
+
"file-population": "file_population",
|
|
1549
|
+
"gate-meta": "gate_meta",
|
|
1550
|
+
"id-integrity": "__whole__",
|
|
1551
|
+
"render-freshness": "render_freshness",
|
|
1552
|
+
"register-schema": "register_schema",
|
|
1553
|
+
"self-declared-closure": "self_declared_closure",
|
|
1554
|
+
"filename-schema": "filename_schema",
|
|
1555
|
+
"cross-reference": "cross_reference",
|
|
1556
|
+
"git-status-reconcile": "merged_status",
|
|
1557
|
+
"computed-claim": "computed_claim",
|
|
1558
|
+
"term-ownership": "term_ownership",
|
|
1559
|
+
"rule-propagation": "rule_propagation",
|
|
1560
|
+
"git-state": "git_state",
|
|
1561
|
+
"merge-driver-check": "merge_driver_check"
|
|
1562
|
+
})[engine] ?? engine;
|
|
1563
|
+
function appendLedger(repoRoot, runs, stamp) {
|
|
1564
|
+
const { runner } = loadRegistry(repoRoot);
|
|
1565
|
+
if (runner.ledger === false) return;
|
|
1566
|
+
const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
|
|
1567
|
+
const lines = runs.map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined })).join("\n");
|
|
1568
|
+
appendFileSync(path, lines + "\n");
|
|
1569
|
+
}
|
|
1570
|
+
function ledgerQuestions(repoRoot, gates) {
|
|
1571
|
+
const path = join9(repoRoot, ".ai", ".gate-ledger.jsonl");
|
|
1572
|
+
if (!existsSync7(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
|
|
1573
|
+
const rows = readFileSync8(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
1574
|
+
const by = /* @__PURE__ */ new Map();
|
|
1575
|
+
for (const r of rows) {
|
|
1576
|
+
const e = by.get(r.id) ?? { total: 0, failed: 0 };
|
|
1577
|
+
e.total++;
|
|
1578
|
+
if (r.status === "fail") e.failed++;
|
|
1579
|
+
by.set(r.id, e);
|
|
1580
|
+
}
|
|
1581
|
+
const whyOf = (id) => gates.find((g) => g.id === id)?.why;
|
|
1582
|
+
const neverFired = [...by].filter(([, e]) => e.total >= 3 && e.failed === 0).map(([id]) => ({ id, why: whyOf(id) }));
|
|
1583
|
+
const alwaysFires = [...by].filter(([, e]) => e.total >= 3 && e.failed / e.total > 0.9).map(([id, e]) => ({ id, why: whyOf(id), rate: `${e.failed}/${e.total}` }));
|
|
1584
|
+
return { neverFired, alwaysFires, runs: rows.length };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// src/lifecycle.ts
|
|
1588
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1589
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
1590
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1591
|
+
import { execSync as execSync4 } from "node:child_process";
|
|
1592
|
+
import { parse as parse3 } from "smol-toml";
|
|
1593
|
+
var SRC = dirname5(fileURLToPath2(import.meta.url));
|
|
1594
|
+
var PROFILES = {
|
|
1595
|
+
minimal: ["instructions"],
|
|
1596
|
+
tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
|
|
1597
|
+
disciplined: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit"],
|
|
1598
|
+
hardened: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority"],
|
|
1599
|
+
fleet: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority", "concurrency", "design-sync"]
|
|
1600
|
+
};
|
|
1601
|
+
function readRecord(repoRoot) {
|
|
1602
|
+
const p = join10(repoRoot, ".ai", "rungs.toml");
|
|
1603
|
+
if (!existsSync8(p)) return null;
|
|
1604
|
+
try {
|
|
1605
|
+
const raw = parse3(readFileSync9(p, "utf8"));
|
|
1606
|
+
return { harnesses: raw.repo?.harnesses ?? [], modules: raw.modules ?? {} };
|
|
1607
|
+
} catch {
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
function planUpgrade(repoRoot, mods, record) {
|
|
1612
|
+
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
1613
|
+
const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
|
|
1614
|
+
const items = [];
|
|
1615
|
+
for (const mod of mods) {
|
|
1616
|
+
const installed = record.modules[mod.name];
|
|
1617
|
+
if (!installed) continue;
|
|
1618
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
1619
|
+
const files = [];
|
|
1620
|
+
const kept = new Set(installed.kept?.files ?? []);
|
|
1621
|
+
for (const [rel, wouldEmit] of emitted) {
|
|
1622
|
+
if (kept.has(rel)) continue;
|
|
1623
|
+
const full = join10(repoRoot, rel);
|
|
1624
|
+
if (!existsSync8(full)) {
|
|
1625
|
+
files.push({ rel, state: "missing" });
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
const onDisk = contentHash(readFileSync9(full, "utf8"));
|
|
1629
|
+
const recorded = installed.hashes?.[rel];
|
|
1630
|
+
if (onDisk === contentHash(wouldEmit)) files.push({ rel, state: "current" });
|
|
1631
|
+
else if (recorded && onDisk === recorded) files.push({ rel, state: "stale" });
|
|
1632
|
+
else files.push({ rel, state: "diverged" });
|
|
1633
|
+
}
|
|
1634
|
+
items.push({ module: mod.name, from: installed.version, to: mod.version, files });
|
|
1635
|
+
}
|
|
1636
|
+
return items;
|
|
1637
|
+
}
|
|
1638
|
+
function applyUpgrade(repoRoot, mods, record, plan) {
|
|
1639
|
+
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
1640
|
+
const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
|
|
1641
|
+
let written = 0;
|
|
1642
|
+
for (const item of plan) {
|
|
1643
|
+
const mod = mods.find((m) => m.name === item.module);
|
|
1644
|
+
const emitted = emittedFiles(mod, params, skillsDir);
|
|
1645
|
+
for (const f of item.files) {
|
|
1646
|
+
if (f.state !== "stale" && f.state !== "missing") continue;
|
|
1647
|
+
const full = join10(repoRoot, f.rel);
|
|
1648
|
+
mkdirSync3(dirname5(full), { recursive: true });
|
|
1649
|
+
writeFileSync3(full, emitted.get(f.rel));
|
|
1650
|
+
written++;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
return written;
|
|
1654
|
+
}
|
|
1655
|
+
function paramsFrom(record) {
|
|
1656
|
+
const out = {};
|
|
1657
|
+
for (const [name, entry] of Object.entries(record.modules)) {
|
|
1658
|
+
if (entry.params) out[name] = { ...entry.params };
|
|
1659
|
+
}
|
|
1660
|
+
return out;
|
|
1661
|
+
}
|
|
1662
|
+
function eject(repoRoot, mods, dryRun = false) {
|
|
1663
|
+
const dest = join10(repoRoot, ".rungs");
|
|
1664
|
+
const engines = ["glob.ts", "engines.ts", "engines2.ts"];
|
|
1665
|
+
const { gates } = loadRegistry(repoRoot);
|
|
1666
|
+
const declared = gates.filter((g) => g.kind === "declared" && g.table);
|
|
1667
|
+
const tables = [...new Set(declared.map((g) => g.table))];
|
|
1668
|
+
const actions = [];
|
|
1669
|
+
for (const f of engines) actions.push(`.rungs/${f}`);
|
|
1670
|
+
for (const t of tables) actions.push(`.rungs/tables/${t.replace("/", "-").replace(/.toml$/, ".json")}`);
|
|
1671
|
+
actions.push(".rungs/run-gate.mjs", ".ai/gates.toml (rewritten to command gates)");
|
|
1672
|
+
if (dryRun) return { actions, gates: declared.length };
|
|
1673
|
+
mkdirSync3(join10(dest, "tables"), { recursive: true });
|
|
1674
|
+
for (const f of engines) copyFileSync(join10(SRC, f), join10(dest, f));
|
|
1675
|
+
const record = readRecord(repoRoot);
|
|
1676
|
+
const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
|
|
1677
|
+
for (const t of tables) {
|
|
1678
|
+
const [mod, file] = t.split("/");
|
|
1679
|
+
const src = join10(SRC, "..", "modules", mod, "gates", file);
|
|
1680
|
+
if (!existsSync8(src)) continue;
|
|
1681
|
+
try {
|
|
1682
|
+
const parsed = parse3(substitute(readFileSync9(src, "utf8"), mod, params));
|
|
1683
|
+
writeFileSync3(join10(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
|
|
1684
|
+
} catch {
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
writeFileSync3(join10(dest, "run-gate.mjs"), RUNNER);
|
|
1688
|
+
writeFileSync3(join10(dest, "README.md"), EJECT_README);
|
|
1689
|
+
const registry = join10(repoRoot, ".ai", "gates.toml");
|
|
1690
|
+
let text = readFileSync9(registry, "utf8");
|
|
1691
|
+
for (const g of declared) {
|
|
1692
|
+
text = text.replace(
|
|
1693
|
+
new RegExp(`(id\\s*=\\s*"${g.id}"[\\s\\S]*?)kind\\s*=\\s*"declared"`),
|
|
1694
|
+
`$1kind = "command"
|
|
1695
|
+
command = "node .rungs/run-gate.mjs ${g.id}"`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
writeFileSync3(registry, `${text}
|
|
1699
|
+
# Ejected: gates above run from .rungs/ and no longer need rungs installed.
|
|
1700
|
+
`);
|
|
1701
|
+
return { actions, gates: declared.length };
|
|
1702
|
+
}
|
|
1703
|
+
var RUNNER = `#!/usr/bin/env node
|
|
1704
|
+
// Ejected gate runner. Runs one declared gate from the tables in ./tables/.
|
|
1705
|
+
// Self-contained: this repo no longer needs rungs installed to run its gates.
|
|
1706
|
+
import { readFileSync } from 'node:fs';
|
|
1707
|
+
import { join, dirname } from 'node:path';
|
|
1708
|
+
import { fileURLToPath } from 'node:url';
|
|
1709
|
+
import { ENGINES } from './engines.ts';
|
|
1710
|
+
import { walk } from './glob.ts';
|
|
1711
|
+
|
|
1712
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1713
|
+
const root = join(here, '..');
|
|
1714
|
+
const id = process.argv[2];
|
|
1715
|
+
const registry = readFileSync(join(root, '.ai', 'gates.toml'), 'utf8');
|
|
1716
|
+
const entry = registry.split('[[gates]]').find((b) => b.includes(\`id = "\${id}"\`) || b.includes(\`id = "\${id}"\`));
|
|
1717
|
+
if (!entry) { console.error(\`unknown gate \${id}\`); process.exit(2); }
|
|
1718
|
+
|
|
1719
|
+
const engine = entry.match(/^engine\\s*=\\s*"(.+)"/m)?.[1];
|
|
1720
|
+
const table = entry.match(/^table\\s*=\\s*"(.+)"/m)?.[1];
|
|
1721
|
+
if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engine}' unavailable\`); process.exit(2); }
|
|
1722
|
+
|
|
1723
|
+
// Tables were converted to JSON when this was ejected, so nothing here needs a
|
|
1724
|
+
// TOML parser \u2014 or any dependency at all beyond Node itself.
|
|
1725
|
+
const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
|
|
1726
|
+
const KEYS = { 'file-budget': 'file_budget', 'frontmatter-schema': 'frontmatter_schema', 'link-integrity': 'link_integrity', 'file-population': 'file_population', 'gate-meta': 'gate_meta', 'render-freshness': 'render_freshness', 'register-schema': 'register_schema', 'self-declared-closure': 'self_declared_closure', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
|
|
1727
|
+
let section = raw[KEYS[engine] ?? engine] ?? raw;
|
|
1728
|
+
if (Array.isArray(section) && section.some((s) => s?.id)) {
|
|
1729
|
+
const mine = section.filter((s) => !s.id || id.includes(s.id));
|
|
1730
|
+
if (mine.length) section = mine;
|
|
1731
|
+
}
|
|
1732
|
+
const r = ENGINES[engine](section, root, walk(root));
|
|
1733
|
+
for (const f of r.findings) console.error(\` \${f.file ? f.file + ': ' : ''}\${f.message}\`);
|
|
1734
|
+
process.exit(r.findings.length ? 1 : 0);
|
|
1735
|
+
`;
|
|
1736
|
+
var EJECT_README = `# .rungs \u2014 ejected
|
|
1737
|
+
|
|
1738
|
+
The gate engines and tables, materialised into this repo. Every gate in
|
|
1739
|
+
\`.ai/gates.toml\` now runs as a \`command\` gate pointing here, so **this repo no
|
|
1740
|
+
longer needs rungs installed** to run its checks.
|
|
1741
|
+
|
|
1742
|
+
What you gave up: engine fixes no longer arrive with a CLI version bump. These
|
|
1743
|
+
files are yours now, including their bugs.
|
|
1744
|
+
|
|
1745
|
+
What you kept: every gate, every table, and the reason each one exists \u2014 the
|
|
1746
|
+
\`why\` field travelled with the registry entry, so a gate can still explain
|
|
1747
|
+
itself to whoever finds it.
|
|
1748
|
+
|
|
1749
|
+
To go back, delete this directory and re-run \`rungs add\`.
|
|
1750
|
+
`;
|
|
1751
|
+
function setupGit(repoRoot, dryRun = false) {
|
|
1752
|
+
const attrs = join10(repoRoot, ".gitattributes");
|
|
1753
|
+
if (!existsSync8(attrs)) return { drivers: [], rerere: false };
|
|
1754
|
+
const drivers = [...new Set([...readFileSync9(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
|
|
1755
|
+
const done = [];
|
|
1756
|
+
for (const d of drivers) {
|
|
1757
|
+
const cmd2 = d === "rungs-generated" ? `node -e "process.stderr.write('refusing to text-merge a generated artifact; regenerate it instead
|
|
1758
|
+
');process.exit(1)"` : "git merge-file -L ours -L base -L theirs %A %O %B";
|
|
1759
|
+
if (!dryRun) {
|
|
1760
|
+
try {
|
|
1761
|
+
execSync4(`git config merge.${d}.name "rungs ${d.replace("rungs-", "")} driver"`, { cwd: repoRoot, stdio: "pipe" });
|
|
1762
|
+
execSync4(`git config merge.${d}.driver ${JSON.stringify(cmd2)}`, { cwd: repoRoot, stdio: "pipe" });
|
|
1763
|
+
} catch {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
done.push(d);
|
|
1768
|
+
}
|
|
1769
|
+
let rerere = false;
|
|
1770
|
+
if (!dryRun) {
|
|
1771
|
+
try {
|
|
1772
|
+
execSync4("git config rerere.enabled true", { cwd: repoRoot, stdio: "pipe" });
|
|
1773
|
+
rerere = true;
|
|
1774
|
+
} catch {
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return { drivers: done, rerere };
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/cli.ts
|
|
1781
|
+
var HERE = dirname6(fileURLToPath3(import.meta.url));
|
|
1782
|
+
var MODULES2 = join11(HERE, "..", "modules");
|
|
1783
|
+
var c = {
|
|
1784
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`,
|
|
1785
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
1786
|
+
red: (s) => `\x1B[31m${s}\x1B[0m`,
|
|
1787
|
+
yellow: (s) => `\x1B[33m${s}\x1B[0m`,
|
|
1788
|
+
green: (s) => `\x1B[32m${s}\x1B[0m`,
|
|
1789
|
+
cyan: (s) => `\x1B[36m${s}\x1B[0m`
|
|
1790
|
+
};
|
|
1791
|
+
var STATE_LABEL = {
|
|
1792
|
+
absent: c.dim("absent"),
|
|
1793
|
+
"ours-current": c.green("ours"),
|
|
1794
|
+
"ours-diverged": c.yellow("diverged"),
|
|
1795
|
+
theirs: c.cyan("theirs"),
|
|
1796
|
+
paradigm: c.yellow("paradigm"),
|
|
1797
|
+
unknown: c.red("unknown")
|
|
1798
|
+
};
|
|
1799
|
+
function cmdModules(showParams = false) {
|
|
1800
|
+
const mods = loadAllModules(MODULES2);
|
|
1801
|
+
console.log(c.bold(`
|
|
1802
|
+
${mods.length} modules
|
|
1803
|
+
`));
|
|
1804
|
+
for (const m of mods) {
|
|
1805
|
+
const deps = m.requires.length ? c.dim(` \u2190 ${m.requires.join(", ")}`) : "";
|
|
1806
|
+
console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}`);
|
|
1807
|
+
console.log(` ${" ".repeat(14)} ${c.dim(m.summary)}`);
|
|
1808
|
+
if (!showParams) continue;
|
|
1809
|
+
for (const [name, spec] of Object.entries(m.params)) {
|
|
1810
|
+
const shown = spec.default === void 0 ? c.dim("(none)") : JSON.stringify(spec.default);
|
|
1811
|
+
const notes = [
|
|
1812
|
+
spec.allowed ? `one of ${spec.allowed.map(String).join(" \xB7 ")}` : "",
|
|
1813
|
+
// Behavioural parameters never appear as {{token}}, so a reader hunting for one in a
|
|
1814
|
+
// template would conclude the parameter was dead. Say so where they meet it.
|
|
1815
|
+
spec.consumed_by ? `behavioural \u2014 changes what \`${spec.consumed_by}\` does, not a template` : "",
|
|
1816
|
+
spec.required ? "required" : ""
|
|
1817
|
+
].filter(Boolean);
|
|
1818
|
+
console.log(` ${" ".repeat(14)} ${c.cyan(`${m.name}.${name}`.padEnd(30))} ${c.dim("=")} ${shown}`);
|
|
1819
|
+
if (spec.description) console.log(` ${" ".repeat(16)} ${c.dim(firstSentence(spec.description))}`);
|
|
1820
|
+
for (const n of notes) console.log(` ${" ".repeat(16)} ${c.dim(n)}`);
|
|
1821
|
+
}
|
|
1822
|
+
if (Object.keys(m.params).length) console.log();
|
|
1823
|
+
}
|
|
1824
|
+
if (showParams) {
|
|
1825
|
+
console.log(c.dim(" Set one with `--set module.param=value` on `add` or `init`; either spelling works."));
|
|
1826
|
+
console.log(c.dim(" Resolved values are recorded in `.ai/rungs.toml`. See docs/design/parameters.md.\n"));
|
|
1827
|
+
}
|
|
1828
|
+
const issues = auditModules(mods);
|
|
1829
|
+
console.log();
|
|
1830
|
+
if (issues.length === 0) {
|
|
1831
|
+
console.log(c.green(" audit clean") + c.dim(" \u2014 every parameter accounted for, every gate has a table and a why"));
|
|
1832
|
+
} else {
|
|
1833
|
+
console.log(c.red(` ${issues.length} issue(s):`));
|
|
1834
|
+
for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} \u2014 ${i.detail}`);
|
|
1835
|
+
}
|
|
1836
|
+
console.log();
|
|
1837
|
+
return issues.length === 0 ? 0 : 1;
|
|
1838
|
+
}
|
|
1839
|
+
function cmdDoctor(target) {
|
|
1840
|
+
const root = resolve3(target);
|
|
1841
|
+
const mods = loadAllModules(MODULES2);
|
|
1842
|
+
console.log(c.bold(`
|
|
1843
|
+
rungs doctor \u2014 ${root}
|
|
1844
|
+
`));
|
|
1845
|
+
const files = scanRepo(root);
|
|
1846
|
+
const record = readRecord(root);
|
|
1847
|
+
console.log(
|
|
1848
|
+
c.dim(` scanned ${files.length} files`) + (record ? c.dim(` \xB7 installed ${Object.keys(record.modules).length} module(s)`) : c.dim(" \xB7 not a rungs repo")) + "\n"
|
|
1849
|
+
);
|
|
1850
|
+
const params = resolveParams(mods, Object.fromEntries(
|
|
1851
|
+
Object.entries(record?.modules ?? {}).flatMap(([n, e]) => e.params ? [[n, e.params]] : [])
|
|
1852
|
+
), root);
|
|
1853
|
+
const skillsDir = record?.harnesses.includes("claude") === false ? ".agents/skills" : ".claude/skills";
|
|
1854
|
+
const results = mods.map((m) => {
|
|
1855
|
+
const installed = record?.modules[m.name];
|
|
1856
|
+
return detect(m, root, files, installed ? { ...installed, skillsDir, params_all: params } : void 0);
|
|
1857
|
+
});
|
|
1858
|
+
const byState = (s) => results.filter((r) => r.state === s);
|
|
1859
|
+
for (const r of results) {
|
|
1860
|
+
const mod = mods.find((m) => m.name === r.module);
|
|
1861
|
+
const line = ` ${r.module.padEnd(14)} ${STATE_LABEL[r.state]}`;
|
|
1862
|
+
if (r.state === "absent") {
|
|
1863
|
+
console.log(c.dim(line));
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
console.log(line);
|
|
1867
|
+
if (r.ours) {
|
|
1868
|
+
const parts = [`v${r.ours.version}`, `${r.ours.current.length} current`];
|
|
1869
|
+
if (r.ours.stale.length) parts.push(c.cyan(`${r.ours.stale.length} stale`));
|
|
1870
|
+
if (r.ours.missing.length) parts.push(c.yellow(`${r.ours.missing.length} missing`));
|
|
1871
|
+
if (r.ours.kept.length) parts.push(c.dim(`${r.ours.kept.length} kept (yours from the start)`));
|
|
1872
|
+
console.log(c.dim(` ${parts.join(" \xB7 ")}`));
|
|
1873
|
+
for (const f of r.ours.diverged.slice(0, 3)) {
|
|
1874
|
+
console.log(` ${c.yellow("diverged")} ${f} ${c.dim("\u2014 yours, never overwritten")}`);
|
|
1875
|
+
}
|
|
1876
|
+
if (r.ours.diverged.length > 3) console.log(c.dim(` \u2026and ${r.ours.diverged.length - 3} more`));
|
|
1877
|
+
if (r.ours.stale.length || r.ours.missing.length) {
|
|
1878
|
+
console.log(c.dim(" run `rungs upgrade --apply`"));
|
|
1879
|
+
}
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1882
|
+
for (const p of r.matchedPaths.slice(0, 2)) {
|
|
1883
|
+
console.log(c.dim(` ${p.count}\xD7 ${p.pattern} e.g. ${p.sample[0]}`));
|
|
1884
|
+
}
|
|
1885
|
+
if (r.matchedMarkers.length) console.log(c.dim(` markers: ${r.matchedMarkers.join(", ")}`));
|
|
1886
|
+
for (const prop of r.proposals) {
|
|
1887
|
+
console.log(` ${c.cyan("proposes")} ${prop.param} = ${c.bold(prop.value)} ${c.dim(`(${prop.evidence})`)}`);
|
|
1888
|
+
}
|
|
1889
|
+
for (const a of r.adoptable) {
|
|
1890
|
+
console.log(` ${c.cyan("adoptable")} ${a.count} as ${a.kind} ${c.dim(`e.g. ${a.sample[0]}`)}`);
|
|
1891
|
+
}
|
|
1892
|
+
if (r.paradigm) {
|
|
1893
|
+
console.log(` ${c.yellow("different paradigm")}: ${r.paradigm.id} ${c.dim(`(${r.paradigm.matched[0]})`)}`);
|
|
1894
|
+
if (r.paradigm.note) console.log(c.dim(` ${firstSentence(r.paradigm.note)}`));
|
|
1895
|
+
}
|
|
1896
|
+
if (mod.threshold?.confirm) {
|
|
1897
|
+
console.log(c.yellow(` threshold: ${mod.threshold.minimum}+ ${mod.threshold.metric} \u2014 add requires confirmation`));
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const ours = byState("ours-current").length + byState("ours-diverged").length;
|
|
1901
|
+
console.log(
|
|
1902
|
+
`
|
|
1903
|
+
${ours ? `${ours} installed (${byState("ours-diverged").length} diverged) \xB7 ` : ""}${byState("theirs").length} present \xB7 ${byState("paradigm").length} different paradigm \xB7 ${byState("absent").length} absent
|
|
1904
|
+
`
|
|
1905
|
+
);
|
|
1906
|
+
console.log(c.dim(" This reports presence, never quality. It cannot tell whether an adopted"));
|
|
1907
|
+
console.log(c.dim(" system is good, complete, or working \u2014 only that files are where a"));
|
|
1908
|
+
console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
|
|
1909
|
+
const theirs = byState("theirs");
|
|
1910
|
+
console.log(c.bold(" Next\n"));
|
|
1911
|
+
if (ours) {
|
|
1912
|
+
const behind = results.some((r) => r.ours?.stale.length || r.ours?.missing.length);
|
|
1913
|
+
console.log(
|
|
1914
|
+
behind ? ` ${c.cyan("rungs upgrade --apply")} ${c.dim("\u2014 bring the stale and missing files up to date")}` : ` ${c.cyan("rungs check")} ${c.dim("\u2014 run the gates this repo already registered")}`
|
|
1915
|
+
);
|
|
1916
|
+
console.log(c.dim(` Add more with \`rungs add <module>\`; \`rungs modules\` lists the set.
|
|
1917
|
+
`));
|
|
1918
|
+
} else if (theirs.length) {
|
|
1919
|
+
const names = theirs.map((r) => r.module).slice(0, 3).join(" ");
|
|
1920
|
+
console.log(` ${c.cyan(`rungs add ${names}`)} ${c.dim("\u2014 adopt what you already built, in place")}`);
|
|
1921
|
+
console.log(c.dim(" Nothing is overwritten. Files you already have are kept and reported as"));
|
|
1922
|
+
console.log(c.dim(" yours; only what is missing gets written.\n"));
|
|
1923
|
+
} else {
|
|
1924
|
+
console.log(` ${c.cyan("rungs init . tracked")} ${c.dim("\u2014 instructions \xB7 gates \xB7 backlog \xB7 findings \xB7 adr \xB7 session")}`);
|
|
1925
|
+
console.log(c.dim(" `tracked` is the rung for more than one thing in flight. `minimal` is just"));
|
|
1926
|
+
console.log(c.dim(" the entry document; higher profiles cost more than they return until the"));
|
|
1927
|
+
console.log(c.dim(" problem they answer actually exists. `rungs modules` lists all fifteen.\n"));
|
|
1928
|
+
}
|
|
1929
|
+
return 0;
|
|
1930
|
+
}
|
|
1931
|
+
function firstSentence(s) {
|
|
1932
|
+
return s.trim().replace(/\s+/g, " ").split(/(?<=\.)\s/)[0];
|
|
1933
|
+
}
|
|
1934
|
+
function cmdAdd(names, root, dryRun, harnesses, stamp) {
|
|
1935
|
+
const mods = loadAllModules(MODULES2);
|
|
1936
|
+
const { order, missing } = resolveInstallOrder(names, mods);
|
|
1937
|
+
if (missing.length) {
|
|
1938
|
+
console.log(c.red(`
|
|
1939
|
+
unknown module(s): ${missing.join(", ")}
|
|
1940
|
+
`));
|
|
1941
|
+
return 1;
|
|
1942
|
+
}
|
|
1943
|
+
const pulled = order.filter((m) => !names.includes(m.name));
|
|
1944
|
+
const overrides = {};
|
|
1945
|
+
for (const raw of flagValues["--set"] ?? []) {
|
|
1946
|
+
const [key, ...rhs] = raw.split("=");
|
|
1947
|
+
const [modName, param] = key.split(".");
|
|
1948
|
+
if (!modName || !param || !rhs.length) {
|
|
1949
|
+
console.log(c.red(`
|
|
1950
|
+
--set expects module.param=value, got: ${raw}
|
|
1951
|
+
`));
|
|
1952
|
+
return 1;
|
|
1953
|
+
}
|
|
1954
|
+
(overrides[modName] ??= {})[param] = rhs.join("=");
|
|
1955
|
+
}
|
|
1956
|
+
const params = resolveParams(mods, overrides, root);
|
|
1957
|
+
for (const [m, vals] of Object.entries(overrides)) {
|
|
1958
|
+
for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
|
|
1959
|
+
}
|
|
1960
|
+
const skillsDir = harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
|
|
1961
|
+
console.log(c.bold(`
|
|
1962
|
+
rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
|
|
1963
|
+
`));
|
|
1964
|
+
if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(", ")}
|
|
1965
|
+
`));
|
|
1966
|
+
const installed = [];
|
|
1967
|
+
const wrote = /* @__PURE__ */ new Map();
|
|
1968
|
+
for (const mod of order) {
|
|
1969
|
+
if (mod.threshold?.confirm && !dryRun && !flags.has("--confirm-threshold")) {
|
|
1970
|
+
console.log(
|
|
1971
|
+
c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) + c.dim(" Skipped \u2014 pass --confirm-threshold to install it.\n")
|
|
1972
|
+
);
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
const actions = addModule(mod, root, params, { dryRun, skillsDir });
|
|
1976
|
+
installed.push(mod);
|
|
1977
|
+
wrote.set(mod.name, new Set(actions.filter((a) => a.disposition !== "skip-exists" && a.disposition !== "merge" && a.disposition !== "gate").map((a) => a.target)));
|
|
1978
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1979
|
+
for (const a of actions) counts.set(a.disposition, (counts.get(a.disposition) ?? 0) + 1);
|
|
1980
|
+
console.log(` ${c.bold(mod.name.padEnd(14))} ${[...counts].map(([k, v]) => `${v} ${k}`).join(" \xB7 ")}`);
|
|
1981
|
+
for (const a of actions.filter((x) => x.disposition === "skip-exists")) {
|
|
1982
|
+
console.log(c.dim(` kept ${a.target}`));
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const repoFiles = scanRepo(root);
|
|
1986
|
+
const adopted = installed.flatMap(
|
|
1987
|
+
(m) => (m.detect.adopt_as ?? []).filter((a) => a.kind === "command").flatMap((a) => adoptableGates(repoFiles, a.paths ?? [], root))
|
|
1988
|
+
);
|
|
1989
|
+
if (adopted.length) {
|
|
1990
|
+
console.log(
|
|
1991
|
+
"\n " + c.cyan(`adopting ${adopted.length} existing validator(s)`) + " as command gates" + c.dim(" \u2014 their scripts are untouched")
|
|
1992
|
+
);
|
|
1993
|
+
for (const a of adopted.slice(0, 3)) console.log(c.dim(` ${a.command}`));
|
|
1994
|
+
if (adopted.length > 3) console.log(c.dim(` \u2026and ${adopted.length - 3} more`));
|
|
1995
|
+
}
|
|
1996
|
+
const gateActions = registerGates(installed, root, dryRun, adopted);
|
|
1997
|
+
if (gateActions.length) {
|
|
1998
|
+
console.log(c.dim(`
|
|
1999
|
+
registered ${gateActions.reduce((n, a) => n + Number(a.note.split(": ")[1].split(" ")[0]), 0)} gates from ${gateActions.length} module(s)`));
|
|
2000
|
+
}
|
|
2001
|
+
if (!dryRun) {
|
|
2002
|
+
writeInstallRecord(root, order, params, harnesses, stamp, skillsDir, wrote);
|
|
2003
|
+
const entries = render(root, harnesses);
|
|
2004
|
+
writeReport(root, entries, harnesses, stamp);
|
|
2005
|
+
console.log(
|
|
2006
|
+
`
|
|
2007
|
+
rendered ${entries.filter((e) => e.target).length} file(s) \xB7 ${entries.filter((e) => e.degraded).length} degraded ` + c.dim("\u2192 .ai/render-report.md")
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
console.log();
|
|
2011
|
+
return 0;
|
|
2012
|
+
}
|
|
2013
|
+
function cmdRender(root, harnesses, stamp) {
|
|
2014
|
+
const entries = render(root, harnesses);
|
|
2015
|
+
writeReport(root, entries, harnesses, stamp);
|
|
2016
|
+
console.log(c.bold(`
|
|
2017
|
+
rungs render \u2014 ${root}
|
|
2018
|
+
`));
|
|
2019
|
+
for (const e of entries) {
|
|
2020
|
+
const lost = e.degraded ?? (e.dropped?.length ? c.dim(` (dropped ${e.dropped.join(", ")})`) : "");
|
|
2021
|
+
console.log(` ${e.rule.padEnd(24)} ${e.harness.padEnd(10)} ${e.target ?? c.yellow("not emitted")}${lost}`);
|
|
2022
|
+
}
|
|
2023
|
+
console.log(c.dim(`
|
|
2024
|
+
${entries.length} rendering(s) \u2192 .ai/render-report.md
|
|
2025
|
+
`));
|
|
2026
|
+
if (entries.length === 0) {
|
|
2027
|
+
console.log(c.yellow(" Nothing to render.") + c.dim(" This command re-emits path-scoped rules from `.ai/rules/`."));
|
|
2028
|
+
console.log(c.dim(" It does not re-substitute parameters \u2014 a changed value in `.ai/rungs.toml`"));
|
|
2029
|
+
console.log(c.dim(" does not rewrite a file that already exists.\n"));
|
|
2030
|
+
}
|
|
2031
|
+
return 0;
|
|
2032
|
+
}
|
|
2033
|
+
function cmdCheck(root, tier, stamp) {
|
|
2034
|
+
const runs = runGates(root, tier);
|
|
2035
|
+
if (!runs.length) {
|
|
2036
|
+
console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
|
|
2037
|
+
return 1;
|
|
2038
|
+
}
|
|
2039
|
+
appendLedger(root, runs, stamp);
|
|
2040
|
+
console.log(c.bold(`
|
|
2041
|
+
rungs check \u2014 ${root}${tier ? ` (${tier} tier)` : ""}
|
|
2042
|
+
`));
|
|
2043
|
+
const mark = { pass: c.green("pass"), fail: c.red("FAIL"), unimplemented: c.yellow("unimpl"), error: c.red("error") };
|
|
2044
|
+
for (const r of runs) {
|
|
2045
|
+
console.log(
|
|
2046
|
+
` ${mark[r.status]} ${r.id.padEnd(34)} ${c.dim(`${r.ms}ms`)}` + (r.examined ? c.dim(` ${r.examined} examined`) : "")
|
|
2047
|
+
);
|
|
2048
|
+
for (const f of r.findings.slice(0, 4)) {
|
|
2049
|
+
console.log(` ${c.dim(f.file ? `${f.file}: ` : "")}${f.message}`);
|
|
2050
|
+
}
|
|
2051
|
+
if (r.findings.length > 4) console.log(c.dim(` \u2026and ${r.findings.length - 4} more`));
|
|
2052
|
+
}
|
|
2053
|
+
const n = (s) => runs.filter((r) => r.status === s).length;
|
|
2054
|
+
console.log(
|
|
2055
|
+
`
|
|
2056
|
+
${c.green(`${n("pass")} pass`)} \xB7 ${c.red(`${n("fail")} fail`)} \xB7 ${c.yellow(`${n("unimplemented")} unimplemented`)} \xB7 ${n("error")} error` + c.dim(` (${runs.reduce((t, r) => t + r.ms, 0)}ms total)`)
|
|
2057
|
+
);
|
|
2058
|
+
if (n("unimplemented")) {
|
|
2059
|
+
console.log(
|
|
2060
|
+
c.yellow("\n Unimplemented gates are not passes.") + 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.")
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
const { gates } = loadRegistry(root);
|
|
2064
|
+
const q = ledgerQuestions(root, gates);
|
|
2065
|
+
if (q.neverFired.length || q.alwaysFires.length) {
|
|
2066
|
+
console.log(c.bold(`
|
|
2067
|
+
Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
|
|
2068
|
+
for (const g of q.neverFired.slice(0, 3)) {
|
|
2069
|
+
console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ""))}`);
|
|
2070
|
+
console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
|
|
2071
|
+
}
|
|
2072
|
+
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
2073
|
+
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
|
|
2074
|
+
}
|
|
2075
|
+
console.log(
|
|
2076
|
+
c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran")
|
|
2077
|
+
);
|
|
2078
|
+
console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
|
|
2079
|
+
console.log(c.dim(" directly, and CI runs, are not counted."));
|
|
2080
|
+
}
|
|
2081
|
+
console.log();
|
|
2082
|
+
return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
|
|
2083
|
+
}
|
|
2084
|
+
function cmdInit(root, profile, dryRun, harnesses, stamp) {
|
|
2085
|
+
if (readRecord(root)) {
|
|
2086
|
+
console.log(
|
|
2087
|
+
c.yellow("\n this repo is already initialised.") + c.dim(" Use `rungs add <module>` to install more, or `rungs upgrade`.\n")
|
|
2088
|
+
);
|
|
2089
|
+
return 1;
|
|
2090
|
+
}
|
|
2091
|
+
const names = PROFILES[profile];
|
|
2092
|
+
if (!names) {
|
|
2093
|
+
console.log(c.red(`
|
|
2094
|
+
unknown profile '${profile}'.`) + c.dim(` Known: ${Object.keys(PROFILES).join(", ")}
|
|
2095
|
+
`));
|
|
2096
|
+
return 1;
|
|
2097
|
+
}
|
|
2098
|
+
console.log(c.dim(`
|
|
2099
|
+
profile '${profile}' \u2014 ${names.length} modules`));
|
|
2100
|
+
return cmdAdd(names, root, dryRun, harnesses, stamp);
|
|
2101
|
+
}
|
|
2102
|
+
function cmdUpgrade(root, apply) {
|
|
2103
|
+
const record = readRecord(root);
|
|
2104
|
+
if (!record) {
|
|
2105
|
+
console.log(c.yellow("\n not a rungs repo \u2014 nothing to upgrade.\n"));
|
|
2106
|
+
return 1;
|
|
2107
|
+
}
|
|
2108
|
+
const mods = loadAllModules(MODULES2);
|
|
2109
|
+
const plan = planUpgrade(root, mods, record);
|
|
2110
|
+
console.log(c.bold(`
|
|
2111
|
+
rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
|
|
2112
|
+
`));
|
|
2113
|
+
let stale = 0;
|
|
2114
|
+
let diverged = 0;
|
|
2115
|
+
for (const item of plan) {
|
|
2116
|
+
const counts = item.files.reduce((a, f) => ({ ...a, [f.state]: (a[f.state] ?? 0) + 1 }), {});
|
|
2117
|
+
stale += (counts.stale ?? 0) + (counts.missing ?? 0);
|
|
2118
|
+
diverged += counts.diverged ?? 0;
|
|
2119
|
+
const moved = item.from === item.to ? c.dim(item.to) : `${item.from} \u2192 ${c.bold(item.to)}`;
|
|
2120
|
+
console.log(` ${item.module.padEnd(14)} ${moved} ${c.dim(Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(" \xB7 "))}`);
|
|
2121
|
+
for (const f of item.files.filter((x) => x.state === "diverged")) {
|
|
2122
|
+
console.log(` ${c.yellow("diverged")} ${f.rel} ${c.dim("\u2014 yours, left alone")}`);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
if (apply && stale) {
|
|
2126
|
+
const written = applyUpgrade(root, mods, record, plan);
|
|
2127
|
+
console.log(c.green(`
|
|
2128
|
+
updated ${written} file(s)`));
|
|
2129
|
+
}
|
|
2130
|
+
console.log(
|
|
2131
|
+
`
|
|
2132
|
+
${stale} to update \xB7 ${diverged} diverged
|
|
2133
|
+
` + c.dim(" Divergence is a decision, not an error: a file you edited is never overwritten.\n") + (apply ? "" : c.dim(" Run with --apply to write.\n"))
|
|
2134
|
+
);
|
|
2135
|
+
return 0;
|
|
2136
|
+
}
|
|
2137
|
+
function cmdEject(root, dryRun) {
|
|
2138
|
+
if (!readRecord(root)) {
|
|
2139
|
+
console.log(c.yellow("\n not a rungs repo \u2014 nothing to eject.\n"));
|
|
2140
|
+
return 1;
|
|
2141
|
+
}
|
|
2142
|
+
const result = eject(root, loadAllModules(MODULES2), dryRun);
|
|
2143
|
+
console.log(c.bold(`
|
|
2144
|
+
rungs eject \u2014 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
|
|
2145
|
+
`));
|
|
2146
|
+
for (const a of result.actions.slice(0, 6)) console.log(c.dim(` ${a}`));
|
|
2147
|
+
if (result.actions.length > 6) console.log(c.dim(` \u2026and ${result.actions.length - 6} more`));
|
|
2148
|
+
console.log(
|
|
2149
|
+
`
|
|
2150
|
+
${result.gates} declared gate(s) rewritten as commands.` + c.dim("\n This repo no longer needs rungs installed to run its checks.\n") + c.dim(" Engine fixes stop arriving with a version bump \u2014 these files are yours now.\n")
|
|
2151
|
+
);
|
|
2152
|
+
return 0;
|
|
2153
|
+
}
|
|
2154
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["--set"]);
|
|
2155
|
+
var COMMANDS = [
|
|
2156
|
+
["init [path] [profile]", "scaffold a repo \u2014 minimal \xB7 tracked \xB7 disciplined \xB7 hardened \xB7 fleet"],
|
|
2157
|
+
["doctor [path]", "detect what a repo already has, installed or not"],
|
|
2158
|
+
["add <module\u2026> [--into p]", "install modules, resolving dependencies and adopting what exists"],
|
|
2159
|
+
["check [path] [tier]", "run the registered gates and record the ledger"],
|
|
2160
|
+
["render [path]", "re-emit path-scoped rules per harness"],
|
|
2161
|
+
["upgrade [path]", "move to newer module versions, never touching what you edited"],
|
|
2162
|
+
["eject [path]", "materialise the engines; stop depending on rungs"],
|
|
2163
|
+
["setup git [path]", "install the merge drivers .gitattributes names"],
|
|
2164
|
+
["modules", "list the module set and audit the manifests"]
|
|
2165
|
+
];
|
|
2166
|
+
var FLAGS = [
|
|
2167
|
+
["--dry-run", "report what would happen, write nothing"],
|
|
2168
|
+
["--into <path>", "add: install into this repo instead of the working directory"],
|
|
2169
|
+
["--set m.param=value", "add/init: override a module parameter. Repeatable"],
|
|
2170
|
+
["--confirm-threshold", "add: install a module whose rung is above this repo"],
|
|
2171
|
+
["--apply", "upgrade: write the changes, rather than preview them"],
|
|
2172
|
+
["--fast, --full", "check: pick the gate tier, as the positional also does"],
|
|
2173
|
+
["--params", "modules: show every module parameter, its default and its allowed values"],
|
|
2174
|
+
["--copilot", "also emit Copilot instruction files"]
|
|
2175
|
+
];
|
|
2176
|
+
function renderHelp() {
|
|
2177
|
+
const pad = Math.max(...COMMANDS.map(([u]) => u.length)) + 2;
|
|
2178
|
+
const fpad = Math.max(...FLAGS.map(([f]) => f.length)) + 2;
|
|
2179
|
+
return [
|
|
2180
|
+
``,
|
|
2181
|
+
`${c.bold("rungs")} \u2014 installs and maintains a repository's agentic development system`,
|
|
2182
|
+
``,
|
|
2183
|
+
...COMMANDS.map(([u, b]) => ` ${c.bold(`rungs ${u.split(" ")[0]}`)}${u.slice(u.split(" ")[0].length).padEnd(pad - u.split(" ")[0].length)} ${c.dim(b)}`),
|
|
2184
|
+
``,
|
|
2185
|
+
...FLAGS.map(([f, b]) => ` ${c.dim(f.padEnd(fpad))} ${c.dim(b)}`),
|
|
2186
|
+
``
|
|
2187
|
+
].join("\n");
|
|
2188
|
+
}
|
|
2189
|
+
var [, , cmd, ...rest] = process.argv;
|
|
2190
|
+
var flags = /* @__PURE__ */ new Set();
|
|
2191
|
+
var args = [];
|
|
2192
|
+
var flagValues = {};
|
|
2193
|
+
var missingValue = null;
|
|
2194
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2195
|
+
const token = rest[i];
|
|
2196
|
+
if (!token.startsWith("--")) {
|
|
2197
|
+
args.push(token);
|
|
2198
|
+
continue;
|
|
2199
|
+
}
|
|
2200
|
+
const eq = token.indexOf("=");
|
|
2201
|
+
const name = eq === -1 ? token : token.slice(0, eq);
|
|
2202
|
+
if (!VALUE_FLAGS.has(name)) {
|
|
2203
|
+
flags.add(name);
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
const next = rest[i + 1];
|
|
2207
|
+
const value = eq === -1 ? next === void 0 || next.startsWith("--") ? void 0 : rest[++i] : token.slice(eq + 1);
|
|
2208
|
+
if (value === void 0) missingValue = name;
|
|
2209
|
+
else (flagValues[name] ??= []).push(value);
|
|
2210
|
+
}
|
|
2211
|
+
var strayOverride = args.find((a) => /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_]*=/.test(a));
|
|
2212
|
+
var STAMP = process.env.RUNGS_DATE ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2213
|
+
var HARNESSES = flags.has("--copilot") ? ["claude", "copilot", "agents-md"] : ["claude", "agents-md"];
|
|
2214
|
+
if (missingValue) {
|
|
2215
|
+
console.log(c.red(`
|
|
2216
|
+
${missingValue} expects a value \u2014 ${missingValue} module.param=value
|
|
2217
|
+
`));
|
|
2218
|
+
process.exit(1);
|
|
2219
|
+
}
|
|
2220
|
+
if (strayOverride) {
|
|
2221
|
+
console.log(
|
|
2222
|
+
c.red(`
|
|
2223
|
+
stray override: ${strayOverride}`) + c.dim(`
|
|
2224
|
+
Nothing claimed it, so it would be read as a path or a module name.`) + c.dim(`
|
|
2225
|
+
Did you mean: --set ${strayOverride}
|
|
2226
|
+
`)
|
|
2227
|
+
);
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
switch (cmd) {
|
|
2231
|
+
case "modules":
|
|
2232
|
+
process.exit(cmdModules(flags.has("--params")));
|
|
2233
|
+
case "doctor":
|
|
2234
|
+
process.exit(cmdDoctor(args[0] ?? process.cwd()));
|
|
2235
|
+
case "check": {
|
|
2236
|
+
const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
|
|
2237
|
+
process.exit(cmdCheck(resolve3(args[0] ?? process.cwd()), tier, STAMP));
|
|
2238
|
+
}
|
|
2239
|
+
case "init": {
|
|
2240
|
+
const profile = args[1] ?? "tracked";
|
|
2241
|
+
process.exit(cmdInit(resolve3(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
|
|
2242
|
+
}
|
|
2243
|
+
case "upgrade":
|
|
2244
|
+
process.exit(cmdUpgrade(resolve3(args[0] ?? process.cwd()), flags.has("--apply")));
|
|
2245
|
+
case "eject":
|
|
2246
|
+
process.exit(cmdEject(resolve3(args[0] ?? process.cwd()), flags.has("--dry-run")));
|
|
2247
|
+
case "setup": {
|
|
2248
|
+
const r = setupGit(resolve3(args[1] ?? process.cwd()), flags.has("--dry-run"));
|
|
2249
|
+
console.log(
|
|
2250
|
+
r.drivers.length ? `
|
|
2251
|
+
installed ${r.drivers.length} merge driver(s): ${r.drivers.join(", ")}` + (r.rerere ? c.dim(" \xB7 rerere on") : "") + c.dim("\n Declared drivers were inert until now \u2014 a fresh clone needs this once.\n") : c.dim("\n no rungs merge drivers declared in .gitattributes\n")
|
|
2252
|
+
);
|
|
2253
|
+
process.exit(0);
|
|
2254
|
+
}
|
|
2255
|
+
case "render":
|
|
2256
|
+
process.exit(cmdRender(resolve3(args[0] ?? process.cwd()), HARNESSES, STAMP));
|
|
2257
|
+
case "add": {
|
|
2258
|
+
const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
|
|
2259
|
+
const names = flags.has("--into") ? args.slice(0, -1) : args;
|
|
2260
|
+
process.exit(cmdAdd(names, resolve3(target), flags.has("--dry-run"), HARNESSES, STAMP));
|
|
2261
|
+
}
|
|
2262
|
+
default: {
|
|
2263
|
+
const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";
|
|
2264
|
+
if (!wantedHelp) console.log(c.red(`
|
|
2265
|
+
unknown command: ${cmd}`));
|
|
2266
|
+
console.log(renderHelp());
|
|
2267
|
+
process.exit(wantedHelp ? 0 : 1);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
//# sourceMappingURL=cli.js.map
|