bmad-module-skill-forge 2.0.2 → 2.2.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -1
  3. package/docs/404.md +4 -4
  4. package/docs/_data/pinned.yaml +1 -1
  5. package/docs/_internal/RELEASING.md +41 -4
  6. package/docs/agents.md +2 -2
  7. package/docs/architecture.md +1 -1
  8. package/docs/bmad-synergy.md +13 -13
  9. package/docs/campaign.md +3 -3
  10. package/docs/concepts.md +3 -3
  11. package/docs/examples.md +9 -9
  12. package/docs/forge-auto.md +6 -6
  13. package/docs/getting-started.md +12 -12
  14. package/docs/how-it-works.md +7 -7
  15. package/docs/index.md +7 -1
  16. package/docs/skill-model.md +2 -2
  17. package/docs/troubleshooting.md +7 -7
  18. package/docs/why-skf.md +8 -8
  19. package/docs/workflows.md +5 -5
  20. package/package.json +7 -3
  21. package/src/shared/references/description-guard-protocol.md +2 -0
  22. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  23. package/src/shared/scripts/skf-description-guard.py +22 -2
  24. package/src/shared/scripts/skf-detect-docs.py +198 -2
  25. package/src/shared/scripts/skf-detect-language.py +20 -0
  26. package/src/shared/scripts/skf-detect-tools.py +40 -0
  27. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  28. package/src/shared/scripts/skf-language-corpora.py +20 -0
  29. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  30. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  31. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  32. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  33. package/src/shared/scripts/skf-shape-detect.py +20 -0
  34. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  35. package/src/skf-analyze-source/references/recommend.md +1 -1
  36. package/src/skf-audit-skill/references/re-index.md +1 -1
  37. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  38. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  39. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  40. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  41. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  42. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  43. package/src/skf-create-skill/assets/skill-sections.md +3 -2
  44. package/src/skf-create-skill/references/compile.md +12 -3
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  47. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  48. package/src/skf-create-skill/references/step-doc-sources.md +31 -3
  49. package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
  50. package/src/skf-create-skill/references/validate.md +2 -2
  51. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  52. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  53. package/src/skf-setup/references/ccc-index.md +5 -4
  54. package/src/skf-setup/references/detect-and-tier.md +1 -0
  55. package/src/skf-setup/references/report.md +1 -1
  56. package/src/skf-test-skill/references/coverage-check.md +11 -5
  57. package/src/skf-test-skill/references/score.md +7 -1
  58. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  59. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  60. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  61. package/src/skf-update-skill/references/write.md +2 -2
  62. package/tools/cli/lib/installer.js +31 -1
  63. package/tools/validate-docs-links.js +665 -0
@@ -496,7 +496,27 @@ def cmd_emit_blocked() -> None:
496
496
  print(emit_envelope_line(envelope))
497
497
 
498
498
 
499
+ def _force_utf8(*streams) -> None:
500
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
501
+
502
+ A default Windows console decodes stdio as cp1252, which cannot carry
503
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
504
+ each stream's existing error handler — reconfigure(encoding=...) alone
505
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
506
+ Linux. For stdin this must run before the first read. Skips in-process
507
+ test doubles without reconfigure().
508
+ """
509
+ for stream in streams:
510
+ if hasattr(stream, "reconfigure"):
511
+ errors = getattr(stream, "errors", None)
512
+ if errors is None:
513
+ stream.reconfigure(encoding="utf-8")
514
+ else:
515
+ stream.reconfigure(encoding="utf-8", errors=errors)
516
+
517
+
499
518
  def main() -> None:
519
+ _force_utf8(sys.stdin, sys.stdout)
500
520
  parser = argparse.ArgumentParser(
501
521
  description=__doc__,
502
522
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -80,7 +80,27 @@ def corpora_for(language: str) -> list[dict]:
80
80
  return out
81
81
 
82
82
 
83
+ def _force_utf8(*streams) -> None:
84
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
85
+
86
+ A default Windows console decodes stdio as cp1252, which cannot carry
87
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
88
+ each stream's existing error handler — reconfigure(encoding=...) alone
89
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
90
+ Linux. For stdin this must run before the first read. Skips in-process
91
+ test doubles without reconfigure().
92
+ """
93
+ for stream in streams:
94
+ if hasattr(stream, "reconfigure"):
95
+ errors = getattr(stream, "errors", None)
96
+ if errors is None:
97
+ stream.reconfigure(encoding="utf-8")
98
+ else:
99
+ stream.reconfigure(encoding="utf-8", errors=errors)
100
+
101
+
83
102
  def main(argv: list[str]) -> int:
103
+ _force_utf8(sys.stdout, sys.stderr)
84
104
  parser = argparse.ArgumentParser(
85
105
  description="Look up canonical companion prose corpora for a language.",
86
106
  )
