bmad-module-skill-forge 2.0.2 → 2.1.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/404.md +4 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/RELEASING.md +7 -4
  5. package/docs/agents.md +2 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/bmad-synergy.md +13 -13
  8. package/docs/campaign.md +3 -3
  9. package/docs/concepts.md +3 -3
  10. package/docs/examples.md +9 -9
  11. package/docs/forge-auto.md +6 -6
  12. package/docs/getting-started.md +12 -12
  13. package/docs/how-it-works.md +7 -7
  14. package/docs/index.md +7 -1
  15. package/docs/skill-model.md +2 -2
  16. package/docs/troubleshooting.md +7 -7
  17. package/docs/why-skf.md +8 -8
  18. package/docs/workflows.md +5 -5
  19. package/package.json +7 -3
  20. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  21. package/src/shared/scripts/skf-detect-language.py +20 -0
  22. package/src/shared/scripts/skf-detect-tools.py +34 -0
  23. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  24. package/src/shared/scripts/skf-language-corpora.py +20 -0
  25. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  26. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  27. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  28. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  29. package/src/shared/scripts/skf-shape-detect.py +20 -0
  30. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  31. package/src/skf-analyze-source/references/recommend.md +1 -1
  32. package/src/skf-audit-skill/references/re-index.md +1 -1
  33. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  34. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  35. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  36. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  37. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  38. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  39. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  40. package/src/skf-create-skill/references/compile.md +12 -3
  41. package/src/skf-create-skill/references/extract.md +1 -1
  42. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  43. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  44. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  45. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  46. package/src/skf-setup/references/ccc-index.md +2 -2
  47. package/src/skf-setup/references/report.md +1 -1
  48. package/src/skf-test-skill/references/coverage-check.md +11 -5
  49. package/src/skf-test-skill/references/score.md +7 -1
  50. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  51. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  52. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  53. package/tools/cli/lib/installer.js +31 -1
  54. package/tools/validate-docs-links.js +665 -0
@@ -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,7 +134,7 @@ 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" }],
@@ -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
 
@@ -8,13 +8,25 @@ step 5c self-declares its correction scan "grep-based and deterministic — no A
8
8
  judgment is used for detection." This helper *is* that grep: it walks the
9
9
  resolved feeder artifacts, matches every line against the fixed 13-row
10
10
  correction-pattern table with case-insensitive substring containment (no regex,
11
- no semantics), and emits the matches as JSON. It applies the one positional
12
- filter the step documents — dropping matches that land inside the compiled
13
- SKILL.md's own `## Migration & Deprecation Warnings` section (compile §4b
14
- already authored those, so re-emitting them would be circular) — before it
15
- returns. Running the scan here (instead of in-prompt) makes the "deterministic,
16
- identical input → identical output" promise actually hold: the model no longer
17
- hand-greps multi-KB artifacts.
11
+ no semantics), and emits the matches as JSON. It applies the positional filters
12
+ the step documents — dropping matches that land inside the compiled SKILL.md's
13
+ own YAML frontmatter or its own `## Migration & Deprecation Warnings` section
14
+ (both are self-authored: compile §2 wrote the frontmatter `description` and
15
+ compile §4b wrote the migration bullets, so re-emitting either would be
16
+ circular) — and then bounds what survives, before it returns. Running the scan
17
+ here (instead of in-prompt) makes the "deterministic, identical input →
18
+ identical output" promise actually hold: the model no longer hand-greps
19
+ multi-KB artifacts.
20
+
21
+ Bounding matters because §2's own example command passes the raw temporal
22
+ changelog as a feeder. That artifact is a verbatim upstream dump (thousands of
23
+ lines of release history), so an unbounded run turns years of "breaking" /
24
+ "deprecated" lines into hundreds of `## CORRECTION` blocks and blows the
25
+ compiled body's line budget. Two deterministic bounds run after the exclusions:
26
+ duplicate collapse (same category + same normalized line text) and a hard cap
27
+ on how many blocks a single run may propose. Nothing is silently destroyed —
28
+ a collapsed record carries `occurrences` and `duplicate_of`, and the counts of
29
+ what was collapsed and capped are reported so the step can log them.
18
30
 
19
31
  The genuine judgment the step keeps in-prompt is untouched by this script:
20
32
  enriching each match's `affected` symbol from surrounding context (§2) and
