bmad-module-skill-forge 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -1
  3. package/docs/404.md +4 -4
  4. package/docs/_data/pinned.yaml +1 -1
  5. package/docs/_internal/RELEASING.md +41 -4
  6. package/docs/agents.md +2 -2
  7. package/docs/architecture.md +1 -1
  8. package/docs/bmad-synergy.md +13 -13
  9. package/docs/campaign.md +3 -3
  10. package/docs/concepts.md +3 -3
  11. package/docs/examples.md +9 -9
  12. package/docs/forge-auto.md +6 -6
  13. package/docs/getting-started.md +12 -12
  14. package/docs/how-it-works.md +7 -7
  15. package/docs/index.md +7 -1
  16. package/docs/skill-model.md +2 -2
  17. package/docs/troubleshooting.md +7 -7
  18. package/docs/why-skf.md +8 -8
  19. package/docs/workflows.md +5 -5
  20. package/package.json +7 -3
  21. package/src/shared/references/description-guard-protocol.md +2 -0
  22. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
  23. package/src/shared/scripts/skf-description-guard.py +22 -2
  24. package/src/shared/scripts/skf-detect-docs.py +198 -2
  25. package/src/shared/scripts/skf-detect-language.py +20 -0
  26. package/src/shared/scripts/skf-detect-tools.py +40 -0
  27. package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
  28. package/src/shared/scripts/skf-language-corpora.py +20 -0
  29. package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
  30. package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
  31. package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
  32. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  33. package/src/shared/scripts/skf-shape-detect.py +20 -0
  34. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  35. package/src/skf-analyze-source/references/recommend.md +1 -1
  36. package/src/skf-audit-skill/references/re-index.md +1 -1
  37. package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
  38. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  39. package/src/skf-brief-skill/references/analyze-target.md +2 -2
  40. package/src/skf-brief-skill/references/gather-intent.md +4 -2
  41. package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
  42. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
  43. package/src/skf-create-skill/assets/skill-sections.md +3 -2
  44. package/src/skf-create-skill/references/compile.md +12 -3
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +5 -3
  47. package/src/skf-create-skill/references/step-doc-rot.md +23 -6
  48. package/src/skf-create-skill/references/step-doc-sources.md +31 -3
  49. package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
  50. package/src/skf-create-skill/references/validate.md +2 -2
  51. package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
  52. package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
  53. package/src/skf-setup/references/ccc-index.md +5 -4
  54. package/src/skf-setup/references/detect-and-tier.md +1 -0
  55. package/src/skf-setup/references/report.md +1 -1
  56. package/src/skf-test-skill/references/coverage-check.md +11 -5
  57. package/src/skf-test-skill/references/score.md +7 -1
  58. package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
  59. package/src/skf-test-skill/scripts/compute-score.py +17 -4
  60. package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
  61. package/src/skf-update-skill/references/write.md +2 -2
  62. package/tools/cli/lib/installer.js +31 -1
  63. package/tools/validate-docs-links.js +665 -0
@@ -3,7 +3,8 @@ nextStepFile: 'step-auto-shard.md'
3
3
  # Resolve `{detectDocsHelper}` by probing `{detectDocsProbeOrder}` in order
4
4
  # (installed SKF module path first, src/ dev-checkout fallback); first existing
5
5
  # path wins. HALT if neither resolves — §2 has no prose fallback for doc-source
6
- # detection (Pages-API walk, docs/ folder scan, content hashing).
6
+ # detection (Pages-API walk, docs/ folder scan, content hashing), and §2a none
7
+ # for docs-only URL hashing.
7
8
  detectDocsProbeOrder:
8
9
  - '{project-root}/_bmad/skf/shared/scripts/skf-detect-docs.py'
9
10
  - '{project-root}/src/shared/scripts/skf-detect-docs.py'
@@ -15,7 +16,7 @@ detectDocsProbeOrder:
15
16
 
16
17
  ## STEP GOAL:
17
18
 
18
- Record detected documentation pages and README with content hashes in metadata.json so that downstream audit (analyze-skill) can detect when upstream docs have changed since the skill was compiled.
19
+ Record detected documentation pages and README — or, for a docs-only skill, the brief's documentation URLs — with content hashes in metadata.json so that downstream audit (analyze-skill) can detect when upstream docs have changed since the skill was compiled.
19
20
 
