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,413 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Forge Tier RW — Read/write primitives for forger-sidecar YAML files.
6
+
7
+ Replaces the prose-driven YAML emission in `src/skf-setup/steps-c/
8
+ step-02-write-config.md` §1-§3 (and the prose-driven cleanup logic in
9
+ step-03 §5/§5b) with one Python invocation. The script is the source
10
+ of truth for the on-disk forge-tier.yaml schema (matching the
11
+ canonical template at step-02-write-config.md:24-58) and guarantees
12
+ that registry arrays are PRESERVED across rewrites — losing
13
+ `qmd_collections` or `ccc_index_registry` would break every
14
+ downstream skill (skf-create-skill, skf-audit-skill, skf-update-skill,
15
+ skf-brief-skill) that reads them.
16
+
17
+ Subcommands:
18
+
19
+ read Read forge-tier.yaml and emit the parsed structure as JSON
20
+ on stdout. Missing-file is not an error — emits null
21
+ payload with status=ok so first-run callers can branch.
22
+
23
+ write-tools Write a fresh forge-tier.yaml from a JSON context payload
24
+ on stdin. Preserves `qmd_collections`,
25
+ `ccc_index_registry`, and the user-customizable
26
+ `ccc_index.staleness_threshold_hours` from the existing
27
+ file (if any) by reading it first, then merging.
28
+
29
+ init-prefs Create preferences.yaml with first-run defaults IF it
30
+ does not exist. Idempotent — refuses to overwrite an
31
+ existing file (preserves user customization).
32
+
33
+ clean-stale Two cleanup operations gated by flags:
34
+ --qmd-live-names a,b,c — remove qmd_collections
35
+ entries whose `name` is not in the comma-separated
36
+ live-names list. Caller computes liveness via
37
+ `qmd collection list` (or skf-qmd-classify-
38
+ collections.py once that ships in PR B).
39
+ --prune-missing-ccc-paths — remove ccc_index_registry
40
+ entries whose `path` no longer exists on disk.
41
+ Both flags can be passed in the same invocation.
42
+
43
+ Output schema (the read subcommand and the response from every write):
44
+
45
+ {"status": "ok", "version": "v1", ...subcommand-specific fields...}
46
+
47
+ Errors emit `{"status": "error", "message": "..."}` to stderr and
48
+ exit non-zero (1 for user error, 2 for I/O failure).
49
+
50
+ Cross-platform: pure stdlib + PyYAML. Atomic writes via temp + rename
51
+ mirror skf-atomic-write.py's pattern. Concurrent access requires
52
+ external `flock` coordination — see step-07 of skf-create-skill for
53
+ the precedent.
54
+
55
+ CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
56
+ above is auto-resolved on first call and cached. `docs/getting-started.md`
57
+ documents uv as the runtime prerequisite for exactly this. Bare
58
+ `python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
59
+ on a fresh interpreter where pyyaml has not been pip-installed
60
+ system-wide:
61
+
62
+ uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
63
+ echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
64
+ uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
65
+ uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
66
+ --qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import argparse
72
+ import json
73
+ import os
74
+ import sys
75
+ from datetime import datetime, timezone
76
+ from pathlib import Path
77
+
78
+ import yaml
79
+
80
+
81
+ DEFAULT_STALENESS_HOURS = 24
82
+ PREFERENCES_TEMPLATE = """# Ferris Sidecar: User Preferences
83
+ # Created by setup workflow on first run
84
+ # Edit this file to customize Ferris behavior
85
+
86
+ # Override detected tier (set to Quick, Forge, Forge+, or Deep to force a tier)
87
+ tier_override: ~
88
+
89
+ # Passive context injection (set to false to skip snippet generation and CLAUDE.md updates during export)
90
+ passive_context: true
91
+
92
+ # Headless mode (set to true to skip confirmation gates in all workflows)
93
+ headless_mode: false
94
+
95
+ # Compact greeting (set to true to skip the full capabilities table on session start)
96
+ compact_greeting: false
97
+
98
+ # Reserved for future use — these fields are not yet consumed by any workflow step
99
+ # output_language: ~
100
+ # skill_format_version: ~
101
+ # citation_style: ~
102
+ # confidence_display: ~
103
+ """
104
+
105
+
106
+ def _die(code: int, message: str) -> None:
107
+ print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
108
+ sys.exit(code)
109
+
110
+
111
+ def _ok(payload: dict) -> None:
112
+ payload.setdefault("status", "ok")
113
+ payload.setdefault("version", "v1")
114
+ print(json.dumps(payload, default=str))
115
+
116
+
117
+ def _read_yaml(path: Path) -> dict | None:
118
+ """Return parsed YAML as dict, or None if file missing. Raises on parse error."""
119
+ if not path.exists():
120
+ return None
121
+ try:
122
+ with path.open("r", encoding="utf-8") as f:
123
+ data = yaml.safe_load(f)
124
+ except yaml.YAMLError as e:
125
+ _die(2, f"failed to parse {path}: {e}")
126
+ if data is None:
127
+ return {}
128
+ if not isinstance(data, dict):
129
+ _die(2, f"expected mapping at top of {path}, got {type(data).__name__}")
130
+ return data
131
+
132
+
133
+ def _atomic_write(target: Path, content: str) -> None:
134
+ """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
135
+ target.parent.mkdir(parents=True, exist_ok=True)
136
+ tmp = target.with_name(target.name + ".skf-tmp")
137
+ try:
138
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
139
+ try:
140
+ os.write(fd, content.encode("utf-8"))
141
+ os.fsync(fd)
142
+ finally:
143
+ os.close(fd)
144
+ os.replace(tmp, target)
145
+ except OSError as e:
146
+ if tmp.exists():
147
+ try:
148
+ tmp.unlink()
149
+ except OSError:
150
+ pass
151
+ _die(2, f"atomic write failed for {target}: {e}")
152
+
153
+
154
+ def _yaml_block(value, indent: int = 0) -> str:
155
+ """Dump a value as a YAML fragment, indented by `indent` spaces, no trailing newline."""
156
+ text = yaml.safe_dump(value, default_flow_style=False, sort_keys=False, allow_unicode=True)
157
+ text = text.rstrip("\n")
158
+ if indent == 0:
159
+ return text
160
+ pad = " " * indent
161
+ return "\n".join(pad + line if line else line for line in text.split("\n"))
162
+
163
+
164
+ def render_forge_tier_yaml(payload: dict) -> str:
165
+ """Render the canonical forge-tier.yaml from a context payload.
166
+
167
+ Preserves the human-readable section comments from the template at
168
+ step-02-write-config.md:24-58. Sections are emitted in a fixed order
169
+ so re-runs against unchanged inputs produce byte-identical output.
170
+ """
171
+ tools = payload["tools"]
172
+ tier = payload["tier"]
173
+ tier_detected_at = payload["tier_detected_at"]
174
+ ccc_index = payload["ccc_index"]
175
+ ccc_index_registry = payload.get("ccc_index_registry", [])
176
+ qmd_collections = payload.get("qmd_collections", [])
177
+
178
+ # Render `tools` block in the canonical key order (matches step-02 template).
179
+ tools_ordered = {
180
+ "ast_grep": tools["ast_grep"],
181
+ "gh_cli": tools["gh_cli"],
182
+ "qmd": tools["qmd"],
183
+ "ccc": tools["ccc"],
184
+ "ccc_daemon": tools.get("ccc_daemon"),
185
+ "security_scan": tools.get("security_scan", False),
186
+ }
187
+
188
+ # ccc_index keys in canonical order.
189
+ ccc_ordered = {
190
+ "indexed_path": ccc_index.get("indexed_path"),
191
+ "last_indexed": ccc_index.get("last_indexed"),
192
+ "status": ccc_index.get("status"),
193
+ "staleness_threshold_hours": ccc_index.get("staleness_threshold_hours", DEFAULT_STALENESS_HOURS),
194
+ "file_count": ccc_index.get("file_count"),
195
+ "exclude_patterns": ccc_index.get("exclude_patterns", []),
196
+ }
197
+
198
+ parts = [
199
+ "# Ferris Sidecar: Forge Tier State",
200
+ "# Written by setup workflow",
201
+ "",
202
+ "# Tool availability (detected during [SF] Setup Forge)",
203
+ _yaml_block({"tools": tools_ordered}),
204
+ "",
205
+ "# Capability tier (derived from tool availability)",
206
+ "# Quick = no tools | Forge = + ast-grep | Forge+ = + ast-grep + ccc | Deep = + ast-grep + gh + QMD",
207
+ f"tier: {tier}",
208
+ f"tier_detected_at: {_yaml_scalar(tier_detected_at)}",
209
+ "",
210
+ "# CCC semantic index state (managed by setup step-01b and extraction workflows)",
211
+ _yaml_block({"ccc_index": ccc_ordered}),
212
+ "",
213
+ "# CCC index registry (tracks which source paths have been indexed for skill workflows)",
214
+ "# PRESERVE existing entries on re-runs",
215
+ _yaml_block({"ccc_index_registry": ccc_index_registry}),
216
+ "",
217
+ "# QMD collection registry (populated by create-skill, consumed by audit/update-skill)",
218
+ "# PRESERVE existing entries on re-runs",
219
+ _yaml_block({"qmd_collections": qmd_collections}),
220
+ "",
221
+ ]
222
+ return "\n".join(parts)
223
+
224
+
225
+ def _yaml_scalar(value) -> str:
226
+ """Render a scalar value using YAML's own quoting rules so timestamps, booleans, etc. round-trip."""
227
+ return yaml.safe_dump(value, default_flow_style=False).rstrip("\n").rstrip("...").rstrip()
228
+
229
+
230
+ def _merge_preserved_fields(payload: dict, existing: dict | None) -> dict:
231
+ """Inject preserved fields from the existing file into the new payload.
232
+
233
+ Three preservation rules (per step-02 §1 "Note on re-runs"):
234
+ - `qmd_collections` array — preserved entirely from existing.
235
+ - `ccc_index_registry` array — preserved entirely from existing.
236
+ - `ccc_index.staleness_threshold_hours` scalar — preserved if user set
237
+ a non-default value; else uses payload value or DEFAULT.
238
+ Note: `ccc_index.exclude_patterns` is NOT preserved (rewritten fresh
239
+ by step-01b on every run, per the explicit step-02 contract).
240
+ """
241
+ if existing is None:
242
+ return payload
243
+
244
+ payload.setdefault("qmd_collections", existing.get("qmd_collections", []))
245
+ payload.setdefault("ccc_index_registry", existing.get("ccc_index_registry", []))
246
+
247
+ payload.setdefault("ccc_index", {})
248
+ existing_ccc = existing.get("ccc_index", {}) or {}
249
+ if "staleness_threshold_hours" not in payload["ccc_index"]:
250
+ payload["ccc_index"]["staleness_threshold_hours"] = existing_ccc.get(
251
+ "staleness_threshold_hours", DEFAULT_STALENESS_HOURS
252
+ )
253
+ return payload
254
+
255
+
256
+ # ─── Subcommands ─────────────────────────────────────────────────────────────
257
+
258
+
259
+ def cmd_read(target: Path) -> None:
260
+ data = _read_yaml(target)
261
+ if data is None:
262
+ _ok({"exists": False, "data": None})
263
+ return
264
+ _ok({"exists": True, "data": data})
265
+
266
+
267
+ def cmd_write_tools(target: Path) -> None:
268
+ raw = sys.stdin.read()
269
+ if not raw.strip():
270
+ _die(1, "write-tools: empty stdin (expected JSON payload)")
271
+ try:
272
+ payload = json.loads(raw)
273
+ except json.JSONDecodeError as e:
274
+ _die(1, f"write-tools: invalid JSON on stdin: {e}")
275
+
276
+ required = {"tools", "tier", "ccc_index"}
277
+ missing = required - set(payload.keys())
278
+ if missing:
279
+ _die(1, f"write-tools: payload missing required keys: {sorted(missing)}")
280
+
281
+ payload.setdefault("tier_detected_at", datetime.now(timezone.utc).isoformat())
282
+
283
+ existing = _read_yaml(target)
284
+ payload = _merge_preserved_fields(payload, existing)
285
+
286
+ rendered = render_forge_tier_yaml(payload)
287
+ _atomic_write(target, rendered)
288
+ _ok({
289
+ "wrote": str(target),
290
+ "preserved_arrays": {
291
+ "qmd_collections": len(payload.get("qmd_collections", [])),
292
+ "ccc_index_registry": len(payload.get("ccc_index_registry", [])),
293
+ },
294
+ "tier": payload["tier"],
295
+ })
296
+
297
+
298
+ def cmd_init_prefs(target: Path) -> None:
299
+ if target.exists():
300
+ _ok({"exists": True, "wrote": False, "path": str(target)})
301
+ return
302
+ _atomic_write(target, PREFERENCES_TEMPLATE)
303
+ _ok({"exists": True, "wrote": True, "path": str(target), "first_run": True})
304
+
305
+
306
+ def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
307
+ prune_missing_ccc_paths: bool) -> None:
308
+ data = _read_yaml(target)
309
+ if data is None:
310
+ _die(1, f"clean-stale: target does not exist: {target}")
311
+
312
+ qmd_removed: list[str] = []
313
+ ccc_removed: list[str] = []
314
+
315
+ if qmd_live_names is not None:
316
+ live_set = set(qmd_live_names)
317
+ kept = []
318
+ for entry in data.get("qmd_collections", []) or []:
319
+ if not isinstance(entry, dict):
320
+ kept.append(entry)
321
+ continue
322
+ name = entry.get("name")
323
+ if name in live_set:
324
+ kept.append(entry)
325
+ else:
326
+ qmd_removed.append(str(name))
327
+ data["qmd_collections"] = kept
328
+
329
+ if prune_missing_ccc_paths:
330
+ kept = []
331
+ for entry in data.get("ccc_index_registry", []) or []:
332
+ if not isinstance(entry, dict):
333
+ kept.append(entry)
334
+ continue
335
+ entry_path = entry.get("path")
336
+ if entry_path and Path(entry_path).exists():
337
+ kept.append(entry)
338
+ else:
339
+ ccc_removed.append(str(entry_path))
340
+ data["ccc_index_registry"] = kept
341
+
342
+ if not qmd_removed and not ccc_removed:
343
+ _ok({"qmd_removed": [], "ccc_removed": [], "wrote": False})
344
+ return
345
+
346
+ # Round-trip through render to preserve the canonical format.
347
+ # Existing data already contains the full state; render needs the same shape.
348
+ payload = {
349
+ "tools": data.get("tools", {}),
350
+ "tier": data.get("tier", "Quick"),
351
+ "tier_detected_at": data.get("tier_detected_at",
352
+ datetime.now(timezone.utc).isoformat()),
353
+ "ccc_index": data.get("ccc_index", {}),
354
+ "ccc_index_registry": data.get("ccc_index_registry", []),
355
+ "qmd_collections": data.get("qmd_collections", []),
356
+ }
357
+ rendered = render_forge_tier_yaml(payload)
358
+ _atomic_write(target, rendered)
359
+ _ok({
360
+ "qmd_removed": qmd_removed,
361
+ "ccc_removed": ccc_removed,
362
+ "wrote": True,
363
+ })
364
+
365
+
366
+ # ─── CLI ─────────────────────────────────────────────────────────────────────
367
+
368
+
369
+ def main() -> None:
370
+ parser = argparse.ArgumentParser(
371
+ description="Read/write primitives for forger-sidecar YAML files.",
372
+ formatter_class=argparse.RawDescriptionHelpFormatter,
373
+ )
374
+ sub = parser.add_subparsers(dest="cmd", required=True)
375
+
376
+ p_read = sub.add_parser("read", help="Read a forge-tier.yaml and emit JSON")
377
+ p_read.add_argument("--target", type=Path, required=True)
378
+
379
+ p_write = sub.add_parser("write-tools",
380
+ help="Write a fresh forge-tier.yaml from a JSON payload on stdin")
381
+ p_write.add_argument("--target", type=Path, required=True)
382
+
383
+ p_init = sub.add_parser("init-prefs",
384
+ help="Create preferences.yaml with first-run defaults if missing")
385
+ p_init.add_argument("--target", type=Path, required=True)
386
+
387
+ p_clean = sub.add_parser("clean-stale",
388
+ help="Remove stale qmd_collections / ccc_index_registry entries")
389
+ p_clean.add_argument("--target", type=Path, required=True)
390
+ p_clean.add_argument("--qmd-live-names", default=None,
391
+ help="Comma-separated list of currently-live QMD collection names. "
392
+ "Entries in qmd_collections whose name is NOT in this list are removed. "
393
+ "Omit the flag entirely to skip QMD cleanup.")
394
+ p_clean.add_argument("--prune-missing-ccc-paths", action="store_true",
395
+ help="Remove ccc_index_registry entries whose path no longer exists.")
396
+
397
+ args = parser.parse_args()
398
+
399
+ if args.cmd == "read":
400
+ cmd_read(args.target)
401
+ elif args.cmd == "write-tools":
402
+ cmd_write_tools(args.target)
403
+ elif args.cmd == "init-prefs":
404
+ cmd_init_prefs(args.target)
405
+ elif args.cmd == "clean-stale":
406
+ live = None
407
+ if args.qmd_live_names is not None:
408
+ live = [n.strip() for n in args.qmd_live_names.split(",") if n.strip()]
409
+ cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
410
+
411
+
412
+ if __name__ == "__main__":
413
+ main()