bmad-module-skill-forge 1.3.0 → 1.4.1

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +34 -15
  4. package/package.json +2 -2
  5. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
  6. package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
  7. package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
  8. package/src/shared/scripts/skf-detect-language.py +277 -0
  9. package/src/shared/scripts/skf-detect-workspaces.py +427 -0
  10. package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
  11. package/src/shared/scripts/skf-extract-public-api.py +29 -0
  12. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  13. package/src/shared/scripts/skf-merge-ccc-exclusions.py +14 -0
  14. package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
  15. package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
  16. package/src/shared/scripts/skf-write-skill-brief.py +509 -0
  17. package/src/skf-audit-skill/steps-c/step-01-init.md +49 -3
  18. package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +6 -5
  19. package/src/skf-brief-skill/SKILL.md +41 -12
  20. package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
  21. package/src/skf-brief-skill/assets/scope-templates.md +5 -0
  22. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
  23. package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
  24. package/src/skf-brief-skill/references/headless-args.md +22 -0
  25. package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
  26. package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
  27. package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
  28. package/src/skf-brief-skill/references/version-resolution.md +46 -0
  29. package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
  30. package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
  31. package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
  32. package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
  33. package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
  34. package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
  35. package/src/skf-create-skill/steps-c/step-07-generate-artifacts.md +9 -1
  36. package/src/skf-export-skill/steps-c/step-01-load-skill.md +11 -3
  37. package/src/skf-export-skill/steps-c/step-03-generate-snippet.md +8 -2
  38. package/src/skf-export-skill/steps-c/step-04-update-context.md +29 -0
  39. package/src/skf-export-skill/steps-c/step-06-summary.md +12 -0
  40. package/src/skf-setup/steps-c/step-01b-ccc-index.md +10 -3
@@ -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__":
@@ -33,6 +33,11 @@ Validation rules for the 2 conditional values (PR #248):
33
33
  → produces `**//abs/path`, `**/~/x`, or `**/./rel` — malformed glob
34
34
  - REJECT values containing glob meta-characters (`*`, `?`, `[`)
35
35
  → interpolation collides with the surrounding pattern syntax
36
+ - REJECT values containing unresolved `{project-root}`-style placeholders
37
+ (any `{` or `}` character) — produces `**/{project-root}/x`, which
38
+ cocoindex accepts as a literal-segment glob and silently never matches.
39
+ This is the bug from issue #293: the step file was passing template
40
+ strings straight through.
36
41
 
37
42
  Rejected values are SKIPPED (the pattern is not added) and a warning
38
43
  is appended to the output. The 4 always-include patterns are applied
@@ -96,6 +101,7 @@ ALWAYS_INCLUDE = (
96
101
  "**/_skf-learn",
97
102
  )
98
103
  GLOB_META_CHARS = set("*?[")
104
+ PLACEHOLDER_CHARS = set("{}")
99
105
 
100
106
 
101
107
  def _die(code: int, message: str) -> None:
@@ -163,6 +169,14 @@ def validate_config_value(key: str, raw_value: str) -> tuple[str | None, str | N
163
169
  f"pattern syntax — fix the value in {{project-root}}/_bmad/skf/config.yaml"
164
170
  )
165
171
 
172
+ if any(ch in PLACEHOLDER_CHARS for ch in value):
173
+ return None, (
174
+ f"{key} contains an unresolved template placeholder ({{ or }}); "
175
+ f"refused for ccc exclusion because the step file is supposed to "
176
+ f"substitute {{project-root}} and reduce to a basename before "
177
+ f"invoking the helper — see step-01b §3 in src/skf-setup/steps-c/"
178
+ )
179
+
166
180
  return value, None
167
181
 
168
182
 
@@ -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:]))