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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +6 -4
- package/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/skill-model.md +26 -32
- package/docs/troubleshooting.md +13 -1
- package/docs/why-skf.md +5 -4
- package/docs/workflows.md +53 -0
- package/package.json +2 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- package/src/shared/references/output-contract-schema.md +10 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
- package/src/shared/scripts/skf-detect-tools.py +359 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
- package/src/shared/scripts/skf-extract-public-api.py +505 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
- package/src/shared/scripts/skf-preflight.py +14 -4
- package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
- package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
- package/src/shared/scripts/skf-resolve-package.py +264 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
- package/src/shared/scripts/skf-validate-output.py +24 -7
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- package/src/skf-quick-skill/SKILL.md +178 -10
- package/src/skf-quick-skill/assets/skill-template.md +5 -1
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
- package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
- package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
- package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
- package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
- package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
- package/src/skf-setup/SKILL.md +25 -10
- package/src/skf-setup/references/tier-rules.md +2 -2
- package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
- package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
- package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
- package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
- package/src/skf-setup/steps-c/step-04-report.md +135 -11
- package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
- package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
- 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
|
|
11
|
-
|
|
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({
|
|
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:
|
|
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()
|