@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
@@ -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":
@@ -47,6 +58,25 @@ uncovered = [
47
58
  ]
48
59
  note = "A green check means these files agree, not that the version is correct everywhere."
49
60
 
61
+ # ── release-fragment-current ──────────────────────────────────────────────────
62
+ #
63
+ # The other half of the fragment discipline. `release-changelog-fragment` catches a release-worthy
64
+ # change that added no fragment; this catches a fragment that was consumed and never deleted.
65
+ #
66
+ # Only filenames that parse as a version are judged. A repo whose fragments are named per-change
67
+ # (`42.feature.md`) states no version, so there is nothing to compare and nothing is claimed — a
68
+ # gate that refused a naming convention it was not asked about is one people turn off.
69
+
70
+ [[changelog_freshness]]
71
+ id = "release-fragment-current"
72
+ fragments = ["{{changelog_dir}}/*.md"]
73
+ version = { file = "package.json", path = "version" }
74
+ message = """
75
+ This fragment names a version below the one being prepared, so it belongs to a release that already
76
+ shipped. Delete it — fragments are consumed, not archived, and one left behind is republished as
77
+ though it were new work.
78
+ """
79
+
50
80
  # ── self-tests ────────────────────────────────────────────────────────────────
51
81
 
52
82
  [[self_test]]
@@ -74,12 +104,50 @@ gate = "release-changelog-fragment"
74
104
  expect = "fail"
75
105
  fixture = { changed = ["src/a.ts"], fragments = [], exempt = "changelog-ok:" }
76
106
 
107
+ [[self_test]]
108
+ gate = "release-fragment-current"
109
+ expect = "fail"
110
+ fixture = { dir = "changelog.d", version = "0.2.0", fragments = ["0.1.1.md"] }
111
+
112
+ [[self_test]]
113
+ gate = "release-fragment-current"
114
+ expect = "pass"
115
+ fixture = { dir = "changelog.d", version = "0.2.0", fragments = ["0.2.0.md"] }
116
+
117
+ # A fragment for a version *above* the one being prepared is not stale — it is a branch that
118
+ # already decided it is a minor while the package has not been bumped yet.
119
+ [[self_test]]
120
+ gate = "release-fragment-current"
121
+ expect = "pass"
122
+ fixture = { dir = "changelog.d", version = "0.2.0", fragments = ["0.3.0.md"] }
123
+
124
+ # Per-change fragment naming states no version, so there is nothing to compare and nothing is
125
+ # claimed. The gate must stay silent rather than refuse a convention it was not asked about.
126
+ [[self_test]]
127
+ gate = "release-fragment-current"
128
+ expect = "pass"
129
+ fixture = { dir = "changelog.d", version = "0.2.0", fragments = ["42.feature.md"] }
130
+
77
131
  [[self_test]]
78
132
  gate = "release-version-consistent"
79
133
  expect = "fail"
80
- 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" } }
81
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.
82
143
  [[self_test]]
83
144
  gate = "release-version-consistent"
84
145
  expect = "pass"
85
- 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.0.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
 
@@ -46,6 +46,7 @@ consumed_by = "render"
46
46
  id = "release-changelog-fragment"
47
47
  kind = "declared"
48
48
  engine = "file-population"
49
+ applicability = "repo-content"
49
50
  table = "gates/release.toml"
50
51
  tier = "fast"
51
52
  why = """
@@ -54,10 +55,24 @@ cut time when nobody remembers what it did. Cheaper to require the fragment on t
54
55
  the change.
55
56
  """
56
57
 
58
+ [[gates]]
59
+ id = "release-fragment-current"
60
+ kind = "declared"
61
+ engine = "changelog-freshness"
62
+ applicability = "repo-content"
63
+ table = "gates/release.toml"
64
+ tier = "fast"
65
+ why = """
66
+ Fragments are consumed at release time, not archived — one left behind appears in the next release
67
+ too, where it reads as unreleased work. `cut-release` §3 has said so in prose since it was written,
68
+ and prose did not hold it: this repo's own `0.1.1` fragment survived two releases (F-022).
69
+ """
70
+
57
71
  [[gates]]
