bmad-module-skill-forge 1.9.0 → 2.0.1
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/.nvmrc +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +4 -3
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +240 -4
- package/src/shared/scripts/skf-detect-language.py +82 -3
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +3 -3
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +13 -18
- package/src/skf-brief-skill/references/confirm-brief.md +2 -15
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +27 -24
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +6 -15
- package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
- package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +19 -13
- package/src/skf-campaign/SKILL.md +121 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +75 -0
- package/src/skf-campaign/manifest.yaml +15 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +121 -0
- package/src/skf-campaign/references/step-02-strategy.md +102 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +88 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +115 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +244 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +15 -4
- package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +37 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +20 -27
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +10 -8
- package/src/skf-create-skill/references/generate-artifacts.md +20 -25
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +27 -14
- package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +33 -15
- package/src/skf-create-skill/references/step-doc-sources.md +13 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +69 -29
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +67 -36
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +44 -10
- package/src/skf-drop-skill/references/select.md +93 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +32 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +16 -8
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +10 -8
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +14 -11
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +112 -82
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +21 -13
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +11 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +10 -8
- package/src/skf-setup/references/report.md +26 -32
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +111 -67
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +39 -21
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +15 -5
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +135 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +11 -6
- package/src/skf-update-skill/references/detect-changes.md +48 -36
- package/src/skf-update-skill/references/health-check.md +5 -11
- package/src/skf-update-skill/references/init.md +40 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +27 -25
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""SKF Co-mention Pairs — compute compose-mode co-mention pairs from an
|
|
7
|
+
architecture document with the same precision guards the prompt used to run
|
|
8
|
+
by hand.
|
|
9
|
+
|
|
10
|
+
`detect-integrations.md` §2 (compose branch) asks the LLM to scan an
|
|
11
|
+
architecture markdown document for skill-name co-mentions using three
|
|
12
|
+
deterministic guards:
|
|
13
|
+
|
|
14
|
+
1. Word-boundary matching (`\\b{skill_name}\\b`, case-insensitive) — no
|
|
15
|
+
substring false positives (`react` must not match inside `reactive`).
|
|
16
|
+
2. Section filtering — paragraphs governed by an H1/H2 header that
|
|
17
|
+
normalises to one of {introduction, overview, glossary, table of
|
|
18
|
+
contents, references, appendix, index} are excluded, and heading text
|
|
19
|
+
itself is never a co-mention source.
|
|
20
|
+
3. Two-paragraph minimum — a pair (A, B) qualifies only when at least two
|
|
21
|
+
distinct body paragraphs each contain word-boundary matches for both A
|
|
22
|
+
and B.
|
|
23
|
+
|
|
24
|
+
Given a fixed architecture doc and skill-name set the qualifying-pair set is
|
|
25
|
+
fully determined, so this belongs in a deterministic helper rather than
|
|
26
|
+
in-prompt regex/counting. It mirrors the co-import subprocess the code-mode
|
|
27
|
+
branch already delegates and the `skf-pair-intersect.py` pre-pass. The LLM
|
|
28
|
+
still does the semantic composition (integration-section authoring, tier
|
|
29
|
+
inheritance, VS-verdict parsing) — the script only emits the qualifying pairs
|
|
30
|
+
and their evidence paragraphs.
|
|
31
|
+
|
|
32
|
+
Subcommand:
|
|
33
|
+
comention --doc <md-file-or-'-'> --skills <json-file-or-'-'>
|
|
34
|
+
--doc path to the architecture markdown document, or '-' for stdin.
|
|
35
|
+
--skills path to a JSON array of loaded skill names
|
|
36
|
+
(e.g. `["react", "express"]`), or '-' for stdin.
|
|
37
|
+
(--doc and --skills cannot both read from stdin.)
|
|
38
|
+
|
|
39
|
+
Emit JSON:
|
|
40
|
+
{
|
|
41
|
+
"pairs": [
|
|
42
|
+
{
|
|
43
|
+
"a": "<skill>", "b": "<skill>",
|
|
44
|
+
"paragraph_count": N,
|
|
45
|
+
"evidence": [{"header": "<governing-header-or-null>",
|
|
46
|
+
"excerpt": "<collapsed paragraph text>"}, ...]
|
|
47
|
+
},
|
|
48
|
+
...
|
|
49
|
+
],
|
|
50
|
+
"excluded_section_count": M
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Only unordered pairs (a < b lexicographically) with paragraph_count >= 2
|
|
54
|
+
are emitted. `pairs[]` is sorted by paragraph_count DESC, then (a, b)
|
|
55
|
+
ASC — stable, mirroring skf-pair-intersect.py. `evidence[]` is in
|
|
56
|
+
document order. `excluded_section_count` is the number of DISTINCT
|
|
57
|
+
excluded H1/H2 sections (by normalised header) that governed at least
|
|
58
|
+
one body paragraph — a diagnostic for the caller, not a gate.
|
|
59
|
+
|
|
60
|
+
CLI examples:
|
|
61
|
+
uv run skf-comention-pairs.py comention --doc arch.md --skills skills.json
|
|
62
|
+
echo '["react","express"]' | uv run skf-comention-pairs.py comention \\
|
|
63
|
+
--doc arch.md --skills -
|
|
64
|
+
|
|
65
|
+
Exit codes:
|
|
66
|
+
0 — operation succeeded (including: no qualifying pairs → empty list)
|
|
67
|
+
1 — user error (bad JSON, missing/unreadable file, both inputs on stdin,
|
|
68
|
+
malformed skills array)
|
|
69
|
+
2 — unexpected internal error
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import argparse
|
|
75
|
+
import json
|
|
76
|
+
import re
|
|
77
|
+
import sys
|
|
78
|
+
from pathlib import Path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Closed exclusion set — governing H1/H2 headers that normalise to one of
|
|
82
|
+
# these drop their paragraphs from co-mention analysis (they typically
|
|
83
|
+
# enumerate all libraries without describing an integration).
|
|
84
|
+
EXCLUDED_HEADERS = frozenset({
|
|
85
|
+
"introduction",
|
|
86
|
+
"overview",
|
|
87
|
+
"glossary",
|
|
88
|
+
"table of contents",
|
|
89
|
+
"references",
|
|
90
|
+
"appendix",
|
|
91
|
+
"index",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
# ATX header line: 1-6 leading hashes followed by at least one space/tab.
|
|
95
|
+
# A line like `#foo` (no space) is NOT a header per CommonMark and is treated
|
|
96
|
+
# as body text. Capture group 1 = hashes (level), group 2 = header text.
|
|
97
|
+
_HEADER_RE = re.compile(r"^(#{1,6})[ \t]+(.*)$")
|
|
98
|
+
|
|
99
|
+
_EXCERPT_LIMIT = 240
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class UserError(Exception):
|
|
103
|
+
"""A caller-facing input error → exit code 1."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------
|
|
107
|
+
# Markdown parsing
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _strip_closing_hashes(text: str) -> str:
|
|
112
|
+
"""Remove a closed-ATX trailing run of hashes (e.g. `Overview ##`)."""
|
|
113
|
+
return re.sub(r"[ \t]*#+[ \t]*$", "", text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def normalize_header(text: str) -> str:
|
|
117
|
+
"""Normalise a header for exclusion-set comparison: lowercase, collapse
|
|
118
|
+
internal whitespace, strip trailing punctuation."""
|
|
119
|
+
t = text.strip().lower()
|
|
120
|
+
t = re.sub(r"\s+", " ", t).strip()
|
|
121
|
+
t = t.rstrip(".,:;!?").strip()
|
|
122
|
+
return t
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_body_paragraphs(doc_text: str) -> list[tuple[str | None, str]]:
|
|
126
|
+
"""Split the document into body paragraphs, each tagged with the raw text
|
|
127
|
+
of its governing (nearest-preceding) H1/H2 header (or None).
|
|
128
|
+
|
|
129
|
+
Paragraphs are maximal runs of consecutive non-blank, non-header lines,
|
|
130
|
+
separated by blank lines. Header lines of ANY level are never emitted as
|
|
131
|
+
paragraphs (headings are not a co-mention source). Only H1/H2 headers
|
|
132
|
+
update the governing header; H3-H6 leave it unchanged.
|
|
133
|
+
"""
|
|
134
|
+
paragraphs: list[tuple[str | None, str]] = []
|
|
135
|
+
governing: str | None = None
|
|
136
|
+
buf: list[str] = []
|
|
137
|
+
|
|
138
|
+
def _flush() -> None:
|
|
139
|
+
if buf:
|
|
140
|
+
text = "\n".join(buf).strip()
|
|
141
|
+
if text:
|
|
142
|
+
paragraphs.append((governing, text))
|
|
143
|
+
buf.clear()
|
|
144
|
+
|
|
145
|
+
for raw_line in doc_text.splitlines():
|
|
146
|
+
m = _HEADER_RE.match(raw_line)
|
|
147
|
+
if m is not None:
|
|
148
|
+
# A header terminates the current paragraph; the header line
|
|
149
|
+
# itself is not body content.
|
|
150
|
+
_flush()
|
|
151
|
+
level = len(m.group(1))
|
|
152
|
+
header_text = _strip_closing_hashes(m.group(2)).strip()
|
|
153
|
+
if level <= 2:
|
|
154
|
+
governing = header_text if header_text else None
|
|
155
|
+
continue
|
|
156
|
+
if raw_line.strip() == "":
|
|
157
|
+
_flush()
|
|
158
|
+
continue
|
|
159
|
+
buf.append(raw_line)
|
|
160
|
+
_flush()
|
|
161
|
+
return paragraphs
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
# Co-mention analysis
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _make_excerpt(text: str, limit: int = _EXCERPT_LIMIT) -> str:
|
|
170
|
+
"""Collapse whitespace and truncate for a stable, compact evidence quote."""
|
|
171
|
+
collapsed = re.sub(r"\s+", " ", text).strip()
|
|
172
|
+
if len(collapsed) > limit:
|
|
173
|
+
return collapsed[: limit - 3].rstrip() + "..."
|
|
174
|
+
return collapsed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def analyze(doc_text: str, skill_names: list[str]) -> dict:
|
|
178
|
+
"""Compute qualifying co-mention pairs and their evidence.
|
|
179
|
+
|
|
180
|
+
Deterministic: same (doc_text, skill_names) → identical output.
|
|
181
|
+
"""
|
|
182
|
+
names = sorted(set(skill_names))
|
|
183
|
+
patterns = {
|
|
184
|
+
n: re.compile(r"\b" + re.escape(n) + r"\b", re.IGNORECASE)
|
|
185
|
+
for n in names
|
|
186
|
+
}
|
|
187
|
+
paragraphs = parse_body_paragraphs(doc_text)
|
|
188
|
+
|
|
189
|
+
excluded_sections: set[str] = set()
|
|
190
|
+
# (a, b) -> list of evidence dicts, one per qualifying paragraph
|
|
191
|
+
pair_evidence: dict[tuple[str, str], list[dict]] = {}
|
|
192
|
+
|
|
193
|
+
for governing, text in paragraphs:
|
|
194
|
+
if governing is not None:
|
|
195
|
+
norm = normalize_header(governing)
|
|
196
|
+
if norm in EXCLUDED_HEADERS:
|
|
197
|
+
excluded_sections.add(norm)
|
|
198
|
+
continue
|
|
199
|
+
# names is sorted, so `present` is sorted → pairs are (a < b)
|
|
200
|
+
present = [n for n in names if patterns[n].search(text)]
|
|
201
|
+
if len(present) < 2:
|
|
202
|
+
continue
|
|
203
|
+
excerpt = _make_excerpt(text)
|
|
204
|
+
evidence_entry = {"header": governing, "excerpt": excerpt}
|
|
205
|
+
for i in range(len(present)):
|
|
206
|
+
for j in range(i + 1, len(present)):
|
|
207
|
+
key = (present[i], present[j])
|
|
208
|
+
pair_evidence.setdefault(key, []).append(evidence_entry)
|
|
209
|
+
|
|
210
|
+
pairs: list[dict] = []
|
|
211
|
+
for (a, b), evidence in pair_evidence.items():
|
|
212
|
+
if len(evidence) >= 2:
|
|
213
|
+
pairs.append({
|
|
214
|
+
"a": a,
|
|
215
|
+
"b": b,
|
|
216
|
+
"paragraph_count": len(evidence),
|
|
217
|
+
"evidence": evidence,
|
|
218
|
+
})
|
|
219
|
+
pairs.sort(key=lambda p: (-p["paragraph_count"], p["a"], p["b"]))
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"pairs": pairs,
|
|
223
|
+
"excluded_section_count": len(excluded_sections),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------
|
|
228
|
+
# CLI
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _read_source(source: str, label: str) -> str:
|
|
233
|
+
"""Read text from a file path or stdin (if source == '-')."""
|
|
234
|
+
if source == "-":
|
|
235
|
+
try:
|
|
236
|
+
return sys.stdin.read()
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
raise UserError(f"failed to read {label} from stdin: {exc}") from exc
|
|
239
|
+
path = Path(source)
|
|
240
|
+
if not path.is_file():
|
|
241
|
+
raise UserError(f"{label} file not found: {path}")
|
|
242
|
+
try:
|
|
243
|
+
return path.read_text(encoding="utf-8")
|
|
244
|
+
except OSError as exc:
|
|
245
|
+
raise UserError(f"failed to read {label} file {path}: {exc}") from exc
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_skills(raw_text: str) -> list[str]:
|
|
249
|
+
"""Parse and validate the --skills JSON array of non-empty strings."""
|
|
250
|
+
try:
|
|
251
|
+
data = json.loads(raw_text)
|
|
252
|
+
except json.JSONDecodeError as exc:
|
|
253
|
+
raise UserError(f"malformed JSON in --skills input: {exc}") from exc
|
|
254
|
+
if not isinstance(data, list):
|
|
255
|
+
raise UserError(
|
|
256
|
+
"--skills input must be a JSON array of strings; "
|
|
257
|
+
f"got {type(data).__name__}"
|
|
258
|
+
)
|
|
259
|
+
names: list[str] = []
|
|
260
|
+
for idx, item in enumerate(data):
|
|
261
|
+
if not isinstance(item, str) or not item:
|
|
262
|
+
raise UserError(
|
|
263
|
+
f"--skills[{idx}] must be a non-empty string; got {item!r}"
|
|
264
|
+
)
|
|
265
|
+
names.append(item)
|
|
266
|
+
return names
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _cmd_comention(args: argparse.Namespace) -> int:
|
|
270
|
+
if args.doc == "-" and args.skills == "-":
|
|
271
|
+
raise UserError(
|
|
272
|
+
"--doc and --skills cannot both read from stdin ('-')"
|
|
273
|
+
)
|
|
274
|
+
# Read the file-backed input first so a single stdin source stays intact.
|
|
275
|
+
if args.doc == "-":
|
|
276
|
+
skills_text = _read_source(args.skills, "--skills")
|
|
277
|
+
doc_text = _read_source(args.doc, "--doc")
|
|
278
|
+
else:
|
|
279
|
+
doc_text = _read_source(args.doc, "--doc")
|
|
280
|
+
skills_text = _read_source(args.skills, "--skills")
|
|
281
|
+
skill_names = parse_skills(skills_text)
|
|
282
|
+
|
|
283
|
+
result = analyze(doc_text, skill_names)
|
|
284
|
+
if args.verbose:
|
|
285
|
+
print(
|
|
286
|
+
f"analyzed {len(set(skill_names))} distinct skill names; "
|
|
287
|
+
f"{len(result['pairs'])} qualifying pairs; "
|
|
288
|
+
f"{result['excluded_section_count']} excluded sections",
|
|
289
|
+
file=sys.stderr,
|
|
290
|
+
)
|
|
291
|
+
json.dump(result, sys.stdout, indent=2)
|
|
292
|
+
sys.stdout.write("\n")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
297
|
+
parser = argparse.ArgumentParser(
|
|
298
|
+
prog="skf-comention-pairs",
|
|
299
|
+
description=(
|
|
300
|
+
"Compute compose-mode co-mention pairs from an architecture "
|
|
301
|
+
"document (detect-integrations.md §2 compose branch)."
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
305
|
+
|
|
306
|
+
p_cm = sub.add_parser(
|
|
307
|
+
"comention",
|
|
308
|
+
help=(
|
|
309
|
+
"emit qualifying co-mention pairs (>=2 body paragraphs, "
|
|
310
|
+
"word-boundary, section-filtered) as JSON"
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
p_cm.add_argument(
|
|
314
|
+
"--doc",
|
|
315
|
+
required=True,
|
|
316
|
+
help="path to the architecture markdown document, or '-' for stdin",
|
|
317
|
+
)
|
|
318
|
+
p_cm.add_argument(
|
|
319
|
+
"--skills",
|
|
320
|
+
required=True,
|
|
321
|
+
help=(
|
|
322
|
+
"path to a JSON array of loaded skill names, or '-' for stdin. "
|
|
323
|
+
"Shape: [\"<skill>\", ...]"
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
p_cm.add_argument(
|
|
327
|
+
"--verbose",
|
|
328
|
+
action="store_true",
|
|
329
|
+
help="print a one-line summary to stderr",
|
|
330
|
+
)
|
|
331
|
+
p_cm.set_defaults(func=_cmd_comention)
|
|
332
|
+
|
|
333
|
+
return parser
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main(argv: list[str] | None = None) -> int:
|
|
337
|
+
parser = _build_parser()
|
|
338
|
+
args = parser.parse_args(argv)
|
|
339
|
+
try:
|
|
340
|
+
return args.func(args)
|
|
341
|
+
except UserError as exc:
|
|
342
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
343
|
+
return 1
|
|
344
|
+
except Exception as exc: # noqa: BLE001 — last-resort guard → exit 2
|
|
345
|
+
print(f"internal error: {exc}", file=sys.stderr)
|
|
346
|
+
return 2
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Count Tokens — Deterministic per-artifact token/word metrics for a skill package.
|
|
6
|
+
|
|
7
|
+
Pre-pass that measures the exported artifacts of a single skill package and the
|
|
8
|
+
shared managed section, so the export skill's token report (references/token-report.md)
|
|
9
|
+
and snippet token check (references/generate-snippet.md §4) render exact numbers from
|
|
10
|
+
JSON instead of re-reading every reference file to word-count it in-prompt.
|
|
11
|
+
|
|
12
|
+
Metrics:
|
|
13
|
+
- words: whitespace-split word count (`len(text.split())`) — the report's "Words" column
|
|
14
|
+
- tokens: char-over-four estimate (`len(text) // 4`) — the SKF-wide convention already
|
|
15
|
+
used by skf-validate-output.py, so every SKF token number agrees
|
|
16
|
+
|
|
17
|
+
Measured artifacts (all relative to the positional skill-package dir):
|
|
18
|
+
- context-snippet.md — the compressed passive-context snippet (if present)
|
|
19
|
+
- SKILL.md — the active skill document
|
|
20
|
+
- metadata.json — machine-readable metadata
|
|
21
|
+
- references/* — every file under references/ (recursively), summed as references_total
|
|
22
|
+
- managed section — the complete <!-- SKF:BEGIN ... --> ... <!-- SKF:END --> block,
|
|
23
|
+
extracted from the first --target-file that contains one
|
|
24
|
+
(shared across the batch; measured once)
|
|
25
|
+
|
|
26
|
+
package_total sums context-snippet.md + SKILL.md + metadata.json + references_total and
|
|
27
|
+
DELIBERATELY EXCLUDES the managed-section row — the managed section is a shared all-skills
|
|
28
|
+
cost reported separately, and including it would double-count this skill's own snippet
|
|
29
|
+
(see references/token-report.md §1).
|
|
30
|
+
|
|
31
|
+
CLI:
|
|
32
|
+
python3 skf-count-tokens.py <skill-package-dir>
|
|
33
|
+
python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md
|
|
34
|
+
python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md --target-file AGENTS.md
|
|
35
|
+
python3 skf-count-tokens.py <skill-package-dir> -o report.json
|
|
36
|
+
|
|
37
|
+
Exit codes: 0 = metrics emitted, 2 = error (skill package dir missing / not a directory).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
# Mirrors skf-rebuild-managed-sections.py: a BEGIN marker may carry attributes
|
|
49
|
+
# (e.g. `updated:2026-07-13`) before its closing `-->`; the END marker is fixed.
|
|
50
|
+
MANAGED_SECTION_RE = re.compile(
|
|
51
|
+
r"<!-- SKF:BEGIN[^>]*-->.*?<!-- SKF:END -->",
|
|
52
|
+
re.DOTALL,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Core per-skill artifacts, in report order. (references/ is handled separately.)
|
|
56
|
+
CORE_ARTIFACTS = [
|
|
57
|
+
("context-snippet.md", "context-snippet"),
|
|
58
|
+
("SKILL.md", "skill-md"),
|
|
59
|
+
("metadata.json", "metadata"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def estimate_tokens(text: str) -> int:
|
|
64
|
+
"""Char-over-four token estimate — the SKF-wide convention (see skf-validate-output.py)."""
|
|
65
|
+
return len(text) // 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_words(text: str) -> int:
|
|
69
|
+
"""Whitespace-split word count — the token report's 'Words' column."""
|
|
70
|
+
return len(text.split())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def measure(text: str) -> dict:
|
|
74
|
+
"""Return the {words, tokens} metrics for a piece of text."""
|
|
75
|
+
return {"words": count_words(text), "tokens": estimate_tokens(text)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_text(path: Path) -> str:
|
|
79
|
+
# encoding is pinned to utf-8 so non-ASCII content is not mojibaked on
|
|
80
|
+
# Windows (cp1252 default).
|
|
81
|
+
return path.read_text(encoding="utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_managed_section(text: str) -> str | None:
|
|
85
|
+
"""Return the complete BEGIN..END managed-section span (markers included), or None."""
|
|
86
|
+
m = MANAGED_SECTION_RE.search(text)
|
|
87
|
+
return m.group(0) if m else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def measure_reference_files(references_dir: Path) -> tuple[list[dict], dict]:
|
|
91
|
+
"""Measure every file under references/ (recursively, sorted). Returns (per-file rows, total)."""
|
|
92
|
+
rows: list[dict] = []
|
|
93
|
+
total_words = 0
|
|
94
|
+
total_tokens = 0
|
|
95
|
+
if references_dir.is_dir():
|
|
96
|
+
for path in sorted(references_dir.rglob("*"), key=lambda p: p.as_posix()):
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
continue
|
|
99
|
+
text = _read_text(path)
|
|
100
|
+
metrics = measure(text)
|
|
101
|
+
rows.append(
|
|
102
|
+
{
|
|
103
|
+
"path": path.relative_to(references_dir.parent).as_posix(),
|
|
104
|
+
"role": "reference",
|
|
105
|
+
"exists": True,
|
|
106
|
+
**metrics,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
total_words += metrics["words"]
|
|
110
|
+
total_tokens += metrics["tokens"]
|
|
111
|
+
total = {"count": len(rows), "words": total_words, "tokens": total_tokens}
|
|
112
|
+
return rows, total
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> dict:
|
|
116
|
+
"""Measure a skill package's artifacts and the shared managed section.
|
|
117
|
+
|
|
118
|
+
Returns the metrics dict (see module docstring for the shape). Missing core
|
|
119
|
+
artifacts are reported with exists=False and zero metrics so package_total is
|
|
120
|
+
always well-defined and callers can render 'N/A' rows without guessing.
|
|
121
|
+
"""
|
|
122
|
+
skill_dir = Path(skill_dir)
|
|
123
|
+
target_files = target_files or []
|
|
124
|
+
|
|
125
|
+
files: list[dict] = []
|
|
126
|
+
|
|
127
|
+
# Core per-skill artifacts.
|
|
128
|
+
for name, role in CORE_ARTIFACTS:
|
|
129
|
+
path = skill_dir / name
|
|
130
|
+
if path.is_file():
|
|
131
|
+
metrics = measure(_read_text(path))
|
|
132
|
+
files.append({"path": name, "role": role, "exists": True, **metrics})
|
|
133
|
+
else:
|
|
134
|
+
files.append({"path": name, "role": role, "exists": False, "words": 0, "tokens": 0})
|
|
135
|
+
|
|
136
|
+
# references/ — per-file rows + summed total.
|
|
137
|
+
ref_rows, references_total = measure_reference_files(skill_dir / "references")
|
|
138
|
+
files.extend(ref_rows)
|
|
139
|
+
|
|
140
|
+
# package_total = snippet + SKILL.md + metadata + references_total; EXCLUDES managed section.
|
|
141
|
+
core_by_role = {row["role"]: row for row in files if row["role"] in {"context-snippet", "skill-md", "metadata"}}
|
|
142
|
+
package_total = {
|
|
143
|
+
"words": sum(core_by_role[r]["words"] for r in core_by_role) + references_total["words"],
|
|
144
|
+
"tokens": sum(core_by_role[r]["tokens"] for r in core_by_role) + references_total["tokens"],
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Managed section — first --target-file that actually contains a block wins.
|
|
148
|
+
managed_section = {"present": False, "source_file": None, "words": 0, "tokens": 0}
|
|
149
|
+
for tf in target_files:
|
|
150
|
+
tf = Path(tf)
|
|
151
|
+
if not tf.is_file():
|
|
152
|
+
continue
|
|
153
|
+
section = extract_managed_section(_read_text(tf))
|
|
154
|
+
if section is not None:
|
|
155
|
+
managed_section = {
|
|
156
|
+
"present": True,
|
|
157
|
+
"source_file": tf.as_posix(),
|
|
158
|
+
**measure(section),
|
|
159
|
+
}
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"status": "ok",
|
|
164
|
+
"skill_package": skill_dir.as_posix(),
|
|
165
|
+
"files": files,
|
|
166
|
+
"references_total": references_total,
|
|
167
|
+
"managed_section": managed_section,
|
|
168
|
+
"package_total": package_total,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv: list[str] | None = None) -> int:
|
|
173
|
+
parser = argparse.ArgumentParser(
|
|
174
|
+
prog="skf-count-tokens.py",
|
|
175
|
+
description=(
|
|
176
|
+
"Deterministic per-artifact word/token metrics for a skill package. Emits JSON "
|
|
177
|
+
"with per-file rows, references_total, the shared managed-section block, and a "
|
|
178
|
+
"package_total that excludes the managed section (see references/token-report.md §1). "
|
|
179
|
+
"tokens = len(text)//4 (SKF-wide convention); words = whitespace-split count."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"skill_package",
|
|
184
|
+
help="Resolved skill-package directory (holds context-snippet.md, SKILL.md, metadata.json, references/)",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--target-file",
|
|
188
|
+
action="append",
|
|
189
|
+
default=[],
|
|
190
|
+
metavar="PATH",
|
|
191
|
+
help="Context file (CLAUDE.md / AGENTS.md / .cursorrules) to extract the managed "
|
|
192
|
+
"section from; repeatable — the first file with a BEGIN..END block is measured.",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("-o", "--output", metavar="PATH", help="Write JSON here (default: stdout)")
|
|
195
|
+
parser.add_argument("--verbose", action="store_true", help="Emit diagnostics to stderr")
|
|
196
|
+
args = parser.parse_args(argv)
|
|
197
|
+
|
|
198
|
+
skill_dir = Path(args.skill_package)
|
|
199
|
+
if not skill_dir.is_dir():
|
|
200
|
+
print(f"error: skill package directory not found: {skill_dir}", file=sys.stderr)
|
|
201
|
+
return 2
|
|
202
|
+
|
|
203
|
+
if args.verbose:
|
|
204
|
+
print(f"measuring skill package: {skill_dir}", file=sys.stderr)
|
|
205
|
+
if args.target_file:
|
|
206
|
+
print(f"managed-section candidates: {args.target_file}", file=sys.stderr)
|
|
207
|
+
|
|
208
|
+
result = count_package(skill_dir, [Path(p) for p in args.target_file])
|
|
209
|
+
|
|
210
|
+
payload = json.dumps(result, indent=2)
|
|
211
|
+
if args.output:
|
|
212
|
+
Path(args.output).write_text(payload + "\n", encoding="utf-8")
|
|
213
|
+
if args.verbose:
|
|
214
|
+
print(f"wrote {args.output}", file=sys.stderr)
|
|
215
|
+
else:
|
|
216
|
+
print(payload)
|
|
217
|
+
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Derive Assembly Shape — decide whether a brief is a whole-language reference.
|
|
6
|
+
|
|
7
|
+
create-skill's SKILL.md assembly is mostly LLM-driven prose, but the BRANCH
|
|
8
|
+
SELECTION — does this skill get the whole-language "Language Guide" treatment
|
|
9
|
+
(prose foregrounded, compiler internals demoted) or the standard
|
|
10
|
+
library-export layout? — must be deterministic. This helper computes that one
|
|
11
|
+
decision from the brief so the assembler (and the doc-fetch step) branch on a
|
|
12
|
+
machine value instead of grepping free-text `scope.notes`.
|
|
13
|
+
|
|
14
|
+
The gate is a single, structured, schema-validated condition: the brief's
|
|
15
|
+
`doc_urls` contains at least one entry with `source: "language-registry"`
|
|
16
|
+
(issue #432). Registry-sourced corpora are stamped ONLY by
|
|
17
|
+
skf-language-corpora.py, which is invoked ONLY for a whole-language reference
|
|
18
|
+
(a grammar_file:/tree_triad: signal) in step-auto-scope.md §6b. So the gate
|
|
19
|
+
fires exactly when there is registry-guaranteed prose to foreground, and never
|
|
20
|
+
for an ordinary library, a parser library (pest), a component-library, a
|
|
21
|
+
reference-app, or a docs-only brief — none of which carry a language-registry
|
|
22
|
+
entry. A registry-miss (N==0) whole-language reference carries no such entry
|
|
23
|
+
and is intentionally NOT gated: there is no canonical prose to foreground, so
|
|
24
|
+
it keeps the standard layout (and its scope.notes DEGRADED caveat stands).
|
|
25
|
+
|
|
26
|
+
Gating on the structured `source` field — not the operator-editable
|
|
27
|
+
`scope.notes` substring — means a hand-edited or interactive brief cannot
|
|
28
|
+
silently disable (or misfire) the override.
|
|
29
|
+
|
|
30
|
+
CLI:
|
|
31
|
+
uv run skf-derive-assembly-shape.py <path/to/skill-brief.yaml>
|
|
32
|
+
cat brief.yaml | uv run skf-derive-assembly-shape.py -
|
|
33
|
+
|
|
34
|
+
Output (JSON on stdout):
|
|
35
|
+
{
|
|
36
|
+
"assembly_shape": "whole-language-reference" | "standard",
|
|
37
|
+
"gate_signal": "language-registry" | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Exit codes:
|
|
41
|
+
0 success (shape emitted)
|
|
42
|
+
2 error (missing/unreadable/invalid brief)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import sys
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import yaml
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def derive_assembly_shape(brief: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Return the assembly-shape decision for a parsed brief."""
|
|
58
|
+
doc_urls = brief.get("doc_urls")
|
|
59
|
+
if isinstance(doc_urls, list):
|
|
60
|
+
for entry in doc_urls:
|
|
61
|
+
if isinstance(entry, dict) and entry.get("source") == "language-registry":
|
|
62
|
+
return {
|
|
63
|
+
"assembly_shape": "whole-language-reference",
|
|
64
|
+
"gate_signal": "language-registry",
|
|
65
|
+
}
|
|
66
|
+
return {"assembly_shape": "standard", "gate_signal": None}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _die(message: str) -> None:
|
|
70
|
+
json.dump({"error": message}, sys.stderr)
|
|
71
|
+
sys.stderr.write("\n")
|
|
72
|
+
sys.exit(2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="skf-derive-assembly-shape",
|
|
78
|
+
description="Decide whether a skill brief is a whole-language reference.",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"path",
|
|
82
|
+
help="path to skill-brief.yaml; pass `-` to read YAML from stdin",
|
|
83
|
+
)
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
|
|
86
|
+
if args.path == "-":
|
|
87
|
+
text = sys.stdin.read()
|
|
88
|
+
else:
|
|
89
|
+
p = Path(args.path)
|
|
90
|
+
if not p.is_file():
|
|
91
|
+
_die(f"Brief not found at {p.as_posix()}")
|
|
92
|
+
text = p.read_text(encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
brief = yaml.safe_load(text)
|
|
96
|
+
except yaml.YAMLError as exc:
|
|
97
|
+
_die(f"Brief is not valid YAML: {exc}")
|
|
98
|
+
if not isinstance(brief, dict):
|
|
99
|
+
_die("Brief must be a YAML mapping")
|
|
100
|
+
|
|
101
|
+
json.dump(derive_assembly_shape(brief), sys.stdout)
|
|
102
|
+
sys.stdout.write("\n")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|