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
|
@@ -415,6 +415,26 @@ def _select_manifest_parser(language: str, manifest_path: str) -> ManifestParser
|
|
|
415
415
|
return LANGUAGE_DISPATCH[language][0]
|
|
416
416
|
|
|
417
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
|
+
|
|
418
438
|
def extract(payload: dict) -> dict:
|
|
419
439
|
"""Orchestrate manifest parse + export scan for one logical package."""
|
|
420
440
|
warnings: list[str] = []
|
|
@@ -436,6 +456,15 @@ def extract(payload: dict) -> dict:
|
|
|
436
456
|
warnings.append(parsed["_parse_error"])
|
|
437
457
|
parsed = {}
|
|
438
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
|
+
|
|
439
468
|
_, scanner = LANGUAGE_DISPATCH[language]
|
|
440
469
|
exports: list[dict] = []
|
|
441
470
|
for entry in payload.get("entries") or []:
|
|
@@ -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__":
|
|
@@ -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:]))
|