bmad-module-skill-forge 1.0.0 → 1.2.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 +9 -7
- package/docs/_data/pinned.yaml +5 -2
- package/docs/{RELEASING.md → _internal/RELEASING.md} +87 -61
- package/docs/{STABILITY.md → _internal/STABILITY.md} +2 -2
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/why-skf.md +5 -4
- package/package.json +2 -7
- package/release-audits/v1.0.0-launch-audit.md +313 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- 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-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-validate-frontmatter.py +9 -3
- package/src/skf-audit-skill/steps-c/step-01-init.md +60 -0
- package/src/skf-audit-skill/steps-c/step-02-re-index.md +7 -1
- package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +19 -1
- package/src/skf-audit-skill/steps-c/step-04-semantic-diff.md +10 -1
- package/src/skf-audit-skill/steps-c/step-05-severity-classify.md +8 -0
- package/src/skf-audit-skill/steps-c/step-06-report.md +8 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +36 -18
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- package/src/skf-drop-skill/steps-c/step-02-execute.md +1 -1
- package/src/skf-export-skill/assets/managed-section-format.md +1 -1
- package/src/skf-export-skill/steps-c/step-04-update-context.md +4 -4
- package/src/skf-rename-skill/steps-c/step-02-execute.md +1 -1
- 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-test-skill/steps-c/step-03-coverage-check.md +1 -1
- package/src/skf-update-skill/steps-c/step-02-detect-changes.md +123 -0
- package/tools/validate-docs-drift.js +42 -2
|
@@ -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()
|
|
@@ -11,9 +11,15 @@ agentskills/agentskills/skills-ref/src/skills_ref/validator.py.
|
|
|
11
11
|
Designed for pre-check use in test-skill, create-skill, update-skill, and
|
|
12
12
|
export-skill workflows. Returns structured JSON for deterministic integration.
|
|
13
13
|
|
|
14
|
-
CLI
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
|
|
15
|
+
above is auto-resolved on first call and cached. `docs/getting-started.md`
|
|
16
|
+
documents uv as the runtime prerequisite for exactly this. Bare
|
|
17
|
+
`python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
|
|
18
|
+
on a fresh interpreter where pyyaml has not been pip-installed
|
|
19
|
+
system-wide:
|
|
20
|
+
|
|
21
|
+
uv run skf-validate-frontmatter.py <skill-md-path>
|
|
22
|
+
uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
|
|
17
23
|
|
|
18
24
|
Input:
|
|
19
25
|
Path to a SKILL.md file.
|
|
@@ -104,12 +104,72 @@ If `{legacy_stack_provenance}` is true: log a note that this stack uses v1 prove
|
|
|
104
104
|
**If provenance map loaded:**
|
|
105
105
|
- Use `source_root` from provenance map as source code path
|
|
106
106
|
- Verify source path still exists and is accessible
|
|
107
|
+
- Extract `baseline_commit` = `provenance-map.source_commit` (fall back to `metadata.source_commit` if absent from the provenance map)
|
|
108
|
+
- Extract `baseline_ref` = `metadata.source_ref` (may be a tag, branch, `HEAD`, or `"local"`)
|
|
107
109
|
|
|
108
110
|
**If degraded mode:**
|
|
109
111
|
- Ask user: "Please provide the path to the current source code."
|
|
112
|
+
- `baseline_commit` and `baseline_ref` are unavailable — §5b will short-circuit
|
|
110
113
|
|
|
111
114
|
**Validate:** Confirm source directory exists and contains expected files.
|
|
112
115
|
|
|
116
|
+
### 5b. Detect Upstream Drift
|
|
117
|
+
|
|
118
|
+
Upstream drift detection is the primary use case of this workflow. If the local clone is still pinned to the baseline commit while upstream has shipped newer tags, auditing against the unchanged tree will misleadingly report CLEAN even after a major release.
|
|
119
|
+
|
|
120
|
+
**Skip this section** if any of the following hold:
|
|
121
|
+
- `baseline_ref` is `"local"`, `null`, or unset (non-git source)
|
|
122
|
+
- `{source_root}` is not a git worktree (`git -C {source_root} rev-parse --git-dir` fails)
|
|
123
|
+
- `baseline_commit` is unavailable
|
|
124
|
+
- Degraded mode is active (no provenance map)
|
|
125
|
+
|
|
126
|
+
When skipping, log the reason, then set the audit-ref context variables to baseline values so step-06 renders a coherent Provenance row: `audit_ref = baseline_ref or "(unknown)"`, `audit_ref_source = "baseline"` (or `"unavailable"` if both `baseline_ref` and `baseline_commit` are unset), `audit_commit = baseline_commit or "(unknown)"`, `latest_tag = null`, `remote_head = null`. Continue to §6.
|
|
127
|
+
|
|
128
|
+
**Otherwise:**
|
|
129
|
+
|
|
130
|
+
1. **Fetch upstream refs** (read-only, no working-tree mutation):
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
git -C {source_root} fetch --tags --quiet origin
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If fetch fails (no network, no remote, detached clone), log the reason, record `upstream_fetch: "failed:{reason}"` in context, set `audit_ref = baseline_ref`, `audit_ref_source = "baseline"`, `audit_commit = baseline_commit`, `latest_tag = null`, `remote_head = null`, and continue to §6 without gating.
|
|
137
|
+
|
|
138
|
+
2. **Find latest remote ref:**
|
|
139
|
+
- Remote default-branch HEAD: `git -C {source_root} rev-parse origin/HEAD` (fall back to `origin/main` or `origin/master` if the symbolic ref is unavailable) — record as `remote_head`.
|
|
140
|
+
- Newest semver tag: `git -C {source_root} for-each-ref --sort=-v:refname --format='%(refname:short)' 'refs/tags/v*' | head -1` — record as `latest_tag`.
|
|
141
|
+
|
|
142
|
+
3. **Compare to baseline:**
|
|
143
|
+
- If `baseline_commit` equals the commit that `remote_head` resolves to AND (`latest_tag` is empty OR semver-equals `baseline_ref` OR is older than `baseline_ref`), upstream has not moved. Set `audit_ref = baseline_ref`, `audit_ref_source = "baseline"`, `audit_commit = baseline_commit`. Continue to §6.
|
|
144
|
+
- Otherwise upstream has moved — proceed to the gate.
|
|
145
|
+
|
|
146
|
+
4. **User gate — Upstream drift detected:**
|
|
147
|
+
|
|
148
|
+
"**Upstream has moved since this skill was created.**
|
|
149
|
+
|
|
150
|
+
| | Baseline | Upstream |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| Ref | `{baseline_ref}` | `{latest_tag}` (newest tag) / `{remote_head}` (default HEAD) |
|
|
153
|
+
| Commit | `{baseline_commit_short}` | `{latest_tag_commit_short}` / `{remote_head_short}` |
|
|
154
|
+
|
|
155
|
+
Auditing against the baseline clone will report little-to-no structural drift even if the upstream API has changed. Options:
|
|
156
|
+
|
|
157
|
+
- **[C] Checkout-and-audit-against-latest** — checkout `{latest_tag}` (or `{remote_head}` if no newer tag) in `{source_root}` and audit against that. Re-extraction will reflect the current upstream surface.
|
|
158
|
+
- **[S] Stay-on-baseline** — keep `{source_root}` at `{baseline_ref}` and audit structural drift against the unchanged tree. The report will note `audit_ref = baseline`.
|
|
159
|
+
- **[X] Abort** — halt the workflow without producing a report.
|
|
160
|
+
|
|
161
|
+
**Select:** [C] / [S] / [X]"
|
|
162
|
+
|
|
163
|
+
**Gate handling:**
|
|
164
|
+
- **[C]:** Acquire an exclusive lock on `{source_root}/.skf-workspace.lock` (`flock -x` or `fcntl.flock(LOCK_EX)`) before mutating the working tree — matches the concurrency discipline in `src/skf-create-skill/references/source-resolution-protocols.md` and avoids racing with a concurrent create-skill / test-skill run against the same workspace clone. If `flock` is unavailable, emit a warning and proceed. Then `git -C {source_root} checkout {chosen_ref}` (prefer `latest_tag` when present, else `remote_head`). Set `audit_ref = {chosen_ref}`, `audit_ref_source = "checkout-latest"`, `audit_commit = git rev-parse HEAD`. Hold the lock through step-02 re-extraction and release only after the extraction snapshot is complete.
|
|
165
|
+
- **[S]:** Keep baseline. Set `audit_ref = baseline_ref`, `audit_ref_source = "baseline"`, `audit_commit = baseline_commit`.
|
|
166
|
+
- **[X]:** HALT workflow — do not create drift report.
|
|
167
|
+
- **Other input:** help user, redisplay gate.
|
|
168
|
+
|
|
169
|
+
**Headless default** (when `{headless_mode}`): auto-select **[S]** and emit a loud log line: `"headless: upstream drift detected ({baseline_ref} → {latest_tag or remote_head}); staying on baseline. Re-run interactively to audit against latest."` Do not check out in headless mode — silent ref changes under automation would mutate the user's working tree without consent.
|
|
170
|
+
|
|
171
|
+
5. **Record for report:** store `audit_ref`, `audit_ref_source`, `audit_commit`, `latest_tag`, `remote_head`, and `baseline_commit` in context. Step-06 surfaces them in the Provenance section so readers can tell which comparison actually ran.
|
|
172
|
+
|
|
113
173
|
### 6. Create Drift Report
|
|
114
174
|
|
|
115
175
|
Create `{outputFile}` from `{templateFile}`:
|