bmad-module-skill-forge 1.3.0 → 1.4.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/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +34 -15
- package/package.json +2 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
- package/src/shared/scripts/skf-detect-language.py +277 -0
- package/src/shared/scripts/skf-detect-workspaces.py +427 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
- package/src/shared/scripts/skf-extract-public-api.py +29 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-write-skill-brief.py +509 -0
- package/src/skf-brief-skill/SKILL.md +41 -12
- package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
- package/src/skf-brief-skill/assets/scope-templates.md +5 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
- package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
- package/src/skf-brief-skill/references/headless-args.md +22 -0
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
- package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
- package/src/skf-brief-skill/references/version-resolution.md +46 -0
- package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
- package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
- package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
- package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
- package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
- package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Validate Brief Inputs — pre-pass validation for brief-skill headless invocation.
|
|
6
|
+
|
|
7
|
+
Validates and normalizes the headless argument set passed to skf-brief-skill
|
|
8
|
+
before the workflow's interactive sequence runs. Catches malformed inputs at
|
|
9
|
+
point of capture instead of letting them surface 5+ minutes later in step-02
|
|
10
|
+
or step-05.
|
|
11
|
+
|
|
12
|
+
CLI:
|
|
13
|
+
uv run skf-validate-brief-inputs.py --json '{...}'
|
|
14
|
+
echo '{...}' | uv run skf-validate-brief-inputs.py
|
|
15
|
+
|
|
16
|
+
Input (JSON object on stdin or via --json):
|
|
17
|
+
Required:
|
|
18
|
+
target_repo — string (URL or path); error if absent
|
|
19
|
+
skill_name — string (kebab-case); error if absent or malformed
|
|
20
|
+
|
|
21
|
+
Optional with enum constraints:
|
|
22
|
+
source_type — "source" | "docs-only" (default "source")
|
|
23
|
+
source_authority — "official" | "community" | "internal" (default "community")
|
|
24
|
+
scope_type — "full-library" | "specific-modules" | "public-api"
|
|
25
|
+
| "component-library" | "reference-app" | "docs-only"
|
|
26
|
+
scripts_intent — "detect" | "none" | free-text (default "detect")
|
|
27
|
+
assets_intent — "detect" | "none" | free-text (default "detect")
|
|
28
|
+
|
|
29
|
+
Optional with format constraints:
|
|
30
|
+
target_version — loose semver string (matches 1.2.3, 1.2.3-rc.1, 1.2.3+build.5)
|
|
31
|
+
|
|
32
|
+
Conditional:
|
|
33
|
+
doc_urls — required when source_type == "docs-only"
|
|
34
|
+
|
|
35
|
+
Free-text / pass-through:
|
|
36
|
+
scope_hint, language_hint, intent, include, exclude, force
|
|
37
|
+
|
|
38
|
+
Unrecognized keys are passed through unchanged but flagged as warnings.
|
|
39
|
+
|
|
40
|
+
Output (JSON on stdout):
|
|
41
|
+
{
|
|
42
|
+
"valid": bool,
|
|
43
|
+
"errors": [{"field": "...", "message": "..."}, ...],
|
|
44
|
+
"warnings": [{"field": "...", "message": "..."}, ...],
|
|
45
|
+
"normalized": { ...input dict with defaults applied... },
|
|
46
|
+
"halt_reason": "input-missing" | "input-invalid" | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Exit codes:
|
|
50
|
+
0 — valid (errors empty)
|
|
51
|
+
1 — invalid (errors present); halt_reason is set
|
|
52
|
+
2 — internal error (bad JSON input, IO failure)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import argparse
|
|
58
|
+
import json
|
|
59
|
+
import re
|
|
60
|
+
import sys
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
KNOWN_FIELDS = {
|
|
64
|
+
"target_repo",
|
|
65
|
+
"skill_name",
|
|
66
|
+
"source_type",
|
|
67
|
+
"source_authority",
|
|
68
|
+
"scope_type",
|
|
69
|
+
"target_version",
|
|
70
|
+
"doc_urls",
|
|
71
|
+
"scope_hint",
|
|
72
|
+
"language_hint",
|
|
73
|
+
"intent",
|
|
74
|
+
"scripts_intent",
|
|
75
|
+
"assets_intent",
|
|
76
|
+
"include",
|
|
77
|
+
"exclude",
|
|
78
|
+
"force",
|
|
79
|
+
}
|
|
80
|
+
# `preset` is intentionally NOT in KNOWN_FIELDS — it is consumed at the step-01 §8 GATE
|
|
81
|
+
# (the LLM merges the named preset YAML into the args dict and drops the `preset` key
|
|
82
|
+
# before calling the validator). If the key leaks through, the validator's existing
|
|
83
|
+
# unknown-field handling emits `"unrecognized field 'preset' — passed through unchanged"`
|
|
84
|
+
# in `warnings[]` so the missed drop is debuggable rather than silent.
|
|
85
|
+
|
|
86
|
+
VALID_SOURCE_TYPES = {"source", "docs-only"}
|
|
87
|
+
VALID_SOURCE_AUTHORITIES = {"official", "community", "internal"}
|
|
88
|
+
VALID_SCOPE_TYPES = {
|
|
89
|
+
"full-library",
|
|
90
|
+
"specific-modules",
|
|
91
|
+
"public-api",
|
|
92
|
+
"component-library",
|
|
93
|
+
"reference-app",
|
|
94
|
+
"docs-only",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
KEBAB_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
98
|
+
# Require full X.Y.Z form (with optional v prefix and pre-release/build suffix).
|
|
99
|
+
# Loose forms like `1`, `1.2`, `v2` are rejected — the user should write the
|
|
100
|
+
# explicit triple. CalVer (e.g. 2024.04.01) is accepted because it satisfies
|
|
101
|
+
# the X.Y.Z shape.
|
|
102
|
+
SEMVER_RE = re.compile(
|
|
103
|
+
r"^v?\d+\.\d+\.\d+([.\-+][0-9A-Za-z][0-9A-Za-z.\-+]*)?$"
|
|
104
|
+
)
|
|
105
|
+
URL_RE = re.compile(r"^https?://", re.IGNORECASE)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _err(field: str, message: str) -> dict[str, str]:
|
|
109
|
+
return {"field": field, "message": message}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate(inp: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
errors: list[dict[str, str]] = []
|
|
114
|
+
warnings: list[dict[str, str]] = []
|
|
115
|
+
|
|
116
|
+
# Required: target_repo, skill_name
|
|
117
|
+
target_repo = inp.get("target_repo")
|
|
118
|
+
skill_name = inp.get("skill_name")
|
|
119
|
+
if not target_repo:
|
|
120
|
+
errors.append(_err("target_repo", "missing required argument target_repo"))
|
|
121
|
+
if not skill_name:
|
|
122
|
+
errors.append(_err("skill_name", "missing required argument skill_name"))
|
|
123
|
+
|
|
124
|
+
# skill_name format
|
|
125
|
+
if skill_name and isinstance(skill_name, str) and not KEBAB_RE.match(skill_name):
|
|
126
|
+
errors.append(
|
|
127
|
+
_err(
|
|
128
|
+
"skill_name",
|
|
129
|
+
f"skill_name must be kebab-case (lowercase letters/digits/hyphens, "
|
|
130
|
+
f"no leading or trailing hyphen). Got: {skill_name!r}",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# source_type enum
|
|
135
|
+
source_type_raw = inp.get("source_type", "source")
|
|
136
|
+
if source_type_raw not in VALID_SOURCE_TYPES:
|
|
137
|
+
errors.append(
|
|
138
|
+
_err(
|
|
139
|
+
"source_type",
|
|
140
|
+
f"source_type must be one of {sorted(VALID_SOURCE_TYPES)}. Got: {source_type_raw!r}",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
# Treat as default for downstream conditional logic
|
|
144
|
+
source_type = "source"
|
|
145
|
+
else:
|
|
146
|
+
source_type = source_type_raw
|
|
147
|
+
|
|
148
|
+
# source_authority enum
|
|
149
|
+
source_authority_raw = inp.get("source_authority", "community")
|
|
150
|
+
if source_authority_raw not in VALID_SOURCE_AUTHORITIES:
|
|
151
|
+
errors.append(
|
|
152
|
+
_err(
|
|
153
|
+
"source_authority",
|
|
154
|
+
f"source_authority must be one of {sorted(VALID_SOURCE_AUTHORITIES)}. "
|
|
155
|
+
f"Got: {source_authority_raw!r}",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# scope_type enum (when present)
|
|
160
|
+
scope_type = inp.get("scope_type")
|
|
161
|
+
if scope_type is not None and scope_type not in VALID_SCOPE_TYPES:
|
|
162
|
+
errors.append(
|
|
163
|
+
_err(
|
|
164
|
+
"scope_type",
|
|
165
|
+
f"scope_type must be one of {sorted(VALID_SCOPE_TYPES)}. Got: {scope_type!r}",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# target_version semver-ish
|
|
170
|
+
target_version = inp.get("target_version")
|
|
171
|
+
if target_version is not None:
|
|
172
|
+
if not isinstance(target_version, str):
|
|
173
|
+
errors.append(
|
|
174
|
+
_err(
|
|
175
|
+
"target_version",
|
|
176
|
+
f"target_version must be a string. Got type {type(target_version).__name__}",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
elif not SEMVER_RE.match(target_version):
|
|
180
|
+
errors.append(
|
|
181
|
+
_err(
|
|
182
|
+
"target_version",
|
|
183
|
+
f"target_version does not look like semver. Got: {target_version!r}. "
|
|
184
|
+
f"Expected forms: 1.2.3, v1.2.3, 1.2.3-rc.1, 1.2.3+build.5. "
|
|
185
|
+
f"Partial forms like '1' or '1.2' are not accepted — write the explicit triple.",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# docs-only requires doc_urls
|
|
190
|
+
doc_urls = inp.get("doc_urls")
|
|
191
|
+
if source_type == "docs-only" and not doc_urls:
|
|
192
|
+
errors.append(
|
|
193
|
+
_err("doc_urls", "doc_urls is required when source_type is docs-only")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# target_repo shape (warning only — script doesn't HEAD-check)
|
|
197
|
+
if isinstance(target_repo, str) and target_repo:
|
|
198
|
+
looks_like_url = URL_RE.match(target_repo) is not None
|
|
199
|
+
looks_like_path = (
|
|
200
|
+
target_repo.startswith("/")
|
|
201
|
+
or target_repo.startswith("./")
|
|
202
|
+
or target_repo.startswith("~")
|
|
203
|
+
)
|
|
204
|
+
if not (looks_like_url or looks_like_path):
|
|
205
|
+
warnings.append(
|
|
206
|
+
_err(
|
|
207
|
+
"target_repo",
|
|
208
|
+
f"target_repo does not look like a URL or absolute/relative path: {target_repo!r}",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Unknown fields → warn
|
|
213
|
+
for key in inp:
|
|
214
|
+
if key not in KNOWN_FIELDS:
|
|
215
|
+
warnings.append(_err(key, f"unrecognized field {key!r} — passed through unchanged"))
|
|
216
|
+
|
|
217
|
+
# Build normalized payload
|
|
218
|
+
normalized: dict[str, Any] = dict(inp)
|
|
219
|
+
normalized.setdefault("source_type", "source")
|
|
220
|
+
# `source_authority` is intentionally NOT setdefault'd here for source-type targets:
|
|
221
|
+
# step-01 §3.3's headless detection branch runs `gh api user` and may resolve to
|
|
222
|
+
# `official` for repos owned by the authenticated user. Stamping a default at
|
|
223
|
+
# validator time would pre-empt the detection because step-01 §8 GATE treats the
|
|
224
|
+
# `normalized` object as the source of truth. Absence is the signal "run detection."
|
|
225
|
+
# The docs-only branch below still forces `community` since detection cannot apply.
|
|
226
|
+
normalized.setdefault("scripts_intent", "detect")
|
|
227
|
+
normalized.setdefault("assets_intent", "detect")
|
|
228
|
+
|
|
229
|
+
# Force community when docs-only
|
|
230
|
+
if normalized.get("source_type") == "docs-only":
|
|
231
|
+
if normalized.get("source_authority") not in (None, "community"):
|
|
232
|
+
warnings.append(
|
|
233
|
+
_err(
|
|
234
|
+
"source_authority",
|
|
235
|
+
f"source_authority forced to 'community' because source_type=docs-only "
|
|
236
|
+
f"(was {normalized['source_authority']!r})",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
normalized["source_authority"] = "community"
|
|
240
|
+
|
|
241
|
+
# halt_reason classification
|
|
242
|
+
if errors:
|
|
243
|
+
missing_required = any(
|
|
244
|
+
e["message"].startswith("missing required") or "is required" in e["message"]
|
|
245
|
+
for e in errors
|
|
246
|
+
)
|
|
247
|
+
halt_reason = "input-missing" if missing_required else "input-invalid"
|
|
248
|
+
else:
|
|
249
|
+
halt_reason = None
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"valid": not errors,
|
|
253
|
+
"errors": errors,
|
|
254
|
+
"warnings": warnings,
|
|
255
|
+
"normalized": normalized,
|
|
256
|
+
"halt_reason": halt_reason,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def main() -> int:
|
|
261
|
+
p = argparse.ArgumentParser(prog="skf-validate-brief-inputs")
|
|
262
|
+
p.add_argument(
|
|
263
|
+
"--json",
|
|
264
|
+
help="JSON object as a string. If omitted, the script reads JSON from stdin.",
|
|
265
|
+
)
|
|
266
|
+
args = p.parse_args()
|
|
267
|
+
|
|
268
|
+
raw = args.json
|
|
269
|
+
if raw is None:
|
|
270
|
+
raw = sys.stdin.read()
|
|
271
|
+
|
|
272
|
+
if not raw or not raw.strip():
|
|
273
|
+
sys.stderr.write("skf-validate-brief-inputs: empty input\n")
|
|
274
|
+
return 2
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
inp = json.loads(raw)
|
|
278
|
+
except json.JSONDecodeError as e:
|
|
279
|
+
sys.stderr.write(f"skf-validate-brief-inputs: invalid JSON input: {e}\n")
|
|
280
|
+
return 2
|
|
281
|
+
|
|
282
|
+
if not isinstance(inp, dict):
|
|
283
|
+
sys.stderr.write("skf-validate-brief-inputs: input must be a JSON object\n")
|
|
284
|
+
return 2
|
|
285
|
+
|
|
286
|
+
result = validate(inp)
|
|
287
|
+
json.dump(result, sys.stdout, indent=2, sort_keys=True)
|
|
288
|
+
sys.stdout.write("\n")
|
|
289
|
+
return 0 if result["valid"] else 1
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
sys.exit(main())
|