@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
|
@@ -32,10 +32,21 @@ sources = [
|
|
|
32
32
|
{ file = "pyproject.toml", path = "project.version" },
|
|
33
33
|
]
|
|
34
34
|
rule = "all-agree"
|
|
35
|
-
|
|
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.
|
|
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 = {
|
|
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 = {
|
|
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.
|
|
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
|
|
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.
|
|
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 = "
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 (
|
|
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
|
/**
|