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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/skill-model.md +26 -32
  4. package/docs/troubleshooting.md +12 -0
  5. package/docs/workflows.md +87 -15
  6. package/package.json +2 -2
  7. package/src/shared/references/output-contract-schema.md +10 -0
  8. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
  9. package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
  10. package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
  11. package/src/shared/scripts/skf-detect-language.py +277 -0
  12. package/src/shared/scripts/skf-detect-workspaces.py +427 -0
  13. package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
  14. package/src/shared/scripts/skf-extract-public-api.py +534 -0
  15. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  16. package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
  17. package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
  18. package/src/shared/scripts/skf-resolve-package.py +264 -0
  19. package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
  20. package/src/shared/scripts/skf-validate-output.py +24 -7
  21. package/src/shared/scripts/skf-write-skill-brief.py +509 -0
  22. package/src/skf-brief-skill/SKILL.md +41 -12
  23. package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
  24. package/src/skf-brief-skill/assets/scope-templates.md +5 -0
  25. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
  26. package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
  27. package/src/skf-brief-skill/references/headless-args.md +22 -0
  28. package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
  29. package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
  30. package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
  31. package/src/skf-brief-skill/references/version-resolution.md +46 -0
  32. package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
  33. package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
  34. package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
  35. package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
  36. package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
  37. package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
  38. package/src/skf-quick-skill/SKILL.md +178 -10
  39. package/src/skf-quick-skill/assets/skill-template.md +5 -1
  40. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  41. package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
  42. package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
  43. package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
  44. package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
  45. package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
  46. package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
  47. package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
  48. package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
