bmad-module-skill-forge 1.1.0 → 1.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/bmad-synergy.md +2 -2
  5. package/docs/getting-started.md +1 -1
  6. package/docs/skill-model.md +26 -32
  7. package/docs/troubleshooting.md +13 -1
  8. package/docs/why-skf.md +5 -4
  9. package/docs/workflows.md +53 -0
  10. package/package.json +2 -2
  11. package/src/knowledge/ccc-bridge.md +1 -1
  12. package/src/shared/references/output-contract-schema.md +10 -0
  13. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
  14. package/src/shared/scripts/skf-detect-tools.py +359 -0
  15. package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
  16. package/src/shared/scripts/skf-extract-public-api.py +505 -0
  17. package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
  18. package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
  19. package/src/shared/scripts/skf-preflight.py +14 -4
  20. package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
  21. package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
  22. package/src/shared/scripts/skf-resolve-package.py +264 -0
  23. package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
  24. package/src/shared/scripts/skf-validate-output.py +24 -7
  25. package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
  26. package/src/skf-quick-skill/SKILL.md +178 -10
  27. package/src/skf-quick-skill/assets/skill-template.md +5 -1
  28. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  29. package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
  30. package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
  31. package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
  32. package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
  33. package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
  34. package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
  35. package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
  36. package/src/skf-setup/SKILL.md +25 -10
  37. package/src/skf-setup/references/tier-rules.md +2 -2
  38. package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
  39. package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
  40. package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
  41. package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
  42. package/src/skf-setup/steps-c/step-04-report.md +135 -11
  43. package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
  44. package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
  45. package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
