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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +9 -7
  3. package/docs/_data/pinned.yaml +5 -2
  4. package/docs/{RELEASING.md → _internal/RELEASING.md} +87 -61
  5. package/docs/{STABILITY.md → _internal/STABILITY.md} +2 -2
  6. package/docs/bmad-synergy.md +2 -2
  7. package/docs/getting-started.md +1 -1
  8. package/docs/troubleshooting.md +1 -1
  9. package/docs/why-skf.md +5 -4
  10. package/package.json +2 -7
  11. package/release-audits/v1.0.0-launch-audit.md +313 -2
  12. package/src/knowledge/ccc-bridge.md +1 -1
  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-forge-tier-rw.py +413 -0
  17. package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
  18. package/src/shared/scripts/skf-preflight.py +14 -4
  19. package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
  20. package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
  21. package/src/skf-audit-skill/steps-c/step-01-init.md +60 -0
  22. package/src/skf-audit-skill/steps-c/step-02-re-index.md +7 -1
  23. package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +19 -1
  24. package/src/skf-audit-skill/steps-c/step-04-semantic-diff.md +10 -1
  25. package/src/skf-audit-skill/steps-c/step-05-severity-classify.md +8 -0
  26. package/src/skf-audit-skill/steps-c/step-06-report.md +8 -0
  27. package/src/skf-brief-skill/assets/skill-brief-schema.md +36 -18
  28. package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
  29. package/src/skf-drop-skill/steps-c/step-02-execute.md +1 -1
  30. package/src/skf-export-skill/assets/managed-section-format.md +1 -1
  31. package/src/skf-export-skill/steps-c/step-04-update-context.md +4 -4
  32. package/src/skf-rename-skill/steps-c/step-02-execute.md +1 -1
  33. package/src/skf-setup/SKILL.md +25 -10
  34. package/src/skf-setup/references/tier-rules.md +2 -2
  35. package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
  36. package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
  37. package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
  38. package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
  39. package/src/skf-setup/steps-c/step-04-report.md +135 -11
  40. package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
  41. package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
  42. package/src/skf-test-skill/steps-c/step-03-coverage-check.md +1 -1
  43. package/src/skf-update-skill/steps-c/step-02-detect-changes.md +123 -0
  44. package/tools/validate-docs-drift.js +42 -2