@@ -29,11 +41,15 @@ CLI usage:
29
41
  uv run scan-doc-rot.py --skill-md <staged SKILL.md> [FEEDER ...]
30
42
  uv run scan-doc-rot.py --feeder evidence-report.md --feeder provenance-map.json
31
43
 
32
- --skill-md the compiled/staged SKILL.md feeder (feeder #4); matches inside its
33
- `## Migration & Deprecation Warnings` section are excluded. It is
34
- also scanned like any other feeder.
35
- FEEDER any other feeder artifact (evidence-report.md, provenance-map.json,
36
- temporal-context files). Repeatable positionally or via --feeder.
44
+ --skill-md the compiled/staged SKILL.md feeder (feeder #4); matches
45
+ inside its YAML frontmatter or its `## Migration &
46
+ Deprecation Warnings` section are excluded. It is also
47
+ scanned like any other feeder.
48
+ FEEDER any other feeder artifact (evidence-report.md,
49
+ provenance-map.json, temporal-context files). Repeatable
50
+ positionally or via --feeder.
51
+ --max-corrections cap on emitted matches (default 10). 0 or negative means
52
+ unlimited.
37
53
 
38
54
  Missing or empty files are skipped silently (not an error), matching §1's
39
55
  "attempt to load; if it does not exist or is empty, skip it."
@@ -43,11 +59,16 @@ Output (stdout, one object):
43
59
  "scanned": ["<path>", ...], # feeders that existed and were non-empty
44
60
  "matches": [ # correction_matches[] (affected added in-prompt)
45
61
  {"source": "<path>", "pattern": "deprecated", "category": "Deprecation",
46
- "context_line": "<line text>", "line_number": <1-indexed int>},
62
+ "context_line": "<line text>", "line_number": <1-indexed int>,
63
+ "occurrences": <int>, # 1 unless duplicates collapsed into this record
64
+ "duplicate_of": [{"source": "<path>", "line_number": <int>}, ...]},
47
65
  ...
48
66
  ],
49
- "match_count": <int>,
50
- "excluded_count": <int> # SKILL.md matches dropped by the §4b filter
67
+ "match_count": <int>, # len(matches), after exclusions/collapse/cap
68
+ "excluded_count": <int>, # SKILL.md matches dropped (frontmatter + §4b)
69
+ "deduped_count": <int>, # matches collapsed into a surviving record
70
+ "capped_count": <int>, # matches dropped because the cap was reached
71
+ "cap": <int> # effective cap (0 = unlimited)
51
72
  }
52
73
 
53
74
  Exit codes:
@@ -85,6 +106,11 @@ PATTERN_TABLE: list[tuple[str, str]] = [
85
106
 
86
107
  _MIGRATION_HEADING = re.compile(r"^\s*##\s+Migration\s*&\s*Deprecation Warnings", re.IGNORECASE)
87
108
 
109
+ # Cap on how many `## CORRECTION` blocks one run may propose (step-doc-rot.md §3).
110
+ # Step 5b budgets the compiled body at 400 lines and each block is ~7 lines, so
111
+ # 10 blocks stays inside the budget with headroom.
112
+ DEFAULT_MAX_CORRECTIONS = 10
113
+
88
114
 
89
115
  def scan_text(text: str, source: str) -> list[dict]:
90
116
  """Case-insensitive substring scan of `text` against PATTERN_TABLE.
@@ -109,6 +135,27 @@ def scan_text(text: str, source: str) -> list[dict]:
109
135
  return matches
110
136
 
111
137
 
138
+ def frontmatter_range(text: str) -> tuple[int, int] | None:
139
+ """1-indexed [start, end] inclusive window of the leading YAML frontmatter
140
+ block (both `---` fences included), or None when the file has no frontmatter.
141
+
142
+ Delimiter handling mirrors the established SKF convention (see
143
+ `skf-shard-body.py` `split_frontmatter` and
144
+ `skf-validate-feasibility-report.py` `split_frontmatter`): the opening fence
145
+ must be the FIRST line and the closing fence is the next line that is
146
+ exactly `---` once surrounding whitespace is stripped. A `---` horizontal
147
+ rule further down the body therefore cannot open a block, and an unterminated
148
+ opening fence yields None rather than swallowing the file.
149
+ """
150
+ lines = text.splitlines()
151
+ if not lines or lines[0].strip() != "---":
152
+ return None
153
+ for idx in range(2, len(lines) + 1):
154
+ if lines[idx - 1].strip() == "---":
155
+ return (1, idx)
156
+ return None
157
+
158
+
112
159
  def migration_section_range(skill_md_text: str) -> tuple[int, int] | None:
113
160
  """1-indexed [start, end) line window of the `## Migration & Deprecation
114
161
  Warnings` section, or None if the section is absent.
@@ -116,11 +163,18 @@ def migration_section_range(skill_md_text: str) -> tuple[int, int] | None:
116
163
  start = the heading line; end = the next level-2 (`## `) heading, or EOF.
117
164
  A `### ` subsection inside the section does NOT close it (only a sibling
118
165
  `## ` heading does), matching §2's "before the next `##` heading".
166
+
167
+ The search starts after any YAML frontmatter. `_MIGRATION_HEADING` tolerates
168
+ leading whitespace, so an indented restatement of the heading inside a folded
169
+ `description:` would otherwise anchor the window in the frontmatter and leave
170
+ the real body section unexcluded.
119
171
  """
120
172
  lines = skill_md_text.splitlines()
173
+ front = frontmatter_range(skill_md_text)
174
+ first_body_line = front[1] + 1 if front else 1
121
175
  start = None
122
- for idx, line in enumerate(lines, start=1):
123
- if _MIGRATION_HEADING.match(line):
176
+ for idx in range(first_body_line, len(lines) + 1):
177
+ if _MIGRATION_HEADING.match(lines[idx - 1]):
124
178
  start = idx
125
179
  break
126
180
  if start is None:
@@ -133,6 +187,38 @@ def migration_section_range(skill_md_text: str) -> tuple[int, int] | None:
133
187
  return (start, end)
134
188
 
135
189
 
190
+ def apply_frontmatter_exclusion(
191
+ matches: list[dict], skill_md_source: str | None, skill_md_text: str | None
192
+ ) -> tuple[list[dict], int]:
193
+ """Drop matches whose source is the compiled SKILL.md and whose line sits
194
+ inside its YAML frontmatter. Returns (kept, dropped).
195
+
196
+ Same circularity argument as the §4b exclusion below: compile (step 5 §2)
197
+ authors the frontmatter `description` from the very annotations this scan
198
+ looks for, so a `breaking change` / `deprecated` phrase there is already
199
+ surfaced, not a new correction. The frontmatter is also a closed key set of
200
+ pipeline scalars (`name`, `description`), so a match in it can never be an
201
+ upstream correction the body has missed.
202
+
203
+ Scoped to the compiled SKILL.md deliberately. evidence-report.md also carries
204
+ frontmatter, but its frontmatter holds pinned counts rather than authored
205
+ prose, so it cannot restate an upstream correction and is left in scope.
206
+ """
207
+ if skill_md_source is None or skill_md_text is None:
208
+ return matches, 0
209
+ window = frontmatter_range(skill_md_text)
210
+ if window is None:
211
+ return matches, 0
212
+ start, end = window
213
+ kept, dropped = [], 0
214
+ for m in matches:
215
+ if m["source"] == skill_md_source and start <= m["line_number"] <= end:
216
+ dropped += 1
217
+ continue
218
+ kept.append(m)
219
+ return kept, dropped
220
+
221
+
136
222
  def apply_migration_exclusion(
137
223
  matches: list[dict], skill_md_source: str | None, skill_md_text: str | None
138
224
  ) -> tuple[list[dict], int]:
@@ -153,6 +239,58 @@ def apply_migration_exclusion(
153
239
  return kept, dropped
154
240
 
155
241
 
242
+ def collapse_duplicates(matches: list[dict]) -> tuple[list[dict], int]:
243
+ """Collapse matches that repeat the same text under the same category.
244
+
245
+ Returns (kept, collapsed). The dedup key is
246
+ `(category, whitespace-normalized lowercased context_line)` — a temporal
247
+ changelog restates the same deprecation across many releases, and each
248
+ restatement would otherwise become its own `## CORRECTION` block saying the
249
+ same thing.
250
+
251
+ The first occurrence in scan order survives and gains two fields:
252
+ `occurrences` (how many lines collapsed into it, including itself) and
253
+ `duplicate_of` (the `{source, line_number}` of every later occurrence), so
254
+ nothing is silently destroyed. Records are copies — the inputs are untouched.
255
+ """
256
+ seen: dict[tuple[str, str], dict] = {}
257
+ kept: list[dict] = []
258
+ for m in matches:
259
+ key = (m["category"], " ".join(m["context_line"].split()).lower())
260
+ survivor = seen.get(key)
261
+ if survivor is not None:
262
+ survivor["occurrences"] += 1
263
+ survivor["duplicate_of"].append(
264
+ {"source": m["source"], "line_number": m["line_number"]}
265
+ )
266
+ continue
267
+ record = dict(m, occurrences=1, duplicate_of=[])
268
+ seen[key] = record
269
+ kept.append(record)
270
+ return kept, len(matches) - len(kept)
271
+
272
+
273
+ def apply_cap(
274
+ matches: list[dict], cap: int, skill_md_source: str | None
275
+ ) -> tuple[list[dict], int]:
276
+ """Keep at most `cap` matches. Returns (kept, dropped).
277
+
278
+ A cap of 0 or less means unlimited. Selection prefers the compiled SKILL.md's
279
+ own annotations over other feeders — a naive head-slice would drop them first,
280
+ because scan order puts the skill-md feeder last — and falls back to scan
281
+ order within each group. The kept records are returned in scan order, so a
282
+ run that does not hit the cap is ordered exactly as it is today.
283
+ """
284
+ if cap <= 0 or len(matches) <= cap:
285
+ return matches, 0
286
+ priority = sorted(
287
+ range(len(matches)),
288
+ key=lambda i: (0 if matches[i]["source"] == skill_md_source else 1, i),
289
+ )
290
+ keep = sorted(priority[:cap])
291
+ return [matches[i] for i in keep], len(matches) - cap
292
+
293
+
156
294
  def _load(path: str) -> str | None:
157
295
  """Read a feeder file as UTF-8. Returns None for missing/empty (skip, not
158
296
  an error). UTF-8 avoids cp1252 mojibake on Windows."""
@@ -165,9 +303,15 @@ def _load(path: str) -> str | None:
165
303
  return text
166
304
 
167
305
 
168
- def scan_files(feeders: list[str], skill_md: str | None) -> dict:
169
- """Scan every feeder + the skill-md feeder, apply the §4b exclusion, return
170
- the result object. Deterministic: feeders scanned in the given order."""
306
+ def scan_files(
307
+ feeders: list[str],
308
+ skill_md: str | None,
309
+ *,
310
+ max_corrections: int = DEFAULT_MAX_CORRECTIONS,
311
+ ) -> dict:
312
+ """Scan every feeder + the skill-md feeder, apply the self-authorship
313
+ exclusions, collapse duplicates, cap the survivors, and return the result
314
+ object. Deterministic: feeders scanned in the given order."""
171
315
  scanned: list[str] = []
172
316
  matches: list[dict] = []
173
317
  skill_md_text = None
@@ -185,12 +329,21 @@ def scan_files(feeders: list[str], skill_md: str | None) -> dict:
185
329
  skill_md_text = text
186
330
  matches.extend(scan_text(text, path))
187
331
 
188
- matches, excluded = apply_migration_exclusion(matches, skill_md, skill_md_text)
332
+ # Frontmatter first, then the §4b section — the two windows are disjoint, so
333
+ # the total is order-independent, but running them in file order keeps the
334
+ # attribution obvious when debugging a run.
335
+ matches, front_excluded = apply_frontmatter_exclusion(matches, skill_md, skill_md_text)
336
+ matches, section_excluded = apply_migration_exclusion(matches, skill_md, skill_md_text)
337
+ matches, deduped = collapse_duplicates(matches)
338
+ matches, capped = apply_cap(matches, max_corrections, skill_md)
189
339
  return {
190
340
  "scanned": scanned,
191
341
  "matches": matches,
192
342
  "match_count": len(matches),
193
- "excluded_count": excluded,
343
+ "excluded_count": front_excluded + section_excluded,
344
+ "deduped_count": deduped,
345
+ "capped_count": capped,
346
+ "cap": max(max_corrections, 0),
194
347
  }
195
348
 
196
349
 
@@ -200,8 +353,9 @@ def _build_parser() -> argparse.ArgumentParser:
200
353
  description=(
201
354
  "Deterministic doc-rot correction-indicator scan (step-doc-rot.md §2): "
202
355
  "case-insensitive substring match of feeder artifacts against the fixed "
203
- "correction-pattern table, with the compiled SKILL.md's Migration & "
204
- "Deprecation Warnings section excluded."
356
+ "correction-pattern table, with the compiled SKILL.md's frontmatter and "
357
+ "Migration & Deprecation Warnings section excluded, duplicates collapsed, "
358
+ "and the survivors capped."
205
359
  ),
206
360
  formatter_class=argparse.RawDescriptionHelpFormatter,
207
361
  )
@@ -222,8 +376,18 @@ def _build_parser() -> argparse.ArgumentParser:
222
376
  "--skill-md",
223
377
  dest="skill_md",
224
378
  default=None,
225
- help="Compiled/staged SKILL.md feeder; its Migration & Deprecation Warnings "
226
- "section is excluded from matches.",
379
+ help="Compiled/staged SKILL.md feeder; its frontmatter and its Migration & "
380
+ "Deprecation Warnings section are excluded from matches.",
381
+ )
382
+ parser.add_argument(
383
+ "--max-corrections",
384
+ dest="max_corrections",
385
+ type=int,
386
+ default=DEFAULT_MAX_CORRECTIONS,
387
+ help=(
388
+ f"Maximum matches to emit (default {DEFAULT_MAX_CORRECTIONS}). "
389
+ "0 or negative means unlimited."
390
+ ),
227
391
  )
228
392
  return parser
229
393
 
@@ -236,7 +400,7 @@ def main(argv=None) -> int:
236
400
  parser.print_usage(file=sys.stderr)
237
401
  print("error: supply at least one feeder path or --skill-md", file=sys.stderr)
238
402
  return 1
239
- result = scan_files(feeders, args.skill_md)
403
+ result = scan_files(feeders, args.skill_md, max_corrections=args.max_corrections)
240
404
  print(json.dumps(result, indent=2))
241
405
  return 0
242
406
 
@@ -145,7 +145,7 @@ For each library pair with **0 or 1 co-import files** (below the 2-file threshol
145
145
 
146
146
  **CCC precision guard for 1-file pairs (H3):** When a CCC hit would elevate a 1-file pair to qualifying status, run a post-hoc verification on that file: re-grep the file and confirm it contains explicit import statements for **both** libraries (per the ecosystem import patterns from `{manifestPatternsPath}`). If either import is missing (e.g., one library is only name-dropped in a comment or string), drop the CCC-added file from the candidate list. Only pairs with ≥2 files that each contain explicit imports for both libraries qualify. Log rejected CCC candidates in workflow state for the evidence report.
147
147
 
148
- **Tool resolution for ccc_bridge.search:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `ccc search "{libA} {libB}" --path {source_root} --top 10` (CLI). See `knowledge/tool-resolution.md`.
148
+ **Tool resolution for ccc_bridge.search:** Use `/ccc` skill search (Claude Code), ccc MCP server (Cursor), or `cd {source_root} && ccc search --limit 10 "{libA} {libB}"` (CLI). `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/tool-resolution.md`.
149
149
 
150
150
  For pairs that already qualify (2+ files), CCC is not needed for detection — but the CCC results may surface additional integration files for richer classification in section 3.
151
151
 
@@ -32,9 +32,9 @@ For Quick and Forge tiers, or when ccc is unavailable, skip silently and proceed
32
32
 
33
33
  Read `{ccc}` and `{ccc_skip_index}` from context.
34
34
 
35
- **If `{ccc}` is false:** Set `{ccc_index_result: "none", ccc_indexed_path: null, ccc_last_indexed: null, ccc_exclude_patterns: [], ccc_exclusion_warnings: [], settings_yml_written: false, settings_yml_patterns_added: 0}`. Proceed directly to section 4 (Auto-Proceed) — no output, no messaging.
35
+ **If `{ccc}` is false:** Set `{ccc_index_result: "none", ccc_indexed_path: null, ccc_last_indexed: null, ccc_exclude_patterns: [], ccc_exclusion_warnings: [], settings_yml_written: false, settings_yml_patterns_added: 0}`. Proceed directly to section 5 (Auto-Proceed) — no output, no messaging.
36
36
 
37
- **If `{ccc}` is true AND `{ccc_skip_index}` is true:** Run the exclusion-merge (section 3) so settings.yml stays current, then set `{ccc_index_result: "skipped", ccc_indexed_path: null, ccc_last_indexed: null}` and proceed to section 4 — do not run `ccc init` or `ccc index`. The envelope's `ccc_index.status` will be `"skipped"` so pipelines that plan an out-of-band re-index can distinguish "the operator opted out" from "indexing failed".
37
+ **If `{ccc}` is true AND `{ccc_skip_index}` is true:** Run the exclusion-merge (section 3) so settings.yml stays current, then set `{ccc_index_result: "skipped", ccc_indexed_path: null, ccc_last_indexed: null}` and proceed to section 5 (Auto-Proceed) — do not run `ccc init` or `ccc index`. The envelope's `ccc_index.status` will be `"skipped"` so pipelines that plan an out-of-band re-index can distinguish "the operator opted out" from "indexing failed".
38
38
 
39
39
  **If `{ccc}` is true AND `{ccc_skip_index}` is false:** Continue to section 2.
40
40
 
@@ -77,7 +77,7 @@ Load and read {tierRulesData} for the tier capability descriptions and re-run me
77
77
  {end if}
78
78
 
79
79
  {if hygiene_result is "qmd_unavailable":}
80
- QMD Registry: skipped (daemon error — retry after `qmd start`).
80
+ QMD Registry: skipped (qmd unavailable — if the daemon is stopped, `qmd start` restores it).
81
81
  {end if}
82
82
 
83
83
  {if tools.ccc is true:}
@@ -216,9 +216,9 @@ On a split-body skill the §1 inventory (documented surface) and the §2 AST out
216
216
 
217
217
  1. **Enumerated path (`denominatorSource: "barrel"`)** — the common split-body case. `barrel_set` is the union of `exports_found[]` across every §2 per-file result. **When a stratified-scope or State-2 denominator applies (see §4) and it resolves to an enumerated name set** — the priority-2/3 re-derivation from `scope.tier_a_include` / `scope.include` globs — pass that resolved name set as `barrelSet` so the script intersects against it instead of the raw per-file union. The script computes `Documented := |documented_set ∩ barrel_set|`, `Missing := barrel_set − documented_set` (in source, not documented), `Stale := documented_set − barrel_set` (documented, not in source), and `Export Coverage = |Documented| / |barrel_set| * 100`.
218
218
 
219
- 2. **Scalar-denominator branch (`denominatorSource: "scalar"`)** — §4 priority 1, when the resolved denominator is the scalar `metadata.json.stats.effective_denominator` (a count with **no enumerated name set**). There is no `barrel_set` to intersect, so the script instead greps each `documented_set` name across `SKILL.md ∪ references/*.md` and counts appearances. Pass `denominatorValue: effective_denominator` and `skillPackagePath: {resolved_skill_package}`; the script sets `Missing := effective_denominator − Documented` and returns an empty `Stale` (not enumerable without a barrel name set). If §4b's numerator-ground-truth arm fires (it triggers only when `exports_documented == effective_denominator`), its verified count is authoritative and **overrides** this numerator — do not apply both.
219
+ 2. **Scalar-denominator branch (`denominatorSource: "scalar"`)** — §4 priority 1, when the resolved denominator is the scalar `metadata.json.stats.effective_denominator` (a count with **no enumerated name set**). There is no `barrel_set` to intersect, so the script instead greps each `documented_set` name across `SKILL.md ∪ references/*.md` and counts appearances. Pass `denominatorValue: effective_denominator` and `skillPackagePath: {resolved_skill_package}`; the script sets `Missing := max(0, effective_denominator − Documented)` and returns an empty `Stale` (not enumerable without a barrel name set). When the grep count overshoots the denominator the script reports the surplus instead of a negative residual — see the surplus note under the returns list. If §4b's numerator-ground-truth arm fires (it triggers only when `exports_documented == effective_denominator`), its verified count is authoritative and **overrides** this numerator — do not apply both.
220
220
 
221
- 3. **Stack-skill branch (`denominatorSource: "stack"`, `metadata.json.skill_type == "stack"`)** — a stack's source barrel is empty by design, so intersecting against it would divide by zero. Pass `denominatorValue: stack_denominator` (the §2b composition-surface denominator) and `compositionNames`: the provenance-map cited-contract names (`::`-excluded) when the map exists and is non-empty, else the `libraries` and `integration_pairs` names. The script greps each composition name across `SKILL.md ∪ references/*.md` for the numerator, sets `Missing := stack_denominator − Documented`, and omits `Stale` (no source barrel to enumerate against).
221
+ 3. **Stack-skill branch (`denominatorSource: "stack"`, `metadata.json.skill_type == "stack"`)** — a stack's source barrel is empty by design, so intersecting against it would divide by zero. Pass `denominatorValue: stack_denominator` (the §2b composition-surface denominator) and `compositionNames`: the provenance-map cited-contract names (`::`-excluded) when the map exists and is non-empty, else the `libraries` and `integration_pairs` names. The script greps each composition name across `SKILL.md ∪ references/*.md` for the numerator, sets `Missing := max(0, stack_denominator − Documented)`, and omits `Stale` (no source barrel to enumerate against). The same surplus reporting as the scalar branch applies.
222
222
 
223
223
  **Build the reconciliation input and run the script:**
224
224
 
@@ -242,11 +242,14 @@ Input JSON (one object — supply only the fields the chosen branch needs; `{rec
242
242
 
243
243
  The script returns (read these — **do not re-derive them by hand**):
244
244
 
245
- - `documented` — the numerator (`|documented_set ∩ barrel_set|` for barrel; grep-verified count for scalar/stack)
246
- - `missing` / `missingCount` — source names not documented (barrel enumerates the names; scalar/stack give only the residual count)
245
+ - `documented` — the numerator (`|documented_set ∩ barrel_set|` for barrel; grep-verified count for scalar/stack). This is always the true count, never capped.
246
+ - `missing` / `missingCount` — source names not documented (barrel enumerates the names; scalar/stack give only the residual count). Never negative.
247
247
  - `stale` / `staleCount` — documented names not in source (barrel only; empty with `staleApplicable: false` for scalar/stack)
248
248
  - `denominator` — `|barrel_set|` (barrel) or the resolved scalar/stack denominator
249
- - `exportCoverage` — `documented / denominator * 100`, already rounded
249
+ - `exportCoverage` — `documented / denominator * 100`, already rounded; capped at 100 on the scalar/stack branches
250
+ - `numeratorSurplus` / `coverageUncapped` / `coverageCapped` — scalar/stack only; see the surplus note below
251
+
252
+ **Surplus on the grep branches.** The scalar and stack branches grep a name set against the skill body, so their numerator and denominator measure independent sets and `documented` can exceed `denominator` — a consumer-surface denominator counts one surface while the documented body may also name migration aliases or re-exported sibling symbols. When that happens the script reports `numeratorSurplus > 0` and `coverageCapped: true`, holds `exportCoverage` at 100, floors `missingCount` at 0, and preserves the raw ratio in `coverageUncapped`. **A surplus is a signal, not a pass:** it means the two sets disagree, so state it in the Coverage Analysis section (§5) alongside both counts. A large surplus on a skill whose brief carries no `scope.tier_a_include` is the deflated-denominator signature the `source-access-protocol.md` deflation guard describes — check that guard before accepting the 100.
250
253
 
251
254
  Carry these into §3's table/summary and §4's Export Coverage, and record the counts in the Coverage Analysis section (§5) so the numerator is auditable. The `exportCoverage` recorded here is the value step 5 feeds to `compute-score.py` — it is the script's value, not a parent estimate.
252
255
 
@@ -267,6 +270,8 @@ Aggregate findings across all source files:
267
270
  - Signature mismatches: {N}
268
271
  - Undocumented in SKILL.md but not in source (stale docs): `staleCount`
269
272
 
273
+ When the script reports `coverageCapped: true`, add the surplus to the summary — `Documented (surplus over denominator): {numeratorSurplus}` and `Uncapped coverage: {coverageUncapped}%` — so the reader can see that `Missing documentation: 0` is a floored residual rather than a verified-complete surface.
274
+
270
275
  ### 4. Load Scoring Rules
271
276
 
272
277
  Load `{scoringRulesFile}` to determine category scores:
@@ -390,6 +395,7 @@ Append the **Coverage Analysis** section to `{outputFile}`:
390
395
  - **Missing Documentation:** {N}
391
396
  - **Signature Mismatches:** {N}
392
397
  - **Stale Documentation:** {N}
398
+ - **Numerator Surplus:** {N} — omit this row unless `coverageCapped: true`; when present, also give the uncapped percentage
393
399
 
394
400
  ### Category Scores
395
401