@@ -0,0 +1,324 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Merge CCC Exclusions — Set-union merge of SKF exclusion patterns.
6
+
7
+ Replaces the prose-driven validation + merge logic in `src/skf-setup/
8
+ steps-c/step-01b-ccc-index.md` §2b with one Python invocation. Reads
9
+ `.cocoindex_code/settings.yml`, applies PR #248's config-value
10
+ validation rules to reject inputs that would silently produce a
11
+ malformed glob (the empty-value-becomes-`**/`-matches-everything bug
12
+ that would silently exclude the entire repo from indexing), and
13
+ performs an idempotent set-union merge of the SKF exclusion list into
14
+ the existing `exclude_patterns` array.
15
+
16
+ Always-included patterns (4 hardcoded — no config needed):
17
+
18
+ **/_bmad SKF framework module (workflows, agents, knowledge)
19
+ **/_bmad-output Build output artifacts
20
+ **/.claude Claude Code configuration
21
+ **/_skf-learn SKF learning materials
22
+
23
+ Conditionally-included patterns (2 from config — validated before use):
24
+
25
+ **/{skills_output_folder} Generated skill files
26
+ **/{forge_data_folder} Compilation workspace
27
+
28
+ Validation rules for the 2 conditional values (PR #248):
29
+
30
+ - REJECT empty or whitespace-only values
31
+ → would produce `**/`, matching every path → ccc indexes nothing
32
+ - REJECT values starting with `/`, `~/`, or `./`
33
+ → produces `**//abs/path`, `**/~/x`, or `**/./rel` — malformed glob
34
+ - REJECT values containing glob meta-characters (`*`, `?`, `[`)
35
+ → interpolation collides with the surrounding pattern syntax
36
+
37
+ Rejected values are SKIPPED (the pattern is not added) and a warning
38
+ is appended to the output. The 4 always-include patterns are applied
39
+ unconditionally — config-value rejection cannot disable indexing
40
+ entirely.
41
+
42
+ CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
43
+ above is auto-resolved on first call and cached. `docs/getting-started.md`
44
+ documents uv as the runtime prerequisite for exactly this. Bare
45
+ `python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
46
+ on a fresh interpreter:
47
+
48
+ uv run skf-merge-ccc-exclusions.py \\
49
+ --project-root /abs/path \\
50
+ --skills-output-folder skills \\
51
+ --forge-data-folder _bmad-output/forge-data
52
+
53
+ Output (single JSON document on stdout):
54
+
55
+ {
56
+ "status": "ok",
57
+ "version": "v1",
58
+ "settings_yml_existed": bool,
59
+ "settings_yml_path": "/abs/path/.cocoindex_code/settings.yml",
60
+ "patterns_added": int,
61
+ "patterns_added_list": ["**/_bmad", ...],
62
+ "patterns_already_present": int,
63
+ "written": bool,
64
+ "warnings": ["string", ...]
65
+ }
66
+
67
+ `written` is false when no new patterns needed adding (idempotent
68
+ re-run — no I/O wasted). Non-zero `warnings` does NOT prevent the
69
+ write — always-include patterns still merge.
70
+
71
+ Atomic writes via temp + fsync + rename (mirrors skf-atomic-write.py).
72
+ Concurrent writers must coordinate via external `flock` (the typical
73
+ pattern for shared-file mutation in this module).
74
+
75
+ Exit codes:
76
+ 0 success (warnings, if any, are still on the success path)
77
+ 1 user error (bad args, missing required flag, malformed settings.yml)
78
+ 2 internal error (write failure)
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import argparse
84
+ import json
85
+ import os
86
+ import sys
87
+ from pathlib import Path
88
+
89
+ import yaml
90
+
91
+
92
+ ALWAYS_INCLUDE = (
93
+ "**/_bmad",
94
+ "**/_bmad-output",
95
+ "**/.claude",
96
+ "**/_skf-learn",
97
+ )
98
+ GLOB_META_CHARS = set("*?[")
99
+
100
+
101
+ def _die(code: int, message: str) -> None:
102
+ print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
103
+ sys.exit(code)
104
+
105
+
106
+ def _ok(payload: dict) -> None:
107
+ payload.setdefault("status", "ok")
108
+ payload.setdefault("version", "v1")
109
+ print(json.dumps(payload))
110
+
111
+
112
+ def _atomic_write(target: Path, content: str) -> None:
113
+ """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
114
+ target.parent.mkdir(parents=True, exist_ok=True)
115
+ tmp = target.with_name(target.name + ".skf-tmp")
116
+ try:
117
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
118
+ try:
119
+ os.write(fd, content.encode("utf-8"))
120
+ os.fsync(fd)
121
+ finally:
122
+ os.close(fd)
123
+ os.replace(tmp, target)
124
+ except OSError as e:
125
+ if tmp.exists():
126
+ try:
127
+ tmp.unlink()
128
+ except OSError:
129
+ pass
130
+ _die(2, f"atomic write failed for {target}: {e}")
131
+
132
+
133
+ # ─── Config-value validation (PR #248 rules) ────────────────────────────────
134
+
135
+
136
+ def validate_config_value(key: str, raw_value: str) -> tuple[str | None, str | None]:
137
+ """Validate a config value used to interpolate `**/{value}`.
138
+
139
+ Returns (cleaned_value, warning_or_None). When cleaned_value is None
140
+ the value was rejected and the caller MUST NOT add the pattern.
141
+ """
142
+ if raw_value is None or not str(raw_value).strip():
143
+ return None, (
144
+ f"{key} is empty or whitespace-only; refused for ccc exclusion because "
145
+ f"interpolating it would produce `**/`, which matches every path and "
146
+ f"would silently exclude the entire repository from indexing — fix the "
147
+ f"value in {{project-root}}/_bmad/skf/config.yaml"
148
+ )
149
+
150
+ value = str(raw_value).strip()
151
+
152
+ if value.startswith("/") or value.startswith("~/") or value.startswith("./"):
153
+ return None, (
154
+ f"{key} is an absolute or anchored path; refused for ccc exclusion "
155
+ f"because interpolating it would produce a malformed glob — fix the "
156
+ f"value in {{project-root}}/_bmad/skf/config.yaml to a repo-relative path"
157
+ )
158
+
159
+ if any(ch in GLOB_META_CHARS for ch in value):
160
+ return None, (
161
+ f"{key} contains glob meta-character (*, ?, [); refused for ccc "
162
+ f"exclusion because interpolation collides with the surrounding "
163
+ f"pattern syntax — fix the value in {{project-root}}/_bmad/skf/config.yaml"
164
+ )
165
+
166
+ return value, None
167
+
168
+
169
+ # ─── Pattern assembly ───────────────────────────────────────────────────────
170
+
171
+
172
+ def assemble_patterns(skills_output_folder: str, forge_data_folder: str
173
+ ) -> tuple[list[str], list[str]]:
174
+ """Return (validated_patterns_to_add, warnings).
175
+
176
+ Validated patterns include the 4 unconditional ones plus any of the
177
+ 2 config-driven ones whose value passed validation.
178
+ """
179
+ patterns = list(ALWAYS_INCLUDE)
180
+ warnings: list[str] = []
181
+
182
+ cleaned, warn = validate_config_value("skills_output_folder", skills_output_folder)
183
+ if cleaned is not None:
184
+ patterns.append(f"**/{cleaned}")
185
+ elif warn:
186
+ warnings.append(warn)
187
+
188
+ cleaned, warn = validate_config_value("forge_data_folder", forge_data_folder)
189
+ if cleaned is not None:
190
+ patterns.append(f"**/{cleaned}")
191
+ elif warn:
192
+ warnings.append(warn)
193
+
194
+ return patterns, warnings
195
+
196
+
197
+ # ─── settings.yml read / merge / render ─────────────────────────────────────
198
+
199
+
200
+ def read_settings_yml(path: Path) -> tuple[dict, bool]:
201
+ """Return (parsed_dict, existed_before).
202
+
203
+ Missing file → ({}, False). Existing file is parsed; non-mapping top-level
204
+ or YAML errors exit with a clear message.
205
+ """
206
+ if not path.exists():
207
+ return {}, False
208
+ try:
209
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
210
+ except yaml.YAMLError as e:
211
+ _die(1, f"failed to parse {path}: {e}")
212
+ if data is None:
213
+ return {}, True
214
+ if not isinstance(data, dict):
215
+ _die(1, f"expected mapping at top of {path}, got {type(data).__name__}")
216
+ return data, True
217
+
218
+
219
+ def merge_patterns(existing_patterns: list[str], to_add: list[str]
220
+ ) -> tuple[list[str], list[str]]:
221
+ """Append `to_add` entries to `existing_patterns` if not already present.
222
+
223
+ Returns (merged_patterns, newly_added_patterns). Order of existing
224
+ entries is preserved; new entries are appended in the order given.
225
+ """
226
+ seen = set(existing_patterns)
227
+ merged = list(existing_patterns)
228
+ newly_added: list[str] = []
229
+ for p in to_add:
230
+ if p in seen:
231
+ continue
232
+ seen.add(p)
233
+ merged.append(p)
234
+ newly_added.append(p)
235
+ return merged, newly_added
236
+
237
+
238
+ def render_settings_yml(data: dict, original_text: str | None) -> str:
239
+ """Render the updated settings dict as YAML.
240
+
241
+ For first-time-write (no original text), emit a minimal file with
242
+ just the exclude_patterns the script controls. For updates, dump the
243
+ full data structure with PyYAML — this DROPS comments from the
244
+ original file. The cocoindex CLI is the only consumer of settings.yml
245
+ contents, and it doesn't care about comments. User-customized
246
+ exclude_patterns entries are preserved (set-union, not overwrite);
247
+ other top-level keys (if any) are also preserved.
248
+ """
249
+ return yaml.safe_dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
250
+
251
+
252
+ def cmd_merge(project_root: Path, skills_output_folder: str, forge_data_folder: str) -> None:
253
+ target = project_root / ".cocoindex_code" / "settings.yml"
254
+
255
+ patterns_to_add, warnings = assemble_patterns(skills_output_folder, forge_data_folder)
256
+ data, existed = read_settings_yml(target)
257
+
258
+ existing_excludes = data.get("exclude_patterns", []) or []
259
+ if not isinstance(existing_excludes, list):
260
+ _die(1, f"exclude_patterns in {target} is not a list "
261
+ f"(got {type(existing_excludes).__name__})")
262
+ # Normalize entries to strings (cocoindex tolerates non-string entries
263
+ # but we want byte-identical comparison)
264
+ existing_excludes = [str(p) for p in existing_excludes]
265
+
266
+ merged, newly_added = merge_patterns(existing_excludes, patterns_to_add)
267
+ patterns_already_present = len(patterns_to_add) - len(newly_added)
268
+
269
+ if not newly_added:
270
+ # Idempotent re-run — no write, no mtime change.
271
+ _ok({
272
+ "settings_yml_existed": existed,
273
+ "settings_yml_path": str(target),
274
+ "patterns_added": 0,
275
+ "patterns_added_list": [],
276
+ "patterns_already_present": patterns_already_present,
277
+ "written": False,
278
+ "warnings": warnings,
279
+ })
280
+ return
281
+
282
+ data["exclude_patterns"] = merged
283
+ rendered = render_settings_yml(data, None)
284
+ _atomic_write(target, rendered)
285
+
286
+ _ok({
287
+ "settings_yml_existed": existed,
288
+ "settings_yml_path": str(target),
289
+ "patterns_added": len(newly_added),
290
+ "patterns_added_list": newly_added,
291
+ "patterns_already_present": patterns_already_present,
292
+ "written": True,
293
+ "warnings": warnings,
294
+ })
295
+
296
+
297
+ def main() -> None:
298
+ parser = argparse.ArgumentParser(
299
+ description="Merge SKF exclusion patterns into .cocoindex_code/settings.yml.",
300
+ formatter_class=argparse.RawDescriptionHelpFormatter,
301
+ )
302
+ parser.add_argument(
303
+ "--project-root", type=Path, required=True,
304
+ help="Absolute path to the project root. Script writes/reads "
305
+ "{project-root}/.cocoindex_code/settings.yml.",
306
+ )
307
+ parser.add_argument(
308
+ "--skills-output-folder", default="",
309
+ help="Raw value of skills_output_folder from {project-root}/_bmad/skf/config.yaml. "
310
+ "Validated before being interpolated into `**/{value}`. Empty/absolute/glob-meta "
311
+ "values are rejected with a warning.",
312
+ )
313
+ parser.add_argument(
314
+ "--forge-data-folder", default="",
315
+ help="Raw value of forge_data_folder from {project-root}/_bmad/skf/config.yaml. "
316
+ "Same validation as --skills-output-folder.",
317
+ )
318
+ args = parser.parse_args()
319
+
320
+ cmd_merge(args.project_root, args.skills_output_folder, args.forge_data_folder)
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
@@ -7,8 +7,15 @@
7
7
  Loads config.yaml, validates sidecar_path, loads preferences.yaml and
8
8
  forge-tier.yaml, and outputs a unified JSON blob for step consumption.
9
9
 
10
- CLI: python3 skf-preflight.py <project-root>
11
- python3 skf-preflight.py <project-root> --config-path <alt-config>
10
+ CLI invoke via `uv run` so the PEP 723 PyYAML dependency declared
11
+ above is auto-resolved on first call and cached. `docs/getting-started.md`
12
+ documents uv as the runtime prerequisite for exactly this. Bare
13
+ `python3` falls back to a defensive ImportError handler that emits
14
+ `{"error": "PyYAML not installed...", "code": "MISSING_DEPENDENCY"}`
15
+ on stdout — that fallback is the safety net, not the canonical path:
16
+
17
+ uv run skf-preflight.py <project-root>
18
+ uv run skf-preflight.py <project-root> --config-path <alt-config>
12
19
 
13
20
  Output: JSON to stdout with all resolved config variables and sidecar state.
14
21
  Exit 0 on success, exit 1 on hard-halt conditions (missing config, bad sidecar).
@@ -24,7 +31,10 @@ try:
24
31
  import yaml
25
32
  except ImportError:
26
33
  print(
27
- json.dumps({"error": "PyYAML not installed. Run: pip install pyyaml", "code": "MISSING_DEPENDENCY"}),
34
+ json.dumps({
35
+ "error": "PyYAML not installed. Invoke via `uv run` (auto-resolves PEP 723 deps — see docs/getting-started.md) or install manually with `pip install pyyaml`.",
36
+ "code": "MISSING_DEPENDENCY",
37
+ }),
28
38
  )
29
39
  sys.exit(1)
30
40
 
@@ -148,7 +158,7 @@ def run_preflight(project_root, config_path=None):
148
158
 
149
159
  if __name__ == "__main__":
150
160
  if len(sys.argv) < 2:
151
- print("Usage: python3 skf-preflight.py <project-root> [--config-path <path>]", file=sys.stderr)
161
+ print("Usage: uv run skf-preflight.py <project-root> [--config-path <path>]", file=sys.stderr)
152
162
  sys.exit(1)
153
163
 
154
164
  proj_root = sys.argv[1]
@@ -0,0 +1,212 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF QMD Classify Collections — Set arithmetic over QMD collection names.
6
+
7
+ Replaces the prose-driven classification logic in `src/skf-setup/steps-c/
8
+ step-03-auto-index.md` §3 with one Python invocation. Compares the live
9
+ QMD collections (from `qmd collection list`) against the forge registry
10
+ (`qmd_collections` array in forge-tier.yaml) and classifies each name as
11
+ Healthy / Orphaned / Stale, applying the forge-namespace suffix filter
12
+ added in PR #244 to silently exclude collections owned by unrelated
13
+ tools sharing the QMD daemon.
14
+
15
+ Classification rules (per step-03 §3):
16
+
17
+ Healthy — name in {forge-suffix-matched live} AND in registry.
18
+ No action needed.
19
+ Orphaned — name in {forge-suffix-matched live} but NOT in registry.
20
+ Flagged for user-prompted removal in step-03 §4.
21
+ Stale — name in registry but NOT in {all live}. Registry entry
22
+ should be removed.
23
+ Foreign — name in live but does NOT match a forge suffix. Silently
24
+ excluded from every classification — never displayed,
25
+ never proposed for removal. Reported as a count for
26
+ telemetry only.
27
+
28
+ Forge suffixes (the only suffixes a forge-managed collection can have,
29
+ set by producers `skf-brief-skill` and `skf-create-skill` per
30
+ src/knowledge/qmd-registry.md § Collection Types):
31
+
32
+ -brief, -temporal, -docs, -extraction
33
+
34
+ Inputs:
35
+
36
+ --live-names Comma-separated list of collection names currently
37
+ in QMD (caller obtains this from `qmd collection list`
38
+ before invoking the script). Empty string → no live
39
+ collections, which is a valid first-run state.
40
+
41
+ --registry-from-yaml <path>
42
+ Path to forge-tier.yaml. The script reads the file's
43
+ `qmd_collections` array and extracts the `name` field
44
+ from each entry. Missing file or missing array → empty
45
+ registry, which is a valid first-run state.
46
+
47
+ Output (single JSON document on stdout):
48
+
49
+ {
50
+ "status": "ok",
51
+ "version": "v1",
52
+ "healthy": ["foo-brief", "foo-extraction"],
53
+ "orphaned": ["bar-extraction"],
54
+ "stale": ["baz-docs"],
55
+ "foreign_filtered_count": 4,
56
+ "foreign_filtered_sample": ["memory-root-1", "sessions-2"]
57
+ }
58
+
59
+ `foreign_filtered_sample` is capped at 5 names (telemetry; the full list
60
+ is never useful — if it were forge-relevant it would have a forge suffix).
61
+
62
+ CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
63
+ above is auto-resolved on first call and cached. `docs/getting-started.md`
64
+ documents uv as the runtime prerequisite for exactly this. Bare
65
+ `python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
66
+ on a fresh interpreter:
67
+
68
+ uv run skf-qmd-classify-collections.py \\
69
+ --live-names foo-brief,foo-extraction,memory-root-1 \\
70
+ --registry-from-yaml /path/forge-tier.yaml
71
+
72
+ Exit codes:
73
+ 0 success
74
+ 1 user error (bad args, malformed registry file)
75
+ 2 internal error
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import argparse
81
+ import json
82
+ import re
83
+ import sys
84
+ from pathlib import Path
85
+
86
+ import yaml
87
+
88
+
89
+ FORGE_SUFFIXES = ("-brief", "-temporal", "-docs", "-extraction")
90
+ FOREIGN_SAMPLE_CAP = 5
91
+
92
+
93
+ def _die(code: int, message: str) -> None:
94
+ print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
95
+ sys.exit(code)
96
+
97
+
98
+ def _ok(payload: dict) -> None:
99
+ payload.setdefault("status", "ok")
100
+ payload.setdefault("version", "v1")
101
+ print(json.dumps(payload))
102
+
103
+
104
+ def is_forge_owned(name: str) -> bool:
105
+ """True if `name` ends with one of the forge suffixes."""
106
+ return any(name.endswith(suffix) for suffix in FORGE_SUFFIXES)
107
+
108
+
109
+ def parse_live_names(raw: str) -> list[str]:
110
+ """Comma-separated → de-duplicated list, preserving first-occurrence order.
111
+
112
+ Collection names from `qmd collection list` are user-facing strings;
113
+ we trust them as-is rather than imposing additional validation.
114
+ """
115
+ seen = set()
116
+ out: list[str] = []
117
+ for token in raw.split(","):
118
+ name = token.strip()
119
+ if not name:
120
+ continue
121
+ if name in seen:
122
+ continue
123
+ seen.add(name)
124
+ out.append(name)
125
+ return out
126
+
127
+
128
+ def load_registry_names(path: Path) -> list[str]:
129
+ """Read forge-tier.yaml and extract `name` from each qmd_collections entry.
130
+
131
+ Missing file → empty list (valid first-run state). Malformed file
132
+ (parse error, wrong top-level type) → exit 1 with an actionable
133
+ message. Entries without a `name` field are skipped silently — the
134
+ forge-tier-rw.py contract guarantees `name` is always present, but
135
+ a hand-edited file might violate it; classify what we can.
136
+ """
137
+ if not path.exists():
138
+ return []
139
+ try:
140
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
141
+ except yaml.YAMLError as e:
142
+ _die(1, f"failed to parse {path}: {e}")
143
+ if data is None:
144
+ return []
145
+ if not isinstance(data, dict):
146
+ _die(1, f"expected mapping at top of {path}, got {type(data).__name__}")
147
+ entries = data.get("qmd_collections", []) or []
148
+ if not isinstance(entries, list):
149
+ _die(1, f"qmd_collections in {path} is not a list (got {type(entries).__name__})")
150
+ names: list[str] = []
151
+ for entry in entries:
152
+ if isinstance(entry, dict) and isinstance(entry.get("name"), str):
153
+ names.append(entry["name"])
154
+ return names
155
+
156
+
157
+ def classify(live: list[str], registry: list[str]) -> dict:
158
+ """Pure function: classify live vs registry into healthy/orphaned/stale/foreign.
159
+
160
+ Returns the classification payload (without status/version envelope).
161
+ """
162
+ forge_live = [n for n in live if is_forge_owned(n)]
163
+ foreign_live = [n for n in live if not is_forge_owned(n)]
164
+
165
+ forge_live_set = set(forge_live)
166
+ registry_set = set(registry)
167
+ all_live_set = set(live)
168
+
169
+ healthy = sorted(forge_live_set & registry_set)
170
+ orphaned = sorted(forge_live_set - registry_set)
171
+ # Stale uses the full live set, NOT just the forge-filtered set, so a
172
+ # registry entry whose name happens to match a non-forge live collection
173
+ # would still count as stale. Registry entries always have forge suffixes
174
+ # by convention, so this distinction matters only on hand-edited files.
175
+ stale = sorted(registry_set - all_live_set)
176
+
177
+ return {
178
+ "healthy": healthy,
179
+ "orphaned": orphaned,
180
+ "stale": stale,
181
+ "foreign_filtered_count": len(foreign_live),
182
+ "foreign_filtered_sample": foreign_live[:FOREIGN_SAMPLE_CAP],
183
+ }
184
+
185
+
186
+ def main() -> None:
187
+ parser = argparse.ArgumentParser(
188
+ description="Classify QMD collections vs forge registry.",
189
+ formatter_class=argparse.RawDescriptionHelpFormatter,
190
+ )
191
+ parser.add_argument(
192
+ "--live-names",
193
+ default="",
194
+ help="Comma-separated list of collection names currently in QMD "
195
+ "(from `qmd collection list`). Empty string → no live collections.",
196
+ )
197
+ parser.add_argument(
198
+ "--registry-from-yaml",
199
+ type=Path,
200
+ required=True,
201
+ help="Path to forge-tier.yaml. Script reads qmd_collections array. "
202
+ "Missing file → empty registry (first-run state).",
203
+ )
204
+ args = parser.parse_args()
205
+
206
+ live = parse_live_names(args.live_names)
207
+ registry = load_registry_names(args.registry_from_yaml)
208
+ _ok(classify(live, registry))
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()