@@ -0,0 +1,419 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Emit Result Envelope — Schema-locked headless output for skf-setup.
6
+
7
+ Replaces the prose-driven envelope assembly in `src/skf-setup/steps-c/
8
+ step-04-report.md` §4 with one Python invocation. The envelope contract
9
+ (SKF_SETUP_RESULT_JSON) was added to step-04 in PR #247, extended in
10
+ PR #248 (header / outputs / failure modes), and extended again in this
11
+ PR (previous_tier, tier_changed, tools_added, tools_removed, error).
12
+
13
+ LLM-rendered envelopes risk silent schema drift on every invocation —
14
+ a pipeline that grep's `SKF_SETUP_RESULT_JSON: {…}` out of the workflow
15
+ log can break if the model decides to rename a key or rearrange a
16
+ nested structure. This script is the single source of truth: it takes
17
+ a context payload as JSON on stdin, computes derived fields
18
+ deterministically, and emits the envelope in a fixed shape that
19
+ matches the JSON Schema at
20
+ `src/shared/scripts/schemas/skf-setup-result-envelope.v1.json`.
21
+
22
+ Subcommands:
23
+
24
+ emit Read context payload as JSON on stdin, derive missing
25
+ fields (tools_added/removed, tier_changed), assemble the
26
+ envelope, emit `SKF_SETUP_RESULT_JSON: {one-line JSON}`
27
+ on stdout. Default subcommand.
28
+
29
+ validate Read an envelope (without the prefix) as JSON on stdin
30
+ and verify it against the documented schema. No stdout
31
+ on success; non-zero exit + stderr error on failure.
32
+ Useful for paranoid pipelines that want to validate a
33
+ received envelope before consuming it.
34
+
35
+ Context payload shape (consumed by `emit`):
36
+
37
+ {
38
+ "tier": "Quick|Forge|Forge+|Deep",
39
+ "previous_tier": "Quick|Forge|Forge+|Deep|null",
40
+ "tools": {"ast_grep": bool, "gh_cli": bool, "qmd": bool, "ccc": bool},
41
+ "previous_tools": {…same shape…|null},
42
+ "config_path": "/abs/path/to/forge-tier.yaml",
43
+ "ccc_index": {"status": "...", "indexed_path": "...|null", "file_count": int|null},
44
+ "files_written": ["forge-tier.yaml", ...],
45
+ "tier_override_active": bool,
46
+ "tier_override_invalid": bool,
47
+ "tier_override_invalid_value": "string|null",
48
+ "tier_override_invalid_suggestion": "string|null",
49
+ "tier_override_unsafe": bool,
50
+ "tier_override_unsafe_missing": ["gh", "qmd", ...],
51
+ "require_tier_satisfied": bool|null,
52
+ "require_tier_failure_missing": ["ccc", ...],
53
+ "qmd_status": "absent|daemon_stopped|healthy",
54
+ "ccc_exclusion_warnings": ["string", ...],
55
+ "ccc_registry_stale_removed": ["/path", ...],
56
+ "ccc_indexing_failed_reason": "string|null",
57
+ "error": null|{"phase","path","reason"}
58
+ }
59
+
60
+ Caller does NOT need to compute warnings, tools_added/removed, or
61
+ tier_changed — the script derives them from the inputs above.
62
+
63
+ CLI — invoke via `uv run` for invocation consistency with sibling
64
+ scripts (PEP 723 inline metadata is honored automatically; this script
65
+ declares dependencies = [] so technically `python3` works too, but
66
+ prefer `uv run` so all 5 cutover scripts share one canonical invocation
67
+ pattern documented in docs/getting-started.md):
68
+
69
+ echo '{...context payload...}' | uv run skf-emit-result-envelope.py emit
70
+ echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
71
+
72
+ Exit codes:
73
+ 0 success
74
+ 1 user error (bad args, malformed JSON, validation failure)
75
+ 2 internal error
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import argparse
81
+ import json
82
+ import os
83
+ import sys
84
+ from pathlib import Path
85
+
86
+
87
+ ENVELOPE_PREFIX = "SKF_SETUP_RESULT_JSON: "
88
+ SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.json"
89
+ TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
90
+ VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
91
+ VALID_FILES = ("forge-tier.yaml", "preferences.yaml", "settings.yml", "ccc_index")
92
+ VALID_CCC_STATUS = ("fresh", "created", "failed", "none")
93
+
94
+
95
+ def _die(code: int, message: str) -> None:
96
+ print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
97
+ sys.exit(code)
98
+
99
+
100
+ def _read_stdin_json(label: str) -> dict:
101
+ raw = sys.stdin.read()
102
+ if not raw.strip():
103
+ _die(1, f"{label}: empty stdin (expected JSON payload)")
104
+ try:
105
+ return json.loads(raw)
106
+ except json.JSONDecodeError as e:
107
+ _die(1, f"{label}: invalid JSON on stdin: {e}")
108
+
109
+
110
+ # ─── derive helpers ─────────────────────────────────────────────────────────
111
+
112
+
113
+ def _normalize_tools(maybe_tools) -> dict:
114
+ """Coerce either {key: bool} or {key: {available: bool}} into {key: bool}.
115
+
116
+ skf-detect-tools.py emits the second shape; some callers will pass the
117
+ first. Tolerate both for robustness; missing keys default to False.
118
+ """
119
+ if maybe_tools is None:
120
+ return {k: False for k in TOOL_KEYS}
121
+ out = {}
122
+ for k in TOOL_KEYS:
123
+ v = maybe_tools.get(k)
124
+ if isinstance(v, dict):
125
+ out[k] = bool(v.get("available", False))
126
+ else:
127
+ out[k] = bool(v)
128
+ return out
129
+
130
+
131
+ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]:
132
+ """Return (added, removed) tool-key lists, sorted for determinism.
133
+
134
+ First-run convention (previous is None or empty): added = currently
135
+ available tools; removed = []. Matches the documented step-04 §4 rule.
136
+ """
137
+ cur = _normalize_tools(current)
138
+ if not previous:
139
+ added = sorted(k for k, v in cur.items() if v)
140
+ return added, []
141
+ prev = _normalize_tools(previous)
142
+ added = sorted(k for k in TOOL_KEYS if cur[k] and not prev[k])
143
+ removed = sorted(k for k in TOOL_KEYS if prev[k] and not cur[k])
144
+ return added, removed
145
+
146
+
147
+ def _assemble_warnings(payload: dict) -> list[str]:
148
+ """Fold every documented warning source into the envelope's warnings array.
149
+
150
+ Matches the field-rules block in step-04 §4 — pipelines should only need
151
+ to consult `warnings` to surface non-fatal issues.
152
+ """
153
+ warnings: list[str] = []
154
+ if payload.get("tier_override_invalid"):
155
+ bad = payload.get("tier_override_invalid_value")
156
+ suggestion = payload.get("tier_override_invalid_suggestion")
157
+ bad_text = bad if bad is not None else "<unknown>"
158
+ if suggestion:
159
+ warnings.append(f"tier_override_invalid: {bad_text} (did you mean {suggestion}?)")
160
+ else:
161
+ warnings.append(f"tier_override_invalid: {bad_text}")
162
+ if payload.get("tier_override_unsafe"):
163
+ missing = payload.get("tier_override_unsafe_missing", []) or []
164
+ warnings.append(f"tier_override_unsafe: missing {', '.join(missing) if missing else '<none>'}")
165
+ for w in payload.get("ccc_exclusion_warnings", []) or []:
166
+ warnings.append(str(w))
167
+ for p in payload.get("ccc_registry_stale_removed", []) or []:
168
+ warnings.append(f"ccc_registry_stale_removed: {p}")
169
+ if payload.get("qmd_status") == "daemon_stopped":
170
+ warnings.append("qmd_daemon_stopped")
171
+ failure_reason = payload.get("ccc_indexing_failed_reason")
172
+ if failure_reason:
173
+ warnings.append(f"ccc_indexing_failed: {failure_reason}")
174
+ if payload.get("require_tier_satisfied") is False:
175
+ missing = payload.get("require_tier_failure_missing", []) or []
176
+ warnings.append(
177
+ f"require_tier_failed: missing {', '.join(missing) if missing else '<none>'}"
178
+ )
179
+ return warnings
180
+
181
+
182
+ def _normalize_files_written(maybe_files) -> list[str]:
183
+ """Accept either a list of names or a dict {name: bool}; emit sorted-canonical list."""
184
+ if maybe_files is None:
185
+ return []
186
+ if isinstance(maybe_files, dict):
187
+ names = [k for k, v in maybe_files.items() if v]
188
+ elif isinstance(maybe_files, list):
189
+ names = [str(x) for x in maybe_files]
190
+ else:
191
+ _die(1, f"files_written must be list or dict, got {type(maybe_files).__name__}")
192
+ # Filter to documented names only; preserve canonical order rather than
193
+ # caller-provided order so two callers with the same files get byte-identical
194
+ # output.
195
+ return [name for name in VALID_FILES if name in names]
196
+
197
+
198
+ def _normalize_ccc_index(maybe_idx) -> dict:
199
+ """Coerce caller's ccc_index dict to the envelope shape (drops extras)."""
200
+ if maybe_idx is None:
201
+ return {"status": "none", "indexed_path": None, "file_count": None}
202
+ return {
203
+ "status": maybe_idx.get("status", "none"),
204
+ "indexed_path": maybe_idx.get("indexed_path"),
205
+ "file_count": maybe_idx.get("file_count"),
206
+ }
207
+
208
+
209
+ def _normalize_error(maybe_error) -> dict | None:
210
+ if maybe_error is None:
211
+ return None
212
+ if not isinstance(maybe_error, dict):
213
+ _die(1, f"error must be null or object, got {type(maybe_error).__name__}")
214
+ required = {"phase", "path", "reason"}
215
+ missing = required - set(maybe_error.keys())
216
+ if missing:
217
+ _die(1, f"error object missing required keys: {sorted(missing)}")
218
+ return {
219
+ "phase": str(maybe_error["phase"]),
220
+ "path": str(maybe_error["path"]),
221
+ "reason": str(maybe_error["reason"]),
222
+ }
223
+
224
+
225
+ # ─── envelope assembly ──────────────────────────────────────────────────────
226
+
227
+
228
+ def assemble_envelope(payload: dict) -> dict:
229
+ """Build the canonical envelope from a context payload. Pure function."""
230
+ tier = payload.get("tier")
231
+ if tier not in VALID_TIERS:
232
+ _die(1, f"tier must be one of {VALID_TIERS}, got {tier!r}")
233
+
234
+ previous_tier = payload.get("previous_tier")
235
+ if previous_tier is not None and previous_tier not in VALID_TIERS:
236
+ _die(1, f"previous_tier must be one of {VALID_TIERS} or null, got {previous_tier!r}")
237
+ tier_changed = previous_tier is not None and previous_tier != tier
238
+
239
+ tools = _normalize_tools(payload.get("tools"))
240
+ tools_added, tools_removed = _compute_tool_deltas(
241
+ payload.get("tools"), payload.get("previous_tools")
242
+ )
243
+
244
+ config_path = payload.get("config_path")
245
+ if not isinstance(config_path, str) or not config_path:
246
+ _die(1, "config_path must be a non-empty string")
247
+
248
+ ccc_index = _normalize_ccc_index(payload.get("ccc_index"))
249
+ if ccc_index["status"] not in VALID_CCC_STATUS:
250
+ _die(1, f"ccc_index.status must be one of {VALID_CCC_STATUS}, got {ccc_index['status']!r}")
251
+
252
+ require_tier_satisfied = payload.get("require_tier_satisfied")
253
+ if require_tier_satisfied is not None and not isinstance(require_tier_satisfied, bool):
254
+ _die(1, f"require_tier_satisfied must be bool or null, got {type(require_tier_satisfied).__name__}")
255
+
256
+ return {
257
+ "skf_setup": {
258
+ "tier": tier,
259
+ "previous_tier": previous_tier,
260
+ "tier_changed": tier_changed,
261
+ "tools": tools,
262
+ "tools_added": tools_added,
263
+ "tools_removed": tools_removed,
264
+ "config_path": config_path,
265
+ "ccc_index": ccc_index,
266
+ "files_written": _normalize_files_written(payload.get("files_written")),
267
+ "tier_override_active": bool(payload.get("tier_override_active", False)),
268
+ "tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
269
+ "require_tier_satisfied": require_tier_satisfied,
270
+ "warnings": _assemble_warnings(payload),
271
+ "error": _normalize_error(payload.get("error")),
272
+ }
273
+ }
274
+
275
+
276
+ def emit_envelope_line(envelope: dict) -> str:
277
+ """Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism."""
278
+ body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
279
+ if "\n" in body:
280
+ _die(2, "envelope serialization produced embedded newline (should be impossible)")
281
+ return ENVELOPE_PREFIX + body
282
+
283
+
284
+ # ─── minimal stdlib JSON Schema validator ───────────────────────────────────
285
+
286
+
287
+ def _load_schema() -> dict:
288
+ if not SCHEMA_FILE.exists():
289
+ _die(2, f"schema file missing: {SCHEMA_FILE}")
290
+ return json.loads(SCHEMA_FILE.read_text(encoding="utf-8"))
291
+
292
+
293
+ def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
294
+ """Return a list of error strings (empty = valid).
295
+
296
+ Implements only the subset of Draft 2020-12 features used by our envelope
297
+ schema: type, enum, oneOf, properties, additionalProperties, required,
298
+ items, uniqueItems, minLength, minimum. Sufficient to catch every
299
+ violation our schema can express; not a general-purpose validator.
300
+ """
301
+ errors: list[str] = []
302
+
303
+ if "oneOf" in schema:
304
+ matches = sum(1 for sub in schema["oneOf"] if not _validate_against_schema(value, sub, path))
305
+ if matches != 1:
306
+ errors.append(f"{path}: matched {matches} of {len(schema['oneOf'])} oneOf branches (expected exactly 1)")
307
+ return errors
308
+
309
+ expected_type = schema.get("type")
310
+ if expected_type is not None:
311
+ if not _matches_type(value, expected_type):
312
+ errors.append(f"{path}: expected type {expected_type}, got {type(value).__name__}")
313
+ return errors
314
+
315
+ if "enum" in schema:
316
+ if value not in schema["enum"]:
317
+ errors.append(f"{path}: value {value!r} not in enum {schema['enum']}")
318
+
319
+ if isinstance(value, dict):
320
+ props = schema.get("properties", {})
321
+ required = set(schema.get("required", []))
322
+ present = set(value.keys())
323
+ missing = required - present
324
+ for k in sorted(missing):
325
+ errors.append(f"{path}: missing required property {k!r}")
326
+ if schema.get("additionalProperties") is False:
327
+ extra = present - set(props.keys())
328
+ for k in sorted(extra):
329
+ errors.append(f"{path}: unexpected property {k!r}")
330
+ for k, sub in props.items():
331
+ if k in value:
332
+ errors.extend(_validate_against_schema(value[k], sub, f"{path}.{k}"))
333
+
334
+ if isinstance(value, list):
335
+ if "items" in schema:
336
+ for i, item in enumerate(value):
337
+ errors.extend(_validate_against_schema(item, schema["items"], f"{path}[{i}]"))
338
+ if schema.get("uniqueItems") and len(value) != len(set(map(_freeze, value))):
339
+ errors.append(f"{path}: items not unique")
340
+
341
+ if isinstance(value, str) and "minLength" in schema:
342
+ if len(value) < schema["minLength"]:
343
+ errors.append(f"{path}: string shorter than minLength {schema['minLength']}")
344
+
345
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and "minimum" in schema:
346
+ if value < schema["minimum"]:
347
+ errors.append(f"{path}: value {value} below minimum {schema['minimum']}")
348
+
349
+ return errors
350
+
351
+
352
+ def _matches_type(value, expected) -> bool:
353
+ """Implement JSON Schema 'type' for a single string OR a list of allowed types."""
354
+ if isinstance(expected, list):
355
+ return any(_matches_type(value, t) for t in expected)
356
+ if expected == "object":
357
+ return isinstance(value, dict)
358
+ if expected == "array":
359
+ return isinstance(value, list)
360
+ if expected == "string":
361
+ return isinstance(value, str)
362
+ if expected == "boolean":
363
+ return isinstance(value, bool)
364
+ if expected == "integer":
365
+ return isinstance(value, int) and not isinstance(value, bool)
366
+ if expected == "null":
367
+ return value is None
368
+ return False
369
+
370
+
371
+ def _freeze(v):
372
+ """Make a value hashable for uniqueItems checks."""
373
+ if isinstance(v, dict):
374
+ return tuple(sorted((k, _freeze(val)) for k, val in v.items()))
375
+ if isinstance(v, list):
376
+ return tuple(_freeze(x) for x in v)
377
+ return v
378
+
379
+
380
+ # ─── subcommands ────────────────────────────────────────────────────────────
381
+
382
+
383
+ def cmd_emit() -> None:
384
+ payload = _read_stdin_json("emit")
385
+ envelope = assemble_envelope(payload)
386
+ schema = _load_schema()
387
+ errors = _validate_against_schema(envelope, schema)
388
+ if errors:
389
+ _die(2, f"assembled envelope failed schema validation (this is a bug): {errors}")
390
+ print(emit_envelope_line(envelope))
391
+
392
+
393
+ def cmd_validate() -> None:
394
+ envelope = _read_stdin_json("validate")
395
+ schema = _load_schema()
396
+ errors = _validate_against_schema(envelope, schema)
397
+ if errors:
398
+ _die(1, "; ".join(errors))
399
+
400
+
401
+ def main() -> None:
402
+ parser = argparse.ArgumentParser(
403
+ description=__doc__,
404
+ formatter_class=argparse.RawDescriptionHelpFormatter,
405
+ )
406
+ sub = parser.add_subparsers(dest="cmd")
407
+ sub.add_parser("emit", help="Build envelope from context payload (default).")
408
+ sub.add_parser("validate", help="Validate an envelope payload against the schema.")
409
+ args = parser.parse_args()
410
+
411
+ cmd = args.cmd or "emit"
412
+ if cmd == "emit":
413
+ cmd_emit()
414
+ elif cmd == "validate":
415
+ cmd_validate()
416
+
417
+
418
+ if __name__ == "__main__":
419
+ main()