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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -1
- package/docs/404.md +4 -4
- package/docs/_data/pinned.yaml +1 -1
- package/docs/_internal/RELEASING.md +41 -4
- package/docs/agents.md +2 -2
- package/docs/architecture.md +1 -1
- package/docs/bmad-synergy.md +13 -13
- package/docs/campaign.md +3 -3
- package/docs/concepts.md +3 -3
- package/docs/examples.md +9 -9
- package/docs/forge-auto.md +6 -6
- package/docs/getting-started.md +12 -12
- package/docs/how-it-works.md +7 -7
- package/docs/index.md +7 -1
- package/docs/skill-model.md +2 -2
- package/docs/troubleshooting.md +7 -7
- package/docs/why-skf.md +8 -8
- package/docs/workflows.md +5 -5
- package/package.json +7 -3
- package/src/shared/references/description-guard-protocol.md +2 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -0
- package/src/shared/scripts/skf-description-guard.py +22 -2
- package/src/shared/scripts/skf-detect-docs.py +198 -2
- package/src/shared/scripts/skf-detect-language.py +20 -0
- package/src/shared/scripts/skf-detect-tools.py +40 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +20 -0
- package/src/shared/scripts/skf-language-corpora.py +20 -0
- package/src/shared/scripts/skf-merge-doc-urls.py +20 -0
- package/src/shared/scripts/skf-qmd-classify-collections.py +44 -2
- package/src/shared/scripts/skf-recommend-scope-type.py +20 -0
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-shape-detect.py +20 -0
- package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
- package/src/skf-analyze-source/references/recommend.md +1 -1
- package/src/skf-audit-skill/references/re-index.md +1 -1
- package/src/skf-brief-skill/assets/description-voice-examples.md +5 -3
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/analyze-target.md +2 -2
- package/src/skf-brief-skill/references/gather-intent.md +4 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +3 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -2
- package/src/skf-create-skill/assets/skill-sections.md +3 -2
- package/src/skf-create-skill/references/compile.md +12 -3
- package/src/skf-create-skill/references/extract.md +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +5 -3
- package/src/skf-create-skill/references/step-doc-rot.md +23 -6
- package/src/skf-create-skill/references/step-doc-sources.md +31 -3
- package/src/skf-create-skill/references/sub/fetch-docs.md +4 -2
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-create-skill/scripts/scan-doc-rot.py +191 -27
- package/src/skf-create-stack-skill/references/detect-integrations.md +1 -1
- package/src/skf-setup/references/ccc-index.md +5 -4
- package/src/skf-setup/references/detect-and-tier.md +1 -0
- package/src/skf-setup/references/report.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +11 -5
- package/src/skf-test-skill/references/score.md +7 -1
- package/src/skf-test-skill/scripts/aggregate-coherence.py +13 -3
- package/src/skf-test-skill/scripts/compute-score.py +17 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +36 -9
- package/src/skf-update-skill/references/write.md +2 -2
- package/tools/cli/lib/installer.js +31 -1
- 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
|
-
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
123
|
-
if _MIGRATION_HEADING.match(
|
|
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(
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
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 &
|
|
226
|
-
"section
|
|
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}"
|
|
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
|
|
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
|
|
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}}
|
|
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 (
|
|
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
|
-
|
|
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 —
|
|
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
|
|