bmad-module-skill-forge 1.2.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/skill-model.md +26 -32
- package/docs/troubleshooting.md +12 -0
- package/docs/workflows.md +87 -15
- package/package.json +2 -2
- package/src/shared/references/output-contract-schema.md +10 -0
- 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 +534 -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-render-quick-metadata.py +192 -0
- package/src/shared/scripts/skf-resolve-package.py +264 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
- package/src/shared/scripts/skf-validate-output.py +24 -7
- 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
- package/src/skf-quick-skill/SKILL.md +178 -10
- package/src/skf-quick-skill/assets/skill-template.md +5 -1
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
- package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
- package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
- package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
- package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
- package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
- package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Recommend Scope Type — deterministic 5-rule heuristic ladder.
|
|
6
|
+
|
|
7
|
+
Single source of truth for the scope-type recommendation logic that
|
|
8
|
+
skf-brief-skill step-03 §2c applies. Both the interactive recommendation
|
|
9
|
+
and the headless auto-selection paths invoke this script with the same
|
|
10
|
+
inputs to eliminate the drift seam between them.
|
|
11
|
+
|
|
12
|
+
The five-rule ladder (apply in order, first match wins):
|
|
13
|
+
|
|
14
|
+
1. component-library — registry.ts / components.ts present (with 10+
|
|
15
|
+
entries or `Component[]` annotation when contents available;
|
|
16
|
+
presence-only when contents not available)
|
|
17
|
+
2. reference-app — intent text mentions wiring / integration / starter
|
|
18
|
+
/ lifecycle / build-config keywords, OR analysis flagged as demo/example
|
|
19
|
+
3. specific-modules — intent names a specific subset ("just the X",
|
|
20
|
+
"only the Y") OR module_count >= 6
|
|
21
|
+
4. public-api — export_count <= 8 AND intent mentions "the API",
|
|
22
|
+
"the SDK", "client library", "public API"
|
|
23
|
+
5. full-library — fallback when no rule matches
|
|
24
|
+
|
|
25
|
+
Plus two short-circuits applied before the ladder:
|
|
26
|
+
|
|
27
|
+
- source_type == "docs-only" → docs-only (no source surface to scope)
|
|
28
|
+
- When mode == "interactive" the component-registry rule additionally
|
|
29
|
+
inspects file contents (10+ entries or Component[] annotation); when
|
|
30
|
+
mode == "headless" with no entry_files supplied, the script falls back
|
|
31
|
+
to file-presence-only matching for that rule.
|
|
32
|
+
|
|
33
|
+
CLI:
|
|
34
|
+
echo '{...}' | uv run skf-recommend-scope-type.py
|
|
35
|
+
uv run skf-recommend-scope-type.py --json '{...}'
|
|
36
|
+
|
|
37
|
+
Input (JSON object on stdin or via --json):
|
|
38
|
+
intent — string (combined intent + scope_hint), default ""
|
|
39
|
+
module_count — integer (top-level modules from step-02), default 0
|
|
40
|
+
export_count — integer (named exports from manifest), default 0
|
|
41
|
+
tree — list of repo-relative file paths, default []
|
|
42
|
+
entry_files — optional [{path, content}], default null
|
|
43
|
+
source_type — "source" | "docs-only" | null, default null
|
|
44
|
+
mode — "interactive" | "headless", default "headless"
|
|
45
|
+
|
|
46
|
+
Output (JSON on stdout):
|
|
47
|
+
scope_type — one of full-library | specific-modules | public-api
|
|
48
|
+
| component-library | reference-app | docs-only
|
|
49
|
+
matched_heuristic — "component-registry" | "reference-app-keywords"
|
|
50
|
+
| "specific-modules-naming" | "specific-modules-count"
|
|
51
|
+
| "narrow-public-api" | "default-full-library"
|
|
52
|
+
| "docs-only-shortcircuit"
|
|
53
|
+
signals — object naming the signals that fired (path, count, etc.)
|
|
54
|
+
rationale — one-sentence explanation referencing the signals
|
|
55
|
+
|
|
56
|
+
Exit codes:
|
|
57
|
+
0 — recommendation produced
|
|
58
|
+
2 — internal error (bad JSON input, IO failure)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import argparse
|
|
64
|
+
import json
|
|
65
|
+
import re
|
|
66
|
+
import sys
|
|
67
|
+
from typing import Any
|
|
68
|
+
|
|
69
|
+
REFERENCE_APP_KEYWORDS = (
|
|
70
|
+
"wiring",
|
|
71
|
+
"integration example",
|
|
72
|
+
"integration sample",
|
|
73
|
+
"starter",
|
|
74
|
+
"lifecycle",
|
|
75
|
+
"build config",
|
|
76
|
+
"build-config",
|
|
77
|
+
"demo app",
|
|
78
|
+
"example app",
|
|
79
|
+
"reference app",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
NARROW_PUBLIC_API_KEYWORDS = (
|
|
83
|
+
"the api",
|
|
84
|
+
"the sdk",
|
|
85
|
+
"public api",
|
|
86
|
+
"client library",
|
|
87
|
+
"client sdk",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Phrases that indicate the user wants only a specific subset of modules
|
|
91
|
+
# rather than the whole library. The match is substring + lowercase, so
|
|
92
|
+
# "just the auth module" / "only the streaming part" / "specifically the
|
|
93
|
+
# parser" all match.
|
|
94
|
+
SPECIFIC_MODULE_NAMING_PATTERNS = (
|
|
95
|
+
re.compile(r"\bjust the\s+\w+", re.IGNORECASE),
|
|
96
|
+
re.compile(r"\bonly the\s+\w+", re.IGNORECASE),
|
|
97
|
+
re.compile(r"\bspecifically the\s+\w+", re.IGNORECASE),
|
|
98
|
+
re.compile(r"\bjust\s+(?:want|need|use)\s+the\s+\w+", re.IGNORECASE),
|
|
99
|
+
re.compile(r"\bonly\s+(?:want|need|use)\s+the\s+\w+", re.IGNORECASE),
|
|
100
|
+
re.compile(r"\bjust\s+\w+\s+module\b", re.IGNORECASE),
|
|
101
|
+
re.compile(r"\bonly\s+\w+\s+module\b", re.IGNORECASE),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
VALID_MODES = {"interactive", "headless"}
|
|
105
|
+
VALID_SOURCE_TYPES = {None, "source", "docs-only"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _die(message: str, code: int = 2) -> None:
|
|
109
|
+
sys.stderr.write(f"skf-recommend-scope-type: {message}\n")
|
|
110
|
+
sys.exit(code)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _find_registry_files(tree: list[str]) -> list[str]:
|
|
114
|
+
"""Return repo-relative paths matching registry.ts / components.ts (any depth)."""
|
|
115
|
+
hits: list[str] = []
|
|
116
|
+
for path in tree:
|
|
117
|
+
if not isinstance(path, str):
|
|
118
|
+
continue
|
|
119
|
+
basename = path.rsplit("/", 1)[-1]
|
|
120
|
+
if basename in {"registry.ts", "components.ts", "registry.tsx", "components.tsx"}:
|
|
121
|
+
hits.append(path)
|
|
122
|
+
return hits
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _registry_entry_count(content: str) -> int:
|
|
126
|
+
"""Approximate the number of entries in a registry array literal.
|
|
127
|
+
|
|
128
|
+
Counts top-level `{ ... }` objects within the first array-like body
|
|
129
|
+
we encounter. Approximate by design — used only as a >=10 threshold.
|
|
130
|
+
"""
|
|
131
|
+
# Strip line comments to reduce noise; do not bother with block comments.
|
|
132
|
+
cleaned = re.sub(r"//.*", "", content)
|
|
133
|
+
# Find array literals — match `[` followed by whitespace then `{`.
|
|
134
|
+
array_match = re.search(r"\[\s*\{", cleaned)
|
|
135
|
+
if not array_match:
|
|
136
|
+
return 0
|
|
137
|
+
body_start = array_match.start()
|
|
138
|
+
# Walk forward, count balanced top-level `{` matches up to the closing `]`.
|
|
139
|
+
depth = 0
|
|
140
|
+
in_string: str | None = None
|
|
141
|
+
count = 0
|
|
142
|
+
for ch in cleaned[body_start:]:
|
|
143
|
+
if in_string:
|
|
144
|
+
if ch == in_string:
|
|
145
|
+
in_string = None
|
|
146
|
+
continue
|
|
147
|
+
if ch in ("'", '"', "`"):
|
|
148
|
+
in_string = ch
|
|
149
|
+
continue
|
|
150
|
+
if ch == "{":
|
|
151
|
+
if depth == 0:
|
|
152
|
+
count += 1
|
|
153
|
+
depth += 1
|
|
154
|
+
elif ch == "}":
|
|
155
|
+
depth = max(depth - 1, 0)
|
|
156
|
+
elif ch == "]" and depth == 0:
|
|
157
|
+
break
|
|
158
|
+
return count
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _has_component_array_annotation(content: str) -> bool:
|
|
162
|
+
"""Detect a `Component[]` type annotation, allowing trivial whitespace and generics."""
|
|
163
|
+
return bool(re.search(r"\bComponent(?:\s*<[^>]*>)?\s*\[\s*\]", content))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _component_registry_match(
|
|
167
|
+
tree: list[str],
|
|
168
|
+
entry_files: list[dict] | None,
|
|
169
|
+
mode: str,
|
|
170
|
+
) -> dict | None:
|
|
171
|
+
"""Apply rule 1 — component-library.
|
|
172
|
+
|
|
173
|
+
Returns a signals dict on match, or None.
|
|
174
|
+
"""
|
|
175
|
+
registry_paths = _find_registry_files(tree)
|
|
176
|
+
if not registry_paths:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# Index entry-file contents by path for O(1) lookup
|
|
180
|
+
contents: dict[str, str] = {}
|
|
181
|
+
if entry_files:
|
|
182
|
+
for ef in entry_files:
|
|
183
|
+
if not isinstance(ef, dict):
|
|
184
|
+
continue
|
|
185
|
+
path = ef.get("path")
|
|
186
|
+
content = ef.get("content")
|
|
187
|
+
if isinstance(path, str) and isinstance(content, str):
|
|
188
|
+
contents[path] = content
|
|
189
|
+
|
|
190
|
+
# When we have contents for any registry file, do the deep check
|
|
191
|
+
for path in registry_paths:
|
|
192
|
+
if path in contents:
|
|
193
|
+
entry_count = _registry_entry_count(contents[path])
|
|
194
|
+
has_annotation = _has_component_array_annotation(contents[path])
|
|
195
|
+
if entry_count >= 10 or has_annotation:
|
|
196
|
+
return {
|
|
197
|
+
"registry_path": path,
|
|
198
|
+
"entry_count": entry_count if entry_count >= 10 else None,
|
|
199
|
+
"component_array_annotation": has_annotation,
|
|
200
|
+
"contents_inspected": True,
|
|
201
|
+
}
|
|
202
|
+
# File present but neither threshold met — content disqualifies
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# No contents available for any registry file
|
|
206
|
+
if mode == "headless":
|
|
207
|
+
return {
|
|
208
|
+
"registry_path": registry_paths[0],
|
|
209
|
+
"entry_count": None,
|
|
210
|
+
"component_array_annotation": False,
|
|
211
|
+
"contents_inspected": False,
|
|
212
|
+
}
|
|
213
|
+
# Interactive without contents — fall through; the rule does not match
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _reference_app_match(intent_lower: str) -> dict | None:
|
|
218
|
+
matches = [kw for kw in REFERENCE_APP_KEYWORDS if kw in intent_lower]
|
|
219
|
+
if matches:
|
|
220
|
+
return {"keywords": matches}
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _specific_modules_match(intent: str, module_count: int) -> tuple[str, dict] | None:
|
|
225
|
+
naming_hits: list[str] = []
|
|
226
|
+
for pattern in SPECIFIC_MODULE_NAMING_PATTERNS:
|
|
227
|
+
m = pattern.search(intent)
|
|
228
|
+
if m:
|
|
229
|
+
naming_hits.append(m.group(0).strip())
|
|
230
|
+
if naming_hits:
|
|
231
|
+
return ("specific-modules-naming", {"phrases": naming_hits})
|
|
232
|
+
if module_count >= 6:
|
|
233
|
+
return ("specific-modules-count", {"module_count": module_count})
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _narrow_public_api_match(intent_lower: str, export_count: int) -> dict | None:
|
|
238
|
+
keyword_hits = [kw for kw in NARROW_PUBLIC_API_KEYWORDS if kw in intent_lower]
|
|
239
|
+
if keyword_hits and 0 < export_count <= 8:
|
|
240
|
+
return {"keywords": keyword_hits, "export_count": export_count}
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def recommend(payload: dict) -> dict:
|
|
245
|
+
"""Apply the five-rule ladder. Always returns a recommendation."""
|
|
246
|
+
intent = (payload.get("intent") or "").strip()
|
|
247
|
+
intent_lower = intent.lower()
|
|
248
|
+
module_count = int(payload.get("module_count") or 0)
|
|
249
|
+
export_count = int(payload.get("export_count") or 0)
|
|
250
|
+
tree = payload.get("tree") or []
|
|
251
|
+
entry_files = payload.get("entry_files")
|
|
252
|
+
source_type = payload.get("source_type")
|
|
253
|
+
mode = payload.get("mode") or "headless"
|
|
254
|
+
|
|
255
|
+
if source_type not in VALID_SOURCE_TYPES:
|
|
256
|
+
_die(f"source_type must be one of {sorted(t for t in VALID_SOURCE_TYPES if t)} or null; got {source_type!r}")
|
|
257
|
+
if mode not in VALID_MODES:
|
|
258
|
+
_die(f"mode must be one of {sorted(VALID_MODES)}; got {mode!r}")
|
|
259
|
+
|
|
260
|
+
# Short-circuit: docs-only has no source surface to scope
|
|
261
|
+
if source_type == "docs-only":
|
|
262
|
+
return {
|
|
263
|
+
"scope_type": "docs-only",
|
|
264
|
+
"matched_heuristic": "docs-only-shortcircuit",
|
|
265
|
+
"signals": {"source_type": "docs-only"},
|
|
266
|
+
"rationale": "source_type is docs-only — there is no source surface to scope, so the brief uses the docs-only template.",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# Rule 1 — component-library
|
|
270
|
+
cr = _component_registry_match(tree, entry_files, mode)
|
|
271
|
+
if cr:
|
|
272
|
+
rationale_bits = [f"a component registry was detected at {cr['registry_path']}"]
|
|
273
|
+
if cr.get("entry_count"):
|
|
274
|
+
rationale_bits.append(f"with {cr['entry_count']} entries")
|
|
275
|
+
if cr.get("component_array_annotation"):
|
|
276
|
+
rationale_bits.append("and a Component[] type annotation")
|
|
277
|
+
if not cr.get("contents_inspected"):
|
|
278
|
+
rationale_bits.append("(presence-only match — file contents not inspected in headless mode)")
|
|
279
|
+
return {
|
|
280
|
+
"scope_type": "component-library",
|
|
281
|
+
"matched_heuristic": "component-registry",
|
|
282
|
+
"signals": cr,
|
|
283
|
+
"rationale": "Component Library because " + " ".join(rationale_bits) + ".",
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Rule 2 — reference-app
|
|
287
|
+
ra = _reference_app_match(intent_lower)
|
|
288
|
+
if ra:
|
|
289
|
+
kw_str = ", ".join(f"'{k}'" for k in ra["keywords"])
|
|
290
|
+
return {
|
|
291
|
+
"scope_type": "reference-app",
|
|
292
|
+
"matched_heuristic": "reference-app-keywords",
|
|
293
|
+
"signals": ra,
|
|
294
|
+
"rationale": f"Reference App because the intent mentions {kw_str} — language consistent with a wiring-pattern skill rather than a library API.",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Rule 3 — specific-modules
|
|
298
|
+
sm = _specific_modules_match(intent, module_count)
|
|
299
|
+
if sm:
|
|
300
|
+
heuristic, signals = sm
|
|
301
|
+
if heuristic == "specific-modules-naming":
|
|
302
|
+
phrases = ", ".join(f"'{p}'" for p in signals["phrases"])
|
|
303
|
+
rationale = f"Specific Modules because the intent names a subset ({phrases})."
|
|
304
|
+
else:
|
|
305
|
+
rationale = f"Specific Modules because the analysis surfaced {signals['module_count']} top-level modules — likely too many for a single cohesive scope."
|
|
306
|
+
return {
|
|
307
|
+
"scope_type": "specific-modules",
|
|
308
|
+
"matched_heuristic": heuristic,
|
|
309
|
+
"signals": signals,
|
|
310
|
+
"rationale": rationale,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Rule 4 — narrow public API
|
|
314
|
+
pa = _narrow_public_api_match(intent_lower, export_count)
|
|
315
|
+
if pa:
|
|
316
|
+
kw_str = ", ".join(f"'{k}'" for k in pa["keywords"])
|
|
317
|
+
return {
|
|
318
|
+
"scope_type": "public-api",
|
|
319
|
+
"matched_heuristic": "narrow-public-api",
|
|
320
|
+
"signals": pa,
|
|
321
|
+
"rationale": f"Public API Only because the intent mentions {kw_str} and the manifest exposes {pa['export_count']} named exports — a clear narrow public surface.",
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
# Rule 5 — fallback
|
|
325
|
+
return {
|
|
326
|
+
"scope_type": "full-library",
|
|
327
|
+
"matched_heuristic": "default-full-library",
|
|
328
|
+
"signals": {
|
|
329
|
+
"module_count": module_count,
|
|
330
|
+
"export_count": export_count,
|
|
331
|
+
},
|
|
332
|
+
"rationale": "Full Library — no signal matched a narrower scope, so the default is to cover everything.",
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_argv(argv: list[str]) -> dict:
|
|
337
|
+
parser = argparse.ArgumentParser(
|
|
338
|
+
description="Recommend a scope type by applying the documented 5-rule heuristic ladder.",
|
|
339
|
+
)
|
|
340
|
+
parser.add_argument(
|
|
341
|
+
"--json",
|
|
342
|
+
help="JSON payload (alternative to stdin)",
|
|
343
|
+
)
|
|
344
|
+
args = parser.parse_args(argv)
|
|
345
|
+
if args.json is not None:
|
|
346
|
+
raw = args.json
|
|
347
|
+
else:
|
|
348
|
+
raw = sys.stdin.read()
|
|
349
|
+
if not raw or not raw.strip():
|
|
350
|
+
_die("empty input (expected JSON payload on stdin or via --json)")
|
|
351
|
+
try:
|
|
352
|
+
payload = json.loads(raw)
|
|
353
|
+
except json.JSONDecodeError as e:
|
|
354
|
+
_die(f"invalid JSON input: {e}")
|
|
355
|
+
if not isinstance(payload, dict):
|
|
356
|
+
_die("payload must be a JSON object")
|
|
357
|
+
return payload
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def main(argv: list[str]) -> int:
|
|
361
|
+
payload = _parse_argv(argv)
|
|
362
|
+
result = recommend(payload)
|
|
363
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
364
|
+
sys.stdout.write("\n")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Render Quick Metadata — render metadata.json per the canonical
|
|
6
|
+
skill-template.md schema with quick-skill-specific population rules.
|
|
7
|
+
|
|
8
|
+
Pure renderer — no I/O beyond stdin/stdout. Reads a JSON payload of
|
|
9
|
+
extracted state on stdin and emits the corresponding metadata.json
|
|
10
|
+
on stdout. Replaces the hand-assembly the LLM previously did in
|
|
11
|
+
skf-quick-skill step-04 §4.
|
|
12
|
+
|
|
13
|
+
Constants vs. input-derived split mirrors step-04's documentation:
|
|
14
|
+
|
|
15
|
+
Constants (always literal):
|
|
16
|
+
skill_type "single"
|
|
17
|
+
spec_version "1.3"
|
|
18
|
+
source_authority "community"
|
|
19
|
+
confidence_tier "Quick"
|
|
20
|
+
generated_by "quick-skill"
|
|
21
|
+
confidence_distribution.t1, t2, t3 0
|
|
22
|
+
tool_versions.ast_grep, tool_versions.qmd null
|
|
23
|
+
stats.exports_internal, stats.scripts_count, stats.assets_count 0
|
|
24
|
+
stats.public_api_coverage, stats.total_coverage 1.0
|
|
25
|
+
|
|
26
|
+
Input-derived (from stdin payload):
|
|
27
|
+
name, version, description, language, source_repo,
|
|
28
|
+
source_root, source_commit, source_package,
|
|
29
|
+
exports[], dependencies[], compatibility,
|
|
30
|
+
provenance.language_hint, provenance.scope_hint,
|
|
31
|
+
tool_versions.skf
|
|
32
|
+
|
|
33
|
+
Computed:
|
|
34
|
+
generation_date ISO 8601 UTC ("YYYY-MM-DDTHH:MM:SSZ")
|
|
35
|
+
confidence_distribution.t1_low count(exports)
|
|
36
|
+
stats.exports_documented / public_api / total count(exports)
|
|
37
|
+
|
|
38
|
+
Input JSON shape (stdin):
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
"name": "foo",
|
|
42
|
+
"version": "1.2.3", (default "1.0.0")
|
|
43
|
+
"description": "...", (default "")
|
|
44
|
+
"language": "python",
|
|
45
|
+
"source_repo": "https://github.com/x/y",
|
|
46
|
+
"source_root": "src/foo", (optional)
|
|
47
|
+
"source_commit": "abc123", (optional)
|
|
48
|
+
"source_package": "foo", (optional)
|
|
49
|
+
"exports": [{"name":"fn","type":"def"}] or list of strings
|
|
50
|
+
"dependencies": ["a", "b"], (default [])
|
|
51
|
+
"compatibility": ">=3.10", (optional, default "")
|
|
52
|
+
"language_hint": null, (echoed verbatim)
|
|
53
|
+
"scope_hint": null, (echoed verbatim)
|
|
54
|
+
"skf_version": "1.2.0" (default "unknown")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
CLI:
|
|
58
|
+
|
|
59
|
+
python3 skf-render-quick-metadata.py < input.json
|
|
60
|
+
|
|
61
|
+
Exit codes:
|
|
62
|
+
|
|
63
|
+
0 success
|
|
64
|
+
1 payload-level error (missing required field)
|
|
65
|
+
2 stdin / argparse / JSON-decode error
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import argparse
|
|
71
|
+
import datetime as dt
|
|
72
|
+
import json
|
|
73
|
+
import sys
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
REQUIRED_FIELDS = ("name", "language", "source_repo")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normalize_exports(raw) -> list[str]:
|
|
80
|
+
"""Accept either a list of strings or a list of {name, type, ...} dicts.
|
|
81
|
+
Returns a flat list of export names in declaration order, deduplicated.
|
|
82
|
+
"""
|
|
83
|
+
out: list[str] = []
|
|
84
|
+
seen: set[str] = set()
|
|
85
|
+
if not isinstance(raw, list):
|
|
86
|
+
return out
|
|
87
|
+
for item in raw:
|
|
88
|
+
name: str | None = None
|
|
89
|
+
if isinstance(item, str):
|
|
90
|
+
name = item
|
|
91
|
+
elif isinstance(item, dict):
|
|
92
|
+
v = item.get("name")
|
|
93
|
+
if isinstance(v, str):
|
|
94
|
+
name = v
|
|
95
|
+
if name and name not in seen:
|
|
96
|
+
seen.add(name)
|
|
97
|
+
out.append(name)
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _iso_utc_now() -> str:
|
|
102
|
+
"""Returns 'YYYY-MM-DDTHH:MM:SSZ' for the current UTC instant."""
|
|
103
|
+
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_metadata(payload: dict, *, now_fn=_iso_utc_now) -> dict:
|
|
107
|
+
"""Render the metadata.json envelope. Pass `now_fn` to inject a
|
|
108
|
+
deterministic timestamp in tests.
|
|
109
|
+
"""
|
|
110
|
+
missing = [f for f in REQUIRED_FIELDS if not payload.get(f)]
|
|
111
|
+
if missing:
|
|
112
|
+
return {"_error": f"missing required field(s): {', '.join(missing)}"}
|
|
113
|
+
|
|
114
|
+
exports = _normalize_exports(payload.get("exports"))
|
|
115
|
+
export_count = len(exports)
|
|
116
|
+
|
|
117
|
+
metadata = {
|
|
118
|
+
"name": payload["name"],
|
|
119
|
+
"version": payload.get("version") or "1.0.0",
|
|
120
|
+
"description": payload.get("description") or "",
|
|
121
|
+
"skill_type": "single",
|
|
122
|
+
"source_authority": "community",
|
|
123
|
+
"source_repo": payload["source_repo"],
|
|
124
|
+
"source_root": payload.get("source_root") or "",
|
|
125
|
+
"source_commit": payload.get("source_commit") or "",
|
|
126
|
+
"source_package": payload.get("source_package") or payload["name"],
|
|
127
|
+
"language": payload["language"],
|
|
128
|
+
"generated_by": "quick-skill",
|
|
129
|
+
"generation_date": now_fn(),
|
|
130
|
+
"confidence_tier": "Quick",
|
|
131
|
+
"spec_version": "1.3",
|
|
132
|
+
"exports": exports,
|
|
133
|
+
"confidence_distribution": {
|
|
134
|
+
"t1": 0,
|
|
135
|
+
"t1_low": export_count,
|
|
136
|
+
"t2": 0,
|
|
137
|
+
"t3": 0,
|
|
138
|
+
},
|
|
139
|
+
"tool_versions": {
|
|
140
|
+
"ast_grep": None,
|
|
141
|
+
"qmd": None,
|
|
142
|
+
"skf": payload.get("skf_version") or "unknown",
|
|
143
|
+
},
|
|
144
|
+
"stats": {
|
|
145
|
+
"exports_documented": export_count,
|
|
146
|
+
"exports_public_api": export_count,
|
|
147
|
+
"exports_internal": 0,
|
|
148
|
+
"exports_total": export_count,
|
|
149
|
+
"public_api_coverage": 1.0,
|
|
150
|
+
"total_coverage": 1.0,
|
|
151
|
+
"scripts_count": 0,
|
|
152
|
+
"assets_count": 0,
|
|
153
|
+
},
|
|
154
|
+
"dependencies": payload.get("dependencies") or [],
|
|
155
|
+
"compatibility": payload.get("compatibility") or "",
|
|
156
|
+
"provenance": {
|
|
157
|
+
"language_hint": payload.get("language_hint"),
|
|
158
|
+
"scope_hint": payload.get("scope_hint"),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
return metadata
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv: list[str]) -> int:
|
|
165
|
+
parser = argparse.ArgumentParser(
|
|
166
|
+
description="Render skf-quick-skill metadata.json from extracted state on stdin.",
|
|
167
|
+
)
|
|
168
|
+
parser.parse_args(argv) # currently no flags; kept for forward compat
|
|
169
|
+
|
|
170
|
+
raw = sys.stdin.read()
|
|
171
|
+
if not raw.strip():
|
|
172
|
+
sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
|
|
173
|
+
return 2
|
|
174
|
+
try:
|
|
175
|
+
payload = json.loads(raw)
|
|
176
|
+
except json.JSONDecodeError as e:
|
|
177
|
+
sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
|
|
178
|
+
return 2
|
|
179
|
+
if not isinstance(payload, dict):
|
|
180
|
+
sys.stderr.write("error: stdin payload must be a JSON object\n")
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
result = render_metadata(payload)
|
|
184
|
+
if "_error" in result:
|
|
185
|
+
sys.stderr.write(f"error: {result['_error']}\n")
|
|
186
|
+
return 1
|
|
187
|
+
print(json.dumps(result, indent=2))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
sys.exit(main(sys.argv[1:]))
|