@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.
Files changed (40) hide show
  1. package/README.md +61 -11
  2. package/dist/cli.js +1250 -129
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +13 -0
  5. package/modules/adr/gates/adr.toml +14 -2
  6. package/modules/adr/module.toml +19 -1
  7. package/modules/audit/module.toml +1 -0
  8. package/modules/backlog/gates/ids.toml +33 -0
  9. package/modules/backlog/module.toml +61 -1
  10. package/modules/ci/files/{{workflow_path}} +9 -1
  11. package/modules/ci/module.toml +2 -1
  12. package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
  13. package/modules/concurrency/module.toml +3 -1
  14. package/modules/design-sync/module.toml +2 -0
  15. package/modules/doc-authority/module.toml +4 -0
  16. package/modules/findings/module.toml +3 -0
  17. package/modules/gates/gates/structural.toml +61 -17
  18. package/modules/gates/module.toml +5 -0
  19. package/modules/instructions/module.toml +4 -0
  20. package/modules/release/gates/release.toml +72 -4
  21. package/modules/release/module.toml +16 -1
  22. package/modules/release/skills/cut-release/SKILL.md +8 -1
  23. package/modules/session/module.toml +4 -2
  24. package/modules/skills/module.toml +3 -0
  25. package/modules/specs/module.toml +4 -0
  26. package/modules/workflows/module.toml +2 -0
  27. package/package.json +1 -1
  28. package/src/add.ts +64 -2
  29. package/src/backlog.ts +197 -0
  30. package/src/check.ts +56 -6
  31. package/src/cli.ts +406 -27
  32. package/src/concurrency.ts +412 -0
  33. package/src/engines.ts +261 -13
  34. package/src/engines2.ts +89 -4
  35. package/src/engines3.ts +147 -0
  36. package/src/explain.ts +189 -0
  37. package/src/lifecycle.ts +90 -3
  38. package/src/manifest.ts +13 -1
  39. package/src/selftest.ts +237 -0
  40. 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, every gate has a table and a why'));
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 order) {
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
- const runs = runGates(root, tier);
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
- console.log(c.yellow('\n no gates registered is this a rungs repo?\n'));
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 || q.alwaysFires.length) {
340
- console.log(c.bold(`\n Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
341
- for (const g of q.neverFired.slice(0, 3)) {
342
- console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ''))}`);
343
- console.log(c.dim(' Is that still a risk here, or is the gate scoped too narrowly?'));
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
- console.log();
355
- return n('fail') + n('unimplemented') + n('error') > 0 ? 1 : 0;
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
- if (apply && stale) {
399
- const written = applyUpgrade(root, mods, record, plan);
400
- console.log(c.green(`\n updated ${written} file(s)`));
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;