@rungs/cli 0.2.0 → 0.3.1

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.
@@ -29,8 +29,16 @@ jobs:
29
29
 
30
30
  # Runs every gate in .ai/gates.toml — the same set, in the same order, as
31
31
  # `rungs check` locally. A CI-only gate is one nobody can reproduce.
32
+ #
33
+ # Plain `check`, no flags. This said `check --tier full --reporter github`:
34
+ # `--tier` is not a flag the CLI accepts and `--reporter` does not exist at
35
+ # all, so the two values were parsed as a *path* and a *tier* — every run
36
+ # checked a directory named `full` and exited 1 with "no gates registered —
37
+ # is this a rungs repo?". Shipped in three of five profiles, so every repo
38
+ # that installed `ci` had a job that failed on every push and blamed the
39
+ # repo for it (F-030).
32
40
  - name: Run gates
33
- run: npx @rungs/cli check --tier full --reporter github
41
+ run: npx @rungs/cli check
34
42
 
35
43
  # Add a matrix job per package here rather than a workflow per package.
36
44
  # A checklist step that creates a file creates N files:
@@ -1,6 +1,6 @@
1
1
  [module]
2
2
  name = "ci"
3
- version = "1.0.0"
3
+ version = "1.1.0"
4
4
  rung = 1
5
5
  summary = "One workflow that runs the gate registry, a matrix instead of a file per item, and a check that keeps it that way."
6
6
 
@@ -35,9 +35,15 @@ deliberate question, never as a ritual.
35
35
 
36
36
  ## A failure is attributed, never just counted
37
37
 
38
- `check` re-runs each failing gate against the merge base in a throwaway worktree and reports it as
39
- **inherited** (already red before you started — stated, never blocking) or **INTRODUCED** (yours —
40
- blocks). Anything it cannot attribute also blocks: **we do not land on an unknown.**
38
+ `land` re-runs each failing gate against the merge base in the throwaway worktree it already has,
39
+ and reports each failure as **inherited** (already red before you started — stated, never blocking)
40
+ or **INTRODUCED** (yours — blocks). Anything it cannot attribute also blocks: **we do not land on an
41
+ unknown.**
42
+
43
+ Attribution is **per finding, not per gate.** The first implementation compared gate ids, which
44
+ made an already-red gate a blind spot: a branch could add new broken links and land them as
45
+ inherited, because that gate was red either way. Measured on a scratch repo, and fixed the same
46
+ day — a gate you have not fixed does not excuse the new violations of it you bring.
41
47
 
42
48
  This is not politeness about blame. *A gate that is red for reasons you did not cause and cannot
43
49
  fix is a gate you learn to bypass, and a bypassed gate reports nothing.*
@@ -62,8 +68,8 @@ bug in whatever it is doing.
62
68
 
63
69
  ## Three things to know when a land refuses
64
70
 
65
- - **Your worktree is left detached.** Your branch and commits are safe; the files in front of you
66
- are simply not yours. Switch back and carry on. Do not "restore" anything.
71
+ - **Your worktree is untouched.** `land` does all its work in a throwaway worktree of its own, so
72
+ whatever you had checked out is still checked out. A refusal costs you nothing to recover from.
67
73
  - **Check the exit code of `land` itself.** Piping it through `tail` or `grep` reports *that*
68
74
  command's status, so a refused land reads as success.
69
75
  - **Reconcile generated artifacts by regenerating, never by merging text.** Take one side, re-run
@@ -1,6 +1,6 @@
1
1
  [module]
2
2
  name = "concurrency"
3
- version = "1.0.0"
3
+ version = "1.2.0"
4
4
  rung = 5
5
5
  summary = "Many sessions on one integration branch: a verified base to cut from, attributed failures, a land protocol that cannot redden the branch, and merge drivers per conflict class."
6
6
 
@@ -32,10 +32,21 @@ sources = [
32
32
  { file = "pyproject.toml", path = "project.version" },
33
33
  ]
34
34
  rule = "all-agree"
35
- autofix = "rungs release sync-version"
35
+
36
+ # Files that are deliberately versioned on their own. Empty by default: which packages share a
37
+ # version is a judgement about *your* repo, and the engine guessing it is how a healthy layout gets
38
+ # a red gate. rungs' own docs site is the case that found this — a private, never-published package
39
+ # sitting at its own version beside the CLI (F-023).
40
+ exclude = []
41
+
42
+ # No `autofix` key. It named a "rungs release sync-version" command that does not exist and never
43
+ # has, so the message told whoever hit this gate to run something that would fail. The engine now
44
+ # names each file beside the version it states, which is what you need to fix it by hand.
45
+ # (Deliberately not in a code span: `module-commands-exist` reads those as command claims, and it
46
+ # is right to — a phantom command in backticks is exactly what it exists to refuse.)
36
47
  message = """
37
48
  Version disagrees across {count} locations: {values}. One of them was bumped and the others were
38
- not, which is discovered by a user rather than by us. Do not hand-edit — run the autofix.
49
+ not, which is discovered by a user rather than by us.
39
50
  """
