bmad-module-skill-forge 1.1.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 +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/troubleshooting.md +1 -1
- package/docs/why-skf.md +5 -4
- package/package.json +2 -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-create-skill/steps-c/step-06-validate.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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Detect Tools — Parallel tool detection + tier calculation for skf-setup.
|
|
6
|
+
|
|
7
|
+
Replaces the prose-driven tool-detection sequence in `src/skf-setup/steps-c/
|
|
8
|
+
step-01-detect-and-tier.md` §3-§8b with one Python invocation. Probes ast-grep,
|
|
9
|
+
gh, qmd, and ccc concurrently, applies the 4-rule tier decision table (see
|
|
10
|
+
`src/skf-setup/references/tier-rules.md`), evaluates --tier-override (with
|
|
11
|
+
sanity check) and --require-tier (with tool-prerequisite check independent of
|
|
12
|
+
the tier name), and emits one JSON document on stdout.
|
|
13
|
+
|
|
14
|
+
Schema documented in DETECT_OUTPUT_SCHEMA at the bottom of this docstring.
|
|
15
|
+
The output is consumed by step-01 prose, step-02 (forge-tier.yaml writer),
|
|
16
|
+
and step-04 (status report + envelope).
|
|
17
|
+
|
|
18
|
+
Tier rules (first match wins):
|
|
19
|
+
Deep = ast-grep + gh-cli + qmd (all healthy)
|
|
20
|
+
Forge+ = ast-grep + ccc (regardless of gh/qmd)
|
|
21
|
+
Forge = ast-grep
|
|
22
|
+
Quick = otherwise
|
|
23
|
+
|
|
24
|
+
CCC verification is two-step (matches step-01 §7):
|
|
25
|
+
Step A: `ccc --help` exits 0 AND output contains "CocoIndex Code" marker.
|
|
26
|
+
Rejects code2prompt-aliased-as-ccc and similar PATH shadowing.
|
|
27
|
+
Step B: `ccc doctor` succeeds (daemon healthy).
|
|
28
|
+
|
|
29
|
+
QMD verification is two-step (matches step-01 §5 post-PR-#248):
|
|
30
|
+
Step A: `qmd --version` exits 0 (binary identity, falls back to --help).
|
|
31
|
+
Step B: `qmd status` succeeds (daemon healthy).
|
|
32
|
+
qmd_status: "absent" | "daemon_stopped" | "healthy" — affects climb hint.
|
|
33
|
+
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 detection completed (status=ok in payload; require_tier may still be
|
|
36
|
+
unsatisfied — that is a payload field, not an exit signal here)
|
|
37
|
+
1 user error (bad args)
|
|
38
|
+
2 internal error (timeout, subprocess crash that escaped the per-probe
|
|
39
|
+
guard)
|
|
40
|
+
|
|
41
|
+
CLI (canonical invocation is `uv run` so PEP 723 inline metadata is
|
|
42
|
+
honored — see docs/getting-started.md for why uv is the documented
|
|
43
|
+
runtime prerequisite):
|
|
44
|
+
|
|
45
|
+
uv run skf-detect-tools.py
|
|
46
|
+
uv run skf-detect-tools.py --tier-override Deep
|
|
47
|
+
uv run skf-detect-tools.py --require-tier Forge+
|
|
48
|
+
uv run skf-detect-tools.py --snyk-env-var SNYK_TOKEN
|
|
49
|
+
|
|
50
|
+
Bare `python3` works when dependencies = [] (this script's case) but
|
|
51
|
+
becomes brittle the moment a non-stdlib dep is added — prefer `uv run`
|
|
52
|
+
for invocation consistency with sibling scripts that DO require pyyaml.
|
|
53
|
+
|
|
54
|
+
DETECT_OUTPUT_SCHEMA (v1):
|
|
55
|
+
{
|
|
56
|
+
"status": "ok",
|
|
57
|
+
"version": "v1",
|
|
58
|
+
"tools": {
|
|
59
|
+
"ast_grep": {"available": bool, "version": str|null},
|
|
60
|
+
"gh_cli": {"available": bool, "version": str|null},
|
|
61
|
+
"qmd": {"available": bool, "status": "absent"|"daemon_stopped"|"healthy",
|
|
62
|
+
"version": str|null},
|
|
63
|
+
"ccc": {"available": bool, "daemon": "healthy"|"stopped"|"error"|null,
|
|
64
|
+
"version": str|null},
|
|
65
|
+
"security_scan": {"available": bool}
|
|
66
|
+
},
|
|
67
|
+
"tier": {
|
|
68
|
+
"calculated": "Quick"|"Forge"|"Forge+"|"Deep",
|
|
69
|
+
"detected": "Quick"|"Forge"|"Forge+"|"Deep",
|
|
70
|
+
"override_applied": bool,
|
|
71
|
+
"override_value": str|null,
|
|
72
|
+
"override_invalid": bool,
|
|
73
|
+
"override_invalid_value": str|null,
|
|
74
|
+
"override_invalid_suggestion": str|null,
|
|
75
|
+
"override_unsafe": bool,
|
|
76
|
+
"override_unsafe_missing": [str]
|
|
77
|
+
},
|
|
78
|
+
"require_tier": {
|
|
79
|
+
"requested": "Quick"|"Forge"|"Forge+"|"Deep"|null,
|
|
80
|
+
"satisfied": bool|null,
|
|
81
|
+
"missing_tools": [str]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import argparse
|
|
89
|
+
import json
|
|
90
|
+
import os
|
|
91
|
+
import subprocess
|
|
92
|
+
import sys
|
|
93
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
97
|
+
PROBE_TIMEOUT_SEC = 8 # per-tool subprocess.run timeout
|
|
98
|
+
CCC_IDENTITY_MARKER = "cocoindex code" # case-insensitive substring
|
|
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 _run(cmd: list[str], timeout: int = PROBE_TIMEOUT_SEC) -> tuple[int, str, str]:
|
|
113
|
+
"""Run a subprocess. Return (returncode, stdout, stderr). Never raises.
|
|
114
|
+
|
|
115
|
+
Treats every failure mode (FileNotFoundError, TimeoutExpired, OSError,
|
|
116
|
+
CalledProcessError) as a failed probe — returns rc=127 and an empty
|
|
117
|
+
stdout/stderr. Tool detection should never crash the workflow.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
cmd,
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
timeout=timeout,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
return result.returncode, result.stdout or "", result.stderr or ""
|
|
128
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
129
|
+
return 127, "", ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _first_line(text: str) -> str | None:
|
|
133
|
+
"""Return the first non-empty stripped line of `text`, or None."""
|
|
134
|
+
for line in text.splitlines():
|
|
135
|
+
stripped = line.strip()
|
|
136
|
+
if stripped:
|
|
137
|
+
return stripped
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def probe_ast_grep() -> dict:
|
|
142
|
+
rc, stdout, _ = _run(["ast-grep", "--version"])
|
|
143
|
+
if rc != 0:
|
|
144
|
+
return {"available": False, "version": None}
|
|
145
|
+
return {"available": True, "version": _first_line(stdout)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def probe_gh_cli() -> dict:
|
|
149
|
+
rc, stdout, _ = _run(["gh", "--version"])
|
|
150
|
+
if rc != 0:
|
|
151
|
+
return {"available": False, "version": None}
|
|
152
|
+
return {"available": True, "version": _first_line(stdout)}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def probe_qmd() -> dict:
|
|
156
|
+
"""Two-step probe: --version (binary identity) then status (daemon health)."""
|
|
157
|
+
rc, stdout, _ = _run(["qmd", "--version"])
|
|
158
|
+
if rc != 0:
|
|
159
|
+
# --version may not be supported on every qmd build; fall back to --help.
|
|
160
|
+
rc, stdout, _ = _run(["qmd", "--help"])
|
|
161
|
+
if rc != 0:
|
|
162
|
+
return {"available": False, "status": "absent", "version": None}
|
|
163
|
+
version = _first_line(stdout)
|
|
164
|
+
|
|
165
|
+
rc_status, _, _ = _run(["qmd", "status"])
|
|
166
|
+
if rc_status != 0:
|
|
167
|
+
return {"available": False, "status": "daemon_stopped", "version": version}
|
|
168
|
+
return {"available": True, "status": "healthy", "version": version}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def probe_ccc() -> dict:
|
|
172
|
+
"""Two-step probe: --help with identity marker, then doctor for daemon health."""
|
|
173
|
+
rc, stdout, _ = _run(["ccc", "--help"])
|
|
174
|
+
if rc != 0:
|
|
175
|
+
return {"available": False, "daemon": None, "version": None}
|
|
176
|
+
if CCC_IDENTITY_MARKER not in stdout.lower():
|
|
177
|
+
# `ccc` resolved to a foreign binary (e.g. code2prompt alias). Refuse.
|
|
178
|
+
return {"available": False, "daemon": None, "version": None}
|
|
179
|
+
|
|
180
|
+
rc_doctor, doctor_stdout, _ = _run(["ccc", "doctor"])
|
|
181
|
+
version = _first_line(doctor_stdout) or _first_line(stdout)
|
|
182
|
+
if rc_doctor == 0:
|
|
183
|
+
return {"available": True, "daemon": "healthy", "version": version}
|
|
184
|
+
# Distinguishing "stopped" from "error" requires parsing doctor output;
|
|
185
|
+
# without a documented contract, default to "error" and let the caller
|
|
186
|
+
# treat both as operational unavailability with daemon-level remediation.
|
|
187
|
+
return {"available": True, "daemon": "error", "version": version}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def probe_security_scan(env_var: str) -> dict:
|
|
191
|
+
"""Informational only — does NOT affect tier."""
|
|
192
|
+
return {"available": bool(os.environ.get(env_var, "").strip())}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def calculate_tier(tools: dict) -> str:
|
|
196
|
+
ag = tools["ast_grep"]["available"]
|
|
197
|
+
gh = tools["gh_cli"]["available"]
|
|
198
|
+
qm = tools["qmd"]["available"]
|
|
199
|
+
cc = tools["ccc"]["available"]
|
|
200
|
+
|
|
201
|
+
if ag and gh and qm:
|
|
202
|
+
return "Deep"
|
|
203
|
+
if ag and cc:
|
|
204
|
+
return "Forge+"
|
|
205
|
+
if ag:
|
|
206
|
+
return "Forge"
|
|
207
|
+
return "Quick"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def suggest_valid_tier(bad_value: str) -> str | None:
|
|
211
|
+
"""For an invalid --tier-override value, return the closest valid tier name.
|
|
212
|
+
|
|
213
|
+
Two-stage match:
|
|
214
|
+
1. Case-insensitive exact match — handles `deep` / `DEEP` / `forge+` /
|
|
215
|
+
`FORGE+` (the most common typo class: right tier, wrong case).
|
|
216
|
+
2. difflib fuzzy match against `VALID_TIERS` with a 0.6 cutoff — handles
|
|
217
|
+
`frorge`, `quik`, `forge plus`, etc.
|
|
218
|
+
|
|
219
|
+
Returns None if no candidate clears the cutoff. Used only for diagnostic
|
|
220
|
+
messages — the override itself is never silently auto-corrected.
|
|
221
|
+
"""
|
|
222
|
+
import difflib
|
|
223
|
+
|
|
224
|
+
if not bad_value or not isinstance(bad_value, str):
|
|
225
|
+
return None
|
|
226
|
+
cleaned = bad_value.strip()
|
|
227
|
+
if not cleaned:
|
|
228
|
+
return None
|
|
229
|
+
cleaned_lower = cleaned.lower()
|
|
230
|
+
for valid in VALID_TIERS:
|
|
231
|
+
if valid.lower() == cleaned_lower:
|
|
232
|
+
return valid
|
|
233
|
+
matches = difflib.get_close_matches(cleaned, VALID_TIERS, n=1, cutoff=0.6)
|
|
234
|
+
return matches[0] if matches else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def tier_prerequisites_met(tier: str, tools: dict) -> tuple[bool, list[str]]:
|
|
238
|
+
"""Return (satisfied, missing_tools) for tier-prerequisite checks.
|
|
239
|
+
|
|
240
|
+
Used by both --tier-override sanity check and --require-tier evaluation.
|
|
241
|
+
Deep does NOT subsume Forge+ (Deep does not require ccc), so a Deep
|
|
242
|
+
calculation with no ccc still fails a Forge+ requirement.
|
|
243
|
+
"""
|
|
244
|
+
needed: dict[str, str] = {
|
|
245
|
+
"Quick": {},
|
|
246
|
+
"Forge": {"ast_grep": "ast-grep"},
|
|
247
|
+
"Forge+": {"ast_grep": "ast-grep", "ccc": "ccc"},
|
|
248
|
+
"Deep": {"ast_grep": "ast-grep", "gh_cli": "gh", "qmd": "qmd"},
|
|
249
|
+
}[tier]
|
|
250
|
+
missing = [display for key, display in needed.items() if not tools[key]["available"]]
|
|
251
|
+
return (len(missing) == 0, missing)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def detect(args: argparse.Namespace) -> dict:
|
|
255
|
+
tools: dict = {}
|
|
256
|
+
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
257
|
+
futures = {
|
|
258
|
+
"ast_grep": ex.submit(probe_ast_grep),
|
|
259
|
+
"gh_cli": ex.submit(probe_gh_cli),
|
|
260
|
+
"qmd": ex.submit(probe_qmd),
|
|
261
|
+
"ccc": ex.submit(probe_ccc),
|
|
262
|
+
}
|
|
263
|
+
for key, fut in futures.items():
|
|
264
|
+
tools[key] = fut.result()
|
|
265
|
+
tools["security_scan"] = probe_security_scan(args.snyk_env_var)
|
|
266
|
+
|
|
267
|
+
detected = calculate_tier(tools)
|
|
268
|
+
|
|
269
|
+
# Tier override handling
|
|
270
|
+
override_applied = False
|
|
271
|
+
override_value: str | None = None
|
|
272
|
+
override_invalid = False
|
|
273
|
+
override_invalid_value: str | None = None
|
|
274
|
+
override_invalid_suggestion: str | None = None
|
|
275
|
+
override_unsafe = False
|
|
276
|
+
override_unsafe_missing: list[str] = []
|
|
277
|
+
|
|
278
|
+
if args.tier_override is not None:
|
|
279
|
+
if args.tier_override in VALID_TIERS:
|
|
280
|
+
override_applied = True
|
|
281
|
+
override_value = args.tier_override
|
|
282
|
+
calculated = args.tier_override
|
|
283
|
+
satisfied, missing = tier_prerequisites_met(calculated, tools)
|
|
284
|
+
if not satisfied:
|
|
285
|
+
override_unsafe = True
|
|
286
|
+
override_unsafe_missing = missing
|
|
287
|
+
else:
|
|
288
|
+
override_invalid = True
|
|
289
|
+
override_invalid_value = args.tier_override
|
|
290
|
+
override_invalid_suggestion = suggest_valid_tier(args.tier_override)
|
|
291
|
+
calculated = detected
|
|
292
|
+
else:
|
|
293
|
+
calculated = detected
|
|
294
|
+
|
|
295
|
+
# Require-tier evaluation
|
|
296
|
+
require_satisfied: bool | None
|
|
297
|
+
require_missing: list[str] = []
|
|
298
|
+
if args.require_tier is not None:
|
|
299
|
+
if args.require_tier not in VALID_TIERS:
|
|
300
|
+
_die(1, f"--require-tier must be one of {VALID_TIERS}, got {args.require_tier!r}")
|
|
301
|
+
require_satisfied, require_missing = tier_prerequisites_met(args.require_tier, tools)
|
|
302
|
+
else:
|
|
303
|
+
require_satisfied = None
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"tools": tools,
|
|
307
|
+
"tier": {
|
|
308
|
+
"calculated": calculated,
|
|
309
|
+
"detected": detected,
|
|
310
|
+
"override_applied": override_applied,
|
|
311
|
+
"override_value": override_value,
|
|
312
|
+
"override_invalid": override_invalid,
|
|
313
|
+
"override_invalid_value": override_invalid_value,
|
|
314
|
+
"override_invalid_suggestion": override_invalid_suggestion,
|
|
315
|
+
"override_unsafe": override_unsafe,
|
|
316
|
+
"override_unsafe_missing": override_unsafe_missing,
|
|
317
|
+
},
|
|
318
|
+
"require_tier": {
|
|
319
|
+
"requested": args.require_tier,
|
|
320
|
+
"satisfied": require_satisfied,
|
|
321
|
+
"missing_tools": require_missing,
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> None:
|
|
327
|
+
parser = argparse.ArgumentParser(
|
|
328
|
+
description="Detect SKF tools and calculate capability tier.",
|
|
329
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
330
|
+
)
|
|
331
|
+
parser.add_argument(
|
|
332
|
+
"--tier-override",
|
|
333
|
+
default=None,
|
|
334
|
+
help="Force a specific tier (must be one of Quick, Forge, Forge+, Deep — case-sensitive)."
|
|
335
|
+
" Invalid values are flagged in the output rather than rejected, so step-04 can"
|
|
336
|
+
" surface the warning to the user.",
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--require-tier",
|
|
340
|
+
default=None,
|
|
341
|
+
help="Require the calculated tier to satisfy this requirement (uses tool-prerequisite"
|
|
342
|
+
" check, not tier-name comparison — Deep does not subsume Forge+ because Deep"
|
|
343
|
+
" does not require ccc). Output reports satisfied/missing-tools; caller decides"
|
|
344
|
+
" whether to halt.",
|
|
345
|
+
)
|
|
346
|
+
parser.add_argument(
|
|
347
|
+
"--snyk-env-var",
|
|
348
|
+
default="SNYK_TOKEN",
|
|
349
|
+
help="Environment variable name to check for security-scan availability"
|
|
350
|
+
" (informational only — does NOT affect tier). Default: SNYK_TOKEN.",
|
|
351
|
+
)
|
|
352
|
+
args = parser.parse_args()
|
|
353
|
+
|
|
354
|
+
payload = detect(args)
|
|
355
|
+
_ok(payload)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|