58
72
  id = "release-version-consistent"
59
73
  kind = "declared"
60
74
  engine = "computed-claim"
75
+ applicability = "our-schema"
61
76
  table = "gates/release.toml"
62
77
  tier = "fast"
63
78
  why = "A version appears in several files and drifts in exactly one of them. It is derivable, so it is computed rather than typed."
@@ -22,9 +22,16 @@ mismatch is usually a fragment somebody skipped, not a versioning disagreement.
22
22
  ## 2. Gate
23
23
 
24
24
  ```bash
25
- rungs check --tier full
25
+ rungs check
26
26
  ```
27
27
 
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
+
33
+ Narrow to a tier (`rungs check --fast`) only when you know the tier holds what you meant to run.
34
+
28
35
  **Do not proceed on a red gate**, and do not weaken one to get through. A release is exactly the
29
36
  moment the temptation is highest and the cost of yielding is highest.
30
37
 
@@ -1,6 +1,6 @@
1
1
  [module]
2
2
  name = "session"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  rung = 1
5
5
  summary = "A handoff document carrying state between sessions: resume point, active constraints, and a dated archive."
6
6
 
@@ -27,7 +27,8 @@ default = ".ai/archive"
27
27
  [[gates]]
28
28
  id = "session-sections-present"
29
29
  kind = "declared"
30
- engine = "frontmatter-schema"
30
+ engine = "sections"
31
+ applicability = "our-schema"
31
32
  table = "gates/session.toml"
32
33
  tier = "fast"
33
34
  why = """
@@ -39,6 +40,7 @@ reopens a settled question, which is the specific failure this document exists t
39
40
  id = "session-archive-named"
40
41
  kind = "declared"
41
42
  engine = "filename-schema"
43
+ applicability = "our-schema"
42
44
  table = "gates/session.toml"
43
45
  tier = "fast"
44
46
  why = "An archive whose filenames do not say what closed and what came next is a folder nobody opens."
@@ -30,6 +30,7 @@ consumed_by = "render"
30
30
  id = "skills-spec-pure"
31
31
  kind = "declared"
32
32
  engine = "frontmatter-schema"
33
+ applicability = "our-schema"
33
34
  table = "gates/skills.toml"
34
35
  tier = "fast"
35
36
  why = """
@@ -43,6 +44,7 @@ is stated at the point of the choice.
43
44
  id = "skills-description-routes"
44
45
  kind = "declared"
45
46
  engine = "frontmatter-schema"
47
+ applicability = "our-schema"
46
48
  table = "gates/skills.toml"
47
49
  tier = "fast"
48
50
  why = """
@@ -55,6 +57,7 @@ what the skill does and the phrases people actually say.
55
57
  id = "skills-names-neighbours"
56
58
  kind = "declared"
57
59
  engine = "cross-reference"
60
+ applicability = "our-schema"
58
61
  table = "gates/skills.toml"
59
62
  tier = "fast"
60
63
  why = """
@@ -37,6 +37,7 @@ default = 600
37
37
  id = "specs-status-evidence"
38
38
  kind = "declared"
39
39
  engine = "register-schema"
40
+ applicability = "our-schema"
40
41
  table = "gates/specs.toml"
41
42
  tier = "fast"