@@ -213,7 +213,27 @@ def merge_doc_urls(
213
213
  return kept, suppressed
214
214
 
215
215
 
216
+ def _force_utf8(*streams) -> None:
217
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
218
+
219
+ A default Windows console decodes stdio as cp1252, which cannot carry
220
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
221
+ each stream's existing error handler — reconfigure(encoding=...) alone
222
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
223
+ Linux. For stdin this must run before the first read. Skips in-process
224
+ test doubles without reconfigure().
225
+ """
226
+ for stream in streams:
227
+ if hasattr(stream, "reconfigure"):
228
+ errors = getattr(stream, "errors", None)
229
+ if errors is None:
230
+ stream.reconfigure(encoding="utf-8")
231
+ else:
232
+ stream.reconfigure(encoding="utf-8", errors=errors)
233
+
234
+
216
235
  def main() -> int:
236
+ _force_utf8(sys.stdin, sys.stdout)
217
237
  raw = sys.stdin.read()
218
238
  if not raw or not raw.strip():
219
239
  sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
@@ -79,7 +79,9 @@ from __future__ import annotations
79
79
 
80
80
  import argparse
81
81
  import json
82
+ import os
82
83
  import re
84
+ import shutil
83
85
  import sys
84
86
  from pathlib import Path
85
87
 
@@ -92,6 +94,10 @@ FOREIGN_SAMPLE_CAP = 5
92
94
  # Header line emitted by newer qmd builds, e.g. "Collections (56):".
93
95
  _QMD_HEADER_RE = re.compile(r"^Collections \(\d+\):\s*$")
94
96
 
97
+ # Empty-state message from `qmd collection list`, e.g.
98
+ # "No collections found. Run 'qmd collection add .' to create one."
99
+ _QMD_EMPTY_STATE_PREFIX = "No collections found"
100
+
95
101
 
96
102
  def _die(code: int, message: str) -> None:
97
103
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
@@ -199,7 +205,12 @@ def parse_collection_list_output(raw: str) -> list[str]:
199
205
  the trailing ` (qmd://name/)` URI and is a no-op for the bare-name form.
200
206
  Without this, suffixed entries fail the `is_forge_owned` suffix check and
201
207
  every forge collection is mis-classified as foreign.
208
+
209
+ The empty-state message ("No collections found. …") is recognized before
210
+ per-line tokenizing — it would otherwise parse as a collection named "No".
202
211
  """
212
+ if raw.lstrip().startswith(_QMD_EMPTY_STATE_PREFIX):
213
+ return []
203
214
  names: list[str] = []
204
215
  for line in raw.splitlines():
205
216
  if not line.strip():
@@ -212,6 +223,29 @@ def parse_collection_list_output(raw: str) -> list[str]:
212
223
  return names
213
224
 
214
225
 
226
+ def _resolve_outside_cwd(command: str) -> str | None:
227
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
228
+
229
+ shutil.which on Windows searches the current directory ahead of PATH,
230
+ and CWD here is the repo under analysis — a bare-name lookup resolving
231
+ into CWD would execute a repo-planted shim (e.g. qmd.cmd). Such a
232
+ resolution is treated as not-found. Explicit paths supplied by callers
233
+ (containing a separator) are honored as-is. Keep identical to the
234
+ sibling guard in skf-detect-tools.py.
235
+ """
236
+ resolved = shutil.which(command)
237
+ if resolved is None:
238
+ return None
239
+ if os.sep in command or (os.altsep and os.altsep in command):
240
+ return resolved
241
+ resolved_dir = os.path.dirname(resolved)
242
+ if resolved_dir:
243
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
244
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
245
+ return None
246
+ return resolved
247
+
248
+
215
249
  def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
216
250
  """Invoke `qmd collection list` and return (names, error).
217
251
 
@@ -221,11 +255,19 @@ def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
221
255
  counts as a "live collection name".
222
256
  """
223
257
  import subprocess
258
+ # Resolve before spawning: on Windows qmd ships from npm as a .CMD
259
+ # shim, which a bare-name subprocess.run cannot launch (WinError 2).
260
+ qmd = _resolve_outside_cwd("qmd")
261
+ if qmd is None:
262
+ return [], "qmd not found on PATH"
224
263
  try:
264
+ # qmd emits UTF-8; a locale-default decode (cp1252 on Windows)
265
+ # mojibakes names or raises UnicodeDecodeError on unmapped bytes.
225
266
  result = subprocess.run(
226
- ["qmd", "collection", "list"],
267
+ [qmd, "collection", "list"],
227
268
  capture_output=True,
228
- text=True,
269
+ encoding="utf-8",
270
+ errors="replace",
229
271
  timeout=30,
230
272
  check=False,
231
273
  )
@@ -356,7 +356,27 @@ def _parse_argv(argv: list[str]) -> dict:
356
356
  return payload
357
357
 
358
358
 
359
+ def _force_utf8(*streams) -> None:
360
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
361
+
362
+ A default Windows console decodes stdio as cp1252, which cannot carry
363
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
364
+ each stream's existing error handler — reconfigure(encoding=...) alone
365
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
366
+ Linux. For stdin this must run before the first read. Skips in-process
367
+ test doubles without reconfigure().
368
+ """
369
+ for stream in streams:
370
+ if hasattr(stream, "reconfigure"):
371
+ errors = getattr(stream, "errors", None)
372
+ if errors is None:
373
+ stream.reconfigure(encoding="utf-8")
374
+ else:
375
+ stream.reconfigure(encoding="utf-8", errors=errors)
376
+
377
+
359
378
  def main(argv: list[str]) -> int:
379
+ _force_utf8(sys.stdin, sys.stdout)
360
380
  payload = _parse_argv(argv)
361
381
  result = recommend(payload)
362
382
  json.dump(result, sys.stdout, ensure_ascii=False)
@@ -64,7 +64,7 @@ Judgment payload (stdin JSON, compute mode)::
64
64
  {
65
65
  "exports_public_api": 40, # int — public-entry-point export count (judgment)
66
66
  "exports_internal": 19, # int — non-underscore internal export count (judgment)
67
- "effective_denominator": 12, # int|null — optional, stratified-scope monorepo only
67
+ "effective_denominator": 12, # int|null — optional, curated-subset scopes only
68
68
  "pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
69
69
  "exports_documented": 0, # int — stack only (own-barrel count, usually 0)
70
70
  "scripts": [ ... ], # list — scripts[] array (or "scripts_count": int)
@@ -1075,7 +1075,27 @@ def path_to_manifest_name(ecosystem: str) -> str:
1075
1075
  # CLI wiring
1076
1076
  # ---------------------------------------------------------------------------
1077
1077
 
1078
+ def _force_utf8(*streams) -> None:
1079
+ """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
1080
+
1081
+ A default Windows console decodes stdio as cp1252, which cannot carry
1082
+ non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
1083
+ each stream's existing error handler — reconfigure(encoding=...) alone
1084
+ would reset it to 'strict', downgrading e.g. an already-UTF-8 stderr on
1085
+ Linux. For stdin this must run before the first read. Skips in-process
1086
+ test doubles without reconfigure().
1087
+ """
1088
+ for stream in streams:
1089
+ if hasattr(stream, "reconfigure"):
1090
+ errors = getattr(stream, "errors", None)
1091
+ if errors is None:
1092
+ stream.reconfigure(encoding="utf-8")
1093
+ else:
1094
+ stream.reconfigure(encoding="utf-8", errors=errors)
1095
+
1096
+
1078
1097
  def main(argv: list[str]) -> int:
1098
+ _force_utf8(sys.stdout, sys.stderr)
1079
1099
  parser = argparse.ArgumentParser(
1080
1100
  description="Classify a repo into a known skill shape from its manifest files.",
1081
1101
  )
@@ -13,7 +13,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
13
13
  | source_repo | string | GitHub URL or local path | Repository or project root (optional when `source_type: "docs-only"`) |
14
14
  | language | string | Recognized language | Primary programming language |
15
15
  | scope | object | See Scope Object below | Boundary definition |
16
- | description | string | 1-3 sentences | What the skill covers |
16
+ | description | string | 1-3 sentences | What the skill covers. Must contain a literal `Use when` clause — validators test for that exact phrase. |
17
17
  | forge_tier | string | `Quick` / `Forge` / `Forge+` / `Deep` | Inherited from forge-tier.yaml (Title Case) |
18
18
  | created | string | ISO date `YYYY-MM-DD` | Generation date |
19
19
  | created_by | string | user_name from config | Who generated the brief |
@@ -31,6 +31,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
31
31
  | `constituent_refs` | object | map of path→ref strings | Optional. Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source `project_paths` array. When absent, a single `target_ref` (or auto-detected ref) applies to all paths. Mutually exclusive with `target_ref`. |
32
32
  | `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
33
33
  | `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
34
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen. See `skf-test-skill/references/source-access-protocol.md`. |
34
35
 
35
36
  When `source_type: "docs-only"`:
36
37
  - `source_repo` becomes optional (set to doc site URL for reference)
@@ -59,7 +59,7 @@ For each qualifying unit, prepare a recommendation card:
59
59
  - name: {suggested kebab-case name}
60
60
  - scope.type: {full-library / specific-modules / public-api / component-library / reference-app / docs-only}
61
61
  - scope.include: {suggested glob patterns}
62
- - description: {suggested 1-3 sentence description}
62
+ - description: {suggested 1-3 sentence description, containing a literal `Use when` clause}
63
63
  ```
64
64
 
65
65
  ### 3. Present Recommendations
@@ -143,7 +143,7 @@ Skip this section. Temporal context requires Deep tier.
143
143
 
144
144
  For each export in the skill baseline that was NOT found at its recorded file path during re-extraction (potential "deleted" export):
145
145
 
146
- 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `ccc search "{export_name}" --path {source_root} --top 5` (CLI) — to find candidate current locations
146
+ 1. Run `ccc_bridge.search("{export_name}", source_root, top_k=5)` — **Tool resolution:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `cd {source_root} && ccc search --limit 5 "{export_name}"` (CLI) — to find candidate current locations. `ccc search` reads the index in the current working directory and has no project-selector flag (`--path` is a file-path glob filter *within* the index, and the result cap is `--limit`, not `--top`) — see `knowledge/ccc-bridge.md`.
147
147
  2. If CCC returns files containing the export name:
148
148
  - Run ast-grep verification on each candidate file
149
149
  - If verified at a new location: reclassify from "deleted" to "moved" with the new file:line reference
@@ -6,9 +6,9 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
6
6
 
7
7
  > Render Markdown to HTML using the marked library. Use when the user pastes raw Markdown and wants formatted output, or asks how to convert MD files in a build pipeline.
8
8
 
9
- > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Triggers on tasks involving Stripe-managed payments, subscription billing, or webhook event handling.
9
+ > Stripe API client for Node.js — payment intents, subscriptions, customer portal, webhooks. Use when the task involves Stripe-managed payments, subscription billing, or webhook event handling.
10
10
 
11
- > Charts and visualizations powered by D3.js. Reach for this when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
11
+ > Charts and visualizations powered by D3.js. Use when the user asks to plot data, build interactive graphs, or wants bare D3 control instead of a React-charts abstraction.
12
12
 
13
13
  > Lint Python code with Ruff. Use when the user wants to add or configure Ruff in a Python project, debug rule selectors, or understand why a specific check fired.
14
14
 
@@ -16,4 +16,6 @@ Loaded by step 1 §7b only. The five examples below show the *range* of acceptab
16
16
 
17
17
  ## Notes on Voice
18
18
 
19
- Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) and matches the trigger phrasing ("Use when...", "Triggers on...", "Reach for this when...") to the voice rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
19
+ Each example leads differently (verb / noun / "Charts and..." / verb / noun-phrase) rather than copy-pasting a single template. Compose in that spirit using the gathered material — the target repo, the user's intent, the version if set, and any scope hints — but **do not template-stamp**.
20
+
21
+ The **lead** is where the voice varies; the **trigger clause** is fixed. Every description must contain a literal `Use when` clause somewhere, because validators test for that exact phrase — `skill-check`'s `description.use_when_phrase` rule scores a description below 100 without it. Phrasings like "Triggers on…" or "Reach for this when…" read well but do not satisfy it on their own, so keep them for additional text rather than as the only trigger.
@@ -9,7 +9,7 @@
9
9
  | source_repo | string | GitHub URL or local path | Repository or project root (optional when `source_type: "docs-only"`) |
10
10
  | language | string | Recognized language | Primary programming language |
11
11
  | scope | object | See Scope Object below | Boundary definition |
12
- | description | string | 1-3 sentences | What the skill covers |
12
+ | description | string | 1-3 sentences | What the skill covers. Must contain a literal `Use when` clause — validators test for that exact phrase. |
13
13
  | forge_tier | string | `Quick` / `Forge` / `Forge+` / `Deep` | Inherited from forge-tier.yaml (Title Case) |
14
14
  | created | string | ISO date `YYYY-MM-DD` | Generation date |
15
15
  | created_by | string | user_name from config | Who generated the brief |
@@ -26,7 +26,7 @@
26
26
  | `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `source_ref`, bypassing version-to-tag matching. Escape hatch for monorepo crate tags whose prefix differs from the skill name (e.g. tag `livekit/v0.7.42` for skill `livekit-rust`). Remote sources only. |
27
27
  | `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
28
28
  | `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
29
- | `scope.tier_a_include` | array | Glob patterns | Optional. Narrower tier-A include list for stratified-scope monorepo skills. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`, so the denominator reflects the authoring surface rather than incidentally-matched internal infrastructure. See `skf-test-skill/references/source-access-protocol.md` stratified-scope resolution. |
29
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional. Narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`, so the denominator reflects the authoring surface rather than incidentally-matched internal infrastructure. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen (listing files outside `scope.include` inverts its meaning). See `skf-test-skill/references/source-access-protocol.md` stratified-scope resolution. |
30
30
 
31
31
  When `source_type: "docs-only"`:
32
32
  - `source_repo` becomes optional (set to doc site URL for reference)
@@ -206,9 +206,9 @@ Fall back to ad-hoc inspection — `Gemfile` / `*.csproj` / `*.sln` / `Package.s
206
206
  If `tools.ccc` is true in forge-tier.yaml, supplement the module listing with a semantic discovery pass:
207
207
 
208
208
  **CCC Semantic Discovery:**
209
- - **Claude Code:** Use `/ccc search "{repo_name} public API exports modules" {source_path}`
209
+ - **Claude Code:** Use `/ccc search "{repo_name} public API exports modules"` from `{source_path}` — the query is variadic, so a trailing path is swallowed into the search string rather than selecting a project
210
210
  - **Cursor:** Use `ccc` MCP server `search` tool with query `"{repo_name} public API exports modules"` and path `{source_path}`
211
- - **CLI fallback:** `ccc search "{repo_name} public API exports modules" --path {source_path} --limit 10`
211
+ - **CLI fallback:** `cd {source_path} && ccc search --limit 10 "{repo_name} public API exports modules"` — `ccc search` reads the index in the current working directory and has no project-selector flag (`--path` is a file-path glob filter *within* the index)
212
212
 
213
213
  See `knowledge/tool-resolution.md` for full bridge-to-tool mapping.
214
214
 
@@ -352,7 +352,9 @@ The schema's `description` field is 1-3 sentences and surfaces in skill registri
352
352
 
353
353
  Compose a candidate 1-3 sentence description from the gathered material. **Write like a human library maintainer would** — what does an agent get from this skill, and when should it route here? Two facts must come through (what the skill is, when to use it); everything else is voice. Resist filling in the same skeleton every time.
354
354
 
355
- Load `{descriptionVoiceExamplesPath}` for the five voice examples (range of acceptable leads, structures, and trigger phrasings) and the "do not template-stamp" guidance, then compose in that spirit. The asset documents what "in that spirit" means; the gathered material to draw on is the target repo, the user's intent, the version if set, and any scope hints.
355
+ Load `{descriptionVoiceExamplesPath}` for the five voice examples (range of acceptable leads and structures) and the "do not template-stamp" guidance, then compose in that spirit. The asset documents what "in that spirit" means; the gathered material to draw on is the target repo, the user's intent, the version if set, and any scope hints.
356
+
357
+ **Whatever lead you choose, the description must contain a literal `Use when` clause somewhere** — validators test for that exact phrase, so alternatives like "Triggers on…" or "Reach for this when…" do not satisfy it on their own. The clause need not lead: a descriptive opener followed by `Use when …` is both good voice and validator-clean.
356
358
 
357
359
  Present:
358
360
 
@@ -360,7 +362,7 @@ Present:
360
362
 
361
363
  > {synthesized description}
362
364
 
363
- This is the text agents read when deciding whether to route to your skill — it sits in the registry row alongside dozens of other skills. Specific triggers ('use when…', 'reach for this when…') help agents match real user requests; generic descriptions blend in and get skipped. Edit, replace, or accept as-is."
365
+ This is the text agents read when deciding whether to route to your skill — it sits in the registry row alongside dozens of other skills. A specific 'use when…' trigger helps agents match real user requests; generic descriptions blend in and get skipped. Edit, replace, or accept as-is."
364
366
 
365
367
  Wait for user confirmation or alternative.
366
368
 
@@ -81,7 +81,9 @@ For each ready Tier A skill:
81
81
  - `{{directive_content}}` — raw content of the file at `campaign.directive_path`, or "No directive configured" if unset/missing.
82
82
  Present the completed kickoff message as the context for the skill's pipeline run.
83
83
  - **AN → BS → CS → TS** — standard forge pipeline for this skill. When BS (Brief Synthesis) produces the skill's brief, set `skills[current].brief_path` to the brief path from the BS result envelope so the field the schema declares is populated and available for resume and reporting.
84
- - **Doc-rot check** — grep feeder artifacts for corrections emitted during the pipeline run. Append any doc-rot findings to the skill's `workarounds_applied` array (prefixed with `[doc-rot]`) so they survive state write and are available for §6 propagation.
84
+ - **Doc-rot check** — **record** what CS step 5c already produced; do not re-derive it. Because the pipeline body runs inline (see the §5 note above), that step's context is directly readable here: `corrections_added`, `correction_matches` (the enriched match records — `source`, `pattern`, `category`, `context_line`, `affected`), `corrections_deduped` and `corrections_capped`. Append one `[doc-rot]`-prefixed entry per `correction_matches` record to the skill's `workarounds_applied` array so they survive state write and are available for §6 propagation. When step 5c took its skip branch (`doc_rot_triggered: false`, `corrections_added: 0`), there is nothing to record — append nothing and move on.
85
+
86
+ **Do not hand-grep the feeder artifacts here, and do not re-run the scan.** `skf-create-skill/references/step-doc-rot.md` §2 requires the scan to run in its helper precisely so identical feeders yield identical matches; a second pass in this loop is free to disagree with the corrections CS actually wrote. It would also be self-contaminating: step 5c has already written `## CORRECTION` blocks into the compiled SKILL.md, and the helper's exclusion windows cover only that file's frontmatter and its Migration & Deprecation Warnings section — so re-scanning it would re-match the blocks 5c itself authored and record them as fresh findings.
85
87
  3. **Record results:**
86
88
  - On success: set `status` to `"completed"`, set `completed_at` to current ISO-8601 with timezone, record `quality_score`. Backup and write state.
87
89
  - On failure: set `status` to `"failed"`. Log the failure reason (the sub-skill's `halt_reason`/exit code, or "unparseable result envelope") to the decision log so `campaign status` and the report surface *why* a skill failed without the operator opening sub-skill logs. Backup and write state. Downstream skills whose `depends_on` does NOT include the failed skill continue processing normally; those that DO depend on it are blocked at §4's dependency gate.
@@ -15,7 +15,7 @@ description: >
15
15
  **Frontmatter rules:**
16
16
 
17
17
  - `name`: lowercase alphanumeric + hyphens only, must match the skill output directory name. Prefer gerund form (`processing-pdfs`, `analyzing-spreadsheets`) for clarity.
18
- - `description`: non-empty, max 1024 chars, optimized for agent discovery. Use third-person voice ("Processes Excel files..." not "I can help you..." or "You can use this to...") and include a trigger phrase so agents know when to match — one of `Use when …`, `Triggers on …`, or `Reach for this when …`. When using `Use when `, follow with a **gerund** (`Use when building/processing/analyzing X…`) or a noun-phrase clause (`Use when the user requests to "X"`); never a bare indicative verb like `builds`/`processes`, because `skill-check --fix` will prepend a literal `Use when ` to a description missing the trigger phrase, producing ungrammatical output (`Use when builds X…`). Inconsistent point-of-view or a missing trigger phrase causes discovery problems since the description is injected into the system prompt. See `skf-brief-skill/assets/description-voice-examples.md` for the canonical voice palette.
18
+ - `description`: non-empty, max 1024 chars, optimized for agent discovery. Use third-person voice ("Processes Excel files..." not "I can help you..." or "You can use this to...") and include a trigger phrase so agents know when to match. **The description must contain a literal `Use when` clause somewhere** — `skill-check`'s `description.use_when_phrase` rule tests for that exact phrase anywhere in the string, so `Triggers on …` or `Reach for this when …` on their own score below 100 with a `description should contain "Use when" phrasing` warning. The lead is still free: a descriptive opener followed by a `Use when …` clause (`Charts and visualizations powered by D3.js. Use when plotting data …`) passes cleanly, and the other two phrases are fine as *additional* text. When using `Use when `, follow with a **gerund** (`Use when building/processing/analyzing X…`) or a noun-phrase clause (`Use when the user requests to "X"`); never a bare indicative verb like `builds`/`processes`, because `skill-check --fix` will prepend a literal `Use when ` to a description missing the trigger phrase, producing ungrammatical output (`Use when builds X…`). Inconsistent point-of-view or a missing trigger phrase causes discovery problems since the description is injected into the system prompt. See `skf-brief-skill/assets/description-voice-examples.md` for the canonical voice palette.
19
19
  - **The `description` cannot contain angle brackets** — neither standalone placeholders like `<name>`, `<component>`, `<path>`, nor inline generics like `` `Array<T>` `` or `` `Meta<typeof X>` ``. Both `skill-check`'s `description_field` validator and `tessl`'s deterministic description check parse the frontmatter description as a raw string and reject any `<` or `>`, regardless of markdown context (backticks do not protect content here). A rejected description fails the review with 0% score. When the natural phrasing would use angle brackets:
20
20
  - Prefer the curly-brace form in prose: `{name}`, `{component-id}`, `{path}` — readable and tessl-safe.
21
21
  - Uppercase placeholders are also acceptable: `NAME`, `COMPONENT_ID`.
@@ -62,6 +62,7 @@ These sections form the essential standalone body. Target: **under 300 lines tot
62
62
  - Provenance citation per function
63
63
 
64
64
  **Section 4b — Migration & Deprecation Warnings (~10 lines, Deep tier only):**
65
+ - Emit the heading verbatim as `## Migration & Deprecation Warnings`. Downstream gates match that exact text, so a naturally-retitled section (`## Deprecations & pre-1.0 hazards`, `## v2 → v3 breaking changes`) is reported as *missing* while its bullets are re-emitted as duplicate `## CORRECTION` blocks.
65
66
  - Only populated when step 4 enrichment produced **T2-future** annotations (deprecation warnings, breaking changes, planned renames)
66
67
  - List each warning as a single-line bullet: function name, what changed or will change, source citation
67
68
  - Max 10 lines — just the actionable warnings, not full context
@@ -268,7 +269,7 @@ Replace per-function subsections with `references/pattern-*.md` groupings: one r
268
269
  - `stats.exports_documented` MAY be set to the Pattern Surface row count as a proxy, but its semantics change: it counts authored pattern surfaces, not public exports. Emit an adjacent `stats.pattern_surfaces_documented` with the same integer so downstream consumers (test-skill, feasibility, discovery) can discriminate.
269
270
  - `exports_public_api` / `exports_internal` / `exports_total` are not meaningful for a reference app — omit them, or set all three to the Pattern Surface count with a note in `stats.notes`: `"reference-app: export counts are pattern-surface proxies, not library exports"`.
270
271
  - Do not fabricate signature / type-coverage data from pattern surfaces. skf-test-skill will skip those categories when metadata flags a reference-app (same skip path as `stackSkill` once that flag is wired — see scoring-rules.md).
271
- - Do not emit `effective_denominator` for reference-app scope, even when the source is a monorepo. `effective_denominator` counts public exports from the authoring-surface barrel — a library concept that `pattern_surfaces_documented` already replaces here. A reference-app-in-monorepo literally satisfies `compile.md` §4's emit-conditions (monorepo + non-`full-library` + stratified `scope.notes`), so this carve-out takes precedence: pattern-surface coverage is the reference-app basis, and skf-test-skill skips library-export coverage for it.
272
+ - Do not emit `effective_denominator` for reference-app scope, even when the source is a monorepo. `effective_denominator` counts public exports from the authoring-surface barrel — a library concept that `pattern_surfaces_documented` already replaces here. A reference-app-in-monorepo literally satisfies `compile.md` §4's emit-conditions (condition 1 arm (a) + non-`full-library` + stratified `scope.notes`), so this carve-out takes precedence: pattern-surface coverage is the reference-app basis, and skf-test-skill skips library-export coverage for it.
272
273
 
273
274
  **Language / spec-reference sub-shape:** A reference app that documents an engine- or spec-versioned **language** — a query language, grammar, or DSL (e.g. SurrealQL @ SurrealDB) whose value is construct idioms rather than wiring or exports — is a recognized reference-app sub-shape. Keep `scope.type: "reference-app"` (there is no separate enum value), so every carve-out above applies unchanged (`pattern_surfaces_documented` proxy, no `effective_denominator`, test-skill signature/type skip). Adapt the three overrides to the language's surface instead of app wiring:
274
275
 
@@ -134,13 +134,14 @@ Indexed pipe-delimited format for CLAUDE.md managed section (~80-120 tokens per
134
134
  "total_coverage": 0.0,
135
135
  "scripts_count": 0,
136
136
  "assets_count": 0
137
- // "effective_denominator": 0 // optional — emitted for stratified-scope monorepo packages; test-skill uses this as the coverage denominator when present. See compile.md §4 emit rules.
137
+ // "effective_denominator": 0 // optional — emitted for the curated-subset shapes in compile.md §4 condition 1; test-skill uses this as the coverage denominator when present. See compile.md §4 emit rules.
138
138
  },
139
139
  // scripts[] and assets[] — include ONLY when inventories are non-empty; omit entirely otherwise
140
140
  // "scripts": [{ "file": "scripts/{name}", "purpose": "{description}", "source_file": "{source-path}", "confidence": "T1-low" }],
141
141
  // "assets": [{ "file": "assets/{name}", "purpose": "{description}", "source_file": "{source-path}", "confidence": "T1-low" }],
142
142
  // doc_sources[] — include ONLY when doc detection ran; omit if detection was skipped entirely
143
- // "doc_sources": [{ "url": "https://...", "detected_via": "homepageUrl|readme_link|pages_api|docs_folder|readme_always", "content_hash": "sha256:{hex}|null", "recorded_at": "ISO-8601" }],
143
+ // "doc_sources": [{ "url": "https://...", "detected_via": "homepageUrl|readme_link|pages_api|docs_folder|readme_always|brief_doc_urls", "content_hash": "sha256:{hex}|null", "recorded_at": "ISO-8601" }],
144
+ // detected_via "brief_doc_urls" = docs-only skills: the brief's doc_urls (plus subpages step 3c fetched), hashed by `skf-detect-docs.py hash-urls`
144
145
  "generated_by": "{quick-skill|create-skill}",
145
146
  "dependencies": [],
146
147
  "compatibility": "{semver-range}"
@@ -111,12 +111,21 @@ Following the structure from the skill-sections data file:
111
111
  - `exports_public_api`: count of exports from public entry points (`__init__.py`, `index.ts`, `lib.rs`, or equivalent) — derive this from step 3's entry-point validation (section 4b), not from the provenance-map entry count (which may be incomplete if extraction patterns missed some export types)
112
112
  - `exports_internal`: count of all other non-underscore-prefixed exports (internal modules, helpers, adapters)
113
113
  - `scripts` / `assets`: the `scripts_inventory` / `assets_inventory` arrays (or `[]` when empty) — the helper sets `stats.scripts_count` / `stats.assets_count` from their lengths
114
- - `effective_denominator` (**optional** — include in the helper's judgment payload only for stratified-scope monorepo packages): the count of public exports from files matched by the brief's authoring-surface globs, filtered by `scope.exclude`, resolved against `source_path`. **Prefer `scope.tier_a_include` when the brief supplies it** — that narrow list represents the authoring surface the brief intends to document; resolve its globs across `source_path` and count the union of **named exports** — items reachable from the language's public entry-point barrel (`lib.rs` `pub use`, `index.ts` / `index.js` re-exports, `__init__.py` exports), where a type counts **once** with its methods and impl-block members rolling up under it (count a `pub fn` only when it is a free function reachable from the barrel, never a method on an already-counted type). **Otherwise use `scope.include`** — the coarse list. This counting unit matches what `skf-test-skill` re-derives on the consumer side (`coverage-check.md` §2c excludes `kind: "method"` from `documented_set`), so the producer- and test-side denominators stay aligned; without it, a type-heavy API (a few handle types carrying hundreds of methods) inflates the denominator by an order of magnitude and auto-fails the coverage gate. This is the coverage denominator `skf-test-skill` uses when the package is a curated subset of a multi-package repository, so it must match the brief's authoring intent. Compute when ALL of the following hold:
115
- 1. The source is a monorepo (detected via `packages/` layout, `workspaces` field in root `package.json`, `lerna.json`, `rush.json`, `nx.json`, or Cargo `[workspace]`).
114
+ - `effective_denominator` (**optional** — include in the helper's judgment payload only for the curated-subset shapes enumerated in condition 1 below): the count of public exports from files matched by the brief's authoring-surface globs, filtered by `scope.exclude`, resolved against `source_path`. **Prefer `scope.tier_a_include` when the brief supplies it** — that narrow list represents the authoring surface the brief intends to document; resolve its globs across `source_path` and count the union of **named exports** — items reachable from the language's public entry-point barrel (`lib.rs` `pub use`, `index.ts` / `index.js` re-exports, `__init__.py` exports), where a type counts **once** with its methods and impl-block members rolling up under it (count a `pub fn` only when it is a free function reachable from the barrel, never a method on an already-counted type). **Otherwise use `scope.include`** — the coarse list. This counting unit matches what `skf-test-skill` re-derives on the consumer side (`coverage-check.md` §2c excludes `kind: "method"` from `documented_set`), so the producer- and test-side denominators stay aligned; without it, a type-heavy API (a few handle types carrying hundreds of methods) inflates the denominator by an order of magnitude and auto-fails the coverage gate. This is the coverage denominator `skf-test-skill` uses when the brief documents a curated subset rather than a package's whole barrel, so it must match the brief's authoring intent. Compute when ALL of the following hold:
115
+ 1. The skill's coverage denominator is resolved by a `skf-test-skill` `references/source-access-protocol.md` §Source API Surface Definition clause that actually consumes `effective_denominator` — **any one** of:
116
+ - **(a) stratified-scope:** the source is a monorepo (detected via `packages/` layout, `workspaces` field in root `package.json`, `lerna.json`, `rush.json`, `nx.json`, or Cargo `[workspace]`);
117
+ - **(b) single-crate curated subset:** `scope.type` is `specific-modules` on a non-monorepo repo;
118
+ - **(c) multi-entry (exports-map):** the in-scope `package.json` declares an `exports` map with more than one non-root subpath, and the repo carries no monorepo markers.
119
+
120
+ Arms (b) and (c) are not a widening for its own sake — both consumer clauses already say "prefer `metadata.json.stats.effective_denominator`", and a monorepo-only emit condition makes those two rungs unreachable, so a non-monorepo curated scope has no way to lock its denominator.
116
121
  2. `scope.type` is not `full-library` (and not `reference-app` — see carve-out below), AND the resolved include list (`tier_a_include` if present, else `scope.include`) lists a curated file/directory subset rather than the full workspace.
117
122
  3. `scope.notes` is present and documents the stratification strategy (e.g., a tiered A/B/C plan) — this serves as the intent marker confirming the subset is by design.
118
123
 
119
- Otherwise omit it from the payload entirely — when absent, `skf-test-skill` falls back to `exports_public_api`. See `skf-test-skill` `references/source-access-protocol.md` §Source API Surface Definition ("Stratified-scope monorepo packages") for the test-side consumption rules.
124
+ Otherwise omit it from the payload entirely — when absent, `skf-test-skill` falls back to `exports_public_api`. See `skf-test-skill` `references/source-access-protocol.md` §Source API Surface Definition ("Stratified-scope monorepo packages", "Single-crate curated subset", "Multi-entry (exports-map) packages") for the test-side consumption rules.
125
+
126
+ **A `public-api` scope on a single-package repo with no multi-subpath `exports` map matches none of the three arms** — that shape falls to the standard root-barrel rule, which honors neither `effective_denominator` nor `tier_a_include`. Emitting the field there has no effect on the test-side denominator, so omit it.
127
+
128
+ **That shape has no brief-side denominator lever, and `scope.exclude` is not one.** The root-barrel rule counts the named exports of a single entry-point file (`index.ts` / `__init__.py` / `lib.rs`), so excluding *files* cannot remove exports from the barrel's own export list — the denominator is unchanged. When the brief genuinely documents a curated subset of a single package's surface (citation-only files alongside a narrow `tier_a_include`), express that with `scope.type: "specific-modules"` rather than `public-api`: the single-crate curated-subset clause in `skf-test-skill` `references/source-access-protocol.md` filters the barrel by `scope.include` / `scope.exclude` **and** honors `effective_denominator`, so condition 1 arm (b) applies and the denominator matches the authoring intent. Reserve `public-api` for the case where the whole root barrel is the intended surface.
120
129
 
121
130
  **Reference-app carve-out:** never emit `effective_denominator` for `scope.type: "reference-app"`, even when the three conditions above are literally satisfied (a reference-app-in-monorepo matches all of them). A reference app's coverage basis is `pattern_surfaces_documented`, not library exports — see the Reference-App stats semantics in `assets/compile-assembly-rules.md`.
122
131
  - **Shape:** pass `--shape reference-app` or `--shape stack` when the matching assembly-rules override applies (default `library`). For a reference app, also put `pattern_surfaces_documented` (the Pattern Surface row count) in the payload — the helper uses it as `exports_documented`, emits `stats.pattern_surfaces_documented`, and refuses to emit `effective_denominator`; the distribution then sums to the per-citation count, not to `exports_documented`. For a stack, put the own-barrel `exports_documented` (usually `0`) in the payload; the distribution sums to the cited-constituent count.
@@ -101,7 +101,7 @@ Then run CCC indexing and discovery on the resolved clone (workspace or ephemera
101
101
  node_modules/, dist/, build/, .git/, vendor/, __pycache__/, .cache/, .next/, .nuxt/, target/, out/, .venv/, .tox/
102
102
  ```
103
103
 
104
- Read `settings.yml`, append any patterns not already present to the `exclude_patterns` array, write back. **Reuse check:** if an existing `.cocoindex_code/settings.yml` was already present (workspace hit), read its `exclude_patterns` first and diff against the standard-exclusion list above. If ANY standard entry is missing from the existing list, append only the missing entries (preserving any user-added patterns) AND force a re-index by running `ccc index --force` (or the equivalent rebuild flag). If every standard entry is already present, skip the write and skip the forced re-index — the existing index is valid. Record `ccc_exclusions_augmented: {count}` in context for the evidence report.
104
+ Read `settings.yml`, append any patterns not already present to the `exclude_patterns` array, write back. **Reuse check:** if an existing `.cocoindex_code/settings.yml` was already present (workspace hit), read its `exclude_patterns` first and diff against the standard-exclusion list above. If ANY standard entry is missing from the existing list, append only the missing entries (preserving any user-added patterns) AND force a re-index by running `ccc reset --force` (discards the stale databases) followed by `ccc index` — `ccc index` itself takes no rebuild flag, so re-running it alone would reuse the existing index. If every standard entry is already present, skip the write and skip the forced re-index — the existing index is valid. Record `ccc_exclusions_augmented: {count}` in context for the evidence report.
105
105
 
106
106
  **Note:** Brief-specific `include_patterns` and `exclude_patterns` are not written to `settings.yml`. The CCC index is general-purpose — it indexes everything (minus standard artifacts). Brief-specific filtering happens at search result time, not index time. This allows a single workspace CCC index to serve multiple briefs with different scope filters.
107
107
 
@@ -194,11 +194,13 @@ Ensure the source path used for extraction is indexed by ccc and registered in t
194
194
 
195
195
  **Index verification:**
196
196
 
197
- `ccc index` requires the directory to be initialized first. Run `ccc init {source_root}` (idempotent — a no-op once initialized) before `ccc index {source_root}`, or use the ccc MCP tool. `ccc_bridge.ensure_index` is a conceptual interface, not a callable function. Indexing is a no-op if the source was already indexed during setup or step 2b.
197
+ **Working-directory contract:** `ccc init`, `ccc index`, and `ccc status` take **no positional path argument** — each operates on the current working directory, and passing a path makes them exit non-zero with `Got unexpected extra argument`. Run them from `{source_root}` (`cd {source_root} && ccc …`, the same form `extract.md` step 2b and `knowledge/ccc-bridge.md` use), then return to `{project-root}` before the registry update below, which resolves `{forgeTierConfig}` relative to the project.
198
198
 
199
- **Nested project marker:** when `{source_root}` is a subtree of a repo that already carries a project marker (e.g. a cloned source tree under `.forge-sources/`, or a `.cocoindex_code` / VCS marker in a parent), `ccc init {source_root}` exits non-zero with `A parent directory has a project marker`. This is expected — re-run as `ccc init -f {source_root}` to initialize at the subtree anyway, then `ccc index`. Do not treat the parent-marker warning as fatal.
199
+ `ccc index` requires the directory to be initialized first. Run `cd {source_root} && ccc init` (idempotent — a no-op once initialized) before `cd {source_root} && ccc index`, or use the ccc MCP tool. `ccc_bridge.ensure_index` is a conceptual interface, not a callable function. Indexing is a no-op if the source was already indexed during setup or step 2b.
200
200
 
201
- **Verify the index is not degraded:** after `ccc index`, run `ccc status {source_root}` and read the `Languages:` breakdown — a non-zero `Chunks`/`Files` total is not sufficient. Confirm the source's primary language (`{brief.language}`) reports a non-trivial chunk count. An index dominated by `markdown`/config chunks with the source language absent or near-zero means `ccc index` ran against degraded settings (a stale `.cocoindex_code/` inherited from a parent project, or a no-op `ccc init` over a prior partial index that returned `Project already initialized.`) — the source code was never indexed, which silently cripples later `skf-audit-skill` / `skf-update-skill` searches with no error surfaced. When the source language is absent, rebuild from a clean init (`ccc init -f {source_root}` then `ccc index {source_root}`) and re-check the breakdown. If the source language is still missing after a forced re-init, log it and continue per the error handling below — a degraded index is not workflow-fatal, but it must be recorded so the gap is visible.
201
+ **Nested project marker:** when `{source_root}` is a subtree of a repo that already carries a project marker (e.g. a cloned source tree under `.forge-sources/`, or a `.cocoindex_code` / VCS marker in a parent), `ccc init` exits non-zero with `A parent directory has a project marker`. This is expected — re-run as `cd {source_root} && ccc init -f` to initialize at the subtree anyway, then `ccc index`. Do not treat the parent-marker warning as fatal.
202
+
203
+ **Verify the index is not degraded:** after `ccc index`, run `cd {source_root} && ccc status` and read the `Languages:` breakdown — a non-zero `Chunks`/`Files` total is not sufficient. Confirm the source's primary language (`{brief.language}`) reports a non-trivial chunk count. An index dominated by `markdown`/config chunks with the source language absent or near-zero means `ccc index` ran against degraded settings (a stale `.cocoindex_code/` inherited from a parent project, or a no-op `ccc init` over a prior partial index that returned `Project already initialized.`) — the source code was never indexed, which silently cripples later `skf-audit-skill` / `skf-update-skill` searches with no error surfaced. When the source language is absent, rebuild from a clean init (`cd {source_root} && ccc init -f` then `ccc index`) and re-check the breakdown. Note that `-f` only skips the parent-marker warning — it does not clear a stale `.cocoindex_code/`; when the degraded settings survive the forced re-init, `ccc reset` (run from `{source_root}`) is what discards the existing databases. If the source language is still missing after a forced re-init, log it and continue per the error handling below — a degraded index is not workflow-fatal, but it must be recorded so the gap is visible.
202
204
 
203
205
  **Registry update:**
204
206
 
@@ -29,7 +29,7 @@ Identify the feeder artifacts in the **staging directory** for the current skill
29
29
  1. **Evidence report:** `_bmad-output/{skill-name}/evidence-report.md`
30
30
  2. **Provenance map:** `_bmad-output/{skill-name}/provenance-map.json` — focus on T2/T3 entries with temporal annotations
31
31
  3. **Temporal context:** changelogs, migration guides, and issue/PR data fetched by step 3b and enriched by step 4 (available in workflow context)
32
- 4. **Compiled SKILL.md:** the staged `_bmad-output/{skill-name}/SKILL.md` itself — check for `[QMD:...]` or `[DOC:...]` annotations referencing corrections. **Do not treat its own `## Migration & Deprecation Warnings` section (§4b) as a correction source:** compile (step 5 §4b) already authored that section from the same T2-future annotations, so its bullets are already-surfaced corrections — §2 discards matches that land inside it.
32
+ 4. **Compiled SKILL.md:** the staged `_bmad-output/{skill-name}/SKILL.md` itself — check for `[QMD:...]` or `[DOC:...]` annotations referencing corrections. **Do not treat its own self-authored regions as correction sources:** compile already wrote the `## Migration & Deprecation Warnings` section (step 5 §4b) and the frontmatter `description` (step 5 §2) from the same T2-future annotations, so both restate already-surfaced corrections — §2 discards matches that land in either.
33
33
 
34
34
  For each artifact, attempt to load its content. If an artifact does not exist or is empty, skip it — this is not an error.
35
35
 
@@ -42,6 +42,7 @@ The scan is deterministic plumbing — a fixed-table substring grep with one cor
42
42
  ```bash
43
43
  uv run {scanDocRotHelper} \
44
44
  --skill-md _bmad-output/{skill-name}/SKILL.md \
45
+ --max-corrections 10 \
45
46
  _bmad-output/{skill-name}/evidence-report.md \
46
47
  _bmad-output/{skill-name}/provenance-map.json \
47
48
  _bmad-output/{skill-name}-temporal/*.md
@@ -49,13 +50,15 @@ uv run {scanDocRotHelper} \
49
50
 
50
51
  (Pass whichever of the §1 feeder paths actually exist — the script skips any that are missing or empty, and reports the loaded set in `scanned`.)
51
52
 
52
- The script emits `{scanned: [...], matches: [...], match_count, excluded_count}` on stdout. Each entry in `matches` is a match record with the deterministic fields the script owns:
53
+ The script emits `{scanned: [...], matches: [...], match_count, excluded_count, deduped_count, capped_count, cap}` on stdout. Each entry in `matches` is a match record with the deterministic fields the script owns:
53
54
 
54
55
  - `source`: the feeder artifact path where the match was found
55
56
  - `pattern`: the specific pattern string that matched
56
57
  - `category`: the category label
57
58
  - `context_line`: the line containing the match
58
- - `line_number`: the 1-indexed line the match sits on
59
+ - `line_number`: the 1-indexed line the match sits on — a representative occurrence when duplicates were collapsed
60
+ - `occurrences`: how many feeder lines collapsed into this record (1 when it is unique)
61
+ - `duplicate_of`: the `{source, line_number}` of every collapsed occurrence (empty when it is unique)
59
62
 
60
63
  Read `matches` into `correction_matches: [{match records}]`. Then add the one judgment field the script cannot infer:
61
64
 
@@ -79,7 +82,19 @@ Read `matches` into `correction_matches: [{match records}]`. Then add the one ju
79
82
  | `migration required` | Migration |
80
83
  | `signature changed` | Signature change |
81
84
 
82
- **Exclusion — drop already-surfaced §4b corrections (deterministic, no AI judgment, applied inside the script before it emits):** the script discards any match whose `source` is the compiled SKILL.md (feeder #4) **and** whose `context_line` sits inside that file's own `## Migration & Deprecation Warnings` section — i.e. on a line at or after the `## Migration & Deprecation Warnings` heading and before the next `##` heading (`excluded_count` reports how many it dropped). Compile (step 5 §4b) authored that section from the same T2-future annotations, so re-emitting its bullets as `## CORRECTION` blocks in §3 would duplicate already-surfaced content verbatim. This is a positional heading-boundary check on text already loaded, not a semantic assessment. Set `feeder_artifacts_scanned` from the script's `scanned` list.
85
+ **Exclusion — drop the compiled SKILL.md's self-authored content (deterministic, no AI judgment, applied inside the script before it emits):** the script discards any match whose `source` is the compiled SKILL.md (feeder #4) **and** whose `line_number` sits in either of two positional windows. `excluded_count` reports the combined total.
86
+
87
+ 1. **Its own `## Migration & Deprecation Warnings` section** — a line at or after the heading and before the next `##` heading. Compile (step 5 §4b) authored that section from the same T2-future annotations, so re-emitting its bullets as `## CORRECTION` blocks in §3 would duplicate already-surfaced content verbatim.
88
+ 2. **Its own YAML frontmatter** — a line at or inside the leading `---` … `---` fences. Compile (step 5 §2) writes the frontmatter `description` from the same annotations, and it routinely restates the release's headline breaking change, so a match there is already-surfaced too. The frontmatter is a closed key set of pipeline scalars, so it can never carry an upstream correction the body has missed — and §3 forbids annotating it in any case, which would leave such a match with nowhere legitimate to go.
89
+
90
+ Both are positional boundary checks on text already loaded, not semantic assessments. The heading search starts after the frontmatter, so an indented restatement of the heading inside a folded `description:` cannot anchor the window in the wrong place. Set `feeder_artifacts_scanned` from the script's `scanned` list.
91
+
92
+ **Bounding — collapse duplicates, then cap (deterministic, applied inside the script after the exclusions):** the temporal feeder above is a verbatim upstream changelog, so a single run can match hundreds of "breaking"/"deprecated" lines drawn from years of release history. Two bounds keep §3's output proportionate:
93
+
94
+ - **Duplicate collapse.** Matches sharing a `category` and the same whitespace-normalized, lowercased `context_line` collapse into their first occurrence, which carries `occurrences` and `duplicate_of`. `deduped_count` reports how many collapsed. A changelog that restates one deprecation across many releases yields one block, not one per release.
95
+ - **Cap.** At most `--max-corrections` matches survive (default 10; pass `0` for unlimited). `capped_count` reports how many were dropped and `cap` the limit in force. Selection prefers the compiled SKILL.md's own annotations over other feeders, then scan order. Ten blocks at ~7 lines each fits step 5b's 400-line body budget with headroom.
96
+
97
+ Neither bound is a judgment call, and neither is silent — §4 logs both counts.
83
98
 
84
99
  **IF `correction_matches` is empty** (the script returned `match_count: 0`, or the run had no feeder files to pass)**:**
85
100
  - Log: `"doc-rot: skipped (no correction indicators found in feeder artifacts)"`
@@ -90,7 +105,7 @@ Read `matches` into `correction_matches: [{match records}]`. Then add the one ju
90
105
 
91
106
  ### §3. Annotate SKILL.md with Correction Blocks
92
107
 
93
- For each entry in `correction_matches`, insert a `## CORRECTION` block into the compiled SKILL.md.
108
+ For each entry in `correction_matches`, insert a `## CORRECTION` block into the compiled SKILL.md. The list is already collapsed and capped by §2's bounding, so this loop is bounded by construction — do not re-expand it from `duplicate_of`, and do not write the drop counts into the artifact (they belong in the §4 log, not in a third-party skill).
94
109
 
95
110
  **Block format:**
96
111
 
@@ -114,11 +129,13 @@ Store: `corrections_added: {count of blocks inserted}`
114
129
 
115
130
  ### §4. Log Results
116
131
 
117
- Log: `"doc-rot: {corrections_added} correction blocks added from {feeder_artifacts_scanned_count} feeder artifacts"`
132
+ Log: `"doc-rot: {corrections_added} correction blocks added from {feeder_artifacts_scanned_count} feeder artifacts ({deduped_count} duplicates collapsed, {capped_count} dropped over cap {cap})"`
118
133
 
119
134
  Set context:
120
135
  - `doc_rot_triggered: true`
121
136
  - `corrections_added: {count}`
137
+ - `corrections_deduped: {deduped_count}`
138
+ - `corrections_capped: {capped_count}`
122
139
  - `feeder_artifacts_scanned: [{list}]`
123
140
  - `correction_matches: [{match records}]`
124
141