@@ -0,0 +1,534 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Extract Public API — pure parser for manifest + entry-point content.
6
+
7
+ Takes a JSON payload on stdin describing one logical package (one
8
+ manifest, one or more entry-point files) and emits a structured
9
+ extraction inventory: package name, version, description, public
10
+ exports, declared dependencies, and (for Maven/Gradle) discovered
11
+ sub-modules. The helper does NO file I/O — the caller fetches files
12
+ (via gh api / web browsing / local filesystem / wherever) and pipes
13
+ their contents in. Multi-module monorepos are caller-orchestrated:
14
+ invoke once per module, aggregate the results.
15
+
16
+ Supported languages (--language values; first listed is preferred):
17
+
18
+ js, ts, javascript, typescript package.json index.{js,ts}, src/index.{js,ts}
19
+ python pyproject.toml __init__.py / setup.py
20
+ rust Cargo.toml src/lib.rs
21
+ go go.mod top-level *.go
22
+ java pom.xml src/main/java/**/*.java
23
+ kotlin build.gradle* src/main/kotlin/**/*.kt
24
+
25
+ Mode flag (currently `quick` only; `full` reserved for skf-create-skill):
26
+
27
+ --mode quick Top-level exports only (one entry file per language).
28
+
29
+ Input JSON shape (stdin):
30
+
31
+ {
32
+ "language": "python",
33
+ "manifest": {"path": "pyproject.toml", "content": "..."},
34
+ "entries": [{"path": "src/foo/__init__.py", "content": "..."}, ...],
35
+ "mode": "quick"
36
+ }
37
+
38
+ Output JSON shape (stdout):
39
+
40
+ {
41
+ "language": "python",
42
+ "package_name": "foo",
43
+ "version": "1.2.3",
44
+ "description": "...",
45
+ "exports": [{"name": "Bar", "type": "class", "source_file": "..."}, ...],
46
+ "dependencies": ["requests", "pydantic", ...],
47
+ "modules": ["server", "client"], (Maven/Gradle only)
48
+ "extra": {"group_id": "com.example"}, (Maven only)
49
+ "warnings": ["..."] (parse failures, fallbacks)
50
+ }
51
+
52
+ Exit codes:
53
+
54
+ 0 success
55
+ 1 payload-level error (unknown language, no manifest content)
56
+ 2 stdin / argparse / JSON-decode error
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import json
63
+ import re
64
+ import sys
65
+ import tomllib
66
+ import xml.etree.ElementTree as ET
67
+ from typing import Callable
68
+
69
+ # --------------------------------------------------------------------------
70
+ # Manifest parsers
71
+ # --------------------------------------------------------------------------
72
+
73
+
74
+ def parse_package_json(content: str) -> dict:
75
+ try:
76
+ data = json.loads(content)
77
+ except json.JSONDecodeError as e:
78
+ return {"_parse_error": f"package.json JSON parse error: {e}"}
79
+ if not isinstance(data, dict):
80
+ return {"_parse_error": "package.json root is not an object"}
81
+ return {
82
+ "name": data.get("name"),
83
+ "version": data.get("version"),
84
+ "description": data.get("description"),
85
+ "main": data.get("main"),
86
+ "exports": data.get("exports"),
87
+ "dependencies": list((data.get("dependencies") or {}).keys()),
88
+ "modules": [],
89
+ }
90
+
91
+
92
+ def parse_pyproject_toml(content: str) -> dict:
93
+ try:
94
+ data = tomllib.loads(content)
95
+ except tomllib.TOMLDecodeError as e:
96
+ return {"_parse_error": f"pyproject.toml parse error: {e}"}
97
+ project = data.get("project") or {}
98
+ raw_deps = project.get("dependencies") or []
99
+ dep_names: list[str] = []
100
+ for d in raw_deps:
101
+ if isinstance(d, str):
102
+ m = re.match(r"^([A-Za-z0-9_.\-]+)", d.strip())
103
+ if m:
104
+ dep_names.append(m.group(1))
105
+ return {
106
+ "name": project.get("name"),
107
+ "version": project.get("version"),
108
+ "description": project.get("description"),
109
+ "dependencies": dep_names,
110
+ "modules": [],
111
+ }
112
+
113
+
114
+ def parse_setup_py(content: str) -> dict:
115
+ """Best-effort regex parse of setup.py — does NOT execute the file."""
116
+
117
+ def find_kwarg(name: str) -> str | None:
118
+ m = re.search(rf'\b{name}\s*=\s*["\']([^"\']+)["\']', content)
119
+ return m.group(1) if m else None
120
+
121
+ return {
122
+ "name": find_kwarg("name"),
123
+ "version": find_kwarg("version"),
124
+ "description": find_kwarg("description"),
125
+ "dependencies": [],
126
+ "modules": [],
127
+ }
128
+
129
+
130
+ def parse_cargo_toml(content: str) -> dict:
131
+ try:
132
+ data = tomllib.loads(content)
133
+ except tomllib.TOMLDecodeError as e:
134
+ return {"_parse_error": f"Cargo.toml parse error: {e}"}
135
+ pkg = data.get("package") or {}
136
+ return {
137
+ "name": pkg.get("name"),
138
+ "version": pkg.get("version"),
139
+ "description": pkg.get("description"),
140
+ "dependencies": list((data.get("dependencies") or {}).keys()),
141
+ "modules": [],
142
+ }
143
+
144
+
145
+ def parse_go_mod(content: str) -> dict:
146
+ name: str | None = None
147
+ deps: list[str] = []
148
+ in_require_block = False
149
+ for line in content.splitlines():
150
+ s = line.strip()
151
+ if not s or s.startswith("//"):
152
+ continue
153
+ if s.startswith("module "):
154
+ name = s.split(None, 1)[1].strip()
155
+ continue
156
+ if s.startswith("require ("):
157
+ in_require_block = True
158
+ continue
159
+ if s == ")" and in_require_block:
160
+ in_require_block = False
161
+ continue
162
+ if s.startswith("require "):
163
+ parts = s.split(None, 2)
164
+ if len(parts) >= 2:
165
+ deps.append(parts[1])
166
+ continue
167
+ if in_require_block:
168
+ parts = s.split()
169
+ if parts:
170
+ deps.append(parts[0])
171
+ return {
172
+ "name": name,
173
+ "version": None,
174
+ "description": None,
175
+ "dependencies": deps,
176
+ "modules": [],
177
+ }
178
+
179
+
180
+ def _strip_ns(tag: str) -> str:
181
+ return tag.split("}", 1)[1] if "}" in tag else tag
182
+
183
+
184
+ def parse_pom_xml(content: str) -> dict:
185
+ try:
186
+ root = ET.fromstring(content)
187
+ except ET.ParseError as e:
188
+ return {"_parse_error": f"pom.xml parse error: {e}"}
189
+
190
+ group_id = artifact_id = version = description = None
191
+ modules: list[str] = []
192
+ deps: list[str] = []
193
+
194
+ for child in root:
195
+ tag = _strip_ns(child.tag)
196
+ if tag == "groupId":
197
+ group_id = (child.text or "").strip() or None
198
+ elif tag == "artifactId":
199
+ artifact_id = (child.text or "").strip() or None
200
+ elif tag == "version":
201
+ version = (child.text or "").strip() or None
202
+ elif tag == "description":
203
+ description = (child.text or "").strip() or None
204
+ elif tag == "modules":
205
+ for m in child:
206
+ if _strip_ns(m.tag) == "module":
207
+ text = (m.text or "").strip()
208
+ if text:
209
+ modules.append(text)
210
+ elif tag == "dependencies":
211
+ for d in child:
212
+ d_artifact = None
213
+ for c in d:
214
+ if _strip_ns(c.tag) == "artifactId":
215
+ d_artifact = (c.text or "").strip() or None
216
+ if d_artifact:
217
+ deps.append(d_artifact)
218
+
219
+ out = {
220
+ "name": artifact_id,
221
+ "version": version,
222
+ "description": description,
223
+ "dependencies": deps,
224
+ "modules": modules,
225
+ }
226
+ if group_id:
227
+ out["_extra"] = {"group_id": group_id}
228
+ return out
229
+
230
+
231
+ def parse_gradle(content: str) -> dict:
232
+ """Best-effort regex parse of build.gradle / build.gradle.kts."""
233
+
234
+ def find_assignment(name: str) -> str | None:
235
+ for pat in (
236
+ rf'\b{name}\s*=\s*["\']([^"\']+)["\']',
237
+ rf'\b{name}\s+["\']([^"\']+)["\']',
238
+ rf'\b{name}\s*\(\s*["\']([^"\']+)["\']',
239
+ rf'\b{name}\s*:\s*["\']([^"\']+)["\']',
240
+ ):
241
+ m = re.search(pat, content)
242
+ if m:
243
+ return m.group(1)
244
+ return None
245
+
246
+ return {
247
+ "name": find_assignment("artifactId") or find_assignment("group"),
248
+ "version": find_assignment("version"),
249
+ "description": find_assignment("description"),
250
+ "dependencies": [],
251
+ "modules": [],
252
+ }
253
+
254
+
255
+ def parse_settings_gradle(content: str) -> list[str]:
256
+ """Extract include('...') / include(":a", ":b") entries."""
257
+ modules: list[str] = []
258
+ for m in re.finditer(r"include\s*\(?(.+?)\)?$", content, flags=re.MULTILINE):
259
+ for s in re.findall(r"""['"]:?([^'"]+)['"]""", m.group(1)):
260
+ modules.append(s)
261
+ return modules
262
+
263
+
264
+ # --------------------------------------------------------------------------
265
+ # Export scanners
266
+ # --------------------------------------------------------------------------
267
+
268
+ _JS_DECL_RE = re.compile(
269
+ r"^export\s+(?:default\s+)?(const|function|class|type|interface|enum)\s+(\w+)",
270
+ re.MULTILINE,
271
+ )
272
+ _JS_REEXPORT_RE = re.compile(r"^export\s*\{([^}]+)\}", re.MULTILINE)
273
+
274
+
275
+ def scan_exports_js(content: str, source_file: str) -> list[dict]:
276
+ out: list[dict] = []
277
+ seen: set[str] = set()
278
+ for m in _JS_DECL_RE.finditer(content):
279
+ kind, name = m.group(1), m.group(2)
280
+ if name not in seen:
281
+ seen.add(name)
282
+ out.append({"name": name, "type": kind, "source_file": source_file})
283
+ for m in _JS_REEXPORT_RE.finditer(content):
284
+ for piece in m.group(1).split(","):
285
+ piece = piece.strip()
286
+ if not piece:
287
+ continue
288
+ # Handle `foo as bar` — keep the local name (foo) and the alias (bar);
289
+ # we only emit one entry, the exposed name.
290
+ if " as " in piece:
291
+ _, _, exposed = piece.partition(" as ")
292
+ exposed = exposed.strip()
293
+ else:
294
+ exposed = piece
295
+ m2 = re.match(r"\w+$", exposed)
296
+ if m2 and exposed not in seen:
297
+ seen.add(exposed)
298
+ out.append({"name": exposed, "type": "re-export", "source_file": source_file})
299
+ return out
300
+
301
+
302
+ def scan_exports_python(content: str, source_file: str) -> list[dict]:
303
+ all_names: list[str] | None = None
304
+ m = re.search(r"^__all__\s*=\s*\[([^\]]+)\]", content, re.MULTILINE | re.DOTALL)
305
+ if m:
306
+ all_names = re.findall(r"""['"]([^'"]+)['"]""", m.group(1))
307
+
308
+ out: list[dict] = []
309
+ for m in re.finditer(r"^(def|class)\s+(\w+)", content, re.MULTILINE):
310
+ kind, name = m.group(1), m.group(2)
311
+ if name.startswith("_"):
312
+ continue
313
+ if all_names is not None and name not in all_names:
314
+ continue
315
+ out.append({"name": name, "type": kind, "source_file": source_file})
316
+ return out
317
+
318
+
319
+ def scan_exports_rust(content: str, source_file: str) -> list[dict]:
320
+ out: list[dict] = []
321
+ for m in re.finditer(
322
+ r"^\s*pub\s+(fn|struct|enum|trait|mod|type|const|static)\s+(\w+)",
323
+ content,
324
+ re.MULTILINE,
325
+ ):
326
+ out.append({"name": m.group(2), "type": m.group(1), "source_file": source_file})
327
+ return out
328
+
329
+
330
+ def scan_exports_go(content: str, source_file: str) -> list[dict]:
331
+ out: list[dict] = []
332
+ for m in re.finditer(r"^(func|type|var|const)\s+([A-Z]\w*)", content, re.MULTILINE):
333
+ out.append({"name": m.group(2), "type": m.group(1), "source_file": source_file})
334
+ return out
335
+
336
+
337
+ _JAVA_PUBLIC_RE = re.compile(
338
+ r"^\s*public\s+(?:static\s+|final\s+|abstract\s+)*(class|interface|enum|record)\s+(\w+)",
339
+ re.MULTILINE,
340
+ )
341
+ _JAVA_ANNOTATION_RE = re.compile(
342
+ r"^\s*@(RestController|Service|Component|Configuration|Controller|Repository|Bean)\b",
343
+ re.MULTILINE,
344
+ )
345
+
346
+
347
+ def scan_exports_java(content: str, source_file: str) -> list[dict]:
348
+ out: list[dict] = []
349
+ seen: set[str] = set()
350
+ for m in _JAVA_PUBLIC_RE.finditer(content):
351
+ name = m.group(2)
352
+ if name in seen:
353
+ continue
354
+ seen.add(name)
355
+ out.append({"name": name, "type": m.group(1), "source_file": source_file})
356
+ # Annotation-marked classes (Spring/Jakarta CDI) — find the annotation, then
357
+ # the next class/interface/enum/record declaration after it.
358
+ for m in _JAVA_ANNOTATION_RE.finditer(content):
359
+ annotation = m.group(1)
360
+ tail = content[m.end():]
361
+ m2 = re.search(r"\b(class|interface|enum|record)\s+(\w+)", tail)
362
+ if m2:
363
+ name = m2.group(2)
364
+ if name not in seen:
365
+ seen.add(name)
366
+ out.append({"name": name, "type": annotation.lower(), "source_file": source_file})
367
+ return out
368
+
369
+
370
+ _KOTLIN_DECL_RE = re.compile(
371
+ r"^(?!\s*(?:internal|private)\b)"
372
+ r"\s*(?:open\s+|sealed\s+|data\s+|abstract\s+)*"
373
+ r"(fun|class|object|interface)\s+(\w+)",
374
+ re.MULTILINE,
375
+ )
376
+
377
+
378
+ def scan_exports_kotlin(content: str, source_file: str) -> list[dict]:
379
+ """Kotlin defaults to public — omit internal/private declarations."""
380
+ out: list[dict] = []
381
+ seen: set[str] = set()
382
+ for m in _KOTLIN_DECL_RE.finditer(content):
383
+ name = m.group(2)
384
+ if name in seen:
385
+ continue
386
+ seen.add(name)
387
+ out.append({"name": name, "type": m.group(1), "source_file": source_file})
388
+ return out
389
+
390
+
391
+ # --------------------------------------------------------------------------
392
+ # Orchestrator
393
+ # --------------------------------------------------------------------------
394
+
395
+ ManifestParser = Callable[[str], dict]
396
+ ExportScanner = Callable[[str, str], list[dict]]
397
+
398
+ LANGUAGE_DISPATCH: dict[str, tuple[ManifestParser, ExportScanner]] = {
399
+ "js": (parse_package_json, scan_exports_js),
400
+ "ts": (parse_package_json, scan_exports_js),
401
+ "javascript": (parse_package_json, scan_exports_js),
402
+ "typescript": (parse_package_json, scan_exports_js),
403
+ "python": (parse_pyproject_toml, scan_exports_python),
404
+ "rust": (parse_cargo_toml, scan_exports_rust),
405
+ "go": (parse_go_mod, scan_exports_go),
406
+ "java": (parse_pom_xml, scan_exports_java),
407
+ "kotlin": (parse_gradle, scan_exports_kotlin),
408
+ }
409
+
410
+
411
+ def _select_manifest_parser(language: str, manifest_path: str) -> ManifestParser:
412
+ """Pick the right manifest parser, special-casing Python's setup.py."""
413
+ if language == "python" and manifest_path.endswith("setup.py"):
414
+ return parse_setup_py
415
+ return LANGUAGE_DISPATCH[language][0]
416
+
417
+
418
+ # Release-time placeholder versions that appear in committed manifests but
419
+ # resolve to a real version only at publish time. Briefs that silently inherit
420
+ # these as the resolved version produce skills tagged with garbage version
421
+ # strings; surface them at brief-creation instead.
422
+ _PLACEHOLDER_VERSION_PREFIXES = ("workspace:",)
423
+ _PLACEHOLDER_VERSION_EXACTS = frozenset({"0.0.0-development", "0.0.0-semantically-released"})
424
+
425
+
426
+ def _detect_placeholder_version(version: str | None) -> str | None:
427
+ """Return the original placeholder string if `version` is a known release-time sentinel, else None."""
428
+ if not version or not isinstance(version, str):
429
+ return None
430
+ v = version.strip().lower()
431
+ if v in _PLACEHOLDER_VERSION_EXACTS:
432
+ return version
433
+ if any(v.startswith(prefix) for prefix in _PLACEHOLDER_VERSION_PREFIXES):
434
+ return version
435
+ return None
436
+
437
+
438
+ def extract(payload: dict) -> dict:
439
+ """Orchestrate manifest parse + export scan for one logical package."""
440
+ warnings: list[str] = []
441
+ language = (payload.get("language") or "").lower()
442
+ if language not in LANGUAGE_DISPATCH:
443
+ return {"_error": f"unknown language: {language!r}; expected one of {sorted(LANGUAGE_DISPATCH)}"}
444
+
445
+ manifest = payload.get("manifest") or {}
446
+ manifest_path = (manifest.get("path") or "").strip()
447
+ manifest_content = manifest.get("content") or ""
448
+
449
+ if not manifest_content:
450
+ warnings.append("no manifest content provided; metadata will be empty")
451
+ parsed: dict = {}
452
+ else:
453
+ manifest_parser = _select_manifest_parser(language, manifest_path)
454
+ parsed = manifest_parser(manifest_content)
455
+ if "_parse_error" in parsed:
456
+ warnings.append(parsed["_parse_error"])
457
+ parsed = {}
458
+
459
+ placeholder = _detect_placeholder_version(parsed.get("version"))
460
+ if placeholder is not None:
461
+ warnings.append(
462
+ f"manifest version {placeholder!r} is a release-time placeholder "
463
+ f"(workspace protocol or semantic-release sentinel) and is not a real version; "
464
+ f"the brief will fall back to user-supplied target_version or the default"
465
+ )
466
+ parsed["version"] = None
467
+
468
+ _, scanner = LANGUAGE_DISPATCH[language]
469
+ exports: list[dict] = []
470
+ for entry in payload.get("entries") or []:
471
+ if not isinstance(entry, dict):
472
+ continue
473
+ content = entry.get("content") or ""
474
+ path = entry.get("path") or ""
475
+ if not content:
476
+ continue
477
+ try:
478
+ exports.extend(scanner(content, path))
479
+ except Exception as e: # noqa: BLE001 — best-effort: scanner errors are warnings, not fatal
480
+ warnings.append(f"export scan failed for {path}: {e}")
481
+
482
+ result = {
483
+ "language": language,
484
+ "package_name": parsed.get("name"),
485
+ "version": parsed.get("version"),
486
+ "description": parsed.get("description"),
487
+ "exports": exports,
488
+ "dependencies": parsed.get("dependencies", []),
489
+ "modules": parsed.get("modules", []),
490
+ "warnings": warnings,
491
+ }
492
+ if "_extra" in parsed:
493
+ result["extra"] = parsed["_extra"]
494
+ return result
495
+
496
+
497
+ # --------------------------------------------------------------------------
498
+ # CLI
499
+ # --------------------------------------------------------------------------
500
+
501
+
502
+ def main(argv: list[str]) -> int:
503
+ parser = argparse.ArgumentParser(
504
+ description="Extract public-API surface from manifest + entry-point content (pure parser, no I/O).",
505
+ )
506
+ parser.add_argument(
507
+ "--mode",
508
+ default="quick",
509
+ choices=("quick",),
510
+ help="Extraction mode (only 'quick' currently; 'full' reserved for skf-create-skill).",
511
+ )
512
+ args = parser.parse_args(argv)
513
+
514
+ raw = sys.stdin.read()
515
+ if not raw.strip():
516
+ sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
517
+ return 2
518
+ try:
519
+ payload = json.loads(raw)
520
+ except json.JSONDecodeError as e:
521
+ sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
522
+ return 2
523
+ if not isinstance(payload, dict):
524
+ sys.stderr.write("error: stdin payload must be a JSON object\n")
525
+ return 2
526
+
527
+ payload.setdefault("mode", args.mode)
528
+ result = extract(payload)
529
+ print(json.dumps(result, indent=2))
530
+ return 1 if "_error" in result else 0
531
+
532
+
533
+ if __name__ == "__main__":
534
+ sys.exit(main(sys.argv[1:]))
@@ -30,6 +30,18 @@ Subcommands:
30
30
  does not exist. Idempotent — refuses to overwrite an
