@rungs/cli 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -11
- package/dist/cli.js +1250 -129
- package/dist/cli.js.map +4 -4
- package/modules/README.md +13 -0
- package/modules/adr/gates/adr.toml +14 -2
- package/modules/adr/module.toml +19 -1
- package/modules/audit/module.toml +1 -0
- package/modules/backlog/gates/ids.toml +33 -0
- package/modules/backlog/module.toml +61 -1
- package/modules/ci/files/{{workflow_path}} +9 -1
- package/modules/ci/module.toml +2 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
- package/modules/concurrency/module.toml +3 -1
- package/modules/design-sync/module.toml +2 -0
- package/modules/doc-authority/module.toml +4 -0
- package/modules/findings/module.toml +3 -0
- package/modules/gates/gates/structural.toml +61 -17
- package/modules/gates/module.toml +5 -0
- package/modules/instructions/module.toml +4 -0
- package/modules/release/gates/release.toml +72 -4
- package/modules/release/module.toml +16 -1
- package/modules/release/skills/cut-release/SKILL.md +8 -1
- package/modules/session/module.toml +4 -2
- package/modules/skills/module.toml +3 -0
- package/modules/specs/module.toml +4 -0
- package/modules/workflows/module.toml +2 -0
- package/package.json +1 -1
- package/src/add.ts +64 -2
- package/src/backlog.ts +197 -0
- package/src/check.ts +56 -6
- package/src/cli.ts +406 -27
- package/src/concurrency.ts +412 -0
- package/src/engines.ts +261 -13
- package/src/engines2.ts +89 -4
- package/src/engines3.ts +147 -0
- package/src/explain.ts +189 -0
- package/src/lifecycle.ts +90 -3
- package/src/manifest.ts +13 -1
- package/src/selftest.ts +237 -0
- package/src/types.ts +34 -0
package/src/cli.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { auditModules, loadAllModules } from './manifest.ts';
|
|
5
5
|
import { detect, scanRepo } from './detect.ts';
|
|
6
|
-
import { addModule, adoptableGates, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
|
|
6
|
+
import { addModule, adoptableGates, blockedByParadigm, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
|
|
7
7
|
import { render, writeReport, type Harness } from './render.ts';
|
|
8
8
|
import { resolveParams } from './substitute.ts';
|
|
9
|
-
import { appendLedger, ledgerQuestions, loadRegistry, runGates } from './check.ts';
|
|
9
|
+
import { appendLedger, type GateRun, ledgerQuestions, loadRegistry, runGates, UnknownTierError } from './check.ts';
|
|
10
10
|
import { applyUpgrade, eject, planUpgrade, PROFILES, readRecord, setupGit } from './lifecycle.ts';
|
|
11
|
+
import { explain, IN_SCOPE as EXPLAINABLE } from './explain.ts';
|
|
12
|
+
import { applyArchive, planArchive } from './backlog.ts';
|
|
13
|
+
import { land, preflight, sessionStart, worktrees } from './concurrency.ts';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
11
15
|
import type { DetectResult, Manifest } from './types.ts';
|
|
12
16
|
|
|
13
17
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -65,7 +69,7 @@ function cmdModules(showParams = false) {
|
|
|
65
69
|
const issues = auditModules(mods);
|
|
66
70
|
console.log();
|
|
67
71
|
if (issues.length === 0) {
|
|
68
|
-
console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for
|
|
72
|
+
console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for; every gate has a table, a why, and a declared applicability'));
|
|
69
73
|
} else {
|
|
70
74
|
console.log(c.red(` ${issues.length} issue(s):`));
|
|
71
75
|
for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} — ${i.detail}`);
|
|
@@ -74,7 +78,7 @@ function cmdModules(showParams = false) {
|
|
|
74
78
|
return issues.length === 0 ? 0 : 1;
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
function cmdDoctor(target: string) {
|
|
81
|
+
function cmdDoctor(target: string, doExplain = false) {
|
|
78
82
|
const root = resolve(target);
|
|
79
83
|
const mods = loadAllModules(MODULES);
|
|
80
84
|
console.log(c.bold(`\nrungs doctor — ${root}\n`));
|
|
@@ -153,6 +157,11 @@ function cmdDoctor(target: string) {
|
|
|
153
157
|
console.log(c.dim(' system is good, complete, or working — only that files are where a'));
|
|
154
158
|
console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
|
|
155
159
|
|
|
160
|
+
reportLedger(root);
|
|
161
|
+
|
|
162
|
+
if (doExplain) reportExplain(mods, results, root, files);
|
|
163
|
+
else advertiseAnalysis(results);
|
|
164
|
+
|
|
156
165
|
// `doctor` is the command the README makes the entry point, and it used to stop on the sentence
|
|
157
166
|
// above — fifteen `absent` lines and nothing to do next. The recommendation is deliberately a
|
|
158
167
|
// **single** command, and never the maximal one: the brief names selling rung 5 to a rung-1 repo
|
|
@@ -186,6 +195,101 @@ function firstSentence(s: string): string {
|
|
|
186
195
|
return s.trim().replace(/\s+/g, ' ').split(/(?<=\.)\s/)[0];
|
|
187
196
|
}
|
|
188
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Say that the analysis exists, and how much of it there is. Never what it
|
|
200
|
+
* found (WI-049).
|
|
201
|
+
*
|
|
202
|
+
* `--explain` is the capability both external reviews called the strongest
|
|
203
|
+
* thing here, and plain `doctor` printed no occurrence of the string `explain`
|
|
204
|
+
* — it was reachable only from `--help`. WI-038 put the *findings* behind a flag
|
|
205
|
+
* for a measured reason: 114 on `hexguard` would bury the `Next` line that
|
|
206
|
+
* WI-005 exists to protect. The flag was never the problem; the silence was.
|
|
207
|
+
*
|
|
208
|
+
* **It reports scope, not findings, and it runs no engine.** The first version
|
|
209
|
+
* printed a finding count, which meant running the detectors on the plain path.
|
|
210
|
+
* Measured on `rift-forge` 2026-08-16: plain `doctor` went from **1.6s to
|
|
211
|
+
* 16.8s** warm — a 10× tax on the entry point to advertise a flag. WI-049's
|
|
212
|
+
* plan named this outcome in advance and named this fallback.
|
|
213
|
+
*
|
|
214
|
+
* So the number is the one detection already computed. It claims what it can
|
|
215
|
+
* prove: these are things the repo has, and our checks can read them. It does
|
|
216
|
+
* not claim anything was found, because finding out costs the 15 seconds.
|
|
217
|
+
*/
|
|
218
|
+
function advertiseAnalysis(results: DetectResult[]) {
|
|
219
|
+
const inScope = results.filter((r) => EXPLAINABLE.has(r.state)).length;
|
|
220
|
+
if (!inScope) return;
|
|
221
|
+
|
|
222
|
+
console.log(c.bold(' Analysis\n'));
|
|
223
|
+
console.log(` ${inScope} of these are things this repo already has, and can be checked against it.`);
|
|
224
|
+
console.log(` ${c.cyan('rungs doctor --explain')} ${c.dim('— evidenced findings, and the incident behind each check')}\n`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The defect half of `doctor` (WI-038). Every line carries a path and a count
|
|
229
|
+
* or a quote; there is no score, grade, bar, or maturity label anywhere, and
|
|
230
|
+
* there is not going to be — ADR-0005 tier C refuses composites permanently,
|
|
231
|
+
* and a single word over incommensurable signals is the purest form of the
|
|
232
|
+
* probe-encoding-a-guess the corpus warns about.
|
|
233
|
+
*
|
|
234
|
+
* The incident is attached to each detector rather than to each finding: it is
|
|
235
|
+
* why the check exists, not what was found, and repeating it per row would bury
|
|
236
|
+
* the evidence under the provenance.
|
|
237
|
+
*/
|
|
238
|
+
function reportExplain(mods: Manifest[], results: DetectResult[], root: string, files: string[]) {
|
|
239
|
+
const { reported, skipped, scope } = explain(mods, results, root, files);
|
|
240
|
+
|
|
241
|
+
console.log(c.bold(' What it also checked\n'));
|
|
242
|
+
|
|
243
|
+
if (!scope.length) {
|
|
244
|
+
console.log(c.dim(' Nothing — detectors run only over what this repo already has, and'));
|
|
245
|
+
console.log(c.dim(' detection found no equivalent of any module. There is nothing here to'));
|
|
246
|
+
console.log(c.dim(' check that would not be checking our conventions against your repo.\n'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const total = reported.reduce((n, r) => n + r.findings.length, 0);
|
|
251
|
+
console.log(
|
|
252
|
+
c.dim(` ran the detectors for ${scope.length} module(s) this repo already has: `) + c.dim(scope.join(' ')) + '\n',
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
for (const r of reported) {
|
|
256
|
+
const n = r.findings.length;
|
|
257
|
+
console.log(` ${c.yellow(r.gate.padEnd(34))} ${c.bold(String(n))} ${n === 1 ? 'finding' : 'findings'}`);
|
|
258
|
+
for (const f of r.findings.slice(0, 4)) {
|
|
259
|
+
console.log(c.dim(` ${f.file ? `${f.file}: ` : ''}${f.message}`));
|
|
260
|
+
}
|
|
261
|
+
if (n > 4) console.log(c.dim(` …and ${n - 4} more`));
|
|
262
|
+
if (r.why) console.log(c.dim(` why: ${firstSentence(r.why)}`));
|
|
263
|
+
console.log();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!total) {
|
|
267
|
+
console.log(c.dim(' No detector fired. That is not a clean bill of health — see below.\n'));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Pins. ADR-0005's rule that green must never read as verified applies with
|
|
271
|
+
// more force here than in the ledger: this pass runs our checks over content
|
|
272
|
+
// written to somebody else's conventions, and the honest failure mode is a
|
|
273
|
+
// sound finding in a frame the repo never adopted.
|
|
274
|
+
console.log(c.dim(' This is not an audit, and it is deliberately incomplete:'));
|
|
275
|
+
console.log(c.dim(' · Detectors ran only for modules this repo already has an equivalent of.'));
|
|
276
|
+
console.log(c.dim(" · They read rungs-shaped inputs. A finding may be true and framed against"));
|
|
277
|
+
console.log(c.dim(' a convention you never adopted — that is our defect, not yours.'));
|
|
278
|
+
if (skipped.command) {
|
|
279
|
+
console.log(c.dim(` · ${skipped.command} command gate(s) not run. rungs does not execute commands in a repo it is only reading.`));
|
|
280
|
+
}
|
|
281
|
+
if (skipped.undeclared.length) {
|
|
282
|
+
console.log(c.dim(` · ${skipped.undeclared.length} gate(s) never said whether they can read a repo like yours, so they did not: ${skipped.undeclared.join(' ')}`));
|
|
283
|
+
}
|
|
284
|
+
if (skipped.unimplemented.length) {
|
|
285
|
+
console.log(c.dim(` · ${skipped.unimplemented.length} declared gate(s) have no engine and were skipped, never passed: ${skipped.unimplemented.join(' ')}`));
|
|
286
|
+
}
|
|
287
|
+
for (const e of skipped.errored) {
|
|
288
|
+
console.log(c.dim(` · ${e.gate} could not run here (${e.message}) — a fact about this pass, not about your repo.`));
|
|
289
|
+
}
|
|
290
|
+
console.log();
|
|
291
|
+
}
|
|
292
|
+
|
|
189
293
|
function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
190
294
|
const mods = loadAllModules(MODULES);
|
|
191
295
|
const { order, missing } = resolveInstallOrder(names, mods);
|
|
@@ -213,6 +317,34 @@ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harne
|
|
|
213
317
|
}
|
|
214
318
|
(overrides[modName] ??= {})[param] = rhs.join('=');
|
|
215
319
|
}
|
|
320
|
+
|
|
321
|
+
// …and an unknown *name* is refused for the same reason a malformed key is.
|
|
322
|
+
// The comment above says a dropped `--set` "proceeded with the default and
|
|
323
|
+
// looked successful"; a mistyped module or parameter did exactly that, and the
|
|
324
|
+
// echo below then printed `set nosuch.param = 1` as though it had applied
|
|
325
|
+
// (F-028). The whole module set is loaded here, so the names are checkable —
|
|
326
|
+
// there was never a reason to trust them.
|
|
327
|
+
for (const [modName, vals] of Object.entries(overrides)) {
|
|
328
|
+
const mod = mods.find((m) => m.name === modName);
|
|
329
|
+
if (!mod) {
|
|
330
|
+
console.log(
|
|
331
|
+
c.red(`\n --set names a module that does not exist: ${modName}`) +
|
|
332
|
+
c.dim(`\n Known: ${mods.map((m) => m.name).join(', ')}\n`),
|
|
333
|
+
);
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
for (const k of Object.keys(vals)) {
|
|
337
|
+
if (!(k in mod.params)) {
|
|
338
|
+
const known = Object.keys(mod.params);
|
|
339
|
+
console.log(
|
|
340
|
+
c.red(`\n --set names a parameter ${modName} does not have: ${k}`) +
|
|
341
|
+
c.dim(`\n ${known.length ? `${modName} takes: ${known.join(', ')}` : `${modName} takes no parameters`}`) +
|
|
342
|
+
c.dim('\n `rungs modules --params` lists every parameter and its default.\n'),
|
|
343
|
+
);
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
216
348
|
const params = resolveParams(mods, overrides, root);
|
|
217
349
|
for (const [m, vals] of Object.entries(overrides)) {
|
|
218
350
|
for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
|
|
@@ -222,9 +354,74 @@ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harne
|
|
|
222
354
|
console.log(c.bold(`\nrungs add ${names.join(' ')} → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
223
355
|
if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(', ')}\n`));
|
|
224
356
|
|
|
357
|
+
// ADR-0004 state 5: a repo that solves this module's problem a different way
|
|
358
|
+
// gets the comparison and a stop, not an install beside what it already runs.
|
|
359
|
+
//
|
|
360
|
+
// The state existed in the ADR and in `doctor` and nowhere else, so `add`
|
|
361
|
+
// wrote straight over it — for every paradigm, since the CLI shipped
|
|
362
|
+
// (WI-043, from F-014). Measured 2026-08-16: a repo with `.github/ISSUE_TEMPLATE/`
|
|
363
|
+
// reported `backlog paradigm · external-tracker`, and `add backlog` then wrote
|
|
364
|
+
// `docs/`, `AGENTS.md`, `.ai/` and 12 gates without mentioning it once.
|
|
365
|
+
//
|
|
366
|
+
// Unlike `--confirm-threshold` above, this refusal **also applies under
|
|
367
|
+
// `--dry-run`**. A preview that installs what the real run refuses is a
|
|
368
|
+
// preview of a different command.
|
|
369
|
+
const scanned = scanRepo(root);
|
|
370
|
+
const paradigms = new Set(
|
|
371
|
+
order.map((m) => detect(m, root, scanned)).filter((r) => r.state === 'paradigm').map((r) => r.module),
|
|
372
|
+
);
|
|
373
|
+
const overridden = flags.has('--confirm-paradigm');
|
|
374
|
+
const blocked = overridden ? new Map<string, string>() : blockedByParadigm(order, paradigms);
|
|
375
|
+
|
|
376
|
+
// An override that prints nothing is indistinguishable from a detection that
|
|
377
|
+
// found nothing, and the two want opposite follow-ups.
|
|
378
|
+
if (overridden && paradigms.size) {
|
|
379
|
+
for (const name of paradigms) {
|
|
380
|
+
const p = detect(order.find((m) => m.name === name)!, root, scanned).paradigm!;
|
|
381
|
+
console.log(
|
|
382
|
+
c.yellow(` ${name}: installing over an existing ${p.id}`) +
|
|
383
|
+
c.dim(` (${p.matched[0]}) — --confirm-paradigm`),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
console.log(c.dim(' You will have two systems for one job. That is a choice, not a merge.\n'));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Re-resolve from what survives rather than filtering `order` in place. A
|
|
390
|
+
// dependency is only ever pulled in *for* something; `add backlog` on an
|
|
391
|
+
// issue-tracker repo was still writing `instructions` and `gates`, which
|
|
392
|
+
// nobody asked for and which were pulled in solely for the module being
|
|
393
|
+
// refused. Recomputing the closure drops them, and keeps anything a *surviving*
|
|
394
|
+
// request still needs.
|
|
395
|
+
let toInstall = order;
|
|
396
|
+
if (blocked.size) {
|
|
397
|
+
for (const mod of order) {
|
|
398
|
+
const cause = blocked.get(mod.name);
|
|
399
|
+
if (!cause) continue;
|
|
400
|
+
if (cause === mod.name) {
|
|
401
|
+
const p = detect(mod, root, scanned).paradigm!;
|
|
402
|
+
console.log(c.yellow(` ${mod.name}: this repo already does this another way — ${p.id}`));
|
|
403
|
+
console.log(c.dim(` matched ${p.matched[0]}`));
|
|
404
|
+
for (const line of (p.note ?? '').trim().split('\n')) console.log(c.dim(` ${line || ''}`));
|
|
405
|
+
if (p.compare) console.log(c.dim(` compare: ${p.compare}`));
|
|
406
|
+
} else {
|
|
407
|
+
console.log(c.yellow(` ${mod.name}: not installed — it requires ${cause}.`));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
|
|
411
|
+
const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
|
|
412
|
+
if (dropped.length) {
|
|
413
|
+
console.log(c.dim(` ${dropped.map((m) => m.name).join(', ')} not written — pulled in only for the above`));
|
|
414
|
+
}
|
|
415
|
+
console.log(
|
|
416
|
+
c.dim(`\n Pass --confirm-paradigm to install anyway.`) +
|
|
417
|
+
(toInstall.length ? c.dim(' Continuing with the rest.\n') : c.dim(' Nothing was written.\n')),
|
|
418
|
+
);
|
|
419
|
+
if (!toInstall.length) return 1;
|
|
420
|
+
}
|
|
421
|
+
|
|
225
422
|
const installed: Manifest[] = [];
|
|
226
423
|
const wrote = new Map<string, Set<string>>();
|
|
227
|
-
for (const mod of
|
|
424
|
+
for (const mod of toInstall) {
|
|
228
425
|
if (mod.threshold?.confirm && !dryRun && !flags.has('--confirm-threshold')) {
|
|
229
426
|
console.log(
|
|
230
427
|
c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) +
|
|
@@ -299,10 +496,86 @@ function cmdRender(root: string, harnesses: Harness[], stamp: string) {
|
|
|
299
496
|
return 0;
|
|
300
497
|
}
|
|
301
498
|
|
|
499
|
+
/** The loop commands return lines and a verdict; printing them is the CLI's job. */
|
|
500
|
+
function report(r: { ok: boolean; lines: string[] }): number {
|
|
501
|
+
console.log();
|
|
502
|
+
for (const l of r.lines) console.log(` ${r.ok ? l : c.yellow(l)}`);
|
|
503
|
+
console.log();
|
|
504
|
+
return r.ok ? 0 : 1;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* `land` verifies the *merged* tree, so it needs the gate runner pointed at a
|
|
509
|
+
* directory that exists only inside the command. This is the reason the loop is
|
|
510
|
+
* CLI commands rather than scripts the module writes (ADR-0009).
|
|
511
|
+
*/
|
|
512
|
+
function landRunner(dir: string, only?: ReadonlySet<string>) {
|
|
513
|
+
const runs = runGates(dir, undefined, undefined, only);
|
|
514
|
+
const failing = runs.filter((r) => r.status === 'fail' || r.status === 'error');
|
|
515
|
+
return {
|
|
516
|
+
pass: runs.filter((r) => r.status === 'pass').length,
|
|
517
|
+
// `file: message`, so the same broken link in the same file is the same
|
|
518
|
+
// finding across two runs, and a *new* one is visibly not.
|
|
519
|
+
failing: failing.map((r) => ({
|
|
520
|
+
id: r.id,
|
|
521
|
+
findings: r.findings.map((f) => `${f.file ? `${f.file}: ` : ''}${f.message}`),
|
|
522
|
+
})),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function cmdWorktrees(root: string) {
|
|
527
|
+
const { rows, integration } = worktrees(root);
|
|
528
|
+
console.log(c.bold(`\nrungs worktrees — merged into ${integration}?\n`));
|
|
529
|
+
if (!rows.length) {
|
|
530
|
+
console.log(c.dim(' no linked worktrees. `rungs session start <branch>` creates one.\n'));
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
for (const w of rows) {
|
|
534
|
+
const state = w.merged && w.dirty ? c.red('merged · DIRTY') : w.merged ? c.green('merged · prunable') : c.dim('in flight');
|
|
535
|
+
console.log(` ${state.padEnd(28)} ${w.branch.padEnd(30)} ${c.dim(w.path)}`);
|
|
536
|
+
}
|
|
537
|
+
const risky = rows.filter((w) => w.merged && w.dirty);
|
|
538
|
+
const prunable = rows.filter((w) => w.merged && !w.dirty);
|
|
539
|
+
console.log();
|
|
540
|
+
if (risky.length) {
|
|
541
|
+
console.log(c.red(` ${risky.length} worktree(s) hold uncommitted work on a branch that already landed.`));
|
|
542
|
+
console.log(c.dim(' That is where work actually gets lost. Commit it somewhere or decide to drop it.'));
|
|
543
|
+
}
|
|
544
|
+
if (prunable.length) console.log(c.dim(` ${prunable.length} prunable. Removing a worktree is your call, not this command's.`));
|
|
545
|
+
console.log();
|
|
546
|
+
return 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
302
549
|
function cmdCheck(root: string, tier: string | undefined, stamp: string) {
|
|
303
|
-
|
|
550
|
+
let runs: GateRun[];
|
|
551
|
+
try {
|
|
552
|
+
runs = runGates(root, tier);
|
|
553
|
+
} catch (e) {
|
|
554
|
+
// ADR-0008. A tier nobody declared used to select nothing and exit as though
|
|
555
|
+
// the gates had passed — the one failure mode a release step cannot have.
|
|
556
|
+
if (!(e instanceof UnknownTierError)) throw e;
|
|
557
|
+
console.log(c.yellow(`\n unknown tier "${e.requested}"`) + c.dim(` — this repo declares ${e.declared.join(', ')}.`));
|
|
558
|
+
console.log(c.dim(' Nothing ran. Use `rungs check` to run every registered gate.\n'));
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
304
561
|
if (!runs.length) {
|
|
305
|
-
|
|
562
|
+
// Two situations printed the same sentence, and it was the wrong one for the case that
|
|
563
|
+
// actually happens: a registry full of `fast` gates filtered by `--full` asked "is this a
|
|
564
|
+
// rungs repo?" about a repo holding 25 of them, and `cut-release` told every consumer to
|
|
565
|
+
// gate a release on exactly that command (F-020). Blame the filter when there is one.
|
|
566
|
+
//
|
|
567
|
+
// Hooks are excluded because a hook fires on a tool call rather than in the runner: it is
|
|
568
|
+
// registered, and no tier value could ever have selected it. Counting it here would offer
|
|
569
|
+
// the reader a gate that changing the tier cannot reach.
|
|
570
|
+
const runnable = loadRegistry(root).gates.filter((g) => !g.trigger);
|
|
571
|
+
if (runnable.length && tier) {
|
|
572
|
+
const tiers = [...new Set(runnable.map((g) => g.tier).filter(Boolean))];
|
|
573
|
+
console.log(c.yellow(`\n no gates in the ${tier} tier — ${runnable.length} are registered`) +
|
|
574
|
+
c.dim(` (${tiers.length ? tiers.join(', ') : 'none tiered'}).`));
|
|
575
|
+
console.log(c.dim(' Nothing ran. Use `rungs check` to run every registered gate.\n'));
|
|
576
|
+
} else {
|
|
577
|
+
console.log(c.yellow('\n no gates registered — is this a rungs repo?\n'));
|
|
578
|
+
}
|
|
306
579
|
return 1;
|
|
307
580
|
}
|
|
308
581
|
appendLedger(root, runs, stamp);
|
|
@@ -334,25 +607,82 @@ function cmdCheck(root: string, tier: string | undefined, stamp: string) {
|
|
|
334
607
|
);
|
|
335
608
|
}
|
|
336
609
|
|
|
610
|
+
console.log();
|
|
611
|
+
return n('fail') + n('unimplemented') + n('error') > 0 ? 1 : 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* ADR-0005 tier B: the two questions the ledger can ask without judgement.
|
|
616
|
+
*
|
|
617
|
+
* This printed from `check` and belonged in `doctor`, which is what both the
|
|
618
|
+
* ADR and the README say (F-012). The ADR does not merely name the command, it
|
|
619
|
+
* gives the reason: *"They must be pull (`doctor`), never push; no output
|
|
620
|
+
* during normal runs."* `check` is the normal run — it is what CI and every
|
|
621
|
+
* pre-merge habit invoke — so printing there was the push the tier was written
|
|
622
|
+
* to forbid, arriving inside the feature that forbade it.
|
|
623
|
+
*/
|
|
624
|
+
function reportLedger(root: string) {
|
|
337
625
|
const { gates } = loadRegistry(root);
|
|
338
626
|
const q = ledgerQuestions(root, gates);
|
|
339
|
-
if (q.neverFired.length
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
346
|
-
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
|
|
347
|
-
}
|
|
348
|
-
console.log(
|
|
349
|
-
c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'),
|
|
350
|
-
);
|
|
351
|
-
console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
|
|
352
|
-
console.log(c.dim(' directly, and CI runs, are not counted.'));
|
|
627
|
+
if (!q.neverFired.length && !q.alwaysFires.length) return;
|
|
628
|
+
|
|
629
|
+
console.log(c.bold(` Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
|
|
630
|
+
for (const g of q.neverFired.slice(0, 3)) {
|
|
631
|
+
console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ''))}`);
|
|
632
|
+
console.log(c.dim(' Is that still a risk here, or is the gate scoped too narrowly?'));
|
|
353
633
|
}
|
|
354
|
-
|
|
355
|
-
|
|
634
|
+
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
635
|
+
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
|
|
636
|
+
}
|
|
637
|
+
console.log(c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'));
|
|
638
|
+
console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
|
|
639
|
+
console.log(c.dim(' directly, and CI runs, are not counted.\n'));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function cmdBacklogArchive(root: string, dryRun: boolean) {
|
|
643
|
+
const record = readRecord(root);
|
|
644
|
+
const configured = record?.modules['backlog']?.params?.root;
|
|
645
|
+
const backlogRoot = `docs/${configured ?? 'backlog'}`;
|
|
646
|
+
|
|
647
|
+
if (!existsSync(join(root, ...backlogRoot.split('/'), 'items'))) {
|
|
648
|
+
console.log(c.red(`\n no backlog at ${backlogRoot}/items\n`));
|
|
649
|
+
return 1;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const plan = planArchive(root, backlogRoot);
|
|
653
|
+
console.log(c.bold(`\nrungs backlog archive → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
654
|
+
|
|
655
|
+
for (const h of plan.held) console.log(c.yellow(` held ${h.file}`) + c.dim(` — ${h.reason}`));
|
|
656
|
+
if (plan.held.length) console.log();
|
|
657
|
+
|
|
658
|
+
if (!plan.moves.length) {
|
|
659
|
+
console.log(c.dim(' nothing to archive — no item is done or rejected.\n'));
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const byStatus = new Map<string, number>();
|
|
664
|
+
for (const m of plan.moves) byStatus.set(m.status, (byStatus.get(m.status) ?? 0) + 1);
|
|
665
|
+
console.log(
|
|
666
|
+
` ${c.bold(String(plan.moves.length))} item(s) — ${[...byStatus].map(([s, n]) => `${n} ${s}`).join(' · ')}`,
|
|
667
|
+
);
|
|
668
|
+
for (const m of plan.moves.slice(0, 5)) console.log(c.dim(` ${m.from} → ${m.to}`));
|
|
669
|
+
if (plan.moves.length > 5) console.log(c.dim(` …and ${plan.moves.length - 5} more`));
|
|
670
|
+
|
|
671
|
+
const touched = plan.rewrites.filter((r) => r.links);
|
|
672
|
+
const links = touched.reduce((n, r) => n + r.links, 0);
|
|
673
|
+
console.log(`\n ${c.bold(String(links))} link(s) repointed across ${touched.length} file(s)`);
|
|
674
|
+
for (const r of touched.slice(0, 5)) console.log(c.dim(` ${r.file} (${r.links})`));
|
|
675
|
+
if (touched.length > 5) console.log(c.dim(` …and ${touched.length - 5} more`));
|
|
676
|
+
|
|
677
|
+
if (dryRun) {
|
|
678
|
+
console.log(c.dim('\n Nothing written. Drop --dry-run to apply.\n'));
|
|
679
|
+
return 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
applyArchive(root, plan);
|
|
683
|
+
console.log(c.green(`\n archived ${plan.moves.length} item(s)`) + c.dim(' — ids stay spent and every citation still resolves.'));
|
|
684
|
+
console.log(c.dim(' Run `rungs check` to confirm.\n'));
|
|
685
|
+
return 0;
|
|
356
686
|
}
|
|
357
687
|
|
|
358
688
|
function cmdInit(root: string, profile: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
@@ -395,9 +725,18 @@ function cmdUpgrade(root: string, apply: boolean) {
|
|
|
395
725
|
}
|
|
396
726
|
}
|
|
397
727
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
728
|
+
// Not `apply && stale`. A module version that only adds a gate has no stale
|
|
729
|
+
// file, so the whole apply step was skipped and the registry silently kept the
|
|
730
|
+
// old block — F-016, measured on a scratch consumer where `session` 1.1.0 →
|
|
731
|
+
// 1.2.0 added a gate and `rungs check` went on running the previous twenty.
|
|
732
|
+
if (apply) {
|
|
733
|
+
const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
|
|
734
|
+
const parts = [
|
|
735
|
+
written ? `${written} file(s)` : '',
|
|
736
|
+
gates ? `${gates} gate registration(s)` : '',
|
|
737
|
+
recorded ? `${recorded} record line(s)` : '',
|
|
738
|
+
].filter(Boolean);
|
|
739
|
+
console.log(c.green(`\n updated ${parts.length ? parts.join(' · ') : 'nothing'}`));
|
|
401
740
|
}
|
|
402
741
|
console.log(
|
|
403
742
|
`\n ${stale} to update · ${diverged} diverged\n` +
|
|
@@ -457,11 +796,18 @@ const COMMANDS: [usage: string, blurb: string][] = [
|
|
|
457
796
|
['eject [path]', 'materialise the engines; stop depending on rungs'],
|
|
458
797
|
['setup git [path]', 'install the merge drivers .gitattributes names'],
|
|
459
798
|
['modules', 'list the module set and audit the manifests'],
|
|
799
|
+
['backlog archive [path]', 'move finished items to archive/, repointing every link'],
|
|
800
|
+
['session start <branch>', 'cut a branch and worktree from the last verified merge'],
|
|
801
|
+
['preflight [path]', 'did the integration branch change files you changed?'],
|
|
802
|
+
['land <branch>', 'merge → verify the merged tree → advance, or refuse and park it'],
|
|
803
|
+
['worktrees [path]', 'which worktrees are merged, prunable, or merged and still dirty'],
|
|
460
804
|
];
|
|
461
805
|
|
|
462
806
|
/** Every flag the parser honours. A flag absent here is a flag nobody can find. */
|
|
463
807
|
const FLAGS: [flag: string, blurb: string][] = [
|
|
464
808
|
['--dry-run', 'report what would happen, write nothing'],
|
|
809
|
+
['--explain', "doctor: also run the detectors over what this repo already has"],
|
|
810
|
+
['--confirm-paradigm', 'add: install a module this repo already solves another way'],
|
|
465
811
|
['--into <path>', 'add: install into this repo instead of the working directory'],
|
|
466
812
|
['--set m.param=value', 'add/init: override a module parameter. Repeatable'],
|
|
467
813
|
['--confirm-threshold', 'add: install a module whose rung is above this repo'],
|
|
@@ -547,7 +893,14 @@ switch (cmd) {
|
|
|
547
893
|
case 'modules':
|
|
548
894
|
process.exit(cmdModules(flags.has('--params')));
|
|
549
895
|
case 'doctor':
|
|
550
|
-
process.exit(cmdDoctor(args[0] ?? process.cwd()));
|
|
896
|
+
process.exit(cmdDoctor(args[0] ?? process.cwd(), flags.has('--explain')));
|
|
897
|
+
case 'backlog': {
|
|
898
|
+
if (args[0] !== 'archive') {
|
|
899
|
+
console.log(c.red(`\n unknown: rungs backlog ${args[0] ?? ''}`) + c.dim('\n The only subcommand is `archive`.\n'));
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
process.exit(cmdBacklogArchive(resolve(args[1] ?? process.cwd()), flags.has('--dry-run')));
|
|
903
|
+
}
|
|
551
904
|
case 'check': {
|
|
552
905
|
const tier = args[1] ?? (flags.has('--full') ? 'full' : flags.has('--fast') ? 'fast' : undefined);
|
|
553
906
|
process.exit(cmdCheck(resolve(args[0] ?? process.cwd()), tier, STAMP));
|
|
@@ -561,6 +914,19 @@ switch (cmd) {
|
|
|
561
914
|
case 'eject':
|
|
562
915
|
process.exit(cmdEject(resolve(args[0] ?? process.cwd()), flags.has('--dry-run')));
|
|
563
916
|
case 'setup': {
|
|
917
|
+
// The path is `args[1]`, *after* the subcommand — so an omitted `git` put the
|
|
918
|
+
// path into the subcommand slot, where it was discarded, and `setup` then
|
|
919
|
+
// wrote git config into the current directory while reporting success about
|
|
920
|
+
// the repo you named (F-027). `backlog` had refused an unknown subcommand
|
|
921
|
+
// since it shipped; this one accepted anything and exited 0. The asymmetry
|
|
922
|
+
// between the two subcommand-taking commands was the whole bug.
|
|
923
|
+
if (args[0] !== 'git') {
|
|
924
|
+
console.log(
|
|
925
|
+
c.red(`\n unknown: rungs setup ${args[0] ?? ''}`.trimEnd()) +
|
|
926
|
+
c.dim('\n The only subcommand is `git`, and the path comes after it: `rungs setup git [path]`.\n'),
|
|
927
|
+
);
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
564
930
|
const r = setupGit(resolve(args[1] ?? process.cwd()), flags.has('--dry-run'));
|
|
565
931
|
console.log(
|
|
566
932
|
r.drivers.length
|
|
@@ -573,6 +939,19 @@ switch (cmd) {
|
|
|
573
939
|
}
|
|
574
940
|
case 'render':
|
|
575
941
|
process.exit(cmdRender(resolve(args[0] ?? process.cwd()), HARNESSES, STAMP));
|
|
942
|
+
case 'session': {
|
|
943
|
+
if (args[0] !== 'start') {
|
|
944
|
+
console.log(c.red(`\n unknown: rungs session ${args[0] ?? ''}`.trimEnd()) + c.dim('\n The only subcommand is `start`: `rungs session start <branch> [path]`.\n'));
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
947
|
+
process.exit(report(sessionStart(process.cwd(), args[1], args[2], flags.has('--dry-run'))));
|
|
948
|
+
}
|
|
949
|
+
case 'preflight':
|
|
950
|
+
process.exit(report(preflight(resolve(args[0] ?? process.cwd()))));
|
|
951
|
+
case 'land':
|
|
952
|
+
process.exit(report(land(process.cwd(), args[0], landRunner, flags.has('--dry-run'))));
|
|
953
|
+
case 'worktrees':
|
|
954
|
+
process.exit(cmdWorktrees(resolve(args[0] ?? process.cwd())));
|
|
576
955
|
case 'add': {
|
|
577
956
|
const target = flags.has('--into') ? args[args.length - 1] : process.cwd();
|
|
578
957
|
const names = flags.has('--into') ? args.slice(0, -1) : args;
|