20
21
  ## Rules
21
22
 
@@ -35,7 +36,9 @@ Check if `doc_detection_results` is already populated in the workflow context (s
35
36
 
36
37
  ### 2. Run Doc Detection (if needed)
37
38
 
38
- Check that `{source_repo}` is available from the skill brief.
39
+ **If `source_type` is `"docs-only"`:** skip detection — `source_repo` is the documentation site, not a GitHub repository, so `{detectDocsHelper} --repo-url` would exit 2 with `INVALID_URL` and leave `doc_sources[]` empty. The brief's `doc_urls` are the doc sources; continue to §2a.
40
+
41
+ Otherwise check that `{source_repo}` is available from the skill brief.
39
42
 
40
43
  **If `{source_repo}` is not available:**
41
44
  - Set `doc_detection_results` to an empty array `[]`
@@ -62,8 +65,30 @@ Pass `--local-path {source_path}` when a local clone exists from the extraction
62
65
  - **Exit 1** (none found): set `doc_detection_results` to empty array `[]`
63
66
  - **Exit 2** (error): set `doc_detection_results` to empty array `[]`, add evidence note: `"Doc sources: detection partial — skf-detect-docs.py error (exit 2)"`
64
67
 
68
+ ### 2a. Docs-Only: Hash the Brief's Documentation URLs
69
+
70
+ Runs only when `source_type` is `"docs-only"` (§2 routed here). Build the URL list: every `doc_urls[].url` from the brief, in brief order, followed by every subpage URL that step 3c (fetch-docs) actually fetched through subpage discovery — the `[EXT:{url}]` provenance set of the doc-fetch inventory. Include a brief URL even if step 3c could not fetch it; the helper fetches independently of the agent's web tools.
71
+
72
+ **Resolve `{detectDocsHelper}`** from `{detectDocsProbeOrder}`; first existing path wins. HALT if no candidate exists.
73
+
74
+ Pipe the list to the `hash-urls` subcommand. It fetches each URL and hashes the raw response bytes with the same primitive `skf-audit-skill` step 5a (`compare-hashes`) uses, so a hash recorded here compares byte-for-byte at audit time. Do not hash the markdown a web-fetch tool rendered in step 3c — the audit re-fetches raw bytes, and a rendered-markdown hash would report every page as drifted.
75
+
76
+ ```bash
77
+ printf '%s' '["{url-1}", "{url-2}", "..."]' | uv run {detectDocsHelper} hash-urls -
78
+ ```
79
+
80
+ The input may also be the brief's `doc_urls` block verbatim — an array of `{url, label}` objects, or an object with a `doc_urls` array.
81
+
82
+ **Handle exit codes:**
83
+ - **Exit 0:** parse the JSON object. Set `doc_detection_results` ← its `doc_sources` array — entries are already in the §4 schema (`url`, `detected_via: "brief_doc_urls"`, `content_hash` — `null` for a URL that could not be fetched — and `recorded_at`). Keep `fetch_failed[]` and `stats` for the §5 evidence note.
84
+ - **Exit 2** (malformed input, unreadable file): set `doc_detection_results` to empty array `[]`, add evidence note: `"Doc sources: detection partial — skf-detect-docs.py hash-urls error (exit 2)"`
85
+
86
+ Skip §3 — a docs-only brief has no repository README to track. Continue at §4.
87
+
65
88
  ### 3. Ensure README Entry
66
89
 
90
+ **If `source_type` is `"docs-only"`:** skip this section — `source_repo` is the documentation site, not a repository, so there is no README to track and `{source_repo}/blob/main/README.md` would be a fabricated URL. Continue at §4.
91
+
67
92
  After obtaining detection results, check if any entry has a URL matching `*/README.md` or `*/readme.md`.
68
93
 
69
94
  **If a README entry already exists** from detection (e.g., `detected_via: "docs_folder"` found a README): keep it as-is.
@@ -99,6 +124,8 @@ Field mapping from `skf-detect-docs.py` output:
99
124
 
100
125
  Note: `content_type` from detect-docs output is not carried into `doc_sources`.
101
126
 
127
+ For a docs-only skill the `hash-urls` output from §2a is already in this shape (`detected_via: "brief_doc_urls"`, `recorded_at` stamped by the helper) — copy its `doc_sources` entries verbatim.
128
+
102
129
  ### 5. Update metadata.json
103
130
 
104
131
  Read the staging `_bmad-output/{skill-name}/metadata.json` that compile (step 5) wrote.
@@ -117,6 +144,7 @@ Add evidence note summarizing the result:
117
144
  - Success: `"Doc sources: {N} detected, README tracked"`
118
145
  - Skip: `"Doc sources: detection skipped — {reason}"`
119
146
  - Partial: `"Doc sources: detection partial — {N} found, {errors}"`
147
+ - Docs-only: `"Doc sources: {stats.hashed}/{stats.total} brief doc URL(s) hashed, {stats.fetch_failed} fetch failed"`
120
148
 
121
149
  ### 6. Auto-Proceed
122
150
 
@@ -28,6 +28,7 @@ Fetch remote documentation from brief-specified URLs using whatever web fetching
28
28
  - Tool-agnostic — use whatever web fetching capability is available
29
29
  - Do not halt the workflow if web fetching is unavailable or fails
30
30
  - Do not override existing T1, T1-low, or T2 extraction data with T3 content
31
+ - Never delete the staging directory of a `docs-only` skill — the fetched pages are its only source corpus
31
32
 
32
33
  ## MANDATORY SEQUENCE
33
34
 
@@ -173,7 +174,7 @@ This artifact is a **distinct** carrier — it is not merged into the extraction
173
174
 
174
175
  **If tier is Deep and at least one URL was fetched successfully:**
175
176
 
176
- 1. Write fetched markdown files to a staging directory: `_bmad-output/{skill-name}-docs/`
177
+ 1. Write fetched markdown files to a staging directory: `_bmad-output/{skill-name}-docs/` — clear any previous contents first, so a corpus retained from an earlier docs-only run (step 5 below) does not mix with this run's pages.
177
178
  2. Index into QMD with atomic replace + rollback: if a `{skill-name}-docs` collection already exists, run `qmd collection remove {skill-name}-docs` first, then `qmd collection add {project-root}/_bmad-output/{skill-name}-docs/ --name {skill-name}-docs --mask "*.md"`. **If `qmd collection add` fails after a successful `remove`:** remove any matching `{skill-name}-docs` entry from `forge-tier.yaml` → `qmd_collections[]` to keep the registry consistent with QMD's actual state, warn in evidence-report, and skip the embed — docs enrichment degrades gracefully.
178
179
  3. Generate embeddings scoped to this collection (only if step 2 `add` succeeded): `qmd embed --collection {skill-name}-docs` (required for semantic `type:'vec'` and HyDE `type:'hyde'` sub-queries within the QMD `query` tool). If the installed `qmd` CLI does not accept `--collection`, gate the embed behind a freshness check: skip re-embedding if the existing `{skill-name}-docs` registry entry is within 24 hours, and log the skip in the evidence report to prevent unbounded batch-mode re-embedding.
179
180
  4. Register in forge-tier.yaml `qmd_collections` array — **acquire an exclusive `flock` on `{sidecar_path}/forge-tier.yaml.lock` for the read-modify-write** (see the locking pattern documented in step 3b §4). Write via `python3 {atomicWriteHelper} write --target {sidecar_path}/forge-tier.yaml`. If `flock` is unavailable, fall back to read-CAS-by-mtime (capture `st_mtime` before, re-check after; refuse to clobber if a concurrent run wrote in between).
@@ -186,7 +187,7 @@ This artifact is a **distinct** carrier — it is not merged into the extraction
186
187
  created_at: "{current ISO date}"
187
188
  ```
188
189
 
189
- 5. Clean up staging directory after indexing: `rm -rf {project-root}/_bmad-output/{skill-name}-docs/`
190
+ 5. Clean up the staging directory after indexing — **only when `source_type` is `"source"`**: `rm -rf {project-root}/_bmad-output/{skill-name}-docs/`. Note that this directory is the source path of the `{skill-name}-docs` collection registered in step 4; removing it is accepted for supplemental docs, whose T3 items already live in the extraction inventory. **When `source_type` is `"docs-only"`, keep the directory.** The fetched pages are the skill's only source corpus — there is no code tree — so deleting them would leave the just-registered collection with nothing to refresh from and nothing to verify citations against. Record the retained path in the evidence report.
190
191
 
191
192
  **If QMD indexing fails:** Warn: "QMD indexing of fetched docs failed. T3 items are still in the extraction inventory — enrichment will proceed without QMD-indexed docs." Continue.
192
193
 
@@ -199,6 +200,7 @@ Display:
199
200
  **T3 items extracted:** {count}
200
201
  **Confidence:** All doc-fetched items are T3 — `[EXT:{url}]` citations applied.
201
202
  {If docs-only mode: '**Mode:** Docs-only — all skill content is T3. source_authority: community'}
203
+ {If docs-only mode AND tier is Deep: '**Docs corpus retained:** `_bmad-output/{skill-name}-docs/`{if the `{skill-name}-docs` collection was registered in §5b: ' — source path of QMD collection `{skill-name}-docs`'}'}
202
204
 
203
205
  Proceeding to enrichment..."
204
206
 
@@ -62,7 +62,7 @@ To validate the compiled SKILL.md content against the agentskills.io specificati
62
62
 
63
63
  **Used by:** §2 (`skill-check check --fix`), §4 (`split-body`), and any future tool invocation that may modify SKILL.md.
64
64
 
65
- Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — the calling sections (§2 and §4) invoke the helper at the capture and verify-restore points.
65
+ Load `{descriptionGuardProtocol}` for the full prose explanation of the four-phase guard (why it exists, what counts as divergence, why token-stream comparison is the right shape). The deterministic phases are executed via `{descriptionGuardHelper}` — the calling sections (§2 and §4) invoke the helper at the capture and verify-restore points. `verify-restore` refuses an empty `--captured-description` (exit 1, file untouched); when that happens, follow the protocol's empty-snapshot rule instead of re-running with the empty value.
66
66
 
67
67
  **This skill's post-restore re-validation hook:** after `{descriptionGuardHelper}` reports `restored: true`, resolve `{frontmatterValidator}` from `{frontmatterValidatorProbeOrder}` (first existing path wins), run `uv run {frontmatterValidator} <staging-skill-dir>/SKILL.md` and capture `schema_revalidation_result` in context. If the validator exits non-zero OR reports failure for the `description` field, flip the Schema result back to `FAIL` in the evidence report (overriding any prior PASS/WARN from §2), record `description_guard_revalidation: FAIL` with the validator's diagnostic message, and continue — do not halt (step 9 health-check and result contract still need to run so the failure is surfaced through the normal artifact path).
68
68
 
@@ -320,7 +320,7 @@ If the sink, the on-disk rows, and `headless_decisions[]` are all empty, keep th
320
320
  - `Original description preserved`: `true` if the restore succeeded (on-disk now matches the pre-tool snapshot), `false` if restoration itself failed (rare — treat as a halt condition in a future version).
321
321
  - `Notes`: a one-sentence description of what the tool had changed. Typical values: `"replaced with generic summary"`, `"truncated at N chars"`, `"angle-bracket tokens re-introduced"`, `"field deleted entirely"`. If `Restored: false`, use `—`.
322
322
 
323
- When `Restored: false`, the three follow-up fields are all `—` — this is the clean-run expected state.
323
+ When `Restored: false`, the three follow-up fields are all `—` — this is the clean-run expected state — with one exception: when `description_guard_refused == "empty-capture"` (the protocol's empty-snapshot rule — `verify-restore` exited 1 and no in-context copy allowed a re-run), the guard did fire and must not render as a clean run. Set `Restored: false`, `Triggering tool` to the recorded tool name, `Original description preserved: false`, and `Notes: guard refused — empty captured snapshot (empty-capture)`.
324
324
 
325
325
  ### 9. Auto-Proceed
326
326
 
@@ -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
 
@@ -25,6 +25,7 @@ For Quick and Forge tiers, or when ccc is unavailable, skip silently and proceed
25
25
  - Do not display skip messages for Quick/Forge tiers
26
26
  - Do not fail the workflow if ccc indexing fails
27
27
  - The script owns exclusion-pattern validation — do not reimplement it in prose
28
+ - Every branch that leaves this step binds all four of `ccc_index_result`, `ccc_indexed_path`, `ccc_last_indexed`, and `ccc_file_count` — step 2 interpolates each bare into the `write-tools` JSON payload, so an unbound flag would emit its literal placeholder and fail the forge-tier.yaml write
28
29
 
29
30
  ## MANDATORY SEQUENCE
30
31
 
@@ -32,9 +33,9 @@ For Quick and Forge tiers, or when ccc is unavailable, skip silently and proceed
32
33
 
33
34
  Read `{ccc}` and `{ccc_skip_index}` from context.
34
35
 
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.
36
+ **If `{ccc}` is false:** Set `{ccc_index_result: "none", ccc_indexed_path: null, ccc_last_indexed: null, ccc_file_count: 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
37
 
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".
38
+ **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, ccc_file_count: 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
39
 
39
40
  **If `{ccc}` is true AND `{ccc_skip_index}` is false:** Continue to section 2.
40
41
 
@@ -44,7 +45,7 @@ Consume prior CCC state from stage 1's detector output (`prior.*` context flags)
44
45
 
45
46
  Decide `{needs_reindex}` and `{ccc_index_result}` from `{ccc_index_fresh}`:
46
47
 
47
- - If `{ccc_index_fresh}` is true → index is fresh. Set `{needs_reindex: false}`, `{ccc_index_result: "fresh", ccc_indexed_path: {project-root}, ccc_last_indexed: {previous_ccc_last_indexed}}`. Exclusions still merge in section 3 (which may force a re-index).
48
+ - If `{ccc_index_fresh}` is true → index is fresh. Set `{needs_reindex: false}`, `{ccc_index_result: "fresh", ccc_indexed_path: {project-root}, ccc_last_indexed: {previous_ccc_last_indexed}, ccc_file_count: {previous_ccc_file_count}}` (the count carried over from the prior forge-tier.yaml by step 1 — nothing re-counts on this path). Exclusions still merge in section 3 (which may force a re-index).
48
49
  - If `{ccc_index_fresh}` is false (path mismatch, non-fresh status, stale timestamp, or any required prior CCC field null) → `{needs_reindex: true}`.
49
50
 
50
51
  ### 3. Merge SKF Exclusion Patterns
@@ -111,7 +112,7 @@ ccc index
111
112
 
112
113
  **If fails:**
113
114
 
114
- - Store `{ccc_index_result: "failed", ccc_indexed_path: null, ccc_last_indexed: null, ccc_indexing_failed_reason: {error}}` (the failed-reason flag flows into step 4's envelope warnings)
115
+ - Store `{ccc_index_result: "failed", ccc_indexed_path: null, ccc_last_indexed: null, ccc_file_count: null, ccc_indexing_failed_reason: {error}}` (the failed-reason flag flows into step 4's envelope warnings)
115
116
  - Display: "CCC indexing failed: {error}. Extraction will use direct AST scanning — semantic pre-ranking unavailable this session."
116
117
  - Continue — this is not a workflow error
117
118
 
@@ -100,6 +100,7 @@ From `prior` (populated by `--prior-state-from`; all fields null/empty on first
100
100
  - `{previous_ccc_indexed_path}` ← `prior.previous_ccc_indexed_path`
101
101
  - `{previous_ccc_last_indexed}` ← `prior.previous_ccc_last_indexed`
102
102
  - `{previous_ccc_staleness_threshold_hours}` ← `prior.previous_ccc_staleness_threshold_hours`
103
+ - `{previous_ccc_file_count}` ← `prior.previous_ccc_file_count` (integer or null; step 1b carries it forward on the fresh-index path so `forge-tier.yaml` keeps its `file_count` across re-runs that do not re-index)
103
104
  - `{ccc_index_fresh}` ← `prior.ccc_index_fresh` (boolean; the script's deterministic freshness verdict — prior index covers this project AND status was fresh/created AND `last_indexed` is within the staleness threshold of now. Step 1b branches on this directly instead of doing timestamp math.)
104
105
 
105
106
  From `deltas` (computed by the script from current tools + prior; eliminates LLM-side set arithmetic in the report banner):
@@ -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
 
@@ -123,7 +123,13 @@ Use these values for Section 4 (pass/fail/inconclusive) and Section 6 (output fo
123
123
 
124
124
  #### 3c. Fallback (if script execution fails)
125
125
 
126
- If the script is unavailable or errors, redistribute each skipped category's weight (the `null`-scored categories in the §3a JSON — naive mode already zeroes coherence, and Quick-tier/docsOnly/state2/stackSkill/referenceApp already null out Signature Accuracy + Type Coverage) proportionally across the active categories, then report `total = Σ(weight/100 × category_score)` using the detected mode's weight table in `{scoringRulesFile}`. Report: "**Note:** Scoring script unavailable — calculated manually per scoring-rules.md."
126
+ **First distinguish "the script could not run" from "the script rejected the input" — they take opposite paths.**
127
+
128
+ A `{"error": ..., "code": "INVALID_INPUT"}` envelope on stdout means the script ran fine and refused what it was given: a field is missing, mistyped, or out of range (exit 2), or the payload was not parseable JSON at all (exit 1). Either way the *input* is wrong, not the script. **Correct the §3a input and re-run.** Do not fall through to the manual redistribution below — it would hand-compute a total from the very numbers the script just refused, so an out-of-range `exportCoverage` would silently become a score. If the input cannot be corrected, report `score: scoring input rejected — {error}` and leave the score unset rather than emitting a computed total.
129
+
130
+ The fallback below applies only when the script genuinely could not run — missing file, no `uv`, unreadable interpreter — that is, **no envelope on stdout at all**.
131
+
132
+ If the script is unavailable, redistribute each skipped category's weight (the `null`-scored categories in the §3a JSON — naive mode already zeroes coherence, and Quick-tier/docsOnly/state2/stackSkill/referenceApp already null out Signature Accuracy + Type Coverage) proportionally across the active categories, then report `total = Σ(weight/100 × category_score)` using the detected mode's weight table in `{scoringRulesFile}`. Report: "**Note:** Scoring script unavailable — calculated manually per scoring-rules.md."
127
133
 
128
134
  ### 3d. Read Post-Score Caps (applied by the script)
129
135
 
@@ -62,10 +62,14 @@ CLI usage (mirrors compute-score.py):
62
62
  uv run aggregate-coherence.py --json-input '<JSON>' # explicit flag form
63
63
  cat input.json | uv run aggregate-coherence.py --stdin # piped input
64
64
 
65
- Exit codes:
66
- 0 — input was parsed; either a result object or a schema-validation error
67
- object ({"error": ..., "code": "INVALID_INPUT"}) was emitted as JSON
65
+ Exit codes (same convention as compute-score.py / reconcile-coverage.py):
66
+ 0 — a result object was emitted
68
67
  1 — input could not be parsed at all (no input provided, or malformed JSON)
68
+ 2 — input parsed but schema/semantics invalid
69
+
70
+ Both 1 and 2 emit an {"error": ..., "code": "INVALID_INPUT"} envelope on stdout,
71
+ so the envelope's presence — not the specific code — tells a caller the input was
72
+ refused rather than scored.
69
73
  """
70
74
 
71
75
  from __future__ import annotations
@@ -269,6 +273,12 @@ def main(argv: list[str] | None = None) -> int:
269
273
 
270
274
  result = aggregate_coherence(data)
271
275
  print(json.dumps(result, indent=2))
276
+ # Same convention as compute-score.py / reconcile-coverage.py. This script
277
+ # produces the coherence percentage that score.md §3a feeds to
278
+ # compute-score.py, so a silently-rejected run here would hand a bogus or
279
+ # absent number to the gate one layer downstream.
280
+ if isinstance(result, dict) and result.get("code") == "INVALID_INPUT":
281
+ return 2
272
282
  return 0
273
283
 
274
284