42
43
  why = """
@@ -49,6 +50,7 @@ integration is worse than no spec, because it is read as verified.
49
50
  id = "specs-scope-section"
50
51
  kind = "declared"
51
52
  engine = "sections"
53
+ applicability = "our-schema"
52
54
  table = "gates/specs.toml"
53
55
  tier = "fast"
54
56
  why = "Out-of-scope is what stops an agent silently expanding a surface while implementing an unrelated story."
@@ -57,6 +59,7 @@ why = "Out-of-scope is what stops an agent silently expanding a surface while
57
59
  id = "specs-ids-unique"
58
60
  kind = "declared"
59
61
  engine = "id-integrity"
62
+ applicability = "our-schema"
60
63
  table = "gates/specs.toml"
61
64
  tier = "fast"
62
65
  why = "Story ids are cited from commits, plans and work items; two stories sharing one make every citation ambiguous."
@@ -65,6 +68,7 @@ why = "Story ids are cited from commits, plans and work items; two stories sh
65
68
  id = "specs-split-threshold"
66
69
  kind = "declared"
67
70
  engine = "file-budget"
71
+ applicability = "repo-content"
68
72
  table = "gates/specs.toml"
69
73
  tier = "fast"
70
74
  why = "A spec past the threshold is one nobody reads to the end, and its later sections are where the unverified claims collect."
@@ -29,6 +29,7 @@ default = "docs/plans"
29
29
  id = "workflows-plan-tier-declared"
30
30
  kind = "declared"
31
31
  engine = "frontmatter-schema"
32
+ applicability = "our-schema"
32
33
  table = "gates/workflows.toml"
33
34
  tier = "fast"
34
35
  why = """
@@ -41,6 +42,7 @@ call the table exists to replace.
41
42
  id = "workflows-plan-sections"
42
43
  kind = "declared"
43
44
  engine = "sections"
45
+ applicability = "our-schema"
44
46
  table = "gates/workflows.toml"
45
47
  tier = "fast"
46
48
  why = "A tier-2 proposal missing its proof-of-reuse gate is a tier-1 plan wearing a heavier label."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rungs/cli",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
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/add.ts CHANGED
@@ -66,7 +66,11 @@ export function addModule(
66
66
  const base = join(mod.dir, 'skills');
67
67
  const dir = opts.skillsDir ?? '.claude/skills';
68
68
  for (const rel of walk(base)) {
69
- write(`${dir}/${rel}`, sub(readFileSync(join(base, rel), 'utf8')), 'skill');
69
+ // Through the same helper `emittedFiles` uses. These two paths both emit
70
+ // skills and are easy to change apart — patching only `emittedFiles` for
71
+ // F-019 left `add` still writing the un-extended file, so an install and
72
+ // an upgrade would have produced different content for the same skill.
73
+ write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync(join(base, rel), 'utf8'))), 'skill');
70
74
  }
71
75
  }
72
76
 
@@ -165,6 +169,31 @@ const gateEntry = (mod: Manifest) => (g: Manifest['gates'][number]) => {
165
169
  };
166
170
 
167
171
  /** Dependency order, refusing anything unmet — naming the incident (ADR-0003). */