31
31
  existing file (preserves user customization).
32
32
 
33
+ register-qmd-collection
34
+ Append-or-replace a single entry in the `qmd_collections`
35
+ array. Reads the entry as JSON on stdin (must include
36
+ `name`; `name` is the upsert key — existing entry with
37
+ the same `name` is replaced, otherwise appended). All
38
+ other forge-tier state (tools / tier / ccc_index /
39
+ ccc_index_registry / other qmd_collections entries) is
40
+ preserved verbatim. Used by skf-brief-skill step-05 §5
41
+ and skf-create-skill to register Deep-tier QMD
42
+ collections without re-rendering the whole file in
43
+ prose.
44
+
33
45
  clean-stale Two cleanup operations gated by flags:
34
46
  --qmd-live-names a,b,c — remove qmd_collections
35
47
  entries whose `name` is not in the comma-separated
@@ -303,6 +315,61 @@ def cmd_init_prefs(target: Path) -> None:
303
315
  _ok({"exists": True, "wrote": True, "path": str(target), "first_run": True})
304
316
 
305
317
 
318
+ def cmd_register_qmd_collection(target: Path) -> None:
319
+ # Note: render_forge_tier_yaml() emits exactly the six known top-level
320
+ # sections (tools, tier, tier_detected_at, ccc_index, ccc_index_registry,
321
+ # qmd_collections). If a future schema revision adds a new top-level key,
322
+ # this subcommand will silently drop it — same limitation that already
323
+ # affects cmd_write_tools and cmd_clean_stale. Update render_forge_tier_yaml()
324
+ # AND those three subcommands together when the schema grows.
325
+ raw = sys.stdin.read()
326
+ if not raw.strip():
327
+ _die(1, "register-qmd-collection: empty stdin (expected JSON entry)")
328
+ try:
329
+ entry = json.loads(raw)
330
+ except json.JSONDecodeError as e:
331
+ _die(1, f"register-qmd-collection: invalid JSON on stdin: {e}")
332
+
333
+ if not isinstance(entry, dict):
334
+ _die(1, "register-qmd-collection: entry must be a JSON object")
335
+ name = entry.get("name")
336
+ if not name or not isinstance(name, str):
337
+ _die(1, "register-qmd-collection: entry must include a non-empty 'name' string")
338
+
339
+ data = _read_yaml(target)
340
+ if data is None:
341
+ _die(1, f"register-qmd-collection: target does not exist: {target}. "
342
+ f"Run setup workflow first to create forge-tier.yaml.")
343
+
344
+ collections = list(data.get("qmd_collections") or [])
345
+ replaced = False
346
+ for i, existing in enumerate(collections):
347
+ if isinstance(existing, dict) and existing.get("name") == name:
348
+ collections[i] = entry
349
+ replaced = True
350
+ break
351
+ if not replaced:
352
+ collections.append(entry)
353
+
354
+ payload = {
355
+ "tools": data.get("tools", {}),
356
+ "tier": data.get("tier", "Quick"),
357
+ "tier_detected_at": data.get("tier_detected_at",
358
+ datetime.now(timezone.utc).isoformat()),
359
+ "ccc_index": data.get("ccc_index", {}),
360
+ "ccc_index_registry": data.get("ccc_index_registry", []),
361
+ "qmd_collections": collections,
362
+ }
363
+ rendered = render_forge_tier_yaml(payload)
364
+ _atomic_write(target, rendered)
365
+ _ok({
366
+ "name": name,
367
+ "action": "replaced" if replaced else "appended",
368
+ "qmd_collections_count": len(collections),
369
+ "wrote": str(target),
370
+ })
371
+
372
+
306
373
  def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
307
374
  prune_missing_ccc_paths: bool) -> None:
308
375
  data = _read_yaml(target)
@@ -394,6 +461,10 @@ def main() -> None:
394
461
  p_clean.add_argument("--prune-missing-ccc-paths", action="store_true",
395
462
  help="Remove ccc_index_registry entries whose path no longer exists.")
396
463
 
464
+ p_register = sub.add_parser("register-qmd-collection",
465
+ help="Append-or-replace a single qmd_collections entry by name")
466
+ p_register.add_argument("--target", type=Path, required=True)
467
+
397
468
  args = parser.parse_args()
398
469
 
399
470
  if args.cmd == "read":
@@ -407,6 +478,8 @@ def main() -> None:
407
478
  if args.qmd_live_names is not None:
408
479
  live = [n.strip() for n in args.qmd_live_names.split(",") if n.strip()]
409
480
  cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
481
+ elif args.cmd == "register-qmd-collection":
482
+ cmd_register_qmd_collection(args.target)
410
483
 
411
484
 
412
485
  if __name__ == "__main__":