bmad-module-skill-forge 2.0.1 → 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 (55) 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 +6 -6
  19. package/package.json +7 -3
  20. package/src/shared/health-check.md +40 -3
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  22. package/src/shared/scripts/skf-detect-language.py +20 -0
  23. package/src/shared/scripts/skf-detect-tools.py +34 -0
  24. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  25. package/src/shared/scripts/skf-language-corpora.py +20 -0
  26. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  27. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  28. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  29. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  30. package/src/shared/scripts/skf-shape-detect.py +20 -0
  31. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  32. package/src/skf-analyze-source/references/recommend.md +1 -1
  33. package/src/skf-audit-skill/references/re-index.md +1 -1
  34. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  35. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  36. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  37. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  38. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  39. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  40. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  41. package/src/skf-create-skill/references/compile.md +12 -3
  42. package/src/skf-create-skill/references/extract.md +1 -1
  43. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  44. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  45. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  46. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  47. package/src/skf-setup/references/ccc-index.md +2 -2
  48. package/src/skf-setup/references/report.md +1 -1
  49. package/src/skf-test-skill/references/coverage-check.md +11 -5
  50. package/src/skf-test-skill/references/score.md +7 -1
  51. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  52. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  53. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  54. package/tools/cli/lib/installer.js +31 -1
  55. package/tools/validate-docs-links.js +665 -0
@@ -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,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