40
51
 
41
52
  # What this does not cover is pinned, so green never reads as "verified":
@@ -120,9 +131,23 @@ fixture = { dir = "changelog.d", version = "0.2.0", fragments = ["42.feature.md"
120
131
  [[self_test]]
121
132
  gate = "release-version-consistent"
122
133
  expect = "fail"
123
- fixture = { values = ["1.2.0", "1.1.0"] }
134
+ fixture = { packages = { "package.json" = "1.2.0", "web/package.json" = "1.1.0" } }
135
+
136
+ [[self_test]]
137
+ gate = "release-version-consistent"
138
+ expect = "pass"
139
+ fixture = { packages = { "package.json" = "1.2.0", "web/package.json" = "1.2.0" } }
124
140
 
141
+ # The F-023 case: a sibling deliberately versioned on its own is excluded and the gate stays green,
142
+ # rather than reporting a healthy layout as a disagreement.
125
143
  [[self_test]]
126
144
  gate = "release-version-consistent"
127
145
  expect = "pass"
128
- fixture = { values = ["1.2.0", "1.2.0"] }
146
+ fixture = { packages = { "package.json" = "1.2.0", "web/package.json" = "0.0.1" }, exclude = ["web/package.json"] }
147
+
148
+ # …and excluding one sibling must not blind the gate to a different one. An `exclude` that
149
+ # silently widened would be worse than no gate, because it would still report green.
150
+ [[self_test]]
151
+ gate = "release-version-consistent"
152
+ expect = "fail"
153
+ fixture = { packages = { "package.json" = "1.2.0", "web/package.json" = "0.0.1", "api/package.json" = "9.9.9" }, exclude = ["web/package.json"] }
@@ -1,6 +1,6 @@
1
1
  [module]
2
2
  name = "release"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  rung = 3
5
5
  summary = "Candidate, stable and deploy branch lines; one procedure for cutting a release, hotfixing and rolling back; changelog fragments instead of a shared file."
6
6
 
@@ -25,10 +25,10 @@ mismatch is usually a fragment somebody skipped, not a versioning disagreement.
25
25
  rungs check
26
26
  ```
27
27
 
28
- Run **every** registered gate. This said `rungs check --tier full`, which selects only gates
29
- labelled `full` on a repo whose gates are all `fast`, that is zero of them, and the run reports
30
- no gates rather than a pass. A release step that gates on nothing while looking green is worse than
31
- one that is skipped, because nobody goes back to check it.
28
+ Run **every** registered gate. This step used to name a "--tier full" flag that the CLI does not
29
+ accept, so both words were parsed as positionals and the run checked a directory that does not
30
+ exist — reporting no gates rather than a pass. A release step that gates on nothing while looking
31
+ green is worse than one that is skipped, because nobody goes back to check it.
32
32
 
33
33
  Narrow to a tier (`rungs check --fast`) only when you know the tier holds what you meant to run.
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rungs/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Installs and maintains a repository's agentic development system, composed from modules.",
5
5
  "author": "Antoine Dancre",
6
6
  "repository": {
package/src/backlog.ts CHANGED
@@ -56,7 +56,14 @@ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): Arc
56
56
  const items = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, itemsDir)) + '/') && f.endsWith('.md'));
57
57
 
58
58
  for (const rel of items) {
59
- if (/README\.md$/i.test(rel) || /TEMPLATE\.md$/i.test(rel)) continue;
59
+ // The **basename**, exactly — not a suffix of the path. `/TEMPLATE\.md$/i`
60
+ // also matches any item whose filename ends in `-template.md`, and it did:
61
+ // `WI-010-framework-extraction-template.md` was skipped on every run since
62
+ // this command shipped, so a `done` item stayed in `items/` while the
63
+ // command reported "nothing to archive". An anchored regex that is anchored
64
+ // to the wrong end reads as careful and is not.
65
+ const base = posix(rel).split('/').pop()!;
66
+ if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
60
67
  const text = readFileSync(join(repoRoot, rel), 'utf8');
61
68
  const status = field(text, 'status');
62
69
  const id = field(text, 'id');
@@ -70,8 +77,16 @@ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): Arc
70
77
  .split(',')
71
78
  .map((s) => s.trim())
72
79
  .filter(Boolean);
80
+ // A child that is **already archived** is finished — that is what being in
81
+ // `archive/` means. Searching only `items/` made every archived child read
82
+ // as unfinished, so an epic whose children had all landed could never be
83
+ // archived and the hold message named five done items as outstanding. The
84
+ // more finished an epic got, the more stuck it became.
85
+ const archived = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, archiveDir)) + '/') && f.endsWith('.md'));
73
86
  const unfinished = children.filter((c) => {
74
- const f = items.find((i) => i.includes(`${c}-`));
87
+ const f = items.find((i) => i.includes(`${c}-`)) ?? archived.find((i) => i.includes(`${c}-`));
88
+ // Still `!f` → genuinely unknown, and an unknown holds. A child nobody
89
+ // can find is not evidence that it finished.
75
90
  return !f || !FINISHED.has(field(readFileSync(join(repoRoot, f), 'utf8'), 'status'));
76
91
  });
77
92
  if (unfinished.length) {
package/src/check.ts CHANGED
@@ -79,7 +79,13 @@ export class UnknownTierError extends Error {
79
79
  }
80
80
  }
81
81
 
82
- export function runGates(repoRoot: string, tier?: string, now = () => Date.now()): GateRun[] {
82
+ /**
83
+ * `only` narrows the run to named gate ids. Attribution needs it: after a merged
84
+ * tree goes red, `land` re-runs **just the failing gates** against the merge base
85
+ * to decide whether they were already red. Re-running all of them would give the
86
+ * same verdict and cost a second full pass for gates nobody asked about.
87
+ */
88
+ export function runGates(repoRoot: string, tier?: string, now = () => Date.now(), only?: ReadonlySet<string>): GateRun[] {
83
89
  const { runner, gates } = loadRegistry(repoRoot);
84
90
  const runnerTiers: string[] = Array.isArray(runner?.tiers) ? runner.tiers : [];
85
91
  // A tier nobody declared selects nothing, and "selected nothing" is
@@ -95,6 +101,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
95
101
  // A hook fires on a tool call, not in the runner. Skipping it here is
96
102
  // correct; counting it as a pass would not be.
97
103
  if (g.trigger) continue;
104
+ if (only && !only.has(g.id)) continue;
98
105
  if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
99
106
 
100
107
  const started = now();
@@ -181,7 +188,7 @@ export function loadTable(ref: string | undefined, repoRoot: string): any | null
181
188
  let paramCache: { root: string; params: Params } | null = null;
182
189
 
183
190
  /** Parameters as the repo installed them, falling back to module defaults. */
184
- function installedParams(repoRoot: string): Params {
191
+ export function installedParams(repoRoot: string): Params {
185
192
  if (paramCache?.root === repoRoot) return paramCache.params;
186
193
  const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
187
194
  const recordPath = join(repoRoot, '.ai', 'rungs.toml');
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { appendLedger, type GateRun, ledgerQuestions, loadRegistry, runGates, Un
10
10
  import { applyUpgrade, eject, planUpgrade, PROFILES, readRecord, setupGit } from './lifecycle.ts';
11
11
  import { explain, IN_SCOPE as EXPLAINABLE } from './explain.ts';
12
12
  import { applyArchive, planArchive } from './backlog.ts';
13
+ import { land, preflight, sessionStart, worktrees } from './concurrency.ts';
13
14
  import { existsSync } from 'node:fs';
14
15
  import type { DetectResult, Manifest } from './types.ts';
15
16
 
@@ -316,6 +317,34 @@ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harne
316
317
  }
317
318
  (overrides[modName] ??= {})[param] = rhs.join('=');
318
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
+ }
319
348
  const params = resolveParams(mods, overrides, root);
320
349
  for (const [m, vals] of Object.entries(overrides)) {
321
350
  for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
@@ -467,6 +496,56 @@ function cmdRender(root: string, harnesses: Harness[], stamp: string) {
467
496
  return 0;
468
497
  }
469
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
+
470
549
  function cmdCheck(root: string, tier: string | undefined, stamp: string) {
471
550
  let runs: GateRun[];
472
551
  try {
@@ -718,6 +797,10 @@ const COMMANDS: [usage: string, blurb: string][] = [
718
797
  ['setup git [path]', 'install the merge drivers .gitattributes names'],
719
798
  ['modules', 'list the module set and audit the manifests'],
720
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'],
721
804
  ];
722
805
 
723
806
  /** Every flag the parser honours. A flag absent here is a flag nobody can find. */
@@ -831,6 +914,19 @@ switch (cmd) {
831
914
  case 'eject':
832
915
  process.exit(cmdEject(resolve(args[0] ?? process.cwd()), flags.has('--dry-run')));
833
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
+ }
834
930
  const r = setupGit(resolve(args[1] ?? process.cwd()), flags.has('--dry-run'));
835
931
  console.log(
836
932
  r.drivers.length
@@ -843,6 +939,19 @@ switch (cmd) {
843
939
  }
844
940
  case 'render':
845
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())));
846
955
  case 'add': {
847
956
  const target = flags.has('--into') ? args[args.length - 1] : process.cwd();
848
957
  const names = flags.has('--into') ? args.slice(0, -1) : args;