172
+ /**
173
+ * Every module in `order` that cannot be installed because one of the modules
174
+ * it needs — or itself — is a different paradigm.
175
+ *
176
+ * A refusal has to travel *up* the dependency edges, not just stop at the
177
+ * module that matched. `add audit` pulls `findings` which pulls `backlog`; if
178
+ * the repo's work lives in an issue tracker, refusing `backlog` and installing
179
+ * `audit` anyway would ship an audit procedure whose findings have nowhere to
180
+ * close — which is the exact incident (268 audit documents, no register) that
181
+ * made `audit → findings → backlog` a declared dependency in the first place.
182
+ */
183
+ export function blockedByParadigm(order: Manifest[], paradigms: ReadonlySet<string>): Map<string, string> {
184
+ const blocked = new Map<string, string>();
185
+ // `order` is already dependency-first, so one forward pass settles it.
186
+ for (const mod of order) {
187
+ if (paradigms.has(mod.name)) {
188
+ blocked.set(mod.name, mod.name);
189
+ continue;
190
+ }
191
+ const dep = mod.requires.find((d) => blocked.has(d));
192
+ if (dep) blocked.set(mod.name, blocked.get(dep)!);
193
+ }
194
+ return blocked;
195
+ }
196
+
168
197
  export function resolveInstallOrder(requested: string[], all: Manifest[]): { order: Manifest[]; missing: string[] } {
169
198
  const byName = new Map(all.map((m) => [m.name, m]));
170
199
  const order: Manifest[] = [];
@@ -232,6 +261,37 @@ export const contentHash = (s: string) => createHash('sha256').update(s.replace(
232
261
  */
233
262
  const SHARED = new Set(['AGENTS.md', 'CLAUDE.md', '.gitignore', '.gitattributes', '.ai/gates.toml']);
234
263
 
264
+ /**
265
+ * Add the harness extensions a module opted this skill into.
266
+ *
267
+ * F-019. `[skills.work-item] extensions = { disable-model-invocation = true }`
268
+ * was declared in the `backlog` manifest, documented in `modules/README.md`, and
269
+ * **implemented at no layer**: `grep -n extensions src/*.ts` returned nothing, so
270
+ * the key never reached the emitted `SKILL.md`, and the gate that is supposed to
271
+ * police it could not see the opt-in either. `work-item` creates branches and
272
+ * merges, and the manifest's stated reason for opting it out of model invocation
273
+ * had been inert since it was written.
274
+ *
275
+ * Injected here rather than written into the source skill because that is the
276
+ * point of the opt-in: the file stays spec-pure and portable
277
+ * ([ADR-0001](../docs/decisions/ADR-0001-multi-harness-rendering.md)), and the
278
+ * extension — with its portability cost — stays attached to the module's
279
+ * decision to take it.
280
+ */
281
+ function withOptedInExtensions(mod: Manifest, rel: string, content: string): string {
282
+ const name = rel.split(/[\\/]/)[0];
283
+ const extensions = mod.skills?.[name]?.extensions;
284
+ if (!extensions || !Object.keys(extensions).length) return content;
285
+
286
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
287
+ if (!m) return content; // no frontmatter to extend; `skills-spec-pure` reports it
288
+ const added = Object.entries(extensions)
289
+ .filter(([k]) => !new RegExp(`^${k}:`, 'm').test(m[1]))
290
+ .map(([k, v]) => `${k}: ${v}`);
291
+ if (!added.length) return content;
292
+ return content.replace(/^---\n[\s\S]*?\n---/, `---\n${m[1]}\n${added.join('\n')}\n---`);
293
+ }
294
+
235
295
  export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude/skills'): Map<string, string> {
236
296
  const out = new Map<string, string>();
237
297
  const sub = (t: string) => substitute(t, mod.name, params);
@@ -245,7 +305,9 @@ export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude
245
305
  for (const rel of walk(base)) {
246
306
  const target = sub(prefix + rel).split('\\').join('/');
247
307
  if (SHARED.has(target)) continue;
248
- out.set(target, sub(readFileSync(join(base, rel), 'utf8')));
308
+ let content = sub(readFileSync(join(base, rel), 'utf8'));
309
+ if (dir === 'skills') content = withOptedInExtensions(mod, rel, content);
310
+ out.set(target, content);
249
311
  }
250
312
  }
251
313
  return out;
package/src/backlog.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative, resolve, sep } from 'node:path';
3
+ import { walk } from './glob.ts';
4
+
5
+ /**
6
+ * `rungs backlog archive` — move finished items out of `items/` and repoint
7
+ * every link in the repo at their new home.
8
+ *
9
+ * F-015. Three files shipped into **every** consumer repo named this command,
10
+ * two of them saying "never by hand", and it did not exist: `rungs backlog`
11
+ * answered *"unknown command"*. So the instruction was unfollowable everywhere
12
+ * rungs had ever been installed, and the reason it says *never by hand* is
13
+ * exactly why it could not be worked around — moving 39 files and rewriting
14
+ * every citation of them is the kind of repo-wide edit that fails silently.
15
+ *
16
+ * The link rewrite is the whole substance of the command. It resolves each
17
+ * link from the citing file's **own** directory rather than pattern-matching
18
+ * text, because the same target is written `items/WI-001-x.md`,
19
+ * `../items/WI-001-x.md` and `WI-001-x.md` depending on who is citing it, and a
20
+ * regex over any one of those spellings silently misses the others.
21
+ */
22
+
23
+ export interface ArchiveMove {
24
+ id: string;
25
+ status: string;
26
+ from: string;
27
+ to: string;
28
+ }
29
+
30
+ export interface ArchivePlan {
31
+ root: string;
32
+ moves: ArchiveMove[];
33
+ /** Files whose links change, with how many links move in each. */
34
+ rewrites: { file: string; links: number }[];
35
+ /** Items that look finished but are not eligible, with the reason. */
36
+ held: { file: string; reason: string }[];
37
+ }
38
+
39
+ /** Statuses whose work can no longer change. Mirrors backlog README §8. */
40
+ const FINISHED = new Set(['done', 'rejected']);
41
+
42
+ const field = (text: string, name: string) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, 'm'))?.[1] ?? '';
43
+
44
+ const posix = (p: string) => p.split(sep).join('/');
45
+
46
+ /** A relative markdown link that could point at a repo file. */
47
+ const LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
48
+
49
+ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): ArchivePlan {
50
+ const itemsDir = join(repoRoot, ...backlogRoot.split('/'), 'items');
51
+ const archiveDir = join(repoRoot, ...backlogRoot.split('/'), 'archive');
52
+ const moves: ArchiveMove[] = [];
53
+ const held: ArchivePlan['held'] = [];
54
+
55
+ const files = walk(repoRoot);
56
+ const items = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, itemsDir)) + '/') && f.endsWith('.md'));
57
+
58
+ for (const rel of items) {
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;
67
+ const text = readFileSync(join(repoRoot, rel), 'utf8');
68
+ const status = field(text, 'status');
69
+ const id = field(text, 'id');
70
+ if (!FINISHED.has(status)) continue;
71
+
72
+ // An epic whose children are not all finished is still live bookkeeping: it
73
+ // is the thing that says what remains. Moving it would file the index of
74
+ // open work under "cannot change any more".
75
+ if (field(text, 'type') === 'epic') {
76
+ const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? '')
77
+ .split(',')
78
+ .map((s) => s.trim())
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'));
86
+ const unfinished = children.filter((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.
90
+ return !f || !FINISHED.has(field(readFileSync(join(repoRoot, f), 'utf8'), 'status'));
91
+ });
92
+ if (unfinished.length) {
93
+ held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(', ')}` });
94
+ continue;
95
+ }
96
+ }
97
+
98
+ // `posix(rel)` first: `walk` yields `/`-separated paths, so splitting on the
99
+ // platform `sep` on Windows never splits and the basename came back as the
100
+ // whole path — producing `archive/docs/backlog/items/WI-001-….md`.
101
+ moves.push({
102
+ id,
103
+ status,
104
+ from: rel,
105
+ to: posix(join(relative(repoRoot, archiveDir), posix(rel).split('/').pop()!)),
106
+ });
107
+ }
108
+
109
+ // Where each moved file ends up, keyed by its absolute old path, so a link can
110
+ // be looked up by what it resolves to rather than by how it was spelled.
111
+ const moved = new Map(moves.map((m) => [resolve(repoRoot, m.from), m.to]));
112
+ const rewrites: ArchivePlan['rewrites'] = [];
113
+
114
+ for (const rel of files) {
115
+ if (!isRewritable(rel)) continue;
116
+ const links = retargets(repoRoot, rel, moved).length;
117
+ if (links || moved.has(resolve(repoRoot, rel))) rewrites.push({ file: rel, links });
118
+ }
119
+
120
+ return { root: backlogRoot, moves, rewrites, held };
121
+ }
122
+
123
+ /**
124
+ * A module's `files/` and `fragments/` are **templates**, not repo content.
125
+ * Their links are relative to wherever the fragment merges into, they carry
126
+ * `{{param}}` tokens, and resolving them here reports every one as broken —
127
+ * which is why `link_integrity.exclude` already skips them. Rewriting them
128
+ * would be worse than reporting them: it would bake this repo's paths into what
129
+ * every consumer repo gets installed.
130
+ */
131
+ function isRewritable(rel: string): boolean {
132
+ const p = posix(rel);
133
+ if (!p.endsWith('.md')) return false;
134
+ return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith('node_modules/');
135
+ }
136
+
137
+ /**
138
+ * The links in one file that this archive run has to change, and what to.
139
+ *
140
+ * Deliberately **only** links whose target moved, plus — when the citing file is
141
+ * itself moving — links that would otherwise break from the new location. The
142
+ * first version compared every link's written form against a freshly computed
143
+ * relative path and counted a difference as a change, which claimed 334 links
144
+ * across 58 files including `AGENTS.md`, `README.md` and module templates. Most
145
+ * of those were equivalent spellings of an unmoved target. Rewriting them would
146
+ * have been a repo-wide reflow disguised as an archive.
147
+ */
148
+ function retargets(repoRoot: string, rel: string, moved: Map<string, string>): { href: string; to: string }[] {
149
+ const oldDir = dirname(resolve(repoRoot, rel));
150
+ const selfMoved = moved.get(resolve(repoRoot, rel));
151
+ const newDir = dirname(resolve(repoRoot, selfMoved ?? rel));
152
+ const out: { href: string; to: string }[] = [];
153
+
154
+ for (const m of readFileSync(join(repoRoot, rel), 'utf8').matchAll(LINK)) {
155
+ const href = m[1];
156
+ if (href.includes('{{')) continue; // a template link, resolved at install
157
+ const target = resolve(oldDir, decodeURIComponent(href));
158
+ const targetMoved = moved.get(target);
159
+ if (!targetMoved && !selfMoved) continue;
160
+ if (!targetMoved && !existsSync(target)) continue; // already broken; not this command's to fix
161
+ const targetNew = targetMoved ? resolve(repoRoot, targetMoved) : target;
162
+ // No `./` prefix. It is never required for a relative markdown link, and
163
+ // adding it rewrites the spelling of paths whose *target* is what changed —
164
+ // turning a one-word diff into a whole-line one across 37 files.
165
+ const to = posix(relative(newDir, targetNew));
166
+ if (to !== posix(href)) out.push({ href, to });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ export function applyArchive(repoRoot: string, plan: ArchivePlan): void {
172
+ const moved = new Map(plan.moves.map((m) => [resolve(repoRoot, m.from), m.to]));
173
+
174
+ // Rewrite before moving. Every path is computed from the plan rather than from
175
+ // the filesystem, so the order is a choice — and this order means a crash
176
+ // halfway leaves the files still where the links say they are.
177
+ for (const rel of walk(repoRoot)) {
178
+ if (!isRewritable(rel)) continue;
179
+ const edits = retargets(repoRoot, rel, moved);
180
+ if (!edits.length) continue;
181
+ const path = join(repoRoot, rel);
182
+ let text = readFileSync(path, 'utf8');
183
+ // Replace through the same matcher that found them, so a href appearing in
184
+ // prose as well as in a link cannot be hit by a bare string replace.
185
+ text = text.replace(LINK, (whole, href: string, anchor: string) => {
186
+ const edit = edits.find((e) => e.href === href);
187
+ return edit ? `](${edit.to}${anchor})` : whole;
188
+ });
189
+ writeFileSync(path, text);
190
+ }
191
+
192
+ for (const m of plan.moves) {
193
+ const to = join(repoRoot, ...m.to.split('/'));
194
+ mkdirSync(dirname(to), { recursive: true });
195
+ renameSync(join(repoRoot, m.from), to);
196
+ }
197
+ }
package/src/check.ts CHANGED
@@ -44,8 +44,56 @@ export function loadRegistry(repoRoot: string): { runner: any; gates: RegistryGa
44
44
  return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
45
45
  }
46
46
 
47
- export function runGates(repoRoot: string, tier?: string, now = () => Date.now()): GateRun[] {
48
- const { gates } = loadRegistry(repoRoot);
47
+ /**
48
+ * ADR-0008: a tier is an ordered **level**, not a tag. `[runner] tiers` declares
49
+ * the order, and asking for one runs every gate at that level or below it.
50
+ *
51
+ * This was string equality, so `full` selected only gates labelled `full` — zero
52
+ * of them on a registry where everything is `fast`, which is this repo. The run
53
+ * then reported no gates and exited as though the release had been gated, and
54
+ * `cut-release` told every consumer to gate on exactly that command (F-020).
55
+ */
56
+ export function tierSelects(runnerTiers: string[], requested: string, gateTier?: string): boolean {
57
+ if (!gateTier) return true; // untiered gates run in every tier
58
+ const at = runnerTiers.indexOf(requested);
59
+ const of = runnerTiers.indexOf(gateTier);
60
+ // An undeclared tier on either side cannot be ordered. Fall back to equality
61
+ // rather than guessing a position — silently including it would be worse.
62
+ if (at < 0 || of < 0) return gateTier === requested;
63
+ return of <= at;
64
+ }
65
+
66
+ /**
67
+ * No parameter properties: Node's strip-only TypeScript mode rejects them, and
68
+ * `dist/` is built from these sources for a runtime that has no compiler. The
69
+ * same constraint is what v0.1.1 shipped broken (ERR_UNSUPPORTED_NODE_MODULES_
70
+ * TYPE_STRIPPING), so it is worth the four extra lines.
71
+ */
72
+ export class UnknownTierError extends Error {
73
+ requested: string;
74
+ declared: string[];
75
+ constructor(requested: string, declared: string[]) {
76
+ super(`unknown tier "${requested}"`);
77
+ this.requested = requested;
78
+ this.declared = declared;
79
+ }
80
+ }
81
+
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[] {
89
+ const { runner, gates } = loadRegistry(repoRoot);
90
+ const runnerTiers: string[] = Array.isArray(runner?.tiers) ? runner.tiers : [];
91
+ // A tier nobody declared selects nothing, and "selected nothing" is
92
+ // indistinguishable from "everything passed" at the exit code. Refuse it here
93
+ // rather than let a typo read as a green release gate.
94
+ if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
95
+ throw new UnknownTierError(tier, runnerTiers);
96
+ }
49
97
  const files = walk(repoRoot);
50
98
  const runs: GateRun[] = [];
51
99
 
@@ -53,7 +101,8 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
53
101
  // A hook fires on a tool call, not in the runner. Skipping it here is
54
102
  // correct; counting it as a pass would not be.
55
103
  if (g.trigger) continue;
56
- if (tier && g.tier && g.tier !== tier) continue;
104
+ if (only && !only.has(g.id)) continue;
105
+ if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
57
106
 
58
107
  const started = now();
59
108
  let status: Status = 'pass';
@@ -124,7 +173,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
124
173
  * parameters before parsing — which is also what makes a gate honour the
125
174
  * prefix, root and budget that repo actually chose.
126
175
  */
127
- function loadTable(ref: string | undefined, repoRoot: string): any | null {
176
+ export function loadTable(ref: string | undefined, repoRoot: string): any | null {
128
177
  if (!ref) return null;
129
178
  const [mod, file] = ref.split('/');
130
179
  const path = join(MODULES, mod, 'gates', file);
@@ -139,7 +188,7 @@ function loadTable(ref: string | undefined, repoRoot: string): any | null {
139
188
  let paramCache: { root: string; params: Params } | null = null;
140
189
 
141
190
  /** Parameters as the repo installed them, falling back to module defaults. */
142
- function installedParams(repoRoot: string): Params {
191
+ export function installedParams(repoRoot: string): Params {
143
192
  if (paramCache?.root === repoRoot) return paramCache.params;
144
193
  const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
145
194
  const recordPath = join(repoRoot, '.ai', 'rungs.toml');
@@ -157,7 +206,7 @@ function installedParams(repoRoot: string): Params {
157
206
  return defaults;
158
207
  }
159
208
 
160
- const tableKey = (engine: string) =>
209
+ export const tableKey = (engine: string) =>
161
210
  ({
162
211
  'file-budget': 'file_budget',
163
212
  sections: 'sections',
@@ -177,6 +226,7 @@ const tableKey = (engine: string) =>
177
226
  'rule-propagation': 'rule_propagation',
178
227
  'git-state': 'git_state',
179
228
  'merge-driver-check': 'merge_driver_check',
229
+ 'board-reconcile': 'board_reconcile',
180
230
  })[engine] ?? engine;
181
231
 
182
232
  /**