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
|
@@ -19,6 +19,17 @@ Two patterns recur across the update-skill workflow's stage prose:
|
|
|
19
19
|
iterated, plus a stat-and-compare. NEW_FILE detection is intentionally
|
|
20
20
|
out of scope here — that lives in `skf-detect-scripts-assets.py`.
|
|
21
21
|
|
|
22
|
+
3. **[MANUAL]-section integrity** — the update-skill workflow's headline
|
|
23
|
+
Workflow Rule is "[MANUAL] sections survive regeneration with zero
|
|
24
|
+
content loss." init.md §5 captures a pre-write inventory of every
|
|
25
|
+
`<!-- [MANUAL:name] --> … <!-- [/MANUAL:name] -->` block; write.md §1
|
|
26
|
+
(HALT gate) and validate.md Check B verify the post-merge SKILL.md
|
|
27
|
+
against it. Doing that by an LLM marker-count + eyeball can silently
|
|
28
|
+
pass a block whose interior was truncated (marker count unchanged).
|
|
29
|
+
The `manual-inventory` / `manual-verify` subcommands extract each block
|
|
30
|
+
by marker regex, hash its byte-exact interior, and emit an exact
|
|
31
|
+
{preserved, modified, missing, moved, ok} verdict.
|
|
32
|
+
|
|
22
33
|
Subcommands:
|
|
23
34
|
hash <path> [--include-path]
|
|
24
35
|
Emit JSON {"content_hash": "sha256:...", "size_bytes": N, "line_count": L}
|
|
@@ -40,6 +51,69 @@ Subcommands:
|
|
|
40
51
|
The provenance file must contain `file_entries` as a top-level array
|
|
41
52
|
OR be the array itself (handles both schemas).
|
|
42
53
|
|
|
54
|
+
manual-inventory <skill-md>
|
|
55
|
+
Extract every `<!-- [MANUAL:name] --> … <!-- [/MANUAL:name] -->` block
|
|
56
|
+
and emit
|
|
57
|
+
{
|
|
58
|
+
"blocks": [
|
|
59
|
+
{"name": "...", "content_hash": "sha256:...",
|
|
60
|
+
"byte_offset": N, "parent_heading": "..."|null}, ...
|
|
61
|
+
],
|
|
62
|
+
"count": N
|
|
63
|
+
}
|
|
64
|
+
`content_hash` is the SHA-256 of the block's byte-exact interior (the
|
|
65
|
+
bytes between the open marker's `-->` and the close marker's `<!--`,
|
|
66
|
+
including surrounding newlines — zero normalization). `byte_offset` is
|
|
67
|
+
the byte position of the opening marker; `parent_heading` is the text
|
|
68
|
+
of the nearest preceding Markdown heading (or null). Blocks are sorted
|
|
69
|
+
by `byte_offset`. Persist this JSON as the captured pre-write inventory.
|
|
70
|
+
|
|
71
|
+
manual-verify --inventory <inventory.json> <skill-md>
|
|
72
|
+
Re-extract the blocks from the (post-merge) on-disk file and classify
|
|
73
|
+
each inventory block by name + hash + parent heading:
|
|
74
|
+
- name present, hash matches, same parent heading → preserved
|
|
75
|
+
- name present, hash matches, parent heading changed → moved
|
|
76
|
+
- name present, hash differs → modified
|
|
77
|
+
- name absent → missing
|
|
78
|
+
Emits {"preserved":[names], "modified":[names], "missing":[names],
|
|
79
|
+
"moved":[names], "ok": bool} where `ok = (modified empty AND missing
|
|
80
|
+
empty)`. A `moved` block does NOT fail the verdict — a byte-identical
|
|
81
|
+
block relocated with its logical parent section is a clean outcome.
|
|
82
|
+
The inventory may be the object emitted by `manual-inventory` OR a bare
|
|
83
|
+
`blocks[]` array (handles both shapes).
|
|
84
|
+
|
|
85
|
+
compare-constituent-hashes <provenance-map.json> [--skills-root <root>]
|
|
86
|
+
Constituent-drift detection for compose-mode stack skills. Replaces the
|
|
87
|
+
per-constituent read + SHA-256 + compare loop at
|
|
88
|
+
`src/skf-audit-skill/references/init.md` (Stack Skill Detection). Reads
|
|
89
|
+
the `constituents[]` array from a compose-mode provenance map (each
|
|
90
|
+
entry carrying `skill_name`, `skill_path`, and the compile-time
|
|
91
|
+
`metadata_hash`), reads each constituent's live
|
|
92
|
+
`{skill_path}/active/{skill_name}/metadata.json`, recomputes its
|
|
93
|
+
SHA-256 with the same `sha256:` prefix convention the writer used
|
|
94
|
+
(`skf-enumerate-stack-skills._sha256_of_bytes`), and buckets each
|
|
95
|
+
constituent:
|
|
96
|
+
{
|
|
97
|
+
"drifted": [{"skill_name","skill_path","stored_hash","current_hash"}],
|
|
98
|
+
"fresh": [{"skill_name"}],
|
|
99
|
+
"missing": [{"skill_name","skill_path","stored_hash","reason"}],
|
|
100
|
+
"skipped_null_hash": [{"skill_name"}],
|
|
101
|
+
"stats": {"total":N,"drifted":N,"fresh":N,"missing":N,
|
|
102
|
+
"skipped_null_hash":N}
|
|
103
|
+
}
|
|
104
|
+
`drifted` is the audit's HIGH-severity constituent-drift signal (the
|
|
105
|
+
live metadata.json differs from the compile-time snapshot). `missing`
|
|
106
|
+
carries a `reason` (`"metadata-not-found"` when the file is absent on
|
|
107
|
+
disk, `"incomplete-record"` when the provenance entry lacks
|
|
108
|
+
skill_name/skill_path). `skipped_null_hash` holds constituents whose
|
|
109
|
+
stored `metadata_hash` was null (recorded from a references/ cascade —
|
|
110
|
+
no baseline to compare against, so never reported as drift). Relative
|
|
111
|
+
`skill_path` values resolve against `--skills-root` (default: current
|
|
112
|
+
directory); absolute `skill_path` values are used as-is. A provenance
|
|
113
|
+
map with no `constituents` field (a single skill) yields all-empty
|
|
114
|
+
buckets. Stored hashes are prefix-normalized before comparison so a
|
|
115
|
+
bare-hex writer form still matches. Buckets are sorted by skill_name.
|
|
116
|
+
|
|
43
117
|
Hash format: `sha256:` prefix on a hex digest. Matches the convention used
|
|
44
118
|
by skf-detect-scripts-assets.py and the existing prose ("SHA-256 content
|
|
45
119
|
hash" — agnostic about prefix, but the prefix is the SKF convention to make
|
|
@@ -50,10 +124,14 @@ CLI examples:
|
|
|
50
124
|
uv run skf-hash-content.py hash docs/AGENTS.md --include-path
|
|
51
125
|
uv run skf-hash-content.py compare /path/to/source \\
|
|
52
126
|
--provenance-map /path/to/provenance-map.json
|
|
127
|
+
uv run skf-hash-content.py manual-inventory SKILL.md
|
|
128
|
+
uv run skf-hash-content.py manual-verify --inventory inventory.json SKILL.md
|
|
53
129
|
|
|
54
130
|
Exit codes:
|
|
55
|
-
0 — operation succeeded (including: file in compare missing on disk
|
|
56
|
-
|
|
131
|
+
0 — operation succeeded (including: file in compare missing on disk, its
|
|
132
|
+
classification is DELETED_FILE; and manual-verify completing with
|
|
133
|
+
ok=false — an integrity failure is a *result*, read from the `ok`
|
|
134
|
+
field, not an operation error)
|
|
57
135
|
1 — user error (bad path, malformed JSON, missing required field)
|
|
58
136
|
"""
|
|
59
137
|
|
|
@@ -62,6 +140,7 @@ from __future__ import annotations
|
|
|
62
140
|
import argparse
|
|
63
141
|
import hashlib
|
|
64
142
|
import json
|
|
143
|
+
import re
|
|
65
144
|
import sys
|
|
66
145
|
from pathlib import Path
|
|
67
146
|
|
|
@@ -104,6 +183,21 @@ def hash_record(path: Path, *, include_path: bool = False) -> dict:
|
|
|
104
183
|
return rec
|
|
105
184
|
|
|
106
185
|
|
|
186
|
+
_HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def normalize_hash(value: str | None) -> str | None:
|
|
190
|
+
"""Strip a leading algorithm-name prefix (`sha256:`, `sha1:`, …) from a
|
|
191
|
+
stored hash so bare-hex and prefixed forms compare equal.
|
|
192
|
+
|
|
193
|
+
Mirrors skf-compare-file-hashes.normalize_hash. Returns None for a
|
|
194
|
+
non-string input; idempotent on bare hex.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(value, str):
|
|
197
|
+
return None
|
|
198
|
+
return _HASH_PREFIX_RE.sub("", value, count=1)
|
|
199
|
+
|
|
200
|
+
|
|
107
201
|
# --------------------------------------------------------------------------
|
|
108
202
|
# Provenance comparison
|
|
109
203
|
# --------------------------------------------------------------------------
|
|
@@ -208,6 +302,314 @@ def compare(source_root: Path, provenance_path: Path) -> dict:
|
|
|
208
302
|
}
|
|
209
303
|
|
|
210
304
|
|
|
305
|
+
# --------------------------------------------------------------------------
|
|
306
|
+
# [MANUAL]-section integrity
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# Open marker: <!-- [MANUAL:name] --> (whitespace-tolerant)
|
|
311
|
+
# Close marker: <!-- [/MANUAL:name] --> (note the leading slash)
|
|
312
|
+
# The open pattern cannot match a close marker: after `<!--` it requires
|
|
313
|
+
# `[MANUAL` with no slash, whereas a close has `[/MANUAL`.
|
|
314
|
+
_OPEN_RE = re.compile(rb"<!--\s*\[MANUAL:([^\]]+)\]\s*-->")
|
|
315
|
+
_CLOSE_RE = re.compile(rb"<!--\s*\[/MANUAL:([^\]]+)\]\s*-->")
|
|
316
|
+
_HEADING_RE = re.compile(rb"^(#{1,6})[ \t]+(.*)$")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def sha256_of_bytes(data: bytes) -> str:
|
|
320
|
+
"""SHA-256 of an in-memory byte string, with sha256: prefix."""
|
|
321
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parent_heading(data: bytes, offset: int) -> str | None:
|
|
325
|
+
"""Text of the nearest Markdown heading preceding `offset` (or None).
|
|
326
|
+
|
|
327
|
+
Returns the heading text with leading `#` markers and surrounding
|
|
328
|
+
whitespace stripped — e.g. b"## Usage Patterns\\n" -> "Usage Patterns".
|
|
329
|
+
"""
|
|
330
|
+
heading: str | None = None
|
|
331
|
+
for line in data[:offset].split(b"\n"):
|
|
332
|
+
m = _HEADING_RE.match(line)
|
|
333
|
+
if m:
|
|
334
|
+
heading = m.group(2).strip().decode("utf-8", errors="replace")
|
|
335
|
+
return heading
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def find_manual_blocks(data: bytes) -> list[dict]:
|
|
339
|
+
"""Extract every well-formed [MANUAL] block from `data`.
|
|
340
|
+
|
|
341
|
+
A block is an opening `<!-- [MANUAL:name] -->` paired with the *earliest*
|
|
342
|
+
following `<!-- [/MANUAL:name] -->` of the same (whitespace-stripped)
|
|
343
|
+
name. `content_hash` covers the byte-exact interior — the bytes between
|
|
344
|
+
the open marker's `-->` and the close marker's `<!--`, with zero
|
|
345
|
+
normalization, so any interior edit (including an equal-marker-count
|
|
346
|
+
truncation) changes the hash.
|
|
347
|
+
|
|
348
|
+
An opening marker with no matching close is malformed and skipped.
|
|
349
|
+
Result is sorted by `byte_offset` for deterministic ordering.
|
|
350
|
+
"""
|
|
351
|
+
closes = [
|
|
352
|
+
(m.start(), m.group(1).decode("utf-8", errors="replace").strip())
|
|
353
|
+
for m in _CLOSE_RE.finditer(data)
|
|
354
|
+
]
|
|
355
|
+
blocks: list[dict] = []
|
|
356
|
+
for om in _OPEN_RE.finditer(data):
|
|
357
|
+
name = om.group(1).decode("utf-8", errors="replace").strip()
|
|
358
|
+
open_end = om.end()
|
|
359
|
+
close_start = None
|
|
360
|
+
for c_start, c_name in closes:
|
|
361
|
+
if c_start >= open_end and c_name == name:
|
|
362
|
+
if close_start is None or c_start < close_start:
|
|
363
|
+
close_start = c_start
|
|
364
|
+
if close_start is None:
|
|
365
|
+
continue # unclosed opening marker — malformed, skip
|
|
366
|
+
content = data[open_end:close_start]
|
|
367
|
+
blocks.append({
|
|
368
|
+
"name": name,
|
|
369
|
+
"content_hash": sha256_of_bytes(content),
|
|
370
|
+
"byte_offset": om.start(),
|
|
371
|
+
"parent_heading": _parent_heading(data, om.start()),
|
|
372
|
+
})
|
|
373
|
+
blocks.sort(key=lambda b: b["byte_offset"])
|
|
374
|
+
return blocks
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def classify_manual_blocks(inv_blocks: list[dict], cur_blocks: list[dict]) -> dict:
|
|
378
|
+
"""Classify each inventory block against the current on-disk blocks.
|
|
379
|
+
|
|
380
|
+
Buckets are mutually exclusive per inventory block:
|
|
381
|
+
- missing: name not present in the current file
|
|
382
|
+
- modified: name present but no current block's hash matches
|
|
383
|
+
- preserved: a current block matches name + hash + parent_heading
|
|
384
|
+
- moved: a current block matches name + hash but parent_heading
|
|
385
|
+
changed (relocated with its logical section — clean)
|
|
386
|
+
|
|
387
|
+
`ok = (modified empty AND missing empty)`; `moved` does not fail it.
|
|
388
|
+
"""
|
|
389
|
+
from collections import defaultdict
|
|
390
|
+
|
|
391
|
+
cur_by_name: dict[str, list[dict]] = defaultdict(list)
|
|
392
|
+
for b in cur_blocks:
|
|
393
|
+
cur_by_name[b["name"]].append(b)
|
|
394
|
+
|
|
395
|
+
preserved: list[str] = []
|
|
396
|
+
modified: list[str] = []
|
|
397
|
+
missing: list[str] = []
|
|
398
|
+
moved: list[str] = []
|
|
399
|
+
|
|
400
|
+
for inv in inv_blocks:
|
|
401
|
+
name = inv.get("name")
|
|
402
|
+
candidates = cur_by_name.get(name, [])
|
|
403
|
+
if not candidates:
|
|
404
|
+
missing.append(name)
|
|
405
|
+
continue
|
|
406
|
+
hash_matches = [c for c in candidates if c["content_hash"] == inv.get("content_hash")]
|
|
407
|
+
if not hash_matches:
|
|
408
|
+
modified.append(name)
|
|
409
|
+
continue
|
|
410
|
+
if any(c.get("parent_heading") == inv.get("parent_heading") for c in hash_matches):
|
|
411
|
+
preserved.append(name)
|
|
412
|
+
else:
|
|
413
|
+
moved.append(name)
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"preserved": preserved,
|
|
417
|
+
"modified": modified,
|
|
418
|
+
"missing": missing,
|
|
419
|
+
"moved": moved,
|
|
420
|
+
"ok": not modified and not missing,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def manual_inventory(path: Path) -> dict:
|
|
425
|
+
"""Build the [MANUAL] inventory for a file: {blocks[], count}."""
|
|
426
|
+
data = path.read_bytes()
|
|
427
|
+
blocks = find_manual_blocks(data)
|
|
428
|
+
return {"blocks": blocks, "count": len(blocks)}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def load_inventory_blocks(inventory_path: Path) -> list[dict]:
|
|
432
|
+
"""Read the blocks[] list from a manual-inventory JSON file.
|
|
433
|
+
|
|
434
|
+
Accepts the object emitted by `manual_inventory` (has a `blocks` key)
|
|
435
|
+
OR a bare list of block records. Raises ValueError on malformed input.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
data = json.loads(inventory_path.read_text(encoding="utf-8"))
|
|
439
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
440
|
+
raise ValueError(
|
|
441
|
+
f"failed to read inventory file {inventory_path}: {exc}"
|
|
442
|
+
) from exc
|
|
443
|
+
if isinstance(data, list):
|
|
444
|
+
return list(data)
|
|
445
|
+
if isinstance(data, dict):
|
|
446
|
+
blocks = data.get("blocks")
|
|
447
|
+
if blocks is None:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"inventory file {inventory_path} has no `blocks` field"
|
|
450
|
+
)
|
|
451
|
+
if not isinstance(blocks, list):
|
|
452
|
+
raise ValueError(
|
|
453
|
+
f"`blocks` in {inventory_path} is not an array"
|
|
454
|
+
)
|
|
455
|
+
return list(blocks)
|
|
456
|
+
raise ValueError(
|
|
457
|
+
f"inventory file {inventory_path} must be an object or array; "
|
|
458
|
+
f"got {type(data).__name__}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def manual_verify(inventory_path: Path, skill_md_path: Path) -> dict:
|
|
463
|
+
"""Verify a post-merge file against a captured [MANUAL] inventory."""
|
|
464
|
+
inv_blocks = load_inventory_blocks(inventory_path)
|
|
465
|
+
cur_blocks = find_manual_blocks(skill_md_path.read_bytes())
|
|
466
|
+
return classify_manual_blocks(inv_blocks, cur_blocks)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# --------------------------------------------------------------------------
|
|
470
|
+
# Constituent-hash comparison (compose-mode stack drift)
|
|
471
|
+
# --------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def load_constituents(provenance_path: Path) -> list[dict]:
|
|
475
|
+
"""Extract the `constituents[]` list from a compose-mode provenance map.
|
|
476
|
+
|
|
477
|
+
Accepts three shapes:
|
|
478
|
+
- top-level object with a `constituents` key (canonical provenance map)
|
|
479
|
+
- top-level object with NO `constituents` field → empty list (a single
|
|
480
|
+
skill omits the array entirely per skill-sections.md — valid, no drift)
|
|
481
|
+
- top-level array of constituent records (already extracted)
|
|
482
|
+
|
|
483
|
+
Raises ValueError on read failure, malformed JSON, or a non-array
|
|
484
|
+
`constituents` field.
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
text = provenance_path.read_text(encoding="utf-8")
|
|
488
|
+
except OSError as exc:
|
|
489
|
+
raise ValueError(
|
|
490
|
+
f"failed to read provenance file {provenance_path}: {exc}"
|
|
491
|
+
) from exc
|
|
492
|
+
try:
|
|
493
|
+
data = json.loads(text)
|
|
494
|
+
except json.JSONDecodeError as exc:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"malformed JSON in provenance file {provenance_path}: {exc}"
|
|
497
|
+
) from exc
|
|
498
|
+
|
|
499
|
+
if isinstance(data, list):
|
|
500
|
+
return list(data)
|
|
501
|
+
if isinstance(data, dict):
|
|
502
|
+
constituents = data.get("constituents")
|
|
503
|
+
if constituents is None:
|
|
504
|
+
return [] # single skill — no constituents to verify
|
|
505
|
+
if not isinstance(constituents, list):
|
|
506
|
+
raise ValueError(
|
|
507
|
+
f"`constituents` in {provenance_path} is not an array"
|
|
508
|
+
)
|
|
509
|
+
return list(constituents)
|
|
510
|
+
raise ValueError(
|
|
511
|
+
f"provenance file {provenance_path} must be an object or array; "
|
|
512
|
+
f"got {type(data).__name__}"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _constituent_metadata_path(skills_root: Path, skill_path: str, skill_name: str) -> Path:
|
|
517
|
+
"""Resolve a constituent's live metadata.json path.
|
|
518
|
+
|
|
519
|
+
Mirrors init.md's Stack Skill Detection prose:
|
|
520
|
+
`{skill_path}/active/{skill_name}/metadata.json`. A relative `skill_path`
|
|
521
|
+
resolves against `skills_root`; an absolute one is used as-is.
|
|
522
|
+
"""
|
|
523
|
+
base = Path(skill_path)
|
|
524
|
+
if not base.is_absolute():
|
|
525
|
+
base = skills_root / base
|
|
526
|
+
return base / "active" / skill_name / "metadata.json"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def compare_constituents(provenance_path: Path, skills_root: Path) -> dict:
|
|
530
|
+
"""Classify each compose-mode constituent as drifted / fresh / missing.
|
|
531
|
+
|
|
532
|
+
For each constituent, the live `metadata.json` is re-hashed with the same
|
|
533
|
+
`sha256:`-prefixed convention the writer used and compared (prefix-
|
|
534
|
+
normalized) against the compile-time `metadata_hash`.
|
|
535
|
+
"""
|
|
536
|
+
constituents = load_constituents(provenance_path)
|
|
537
|
+
|
|
538
|
+
drifted: list[dict] = []
|
|
539
|
+
fresh: list[dict] = []
|
|
540
|
+
missing: list[dict] = []
|
|
541
|
+
skipped_null_hash: list[dict] = []
|
|
542
|
+
|
|
543
|
+
for entry in constituents:
|
|
544
|
+
if not isinstance(entry, dict):
|
|
545
|
+
missing.append({
|
|
546
|
+
"skill_name": None,
|
|
547
|
+
"skill_path": None,
|
|
548
|
+
"stored_hash": None,
|
|
549
|
+
"reason": "incomplete-record",
|
|
550
|
+
})
|
|
551
|
+
continue
|
|
552
|
+
skill_name = entry.get("skill_name")
|
|
553
|
+
skill_path = entry.get("skill_path")
|
|
554
|
+
stored_hash = entry.get("metadata_hash")
|
|
555
|
+
|
|
556
|
+
if not isinstance(skill_name, str) or not skill_name or \
|
|
557
|
+
not isinstance(skill_path, str) or not skill_path:
|
|
558
|
+
missing.append({
|
|
559
|
+
"skill_name": skill_name if isinstance(skill_name, str) else None,
|
|
560
|
+
"skill_path": skill_path if isinstance(skill_path, str) else None,
|
|
561
|
+
"stored_hash": stored_hash if isinstance(stored_hash, str) else None,
|
|
562
|
+
"reason": "incomplete-record",
|
|
563
|
+
})
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
if not isinstance(stored_hash, str) or not stored_hash:
|
|
567
|
+
# No compile-time baseline (recorded from a references/ cascade) —
|
|
568
|
+
# cannot be drift; do not flag.
|
|
569
|
+
skipped_null_hash.append({"skill_name": skill_name})
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
meta_path = _constituent_metadata_path(skills_root, skill_path, skill_name)
|
|
573
|
+
if not meta_path.is_file():
|
|
574
|
+
missing.append({
|
|
575
|
+
"skill_name": skill_name,
|
|
576
|
+
"skill_path": skill_path,
|
|
577
|
+
"stored_hash": stored_hash,
|
|
578
|
+
"reason": "metadata-not-found",
|
|
579
|
+
})
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
current_hash = sha256_of_file(meta_path)
|
|
583
|
+
if normalize_hash(stored_hash) == normalize_hash(current_hash):
|
|
584
|
+
fresh.append({"skill_name": skill_name})
|
|
585
|
+
else:
|
|
586
|
+
drifted.append({
|
|
587
|
+
"skill_name": skill_name,
|
|
588
|
+
"skill_path": skill_path,
|
|
589
|
+
"stored_hash": stored_hash,
|
|
590
|
+
"current_hash": current_hash,
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
drifted.sort(key=lambda r: (r.get("skill_name") or ""))
|
|
594
|
+
fresh.sort(key=lambda r: (r.get("skill_name") or ""))
|
|
595
|
+
missing.sort(key=lambda r: (r.get("skill_name") or ""))
|
|
596
|
+
skipped_null_hash.sort(key=lambda r: (r.get("skill_name") or ""))
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
"drifted": drifted,
|
|
600
|
+
"fresh": fresh,
|
|
601
|
+
"missing": missing,
|
|
602
|
+
"skipped_null_hash": skipped_null_hash,
|
|
603
|
+
"stats": {
|
|
604
|
+
"total": len(constituents),
|
|
605
|
+
"drifted": len(drifted),
|
|
606
|
+
"fresh": len(fresh),
|
|
607
|
+
"missing": len(missing),
|
|
608
|
+
"skipped_null_hash": len(skipped_null_hash),
|
|
609
|
+
},
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
211
613
|
# --------------------------------------------------------------------------
|
|
212
614
|
# CLI
|
|
213
615
|
# --------------------------------------------------------------------------
|
|
@@ -243,6 +645,55 @@ def _cmd_compare(args: argparse.Namespace) -> int:
|
|
|
243
645
|
return 0
|
|
244
646
|
|
|
245
647
|
|
|
648
|
+
def _cmd_manual_inventory(args: argparse.Namespace) -> int:
|
|
649
|
+
path = Path(args.path)
|
|
650
|
+
if not path.is_file():
|
|
651
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
652
|
+
return 1
|
|
653
|
+
result = manual_inventory(path)
|
|
654
|
+
json.dump(result, sys.stdout, indent=2)
|
|
655
|
+
sys.stdout.write("\n")
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _cmd_manual_verify(args: argparse.Namespace) -> int:
|
|
660
|
+
path = Path(args.path)
|
|
661
|
+
inventory = Path(args.inventory)
|
|
662
|
+
if not path.is_file():
|
|
663
|
+
print(f"error: file not found: {path}", file=sys.stderr)
|
|
664
|
+
return 1
|
|
665
|
+
if not inventory.is_file():
|
|
666
|
+
print(f"error: inventory not found: {inventory}", file=sys.stderr)
|
|
667
|
+
return 1
|
|
668
|
+
try:
|
|
669
|
+
result = manual_verify(inventory, path)
|
|
670
|
+
except ValueError as exc:
|
|
671
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
672
|
+
return 1
|
|
673
|
+
json.dump(result, sys.stdout, indent=2)
|
|
674
|
+
sys.stdout.write("\n")
|
|
675
|
+
return 0
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _cmd_compare_constituents(args: argparse.Namespace) -> int:
|
|
679
|
+
provenance = Path(args.provenance_map)
|
|
680
|
+
skills_root = Path(args.skills_root)
|
|
681
|
+
if not provenance.is_file():
|
|
682
|
+
print(f"error: provenance map not found: {provenance}", file=sys.stderr)
|
|
683
|
+
return 1
|
|
684
|
+
if not skills_root.is_dir():
|
|
685
|
+
print(f"error: skills root not a directory: {skills_root}", file=sys.stderr)
|
|
686
|
+
return 1
|
|
687
|
+
try:
|
|
688
|
+
result = compare_constituents(provenance, skills_root)
|
|
689
|
+
except ValueError as exc:
|
|
690
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
691
|
+
return 1
|
|
692
|
+
json.dump(result, sys.stdout, indent=2)
|
|
693
|
+
sys.stdout.write("\n")
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
|
|
246
697
|
def _build_parser() -> argparse.ArgumentParser:
|
|
247
698
|
parser = argparse.ArgumentParser(
|
|
248
699
|
prog="skf-hash-content",
|
|
@@ -271,6 +722,40 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
271
722
|
)
|
|
272
723
|
p_cmp.set_defaults(func=_cmd_compare)
|
|
273
724
|
|
|
725
|
+
p_inv = sub.add_parser(
|
|
726
|
+
"manual-inventory",
|
|
727
|
+
help="extract [MANUAL] blocks and emit {blocks[], count} JSON",
|
|
728
|
+
)
|
|
729
|
+
p_inv.add_argument("path", help="path to the SKILL.md (or reference file)")
|
|
730
|
+
p_inv.set_defaults(func=_cmd_manual_inventory)
|
|
731
|
+
|
|
732
|
+
p_ver = sub.add_parser(
|
|
733
|
+
"manual-verify",
|
|
734
|
+
help="verify a file against a captured [MANUAL] inventory",
|
|
735
|
+
)
|
|
736
|
+
p_ver.add_argument("path", help="path to the post-merge SKILL.md (or reference file)")
|
|
737
|
+
p_ver.add_argument(
|
|
738
|
+
"--inventory",
|
|
739
|
+
required=True,
|
|
740
|
+
help="path to the manual-inventory JSON captured before the write",
|
|
741
|
+
)
|
|
742
|
+
p_ver.set_defaults(func=_cmd_manual_verify)
|
|
743
|
+
|
|
744
|
+
p_con = sub.add_parser(
|
|
745
|
+
"compare-constituent-hashes",
|
|
746
|
+
help="classify compose-mode stack constituents as drifted/fresh/missing",
|
|
747
|
+
)
|
|
748
|
+
p_con.add_argument(
|
|
749
|
+
"provenance_map",
|
|
750
|
+
help="path to the compose-mode provenance-map.json (object with constituents[] or bare array)",
|
|
751
|
+
)
|
|
752
|
+
p_con.add_argument(
|
|
753
|
+
"--skills-root",
|
|
754
|
+
default=".",
|
|
755
|
+
help="base directory for resolving relative constituent skill_path values (default: current directory)",
|
|
756
|
+
)
|
|
757
|
+
p_con.set_defaults(func=_cmd_compare_constituents)
|
|
758
|
+
|
|
274
759
|
return parser
|
|
275
760
|
|
|
276
761
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Language Corpora — canonical companion prose corpora for a language.
|
|
6
|
+
|
|
7
|
+
A whole-language skill (issue #427) is forged from a language-reference repo
|
|
8
|
+
(a compiler/interpreter such as rust-lang/rust). That repo carries the
|
|
9
|
+
language's CODE, but a skill's value comes from the language's PROSE — the
|
|
10
|
+
guide/Book, the standard/library API docs, idioms. README detection
|
|
11
|
+
(skf-detect-docs.py) is the primary source for those; this lookup guarantees
|
|
12
|
+
the canonical corpora for well-known languages even when the repo's README
|
|
13
|
+
does not link them.
|
|
14
|
+
|
|
15
|
+
Pure static lookup over src/shared/data/language-corpora.json — no network,
|
|
16
|
+
no git, no `gh`. Output is the brief `doc_urls` contract (`{url, label,
|
|
17
|
+
source}`), with `source` fixed to `"language-registry"` (issue #432), so the
|
|
18
|
+
result can be seeded directly into a skill brief and later distinguished from
|
|
19
|
+
README-detected docs.
|
|
20
|
+
|
|
21
|
+
CLI:
|
|
22
|
+
uv run src/shared/scripts/skf-language-corpora.py --language <id>
|
|
23
|
+
|
|
24
|
+
Output (JSON array on stdout): [{"url": "...", "label": "...", "source": "language-registry"}, ...]
|
|
25
|
+
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 registry hit — one or more corpora emitted
|
|
28
|
+
1 no entry for this language (long-tail / unknown) — emits []
|
|
29
|
+
2 error (bad args, missing/unreadable/invalid data file)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "language-corpora.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
|
|
43
|
+
json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
|
|
44
|
+
sys.stderr.write("\n")
|
|
45
|
+
sys.exit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_registry() -> dict:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(_DATA_FILE.read_text(encoding="utf-8"))
|
|
51
|
+
except OSError as exc:
|
|
52
|
+
_die(f"Cannot read {_DATA_FILE.as_posix()}: {exc}", "DATA_READ_ERROR")
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
_die(f"Cannot parse {_DATA_FILE.as_posix()}: {exc}", "DATA_PARSE_ERROR")
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
_die("Corpora registry must be a JSON object", "DATA_PARSE_ERROR")
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def corpora_for(language: str) -> list[dict]:
|
|
61
|
+
"""Return the `{url, label, source}` corpora for a language id, or [] if none.
|
|
62
|
+
|
|
63
|
+
Every emitted entry is stamped `source: "language-registry"` (issue #432) so
|
|
64
|
+
downstream — the brief writer, the noise-suppression filter (#431), and the
|
|
65
|
+
assembly tier-ordering (#430) — can distinguish a registry-guaranteed
|
|
66
|
+
corpus from an opportunistically README-detected doc.
|
|
67
|
+
"""
|
|
68
|
+
registry = _load_registry()
|
|
69
|
+
entries = registry.get((language or "").strip().lower())
|
|
70
|
+
if not isinstance(entries, list):
|
|
71
|
+
return []
|
|
72
|
+
out: list[dict] = []
|
|
73
|
+
for e in entries:
|
|
74
|
+
if isinstance(e, dict) and e.get("url"):
|
|
75
|
+
out.append({
|
|
76
|
+
"url": e["url"],
|
|
77
|
+
"label": e.get("label", ""),
|
|
78
|
+
"source": "language-registry",
|
|
79
|
+
})
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str]) -> int:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
description="Look up canonical companion prose corpora for a language.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--language", required=True,
|
|
89
|
+
help="Lowercase language id (rust, python, go, typescript, ruby, ...)",
|
|
90
|
+
)
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
result = corpora_for(args.language)
|
|
94
|
+
json.dump(result, sys.stdout, ensure_ascii=False)
|
|
95
|
+
sys.stdout.write("\n")
|
|
96
|
+
return 0 if result else 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main(sys.argv[1:]))
|