bmad-module-skill-forge 2.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +143 -18
- package/docs/verifying-a-skill.md +87 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +3 -3
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +31 -59
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +119 -70
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +122 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +209 -264
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +164 -91
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +380 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +253 -223
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +254 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Enumerate Stack Skills — inventory skill packages + resolve exports.
|
|
@@ -21,23 +21,79 @@ subprocesses read this inventory's cached result instead of re-reading
|
|
|
21
21
|
SKILL.md, then extract usage patterns per skill (the part that does
|
|
22
22
|
benefit from LLM judgment).
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
enumerate <skills-root> [--pairs] [--reliability]
|
|
26
|
-
Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
24
|
+
Subcommands:
|
|
25
|
+
enumerate <skills-root> [--pairs] [--reliability] [--expect-hashes FILE]
|
|
26
|
+
Emit JSON {"skills": [...], "cycles": [...], "warnings": [...],
|
|
27
|
+
"not_skf_output": [...]} for the skill folders under <skills-root>.
|
|
28
|
+
candidates <skills-root> [--explicit a,b]
|
|
29
|
+
Pick the compose-mode candidates and gate them against the same
|
|
30
|
+
roster, in one call (see Candidates below).
|
|
31
|
+
scope --skills a,b,c --in-scope a,b
|
|
32
|
+
Split the unique pairs of an inventory's skills by the skills in
|
|
33
|
+
scope (see Scope below).
|
|
34
|
+
|
|
35
|
+
Ownership. A stack roster reads only the skills SKF generated: a package
|
|
36
|
+
counts only when its metadata.json carries an SKF marker (generated_by
|
|
37
|
+
naming an SKF generator, tool_versions.skf, or skill_type with forge_tier
|
|
38
|
+
or confidence_tier), the rule skf-skill-inventory.py applies (the marker
|
|
39
|
+
functions are pinned copies of its own). For each top-level folder (dot
|
|
40
|
+
names, `_batch` and `.skf-` names aside) the package is the first marked
|
|
41
|
+
candidate of: the version the `active` link names (a real folder directly
|
|
42
|
+
in the group), the version folders from highest to lowest (`active`,
|
|
43
|
+
links and `.skf-` names aside), then a flat root SKILL.md beside a marked
|
|
44
|
+
root metadata.json. A versioned package is always `{version}/{name}/`,
|
|
45
|
+
named after the folder. `enumerate` never reads the export manifest;
|
|
46
|
+
`candidates` reads it only to pick candidates, and the package of each
|
|
47
|
+
one it keeps still comes from this roster.
|
|
48
|
+
|
|
49
|
+
A folder with no marked candidate is left out of `skills[]`: listed in
|
|
50
|
+
`not_skf_output` when it looks like a skill (a root SKILL.md or a
|
|
51
|
+
`{version}/{name}/` folder), or, when a candidate's metadata.json or
|
|
52
|
+
version folder cannot be read, named in one warning instead, because SKF
|
|
53
|
+
cannot tell whether it generated that folder. A linked package inside a
|
|
54
|
+
version is never SKF output either, and its metadata.json is never read.
|
|
55
|
+
A top-level link is never SKF output: it is listed in `not_skf_output`
|
|
56
|
+
when it looks like a skill, and a broken one, or one SKF cannot read
|
|
57
|
+
through, is skipped. `not_skf_output` is always present, sorted, and
|
|
58
|
+
never counts toward `warnings`, `--pairs` or `--reliability`.
|
|
59
|
+
|
|
60
|
+
Each `skills[]` entry carries:
|
|
61
|
+
name the top-level folder name
|
|
62
|
+
path the package relative to skills-root, forward-slash:
|
|
63
|
+
`x/active/x`, `x/{version}/x` or `x` (flat)
|
|
64
|
+
exports exports list resolved via cascade
|
|
65
|
+
exports_source "metadata|references|skill-md|unknown"
|
|
66
|
+
confidence "T1|T2|T1-low", the exports-source label (mapped from
|
|
67
|
+
exports_source, see Confidence mapping)
|
|
68
|
+
evidence_tier "T1|T1-low|T2|T3", the tier the package's own recorded
|
|
69
|
+
evidence supports (see Evidence tier)
|
|
70
|
+
metadata_hash "sha256:" digest of the package's metadata.json; never
|
|
71
|
+
null for an enumerated package, whose metadata.json is
|
|
72
|
+
always readable
|
|
73
|
+
skill_type metadata.json `skill_type`, when it is a string
|
|
74
|
+
language metadata.json `language`: a string, or a list of
|
|
75
|
+
strings (a stack may list its libraries' languages)
|
|
76
|
+
confidence_tier metadata.json `confidence_tier`, when it belongs to the
|
|
77
|
+
scale its skill_type records: a forge tier
|
|
78
|
+
(Quick|Forge|Forge+|Deep) for a single skill (skill_type
|
|
79
|
+
single, individual or null), a confidence tier
|
|
80
|
+
(T1|T1-low|T2|T3) for a stack, either scale for any
|
|
81
|
+
other skill_type
|
|
82
|
+
exports_documented
|
|
83
|
+
metadata.json `stats.exports_documented`, a whole number
|
|
84
|
+
metadata_schema_version
|
|
85
|
+
metadata.json `spec_version`, the metadata schema the
|
|
86
|
+
package was written to (such as "1.3")
|
|
87
|
+
source_repo metadata.json `source_repo`, trimmed
|
|
88
|
+
source_repo_basename
|
|
89
|
+
the repository name source_repo ends in (see Source
|
|
90
|
+
basenames)
|
|
91
|
+
source_root metadata.json `source_root`, trimmed
|
|
92
|
+
source_root_basename
|
|
93
|
+
the last folder name of source_root (see Source
|
|
94
|
+
basenames)
|
|
95
|
+
Each metadata.json field is null when the file lacks it or holds a value
|
|
96
|
+
of another shape, so a caller never reopens metadata.json to read it.
|
|
41
97
|
|
|
42
98
|
Exports resolution cascade (must match parallel-extract.md §0):
|
|
43
99
|
|
|
@@ -50,12 +106,12 @@ Exports resolution cascade (must match parallel-extract.md §0):
|
|
|
50
106
|
|
|
51
107
|
2. references/*.md — scan top-level `*.md` files under references/
|
|
52
108
|
for an `## API` or `## Exports` section. Bulleted list items in
|
|
53
|
-
that section become exports. Set exports_source="references"
|
|
54
|
-
metadata_hash
|
|
109
|
+
that section become exports. Set exports_source="references"
|
|
110
|
+
(metadata_hash stays the digest of metadata.json when one was read).
|
|
55
111
|
|
|
56
112
|
3. SKILL.md prose — look for an `## Exports` or `## API Surface`
|
|
57
|
-
section and parse its bulleted list. Set exports_source="skill-md"
|
|
58
|
-
metadata_hash
|
|
113
|
+
section and parse its bulleted list. Set exports_source="skill-md"
|
|
114
|
+
(metadata_hash stays the digest of metadata.json when one was read).
|
|
59
115
|
|
|
60
116
|
4. None of the above — exports=[], exports_source="unknown",
|
|
61
117
|
confidence="T1-low", append a warning
|
|
@@ -67,6 +123,31 @@ Confidence mapping:
|
|
|
67
123
|
skill-md → T2 (prose parsing, comparable trust to references)
|
|
68
124
|
unknown → T1-low (degraded extraction; nothing found)
|
|
69
125
|
|
|
126
|
+
`confidence` says only where the exports list came from: every package
|
|
127
|
+
whose metadata.json lists exports (even an empty list, which SKF's
|
|
128
|
+
generators always write) reads T1, whatever evidence it rests on.
|
|
129
|
+
|
|
130
|
+
Evidence tier:
|
|
131
|
+
|
|
132
|
+
`evidence_tier` is the largest bin of the package's metadata.json
|
|
133
|
+
`confidence_distribution` (t1 is T1, t1_low T1-low, t2 T2, t3 T3). A tie
|
|
134
|
+
goes to the weaker tier (T1-low over T1, T2 over T1-low, T3 over T2), so
|
|
135
|
+
the tier never overstates confidence. A bin counts only when it holds a
|
|
136
|
+
positive number; with none (the distribution is absent, not an object or
|
|
137
|
+
all zero) the tier is T1-low, the conservative default. create-stack-skill
|
|
138
|
+
picks a stack's own confidence_tier by the same rule. A docs-only skill
|
|
139
|
+
whose bins are all T3 reads T3 here, while its `confidence` reads T1.
|
|
140
|
+
|
|
141
|
+
Source basenames:
|
|
142
|
+
|
|
143
|
+
Lower case, from the last non-empty segment between `/` or `\\`
|
|
144
|
+
separators, for matching a skill to a technology name.
|
|
145
|
+
source_repo_basename also drops a trailing `.git`, and is null when
|
|
146
|
+
source_repo holds no separator (it is then no URL, owner/repo pair or
|
|
147
|
+
path). Either is null when no name remains (such as `.` or `..`).
|
|
148
|
+
`https://github.com/Org/Repo.git/` and `Org/Repo` both give `repo`; a
|
|
149
|
+
source_root of `packages/Core` gives `core`.
|
|
150
|
+
|
|
70
151
|
Cycle detection:
|
|
71
152
|
|
|
72
153
|
metadata.json may declare `composes: ["<skill-name>", ...]` referencing
|
|
@@ -75,11 +156,12 @@ Cycle detection:
|
|
|
75
156
|
starting node to `cycles[]` and emit a warning. Cycles do not abort
|
|
76
157
|
enumeration; the inventory still lists every skill.
|
|
77
158
|
|
|
78
|
-
|
|
159
|
+
Warnings:
|
|
79
160
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
161
|
+
Each starts `<name>: `: a metadata.json or version folder that cannot
|
|
162
|
+
be read (on a candidate passed over, or on a folder SKF cannot tell it
|
|
163
|
+
generated), an SKF package with no SKILL.md, no exports found via any
|
|
164
|
+
resolution path, a folder that cannot be resolved, and composes cycles.
|
|
83
165
|
|
|
84
166
|
Per-skill errors (malformed metadata.json, OSError, etc.) are captured
|
|
85
167
|
as warnings on the top-level result; they do not exit the process.
|
|
@@ -99,7 +181,8 @@ consumers that read only skills/cycles/warnings are unaffected):
|
|
|
99
181
|
|
|
100
182
|
--reliability
|
|
101
183
|
Attach the inventory reliability verdict computed from the counts
|
|
102
|
-
the script already owns
|
|
184
|
+
the script already owns, over the skills SKF generated and their
|
|
185
|
+
warnings; `not_skf_output` never counts:
|
|
103
186
|
skill_count — len(skills)
|
|
104
187
|
warning_count — len(warnings)
|
|
105
188
|
unreliable_ratio — warning_count / (skill_count + warning_count),
|
|
@@ -109,15 +192,100 @@ consumers that read only skills/cycles/warnings are unaffected):
|
|
|
109
192
|
so consuming prompts read a boolean instead of
|
|
110
193
|
re-deriving a ratio + threshold comparison).
|
|
111
194
|
The raw counts are emitted alongside the boolean so a caller's halt
|
|
112
|
-
message can still render "{warning_count}
|
|
113
|
-
|
|
195
|
+
message can still render "{warning_count} warning(s) across
|
|
196
|
+
{skill_count} skill(s)".
|
|
197
|
+
|
|
198
|
+
--expect-hashes FILE
|
|
199
|
+
Compare each skill's metadata_hash with the one FILE recorded for it
|
|
200
|
+
and attach three sorted name lists: `changed_skills` (enumerated now
|
|
201
|
+
with another metadata_hash), `missing_skills` (recorded, but not
|
|
202
|
+
enumerated now: removed, or no longer readable) and `new_skills`
|
|
203
|
+
(enumerated now, not recorded). FILE, or stdin for `-`, holds an
|
|
204
|
+
earlier enumerate result (its skills[] entries give each name's
|
|
205
|
+
metadata_hash) or an object mapping each skill name to its hash.
|
|
206
|
+
Hashes compare as written. A caller that verifies skills over a long
|
|
207
|
+
run re-enumerates with the result it started from, instead of
|
|
208
|
+
re-reading each metadata.json modification time.
|
|
209
|
+
|
|
210
|
+
Candidates:
|
|
211
|
+
|
|
212
|
+
Compose mode builds a stack from the skills SKF generated. The
|
|
213
|
+
`candidates` subcommand picks them and gates them against the roster
|
|
214
|
+
`enumerate` builds:
|
|
215
|
+
|
|
216
|
+
candidate_source
|
|
217
|
+
Where the candidates came from. "explicit": the --explicit names
|
|
218
|
+
(trimmed, duplicates dropped). An --explicit entry that holds `/` or
|
|
219
|
+
`\\` is a package path instead, absolute or relative to the current
|
|
220
|
+
folder: it names the first folder of its path under <skills-root>
|
|
221
|
+
(`<skills-root>/react/1.2.0/react` and `<skills-root>/react/active/react`
|
|
222
|
+
both name `react`). "manifest": each key of
|
|
223
|
+
`<skills-root>/.export-manifest.json` `exports`. "active-links": when
|
|
224
|
+
there is no --explicit list and the manifest is absent, lists
|
|
225
|
+
nothing or cannot be read, each top-level folder holding
|
|
226
|
+
`active/<name>/SKILL.md` (`active` a link or a real folder; dot
|
|
227
|
+
names never match, and `_batch` and `.skf-` names are skipped as the
|
|
228
|
+
roster skips them).
|
|
229
|
+
kept
|
|
230
|
+
The roster entries (the shape of a skills[] entry) of the candidates
|
|
231
|
+
whose skill_type is single, individual or null (an early Quick Skill
|
|
232
|
+
package), sorted by name.
|
|
233
|
+
excluded
|
|
234
|
+
Every other candidate, once, as {skill_dir, reason, message}, sorted
|
|
235
|
+
by skill_dir. `message` is the log line to show, starting
|
|
236
|
+
`<skill_dir>: `. The reason is the first that applies:
|
|
237
|
+
not-a-skill in the roster with another skill_type (a stack)
|
|
238
|
+
not-skf-output listed in not_skf_output
|
|
239
|
+
roster-warning named by roster warnings (the message holds them)
|
|
240
|
+
no-such-folder an --explicit name with no folder at
|
|
241
|
+
<skills-root>/<name>
|
|
242
|
+
no-skf-package anything else (a folder holding no SKF package)
|
|
243
|
+
and an --explicit package path that is not inside <skills-root> is
|
|
244
|
+
excluded as given, with the reason `outside-skills-root`.
|
|
245
|
+
stale_manifest_keys
|
|
246
|
+
The manifest keys with no folder at <skills-root>/<key>, sorted: the
|
|
247
|
+
manifest names a skill that is gone, which the caller halts on. A
|
|
248
|
+
folder SKF cannot read is not called gone. Never in excluded.
|
|
249
|
+
manifest_parse_error
|
|
250
|
+
Why the manifest could not be read (unreadable, not JSON, its root or
|
|
251
|
+
its `exports` not an object); the candidates then come from the
|
|
252
|
+
active links. null otherwise, and whenever --explicit is given (the
|
|
253
|
+
manifest is not read then).
|
|
254
|
+
|
|
255
|
+
The result also carries the roster's `cycles`, `warnings` and
|
|
256
|
+
`not_skf_output`, with kept[] in place of skills[]. A composes cycle is
|
|
257
|
+
not an exclusion reason: cycles[] reports it as `enumerate` does.
|
|
258
|
+
|
|
259
|
+
Scope:
|
|
260
|
+
|
|
261
|
+
refine-architecture decides which skills its architecture document
|
|
262
|
+
covers after `enumerate` ran. The `scope` subcommand splits the pairs
|
|
263
|
+
`--pairs` emitted, so no prompt sorts them by hand, and a pair is never
|
|
264
|
+
dropped from both halves. It reads no skills root: --skills names the
|
|
265
|
+
inventory (the `name` of each skills[] entry of an enumerate result) and
|
|
266
|
+
--in-scope the skills in scope, both comma-separated, trimmed, with empty
|
|
267
|
+
items dropped. Names compare exactly, as skills[] spells them.
|
|
268
|
+
|
|
269
|
+
in_scope the --in-scope names that are inventory skills, sorted
|
|
270
|
+
out_of_scope every other inventory skill, sorted
|
|
271
|
+
unknown the --in-scope names that are no inventory skill, each
|
|
272
|
+
once, in the order given
|
|
273
|
+
in_scope_pairs the pairs, as `--pairs` emits them over --skills,
|
|
274
|
+
whose library_a and library_b are both in in_scope
|
|
275
|
+
out_of_scope_pairs every other pair: the two lists hold each pair once
|
|
276
|
+
pair_count, in_scope_pair_count, out_of_scope_pair_count
|
|
277
|
+
the sizes of the three pair lists (all, in, out)
|
|
114
278
|
|
|
115
279
|
Exit codes:
|
|
116
280
|
0 enumeration succeeded (including zero skills found)
|
|
117
|
-
1 user error
|
|
281
|
+
1 user error: a bad skills-root path, an --expect-hashes FILE that
|
|
282
|
+
cannot be read or holds neither accepted shape, an --explicit list
|
|
283
|
+
that names no skill, or a scope --skills list that names no skill
|
|
118
284
|
|
|
119
285
|
CLI:
|
|
120
|
-
uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability]
|
|
286
|
+
uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability] [--expect-hashes FILE]
|
|
287
|
+
uv run skf-enumerate-stack-skills.py candidates <skills-root> [--explicit a,b]
|
|
288
|
+
uv run skf-enumerate-stack-skills.py scope --skills a,b,c --in-scope a,b
|
|
121
289
|
"""
|
|
122
290
|
|
|
123
291
|
from __future__ import annotations
|
|
@@ -126,7 +294,10 @@ import argparse
|
|
|
126
294
|
import hashlib
|
|
127
295
|
import itertools
|
|
128
296
|
import json
|
|
297
|
+
import math
|
|
298
|
+
import os
|
|
129
299
|
import re
|
|
300
|
+
import stat
|
|
130
301
|
import sys
|
|
131
302
|
from pathlib import Path
|
|
132
303
|
|
|
@@ -148,6 +319,8 @@ SOURCE_UNKNOWN = "unknown"
|
|
|
148
319
|
# inventory does not trip the halt.
|
|
149
320
|
RELIABILITY_THRESHOLD = 0.20
|
|
150
321
|
|
|
322
|
+
# The exports-source label (`confidence`). It says where the exports list
|
|
323
|
+
# came from, never what evidence the package rests on: that is evidence_tier.
|
|
151
324
|
_CONFIDENCE_BY_SOURCE = {
|
|
152
325
|
SOURCE_METADATA: "T1",
|
|
153
326
|
SOURCE_REFERENCES: "T2",
|
|
@@ -155,6 +328,40 @@ _CONFIDENCE_BY_SOURCE = {
|
|
|
155
328
|
SOURCE_UNKNOWN: "T1-low",
|
|
156
329
|
}
|
|
157
330
|
|
|
331
|
+
# The confidence_distribution bins of metadata.json, from the strongest tier
|
|
332
|
+
# to the weakest. dominant_tier reads them in this order and lets a later bin
|
|
333
|
+
# win a tie, so a tie resolves toward the weaker tier.
|
|
334
|
+
_DISTRIBUTION_BINS = (("t1", "T1"), ("t1_low", "T1-low"), ("t2", "T2"), ("t3", "T3"))
|
|
335
|
+
|
|
336
|
+
# The tier of a package that records no evidence (the conservative default).
|
|
337
|
+
_NO_EVIDENCE_TIER = "T1-low"
|
|
338
|
+
|
|
339
|
+
# confidence_tier holds a different scale per skill_type: a single skill
|
|
340
|
+
# records the forge tier it was compiled at, a stack the dominant confidence
|
|
341
|
+
# tier of its libraries (its forge tier is in forge_tier). skill_type
|
|
342
|
+
# individual (the earlier name for single) and a missing skill_type (an early
|
|
343
|
+
# Quick Skill package) read as single; any other skill_type takes either scale.
|
|
344
|
+
_FORGE_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
345
|
+
_STACK_TIERS = ("T1", "T1-low", "T2", "T3")
|
|
346
|
+
_SINGLE_SKILL_TYPES = (None, "single", "individual")
|
|
347
|
+
|
|
348
|
+
# A source_repo or source_root path or URL splits into segments on either
|
|
349
|
+
# separator.
|
|
350
|
+
_PATH_SEPARATOR_RE = re.compile(r"[/\\]")
|
|
351
|
+
|
|
352
|
+
# candidates: the export manifest in the skills root, where each candidate
|
|
353
|
+
# came from, and why a candidate was excluded.
|
|
354
|
+
EXPORT_MANIFEST = ".export-manifest.json"
|
|
355
|
+
CANDIDATES_EXPLICIT = "explicit"
|
|
356
|
+
CANDIDATES_MANIFEST = "manifest"
|
|
357
|
+
CANDIDATES_ACTIVE_LINKS = "active-links"
|
|
358
|
+
REASON_NOT_A_SKILL = "not-a-skill"
|
|
359
|
+
REASON_NOT_SKF_OUTPUT = "not-skf-output"
|
|
360
|
+
REASON_ROSTER_WARNING = "roster-warning"
|
|
361
|
+
REASON_NO_SUCH_FOLDER = "no-such-folder"
|
|
362
|
+
REASON_NO_SKF_PACKAGE = "no-skf-package"
|
|
363
|
+
REASON_OUTSIDE_SKILLS_ROOT = "outside-skills-root"
|
|
364
|
+
|
|
158
365
|
# Section headings recognised in the references/ cascade and the SKILL.md
|
|
159
366
|
# cascade. Case-sensitive — these are the canonical SKF heading forms
|
|
160
367
|
# documented in parallel-extract.md §0.
|
|
@@ -213,29 +420,31 @@ def _normalize_exports(raw) -> list[str]:
|
|
|
213
420
|
return out
|
|
214
421
|
|
|
215
422
|
|
|
216
|
-
def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str]]:
|
|
423
|
+
def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str], dict]:
|
|
217
424
|
"""Try to resolve exports from metadata.json.
|
|
218
425
|
|
|
219
|
-
Returns (exports, metadata_hash, composes, warnings).
|
|
220
|
-
exports
|
|
426
|
+
Returns (exports, metadata_hash, composes, warnings, metadata).
|
|
427
|
+
exports : list of names if metadata.json was readable AND
|
|
221
428
|
contained an `exports[]` array (may be empty);
|
|
222
429
|
None if metadata.json absent or malformed.
|
|
223
|
-
metadata_hash
|
|
430
|
+
metadata_hash : sha256: digest of metadata.json content, or None
|
|
224
431
|
if file absent / unreadable.
|
|
225
|
-
composes
|
|
432
|
+
composes : list of constituent skill names (for cycle
|
|
226
433
|
detection); empty list if absent.
|
|
227
|
-
warnings
|
|
434
|
+
warnings : per-skill warnings (malformed JSON, etc.)
|
|
435
|
+
metadata : the parsed metadata.json object, or {} when the
|
|
436
|
+
file is absent, unreadable or not a JSON object.
|
|
228
437
|
"""
|
|
229
438
|
meta_path = skill_dir / "metadata.json"
|
|
230
439
|
if not meta_path.is_file():
|
|
231
|
-
return None, None, [], []
|
|
440
|
+
return None, None, [], [], {}
|
|
232
441
|
|
|
233
442
|
warnings: list[str] = []
|
|
234
443
|
try:
|
|
235
444
|
raw_bytes = meta_path.read_bytes()
|
|
236
445
|
except OSError as exc:
|
|
237
446
|
warnings.append(f"failed to read metadata.json: {exc}")
|
|
238
|
-
return None, None, [], warnings
|
|
447
|
+
return None, None, [], warnings, {}
|
|
239
448
|
|
|
240
449
|
metadata_hash = _sha256_of_bytes(raw_bytes)
|
|
241
450
|
|
|
@@ -245,11 +454,11 @@ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | Non
|
|
|
245
454
|
warnings.append(f"metadata.json is not valid JSON: {exc}")
|
|
246
455
|
# Hash is still useful — record it so callers can correlate
|
|
247
456
|
# malformed files across runs.
|
|
248
|
-
return None, metadata_hash, [], warnings
|
|
457
|
+
return None, metadata_hash, [], warnings, {}
|
|
249
458
|
|
|
250
459
|
if not isinstance(data, dict):
|
|
251
460
|
warnings.append("metadata.json root is not an object")
|
|
252
|
-
return None, metadata_hash, [], warnings
|
|
461
|
+
return None, metadata_hash, [], warnings, {}
|
|
253
462
|
|
|
254
463
|
composes_raw = data.get("composes")
|
|
255
464
|
composes = [c for c in composes_raw if isinstance(c, str)] if isinstance(composes_raw, list) else []
|
|
@@ -258,10 +467,10 @@ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | Non
|
|
|
258
467
|
# metadata.json exists and is valid JSON but lacks an exports
|
|
259
468
|
# field — fall through to references/ cascade. Carry the hash
|
|
260
469
|
# forward so callers see metadata existed.
|
|
261
|
-
return None, metadata_hash, composes, warnings
|
|
470
|
+
return None, metadata_hash, composes, warnings, data
|
|
262
471
|
|
|
263
472
|
exports = _normalize_exports(data.get("exports"))
|
|
264
|
-
return exports, metadata_hash, composes, warnings
|
|
473
|
+
return exports, metadata_hash, composes, warnings, data
|
|
265
474
|
|
|
266
475
|
|
|
267
476
|
# --------------------------------------------------------------------------
|
|
@@ -337,6 +546,127 @@ def _resolve_from_skill_md(skill_dir: Path) -> list[str]:
|
|
|
337
546
|
return _parse_list_section(content, _SKILL_MD_HEADINGS)
|
|
338
547
|
|
|
339
548
|
|
|
549
|
+
# --------------------------------------------------------------------------
|
|
550
|
+
# Evidence tier and metadata.json fields
|
|
551
|
+
# --------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _bin_count(value):
|
|
555
|
+
"""A confidence_distribution bin's count: a positive finite number, else 0."""
|
|
556
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
557
|
+
return 0
|
|
558
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
559
|
+
return 0
|
|
560
|
+
return value if value > 0 else 0
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def dominant_tier(distribution) -> str:
|
|
564
|
+
"""The tier of the largest bin of a metadata.json confidence_distribution.
|
|
565
|
+
|
|
566
|
+
The bins are read from the strongest tier to the weakest and a later bin
|
|
567
|
+
wins a tie, so a tie resolves toward the weaker tier (T1-low over T1, T2
|
|
568
|
+
over T1-low, T3 over T2) and the tier never overstates confidence. A bin
|
|
569
|
+
counts only when it holds a positive number. T1-low when none does: the
|
|
570
|
+
distribution is absent, not an object or all zero.
|
|
571
|
+
"""
|
|
572
|
+
tier, best = _NO_EVIDENCE_TIER, 0
|
|
573
|
+
if isinstance(distribution, dict):
|
|
574
|
+
for key, bin_tier in _DISTRIBUTION_BINS:
|
|
575
|
+
count = _bin_count(distribution.get(key))
|
|
576
|
+
if count and count >= best:
|
|
577
|
+
tier, best = bin_tier, count
|
|
578
|
+
return tier
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _text(value) -> str | None:
|
|
582
|
+
"""A string with its surrounding whitespace removed, or None when empty or not a string."""
|
|
583
|
+
if isinstance(value, str) and value.strip():
|
|
584
|
+
return value.strip()
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _language(value) -> str | list[str] | None:
|
|
589
|
+
"""metadata.json `language`: a string, or the strings of a list (a stack may list several)."""
|
|
590
|
+
if isinstance(value, list):
|
|
591
|
+
names = [name for name in (_text(item) for item in value) if name]
|
|
592
|
+
return names or None
|
|
593
|
+
return _text(value)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _confidence_tier(value, skill_type: str | None) -> str | None:
|
|
597
|
+
"""metadata.json `confidence_tier` when it belongs to its skill_type's scale."""
|
|
598
|
+
if skill_type == "stack":
|
|
599
|
+
allowed = _STACK_TIERS
|
|
600
|
+
elif skill_type in _SINGLE_SKILL_TYPES:
|
|
601
|
+
allowed = _FORGE_TIERS
|
|
602
|
+
else:
|
|
603
|
+
allowed = _FORGE_TIERS + _STACK_TIERS
|
|
604
|
+
return value if isinstance(value, str) and value in allowed else None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _whole_number(value) -> int | None:
|
|
608
|
+
"""A non-negative whole number (an integral float too), else None."""
|
|
609
|
+
if isinstance(value, bool):
|
|
610
|
+
return None
|
|
611
|
+
if isinstance(value, float) and value.is_integer():
|
|
612
|
+
value = int(value)
|
|
613
|
+
return value if isinstance(value, int) and value >= 0 else None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _last_segment(value: str) -> str | None:
|
|
617
|
+
"""The lower-case last non-empty `/` or `\\` segment, or None when it names no folder."""
|
|
618
|
+
segments = [segment for segment in _PATH_SEPARATOR_RE.split(value) if segment]
|
|
619
|
+
if not segments or segments[-1] in (".", ".."):
|
|
620
|
+
return None
|
|
621
|
+
return segments[-1].lower()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def source_repo_basename(source_repo: str | None) -> str | None:
|
|
625
|
+
"""The repository name a source_repo URL, owner/repo pair or path ends in.
|
|
626
|
+
|
|
627
|
+
The last segment, lower case, without a trailing `.git`. None when
|
|
628
|
+
source_repo is absent, holds no separator (so it is no URL, pair or
|
|
629
|
+
path), or leaves no name.
|
|
630
|
+
"""
|
|
631
|
+
if source_repo is None or not _PATH_SEPARATOR_RE.search(source_repo):
|
|
632
|
+
return None
|
|
633
|
+
segment = _last_segment(source_repo)
|
|
634
|
+
if segment is not None and segment.endswith(".git"):
|
|
635
|
+
segment = segment[:-len(".git")]
|
|
636
|
+
return segment if segment not in (None, "", ".", "..") else None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def source_root_basename(source_root: str | None) -> str | None:
|
|
640
|
+
"""The last folder name of a source_root path, lower case, or None."""
|
|
641
|
+
return None if source_root is None else _last_segment(source_root)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def metadata_fields(metadata: dict) -> dict:
|
|
645
|
+
"""The metadata.json facts a skills[] entry carries besides its exports.
|
|
646
|
+
|
|
647
|
+
Each is None when metadata.json lacks it or holds a value of another
|
|
648
|
+
shape, so a caller never reopens metadata.json to read it.
|
|
649
|
+
"""
|
|
650
|
+
skill_type = metadata.get("skill_type")
|
|
651
|
+
if not isinstance(skill_type, str):
|
|
652
|
+
skill_type = None
|
|
653
|
+
stats = metadata.get("stats")
|
|
654
|
+
source_repo = _text(metadata.get("source_repo"))
|
|
655
|
+
source_root = _text(metadata.get("source_root"))
|
|
656
|
+
return {
|
|
657
|
+
"skill_type": skill_type,
|
|
658
|
+
"language": _language(metadata.get("language")),
|
|
659
|
+
"confidence_tier": _confidence_tier(metadata.get("confidence_tier"), skill_type),
|
|
660
|
+
"exports_documented": _whole_number(
|
|
661
|
+
stats.get("exports_documented") if isinstance(stats, dict) else None),
|
|
662
|
+
"metadata_schema_version": _text(metadata.get("spec_version")),
|
|
663
|
+
"source_repo": source_repo,
|
|
664
|
+
"source_repo_basename": source_repo_basename(source_repo),
|
|
665
|
+
"source_root": source_root,
|
|
666
|
+
"source_root_basename": source_root_basename(source_root),
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
340
670
|
# --------------------------------------------------------------------------
|
|
341
671
|
# Per-skill resolution (cascade orchestrator)
|
|
342
672
|
# --------------------------------------------------------------------------
|
|
@@ -353,7 +683,7 @@ def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], li
|
|
|
353
683
|
warnings: list[str] = []
|
|
354
684
|
|
|
355
685
|
# 1. metadata.json
|
|
356
|
-
exports, metadata_hash, composes, meta_warnings = _resolve_from_metadata(skill_dir)
|
|
686
|
+
exports, metadata_hash, composes, meta_warnings, metadata = _resolve_from_metadata(skill_dir)
|
|
357
687
|
warnings.extend(f"{skill_name}: {w}" for w in meta_warnings)
|
|
358
688
|
|
|
359
689
|
if exports is not None:
|
|
@@ -382,8 +712,10 @@ def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], li
|
|
|
382
712
|
"exports": exports,
|
|
383
713
|
"exports_source": source,
|
|
384
714
|
"confidence": _CONFIDENCE_BY_SOURCE[source],
|
|
715
|
+
"evidence_tier": dominant_tier(metadata.get("confidence_distribution")),
|
|
385
716
|
"metadata_hash": metadata_hash,
|
|
386
717
|
}
|
|
718
|
+
entry.update(metadata_fields(metadata))
|
|
387
719
|
return entry, composes, warnings
|
|
388
720
|
|
|
389
721
|
|
|
@@ -456,58 +788,197 @@ def _version_sort_key(name: str) -> tuple:
|
|
|
456
788
|
return (tuple(nums), 0 if pre == "" else -1, name)
|
|
457
789
|
|
|
458
790
|
|
|
459
|
-
|
|
460
|
-
|
|
791
|
+
# Keep identical to SKF_GENERATORS in skf-skill-inventory.py
|
|
792
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
793
|
+
SKF_GENERATORS = frozenset({"quick-skill", "create-skill", "create-stack-skill"})
|
|
461
794
|
|
|
462
|
-
|
|
463
|
-
|
|
795
|
+
|
|
796
|
+
# Keep identical to _has_skf_metadata in skf-skill-inventory.py
|
|
797
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
798
|
+
def _has_skf_metadata(path: Path) -> bool:
|
|
799
|
+
"""True when a flat skill's metadata.json carries an SKF marker."""
|
|
800
|
+
try:
|
|
801
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
802
|
+
except (OSError, ValueError):
|
|
803
|
+
return False
|
|
804
|
+
if not isinstance(data, dict):
|
|
805
|
+
return False
|
|
806
|
+
generated_by = data.get("generated_by")
|
|
807
|
+
if isinstance(generated_by, str) and generated_by in SKF_GENERATORS:
|
|
808
|
+
return True
|
|
809
|
+
tool_versions = data.get("tool_versions")
|
|
810
|
+
if isinstance(tool_versions, dict) and "skf" in tool_versions:
|
|
811
|
+
return True
|
|
812
|
+
return (data.get("skill_type") in ("single", "individual", "stack")
|
|
813
|
+
and ("forge_tier" in data or "confidence_tier" in data))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# Keep identical to _is_link_or_junction in skf-skill-inventory.py
|
|
817
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
818
|
+
def _is_link_or_junction(p: Path) -> bool:
|
|
819
|
+
"""True for POSIX symlinks AND Windows junctions/symlinks.
|
|
820
|
+
|
|
821
|
+
`Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
|
|
822
|
+
for both symlinks and junctions (since CPython 3.8 on Windows). A regular
|
|
823
|
+
directory raises OSError on readlink, which is the signal we want to
|
|
824
|
+
refuse replacement. On Windows, any other reparse point (a cloud-sync
|
|
825
|
+
placeholder, a deduplicated file, an app execution alias) raises
|
|
826
|
+
ValueError: it does not redirect to another path, so it is not a link.
|
|
464
827
|
"""
|
|
828
|
+
if p.is_symlink():
|
|
829
|
+
return True
|
|
830
|
+
if not p.exists() and not p.is_symlink():
|
|
831
|
+
return False
|
|
465
832
|
try:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return None
|
|
471
|
-
return None
|
|
472
|
-
|
|
833
|
+
os.readlink(p)
|
|
834
|
+
return True
|
|
835
|
+
except (OSError, ValueError):
|
|
836
|
+
return False
|
|
473
837
|
|
|
474
|
-
def _resolve_package_dir(child: Path) -> Path | None:
|
|
475
|
-
"""Resolve the agentskills package dir (the one containing SKILL.md).
|
|
476
838
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
839
|
+
# Keep identical to _is_marked_version in skf-skill-inventory.py
|
|
840
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
841
|
+
def _is_marked_version(version_dir: Path, name: str) -> bool:
|
|
842
|
+
"""True when `version_dir/name/metadata.json` carries an SKF marker.
|
|
481
843
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
SKILL.md is reachable (caller skips the entry as a non-package).
|
|
844
|
+
SKF never links a version folder or the package inside one: a linked
|
|
845
|
+
one is not its output, whatever the metadata behind the link says.
|
|
485
846
|
"""
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
847
|
+
package = version_dir / name
|
|
848
|
+
return (not _is_link_or_junction(version_dir) and not _is_link_or_junction(package)
|
|
849
|
+
and _has_skf_metadata(package / "metadata.json"))
|
|
489
850
|
|
|
490
|
-
# Nested layout — prefer the stable `active` pointer.
|
|
491
|
-
active = child / "active"
|
|
492
|
-
if active.is_dir(): # is_dir() follows the symlink; False if broken
|
|
493
|
-
pkg = _inner_package(active)
|
|
494
|
-
if pkg is not None:
|
|
495
|
-
return pkg
|
|
496
851
|
|
|
497
|
-
|
|
852
|
+
# Keep identical to _looks_like_skill in skf-skill-inventory.py
|
|
853
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
854
|
+
def _looks_like_skill(skill_group_dir, skill_name):
|
|
855
|
+
"""True when a group holds a root SKILL.md or a {v}/{name}/ folder."""
|
|
856
|
+
if (skill_group_dir / "SKILL.md").is_file():
|
|
857
|
+
return True
|
|
498
858
|
try:
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
]
|
|
859
|
+
return any(child.is_dir() and (child / skill_name).is_dir()
|
|
860
|
+
for child in skill_group_dir.iterdir())
|
|
502
861
|
except OSError:
|
|
862
|
+
return False
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _metadata_problem(package: Path) -> str | None:
|
|
866
|
+
"""What keeps `package/metadata.json` from being read, or None.
|
|
867
|
+
|
|
868
|
+
None when the file is absent or holds a JSON object; otherwise the
|
|
869
|
+
reason, so a corrupt metadata.json on an SKF skill is counted as a
|
|
870
|
+
warning instead of silently reading as "not SKF output".
|
|
871
|
+
"""
|
|
872
|
+
meta_path = package / "metadata.json"
|
|
873
|
+
try:
|
|
874
|
+
# os.stat, not Path.is_file: from Python 3.14 pathlib reads a file
|
|
875
|
+
# SKF cannot reach as absent instead of raising.
|
|
876
|
+
if not stat.S_ISREG(os.stat(meta_path).st_mode):
|
|
877
|
+
return None
|
|
878
|
+
data = json.loads(meta_path.read_bytes().decode("utf-8"))
|
|
879
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
503
880
|
return None
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
881
|
+
except OSError as exc:
|
|
882
|
+
return f"failed to read ({exc})"
|
|
883
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
884
|
+
return f"is not valid JSON ({exc})"
|
|
885
|
+
if not isinstance(data, dict):
|
|
886
|
+
return "root is not an object"
|
|
887
|
+
return None
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _active_version_dir(child: Path) -> Path | None:
|
|
891
|
+
"""The version folder `child/active` names, when it is a folder directly in `child`.
|
|
892
|
+
|
|
893
|
+
`active` as a link counts only when it resolves to a real folder
|
|
894
|
+
directly in the group whose name holds no `.skf-`; a real `active/`
|
|
895
|
+
folder counts as itself.
|
|
896
|
+
"""
|
|
897
|
+
active = child / "active"
|
|
898
|
+
if _is_link_or_junction(active):
|
|
899
|
+
try:
|
|
900
|
+
target = active.resolve(strict=True)
|
|
901
|
+
group = child.resolve(strict=True)
|
|
902
|
+
except (OSError, RuntimeError):
|
|
903
|
+
return None
|
|
904
|
+
version = child / target.name
|
|
905
|
+
if (target.parent != group or ".skf-" in target.name or not target.is_dir()
|
|
906
|
+
or _is_link_or_junction(version)):
|
|
907
|
+
return None
|
|
908
|
+
return version
|
|
909
|
+
if active.is_dir():
|
|
910
|
+
return active
|
|
508
911
|
return None
|
|
509
912
|
|
|
510
913
|
|
|
914
|
+
def _skf_package(child: Path, name: str) -> tuple[Path | None, list[str]]:
|
|
915
|
+
"""The package SKF generated that a stack roster reads, and what it could not read.
|
|
916
|
+
|
|
917
|
+
Candidates, in order: the version `active` names, the version folders
|
|
918
|
+
from highest to lowest (`active`, links and `.skf-` names aside), then a
|
|
919
|
+
flat root `SKILL.md`. The first whose `metadata.json` carries an SKF
|
|
920
|
+
marker wins (`{v}/{name}/metadata.json`, or the root one for the flat
|
|
921
|
+
layout). Returns (package, problems): package is None when no candidate
|
|
922
|
+
is marked; problems names each candidate passed over because SKF could
|
|
923
|
+
not read it, its metadata.json or its version folder. A linked package
|
|
924
|
+
is never SKF output (`_is_marked_version`), so it is passed over without
|
|
925
|
+
a problem and its metadata.json is never read.
|
|
926
|
+
"""
|
|
927
|
+
problems: list[str] = []
|
|
928
|
+
|
|
929
|
+
def marked(version: Path) -> bool:
|
|
930
|
+
package = version / name
|
|
931
|
+
try:
|
|
932
|
+
os.listdir(version) # raises on every Python when SKF cannot search it
|
|
933
|
+
if _is_marked_version(version, name):
|
|
934
|
+
return True
|
|
935
|
+
if _is_link_or_junction(package):
|
|
936
|
+
return False
|
|
937
|
+
except OSError as exc:
|
|
938
|
+
problems.append(f"{name}/{version.name} cannot be read ({exc})")
|
|
939
|
+
return False
|
|
940
|
+
problem = _metadata_problem(package)
|
|
941
|
+
if problem:
|
|
942
|
+
problems.append(f"{name}/{version.name}/{name}/metadata.json {problem}")
|
|
943
|
+
return False
|
|
944
|
+
|
|
945
|
+
active = _active_version_dir(child)
|
|
946
|
+
if active is not None and marked(active):
|
|
947
|
+
return child / "active" / name, problems
|
|
948
|
+
try:
|
|
949
|
+
entries = list(child.iterdir())
|
|
950
|
+
except OSError as exc:
|
|
951
|
+
entries = []
|
|
952
|
+
problems.append(f"{name} cannot be listed ({exc})")
|
|
953
|
+
versions: list[Path] = []
|
|
954
|
+
for entry in entries:
|
|
955
|
+
if (entry.name == "active" or ".skf-" in entry.name
|
|
956
|
+
or (active is not None and entry.name == active.name)):
|
|
957
|
+
continue
|
|
958
|
+
try:
|
|
959
|
+
if _is_link_or_junction(entry) or not entry.is_dir():
|
|
960
|
+
continue
|
|
961
|
+
# os.stat raises on every Python when SKF cannot search `entry`;
|
|
962
|
+
# from 3.14, Path.is_dir would read such a folder as empty.
|
|
963
|
+
if not stat.S_ISDIR(os.stat(entry / name).st_mode):
|
|
964
|
+
continue
|
|
965
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
966
|
+
continue
|
|
967
|
+
except OSError:
|
|
968
|
+
pass # a folder SKF cannot search may hold a version: marked() names it
|
|
969
|
+
versions.append(entry)
|
|
970
|
+
for vdir in sorted(versions, key=lambda d: _version_sort_key(d.name), reverse=True):
|
|
971
|
+
if marked(vdir):
|
|
972
|
+
return vdir / name, problems
|
|
973
|
+
if (child / "SKILL.md").is_file():
|
|
974
|
+
if _has_skf_metadata(child / "metadata.json"):
|
|
975
|
+
return child, problems
|
|
976
|
+
problem = _metadata_problem(child)
|
|
977
|
+
if problem:
|
|
978
|
+
problems.append(f"{name}/metadata.json {problem}")
|
|
979
|
+
return None, problems
|
|
980
|
+
|
|
981
|
+
|
|
511
982
|
# --------------------------------------------------------------------------
|
|
512
983
|
# Enumeration entry point
|
|
513
984
|
# --------------------------------------------------------------------------
|
|
@@ -515,7 +986,7 @@ def _resolve_package_dir(child: Path) -> Path | None:
|
|
|
515
986
|
|
|
516
987
|
def enumerate_stack_skills(skills_root: Path) -> dict:
|
|
517
988
|
"""Walk `skills_root`, build inventory, detect cycles, return result."""
|
|
518
|
-
result: dict = {"skills": [], "cycles": [], "warnings": []}
|
|
989
|
+
result: dict = {"skills": [], "cycles": [], "warnings": [], "not_skf_output": []}
|
|
519
990
|
compose_graph: dict[str, list[str]] = {}
|
|
520
991
|
|
|
521
992
|
try:
|
|
@@ -528,38 +999,44 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
|
|
|
528
999
|
|
|
529
1000
|
for child in children:
|
|
530
1001
|
name = child.name
|
|
531
|
-
if name.startswith("."):
|
|
532
|
-
continue
|
|
1002
|
+
if name.startswith(".") or name == "_batch" or ".skf-" in name:
|
|
1003
|
+
continue # hidden folders and SKF's own batch and staging names
|
|
533
1004
|
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
|
|
538
|
-
if child.is_symlink():
|
|
1005
|
+
# A linked top-level folder is never SKF output: SKF never links a
|
|
1006
|
+
# skill folder. List it when it looks like a skill; a broken link,
|
|
1007
|
+
# or one SKF cannot read through, holds nothing to list.
|
|
1008
|
+
if _is_link_or_junction(child):
|
|
539
1009
|
try:
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if not target.is_dir():
|
|
547
|
-
result["warnings"].append(
|
|
548
|
-
f"{name}: symlink target is not a directory"
|
|
549
|
-
)
|
|
550
|
-
continue
|
|
551
|
-
elif not child.is_dir():
|
|
1010
|
+
if _looks_like_skill(child, name):
|
|
1011
|
+
result["not_skf_output"].append(name)
|
|
1012
|
+
except OSError:
|
|
1013
|
+
pass
|
|
1014
|
+
continue
|
|
1015
|
+
if not child.is_dir():
|
|
552
1016
|
continue
|
|
553
1017
|
|
|
554
|
-
# Resolve the package dir across flat and version-nested layouts
|
|
555
|
-
# (knowledge/version-paths.md). Subdirs with no reachable SKILL.md
|
|
556
|
-
# (e.g. `shared/`, `knowledge/`) are skipped silently — not packages.
|
|
557
1018
|
try:
|
|
558
|
-
skill_dir =
|
|
559
|
-
|
|
1019
|
+
skill_dir, problems = _skf_package(child, name)
|
|
1020
|
+
foreign = skill_dir is None and not problems and _looks_like_skill(child, name)
|
|
1021
|
+
except Exception as exc: # noqa: BLE001 — per-skill failures are warnings, not fatal
|
|
1022
|
+
# An unreadable folder raises OSError; a metadata.json nested
|
|
1023
|
+
# too deeply for the JSON parser raises RecursionError.
|
|
560
1024
|
result["warnings"].append(f"{name}: failed to resolve package dir ({exc})")
|
|
561
1025
|
continue
|
|
562
1026
|
if skill_dir is None:
|
|
1027
|
+
if problems:
|
|
1028
|
+
result["warnings"].append(
|
|
1029
|
+
f"{name}: SKF cannot tell whether it generated this skill: "
|
|
1030
|
+
+ "; ".join(problems))
|
|
1031
|
+
elif foreign:
|
|
1032
|
+
result["not_skf_output"].append(name)
|
|
1033
|
+
continue
|
|
1034
|
+
rel = skill_dir.relative_to(skills_root).as_posix()
|
|
1035
|
+
if problems:
|
|
1036
|
+
result["warnings"].append(
|
|
1037
|
+
f"{name}: " + "; ".join(problems) + f"; read the SKF package at {rel} instead")
|
|
1038
|
+
if not (skill_dir / "SKILL.md").is_file():
|
|
1039
|
+
result["warnings"].append(f"{name}: the SKF package at {rel} has no SKILL.md")
|
|
563
1040
|
continue
|
|
564
1041
|
|
|
565
1042
|
try:
|
|
@@ -568,14 +1045,9 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
|
|
|
568
1045
|
result["warnings"].append(f"{name}: enumeration failed: {exc}")
|
|
569
1046
|
continue
|
|
570
1047
|
|
|
571
|
-
#
|
|
572
|
-
# skills_root
|
|
573
|
-
|
|
574
|
-
try:
|
|
575
|
-
entry["path"] = skill_dir.relative_to(skills_root).as_posix()
|
|
576
|
-
except ValueError:
|
|
577
|
-
entry["path"] = name
|
|
578
|
-
|
|
1048
|
+
# The resolved package location, forward-slash and relative to
|
|
1049
|
+
# skills_root: `x/active/x`, `x/{version}/x` or `x` (flat).
|
|
1050
|
+
entry["path"] = rel
|
|
579
1051
|
result["skills"].append(entry)
|
|
580
1052
|
result["warnings"].extend(skill_warnings)
|
|
581
1053
|
compose_graph[name] = composes
|
|
@@ -611,6 +1083,36 @@ def compute_pairs(skills: list[dict]) -> list[dict]:
|
|
|
611
1083
|
]
|
|
612
1084
|
|
|
613
1085
|
|
|
1086
|
+
def compute_scope(names: list[str], in_scope: list[str]) -> dict:
|
|
1087
|
+
"""Split an inventory's pairs by the skills in scope (`scope`).
|
|
1088
|
+
|
|
1089
|
+
`names` are the inventory's skill names and `in_scope` the names of the
|
|
1090
|
+
skills in scope. The pairs are compute_pairs() over `names`, so they are
|
|
1091
|
+
the ones `--pairs` emitted, in the same order; a pair is in scope when
|
|
1092
|
+
both of its libraries are. See "Scope" in the module docstring for the
|
|
1093
|
+
keys.
|
|
1094
|
+
"""
|
|
1095
|
+
known = set(names)
|
|
1096
|
+
unknown: list[str] = []
|
|
1097
|
+
for name in in_scope:
|
|
1098
|
+
if name not in known and name not in unknown:
|
|
1099
|
+
unknown.append(name)
|
|
1100
|
+
kept = {name for name in in_scope if name in known}
|
|
1101
|
+
pairs = compute_pairs([{"name": name} for name in known])
|
|
1102
|
+
inside = [p for p in pairs if p["library_a"] in kept and p["library_b"] in kept]
|
|
1103
|
+
outside = [p for p in pairs if not (p["library_a"] in kept and p["library_b"] in kept)]
|
|
1104
|
+
return {
|
|
1105
|
+
"in_scope": sorted(kept),
|
|
1106
|
+
"out_of_scope": sorted(known - kept),
|
|
1107
|
+
"unknown": unknown,
|
|
1108
|
+
"in_scope_pairs": inside,
|
|
1109
|
+
"out_of_scope_pairs": outside,
|
|
1110
|
+
"pair_count": len(pairs),
|
|
1111
|
+
"in_scope_pair_count": len(inside),
|
|
1112
|
+
"out_of_scope_pair_count": len(outside),
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
614
1116
|
# --------------------------------------------------------------------------
|
|
615
1117
|
# Derived output: inventory reliability verdict (--reliability)
|
|
616
1118
|
# --------------------------------------------------------------------------
|
|
@@ -623,7 +1125,7 @@ def compute_reliability(skill_count: int, warning_count: int) -> dict:
|
|
|
623
1125
|
or 0.0 for an empty inventory (no ZeroDivisionError). The inventory is
|
|
624
1126
|
reliable when that ratio is <= RELIABILITY_THRESHOLD (boundary
|
|
625
1127
|
inclusive). Emits the raw counts too so a caller's halt message can
|
|
626
|
-
render "{warning_count}
|
|
1128
|
+
render "{warning_count} warning(s) across {skill_count} skill(s)".
|
|
627
1129
|
"""
|
|
628
1130
|
total = skill_count + warning_count
|
|
629
1131
|
unreliable_ratio = (warning_count / total) if total else 0.0
|
|
@@ -635,11 +1137,266 @@ def compute_reliability(skill_count: int, warning_count: int) -> dict:
|
|
|
635
1137
|
}
|
|
636
1138
|
|
|
637
1139
|
|
|
1140
|
+
# --------------------------------------------------------------------------
|
|
1141
|
+
# Derived output: metadata_hash changes (--expect-hashes)
|
|
1142
|
+
# --------------------------------------------------------------------------
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def load_expected_hashes(text: str) -> dict[str, str | None]:
|
|
1146
|
+
"""The metadata_hash recorded for each skill name, from --expect-hashes JSON.
|
|
1147
|
+
|
|
1148
|
+
Takes an enumerate result, whose skills[] entries give each `name` its
|
|
1149
|
+
`metadata_hash`, or an object mapping each skill name to its hash (a
|
|
1150
|
+
string, or null for none). Raises ValueError on anything else.
|
|
1151
|
+
"""
|
|
1152
|
+
data = json.loads(text)
|
|
1153
|
+
if not isinstance(data, dict):
|
|
1154
|
+
raise ValueError("the JSON root is not an object")
|
|
1155
|
+
if not isinstance(data.get("skills"), list):
|
|
1156
|
+
if not all(h is None or isinstance(h, str) for h in data.values()):
|
|
1157
|
+
raise ValueError(
|
|
1158
|
+
"expected an enumerate result (with skills[]) or an object "
|
|
1159
|
+
"mapping each skill name to its metadata_hash")
|
|
1160
|
+
return dict(data)
|
|
1161
|
+
expected: dict[str, str | None] = {}
|
|
1162
|
+
for entry in data["skills"]:
|
|
1163
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
1164
|
+
if not isinstance(name, str):
|
|
1165
|
+
raise ValueError("a skills[] entry has no string name")
|
|
1166
|
+
metadata_hash = entry.get("metadata_hash")
|
|
1167
|
+
if metadata_hash is not None and not isinstance(metadata_hash, str):
|
|
1168
|
+
raise ValueError(f"skills[] entry {name!r} has a metadata_hash that is not a string")
|
|
1169
|
+
if name in expected:
|
|
1170
|
+
raise ValueError(f"skills[] lists {name!r} twice")
|
|
1171
|
+
expected[name] = metadata_hash
|
|
1172
|
+
return expected
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def compare_hashes(skills: list[dict], expected: dict[str, str | None]) -> dict:
|
|
1176
|
+
"""Which skills changed against the metadata_hash recorded for them.
|
|
1177
|
+
|
|
1178
|
+
changed_skills: enumerated now with another metadata_hash;
|
|
1179
|
+
missing_skills: recorded but not enumerated now; new_skills: enumerated
|
|
1180
|
+
now but not recorded. Each sorted by name; hashes compare as written.
|
|
1181
|
+
"""
|
|
1182
|
+
current = {entry["name"]: entry["metadata_hash"] for entry in skills}
|
|
1183
|
+
return {
|
|
1184
|
+
"changed_skills": sorted(
|
|
1185
|
+
name for name, metadata_hash in expected.items()
|
|
1186
|
+
if name in current and current[name] != metadata_hash),
|
|
1187
|
+
"missing_skills": sorted(name for name in expected if name not in current),
|
|
1188
|
+
"new_skills": sorted(name for name in current if name not in expected),
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
# --------------------------------------------------------------------------
|
|
1193
|
+
# Candidates: pick the compose-mode candidates and gate them (candidates)
|
|
1194
|
+
# --------------------------------------------------------------------------
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _is_folder_name(skills_root: Path, name: str) -> bool:
|
|
1198
|
+
"""True when `name` can name one folder directly in `skills_root`."""
|
|
1199
|
+
folder = skills_root / name
|
|
1200
|
+
return (name not in ("", ".", "..") and "\x00" not in name
|
|
1201
|
+
and folder.parent == skills_root and folder.name == name)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _has_folder(skills_root: Path, name: str) -> bool:
|
|
1205
|
+
"""True when `skills_root/name` is a folder, or SKF cannot tell.
|
|
1206
|
+
|
|
1207
|
+
A name that cannot be one folder in the skills root (empty, `.`, `..`, a
|
|
1208
|
+
path) never is. A folder SKF cannot stat counts as present, so a
|
|
1209
|
+
manifest key is never called stale because of a folder SKF only fails to
|
|
1210
|
+
read.
|
|
1211
|
+
"""
|
|
1212
|
+
if not _is_folder_name(skills_root, name):
|
|
1213
|
+
return False
|
|
1214
|
+
try:
|
|
1215
|
+
return stat.S_ISDIR(os.stat(skills_root / name).st_mode)
|
|
1216
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
1217
|
+
return False
|
|
1218
|
+
except OSError:
|
|
1219
|
+
return True
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _manifest_keys(skills_root: Path) -> tuple[list[str], str | None]:
|
|
1223
|
+
"""The skill names the export manifest lists, and why it could not be read.
|
|
1224
|
+
|
|
1225
|
+
([], None) when the manifest is absent or lists nothing; ([], reason)
|
|
1226
|
+
when it cannot be read or is not the manifest's shape (an object whose
|
|
1227
|
+
`exports`, when present, is an object).
|
|
1228
|
+
"""
|
|
1229
|
+
manifest = skills_root / EXPORT_MANIFEST
|
|
1230
|
+
try:
|
|
1231
|
+
raw = manifest.read_bytes()
|
|
1232
|
+
except FileNotFoundError:
|
|
1233
|
+
return [], None
|
|
1234
|
+
except OSError as exc:
|
|
1235
|
+
return [], f"{EXPORT_MANIFEST} cannot be read ({exc})"
|
|
1236
|
+
try:
|
|
1237
|
+
data = json.loads(raw.decode("utf-8"))
|
|
1238
|
+
except (ValueError, RecursionError) as exc: # JSON, UTF-8 or nesting errors
|
|
1239
|
+
return [], f"{EXPORT_MANIFEST} is not valid JSON ({exc})"
|
|
1240
|
+
if not isinstance(data, dict):
|
|
1241
|
+
return [], f"{EXPORT_MANIFEST} root is not an object"
|
|
1242
|
+
exports = data.get("exports")
|
|
1243
|
+
if exports is None:
|
|
1244
|
+
return [], None
|
|
1245
|
+
if not isinstance(exports, dict):
|
|
1246
|
+
return [], f"{EXPORT_MANIFEST} exports is not an object"
|
|
1247
|
+
return list(exports), None
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _active_link_candidates(skills_root: Path) -> list[str]:
|
|
1251
|
+
"""The top-level folders matching `<skills-root>/*/active/*/SKILL.md`.
|
|
1252
|
+
|
|
1253
|
+
`active` may be a link or a real folder. Dot names never match, as in a
|
|
1254
|
+
glob, and SKF's own `_batch` and `.skf-` names are skipped as the roster
|
|
1255
|
+
skips them.
|
|
1256
|
+
"""
|
|
1257
|
+
try:
|
|
1258
|
+
children = list(skills_root.iterdir())
|
|
1259
|
+
except OSError:
|
|
1260
|
+
return []
|
|
1261
|
+
found: list[str] = []
|
|
1262
|
+
for child in children:
|
|
1263
|
+
name = child.name
|
|
1264
|
+
if name.startswith(".") or name == "_batch" or ".skf-" in name:
|
|
1265
|
+
continue
|
|
1266
|
+
active = child / "active"
|
|
1267
|
+
try:
|
|
1268
|
+
packages = os.listdir(active)
|
|
1269
|
+
except OSError: # no `active`, not a folder, or unreadable
|
|
1270
|
+
continue
|
|
1271
|
+
for package in packages:
|
|
1272
|
+
if package.startswith("."):
|
|
1273
|
+
continue
|
|
1274
|
+
try:
|
|
1275
|
+
if stat.S_ISREG(os.stat(active / package / "SKILL.md").st_mode):
|
|
1276
|
+
found.append(name)
|
|
1277
|
+
break
|
|
1278
|
+
except OSError:
|
|
1279
|
+
continue
|
|
1280
|
+
return found
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def explicit_skill_dir(skills_root: Path, entry: str) -> str | None:
|
|
1284
|
+
"""The skill folder an --explicit entry names; None for a path outside `skills_root`.
|
|
1285
|
+
|
|
1286
|
+
An entry with no `/` or `\\` is the folder name itself. Any other entry is
|
|
1287
|
+
a package path, absolute or relative to the current folder, that names
|
|
1288
|
+
the first folder of its path under `skills_root`: compared as written
|
|
1289
|
+
first (so `active/<name>` counts as written), then with links resolved.
|
|
1290
|
+
"""
|
|
1291
|
+
if "/" not in entry and "\\" not in entry:
|
|
1292
|
+
return entry
|
|
1293
|
+
path = Path(entry.replace("\\", "/"))
|
|
1294
|
+
for resolve in (False, True):
|
|
1295
|
+
try:
|
|
1296
|
+
if resolve:
|
|
1297
|
+
parts = path.resolve().relative_to(skills_root.resolve()).parts
|
|
1298
|
+
else:
|
|
1299
|
+
parts = Path(os.path.abspath(path)).relative_to(os.path.abspath(skills_root)).parts
|
|
1300
|
+
except (ValueError, OSError, RuntimeError): # outside, or a path SKF cannot resolve
|
|
1301
|
+
continue
|
|
1302
|
+
if parts:
|
|
1303
|
+
return parts[0]
|
|
1304
|
+
return None
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _excluded(skill_dir: str, reason: str, message: str) -> dict:
|
|
1308
|
+
return {"skill_dir": skill_dir, "reason": reason, "message": message}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def compute_candidates(skills_root: Path, explicit: list[str] | None = None) -> dict:
|
|
1312
|
+
"""Pick the compose-mode candidates and gate them against the roster.
|
|
1313
|
+
|
|
1314
|
+
`explicit` names the candidates when given; else the export manifest
|
|
1315
|
+
keys; else, when the manifest is absent, lists nothing or cannot be
|
|
1316
|
+
read, the folders holding an `active` package. A candidate is kept when
|
|
1317
|
+
the roster has it with skill_type single, individual or null; every
|
|
1318
|
+
other one is excluded with the first reason that applies, except a
|
|
1319
|
+
manifest key with no folder, which is a stale manifest key.
|
|
1320
|
+
"""
|
|
1321
|
+
roster = enumerate_stack_skills(skills_root)
|
|
1322
|
+
manifest_parse_error = None
|
|
1323
|
+
outside: list[str] = []
|
|
1324
|
+
if explicit is not None:
|
|
1325
|
+
source, names = CANDIDATES_EXPLICIT, []
|
|
1326
|
+
for entry in explicit:
|
|
1327
|
+
skill_dir = explicit_skill_dir(skills_root, entry)
|
|
1328
|
+
if skill_dir is None:
|
|
1329
|
+
outside.append(entry)
|
|
1330
|
+
else:
|
|
1331
|
+
names.append(skill_dir)
|
|
1332
|
+
else:
|
|
1333
|
+
names, manifest_parse_error = _manifest_keys(skills_root)
|
|
1334
|
+
source = CANDIDATES_MANIFEST
|
|
1335
|
+
if not names:
|
|
1336
|
+
source, names = CANDIDATES_ACTIVE_LINKS, _active_link_candidates(skills_root)
|
|
1337
|
+
# One order for every source and platform: a Path sorts without case on
|
|
1338
|
+
# Windows, a name always with it.
|
|
1339
|
+
names = sorted(set(names))
|
|
1340
|
+
|
|
1341
|
+
by_name = {entry["name"]: entry for entry in roster["skills"]}
|
|
1342
|
+
not_skf_output = set(roster["not_skf_output"])
|
|
1343
|
+
kept: list[dict] = []
|
|
1344
|
+
excluded: list[dict] = []
|
|
1345
|
+
stale: list[str] = []
|
|
1346
|
+
for name in names:
|
|
1347
|
+
entry = by_name.get(name)
|
|
1348
|
+
if entry is not None:
|
|
1349
|
+
if entry["skill_type"] in _SINGLE_SKILL_TYPES:
|
|
1350
|
+
kept.append(entry)
|
|
1351
|
+
else:
|
|
1352
|
+
excluded.append(_excluded(
|
|
1353
|
+
name, REASON_NOT_A_SKILL,
|
|
1354
|
+
f"{name}: not a skill (skill_type {entry['skill_type']}), excluding"))
|
|
1355
|
+
continue
|
|
1356
|
+
if name in not_skf_output:
|
|
1357
|
+
excluded.append(_excluded(name, REASON_NOT_SKF_OUTPUT, f"{name}: not SKF output, excluding"))
|
|
1358
|
+
continue
|
|
1359
|
+
named = [w for w in roster["warnings"] if w.startswith(f"{name}: ")]
|
|
1360
|
+
if named:
|
|
1361
|
+
excluded.append(_excluded(name, REASON_ROSTER_WARNING, "; ".join(named)))
|
|
1362
|
+
continue
|
|
1363
|
+
if not _has_folder(skills_root, name):
|
|
1364
|
+
if source == CANDIDATES_MANIFEST:
|
|
1365
|
+
stale.append(name)
|
|
1366
|
+
else:
|
|
1367
|
+
excluded.append(_excluded(
|
|
1368
|
+
name, REASON_NO_SUCH_FOLDER, f"{name}: no such skill folder, excluding"))
|
|
1369
|
+
continue
|
|
1370
|
+
excluded.append(_excluded(name, REASON_NO_SKF_PACKAGE, f"{name}: no SKF skill package, excluding"))
|
|
1371
|
+
for entry in sorted(set(outside)):
|
|
1372
|
+
excluded.append(_excluded(
|
|
1373
|
+
entry, REASON_OUTSIDE_SKILLS_ROOT, f"{entry}: not inside the skills folder, excluding"))
|
|
1374
|
+
excluded.sort(key=lambda x: x["skill_dir"])
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
"candidate_source": source,
|
|
1378
|
+
"kept": kept,
|
|
1379
|
+
"excluded": excluded,
|
|
1380
|
+
"stale_manifest_keys": stale,
|
|
1381
|
+
"manifest_parse_error": manifest_parse_error,
|
|
1382
|
+
"cycles": roster["cycles"],
|
|
1383
|
+
"warnings": roster["warnings"],
|
|
1384
|
+
"not_skf_output": roster["not_skf_output"],
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
|
|
638
1388
|
# --------------------------------------------------------------------------
|
|
639
1389
|
# CLI
|
|
640
1390
|
# --------------------------------------------------------------------------
|
|
641
1391
|
|
|
642
1392
|
|
|
1393
|
+
def _read_expected_hashes(source: str) -> dict[str, str | None]:
|
|
1394
|
+
"""Load the --expect-hashes file (`-` reads stdin). Raises ValueError or OSError."""
|
|
1395
|
+
if source == "-":
|
|
1396
|
+
return load_expected_hashes(sys.stdin.read())
|
|
1397
|
+
return load_expected_hashes(Path(source).read_text(encoding="utf-8"))
|
|
1398
|
+
|
|
1399
|
+
|
|
643
1400
|
def _cmd_enumerate(args: argparse.Namespace) -> int:
|
|
644
1401
|
skills_root = Path(args.skills_root)
|
|
645
1402
|
if not skills_root.is_dir():
|
|
@@ -648,9 +1405,16 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
|
|
|
648
1405
|
file=sys.stderr,
|
|
649
1406
|
)
|
|
650
1407
|
return 1
|
|
1408
|
+
expected = None
|
|
1409
|
+
if args.expect_hashes is not None:
|
|
1410
|
+
try:
|
|
1411
|
+
expected = _read_expected_hashes(args.expect_hashes)
|
|
1412
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
1413
|
+
print(f"error: --expect-hashes {args.expect_hashes}: {exc}", file=sys.stderr)
|
|
1414
|
+
return 1
|
|
651
1415
|
result = enumerate_stack_skills(skills_root)
|
|
652
1416
|
# Additive derived output — attached only when the flag is set, so the
|
|
653
|
-
# default shape stays {skills, cycles, warnings}
|
|
1417
|
+
# default shape stays {skills, cycles, warnings, not_skf_output}.
|
|
654
1418
|
if args.pairs:
|
|
655
1419
|
result["pairs"] = compute_pairs(result["skills"])
|
|
656
1420
|
result["pair_count"] = len(result["pairs"])
|
|
@@ -658,11 +1422,47 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
|
|
|
658
1422
|
result.update(
|
|
659
1423
|
compute_reliability(len(result["skills"]), len(result["warnings"]))
|
|
660
1424
|
)
|
|
1425
|
+
if expected is not None:
|
|
1426
|
+
result.update(compare_hashes(result["skills"], expected))
|
|
661
1427
|
json.dump(result, sys.stdout, indent=2)
|
|
662
1428
|
sys.stdout.write("\n")
|
|
663
1429
|
return 0
|
|
664
1430
|
|
|
665
1431
|
|
|
1432
|
+
def _parse_explicit(value: str) -> list[str]:
|
|
1433
|
+
"""The names of a comma-separated list (--explicit, --skills, --in-scope), trimmed, empty items dropped."""
|
|
1434
|
+
return [name for name in (part.strip() for part in value.split(",")) if name]
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _cmd_candidates(args: argparse.Namespace) -> int:
|
|
1438
|
+
skills_root = Path(args.skills_root)
|
|
1439
|
+
if not skills_root.is_dir():
|
|
1440
|
+
print(
|
|
1441
|
+
f"error: skills root not a directory: {skills_root}",
|
|
1442
|
+
file=sys.stderr,
|
|
1443
|
+
)
|
|
1444
|
+
return 1
|
|
1445
|
+
explicit = None
|
|
1446
|
+
if args.explicit is not None:
|
|
1447
|
+
explicit = _parse_explicit(args.explicit)
|
|
1448
|
+
if not explicit:
|
|
1449
|
+
print("error: --explicit names no skill", file=sys.stderr)
|
|
1450
|
+
return 1
|
|
1451
|
+
json.dump(compute_candidates(skills_root, explicit), sys.stdout, indent=2)
|
|
1452
|
+
sys.stdout.write("\n")
|
|
1453
|
+
return 0
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _cmd_scope(args: argparse.Namespace) -> int:
|
|
1457
|
+
names = _parse_explicit(args.skills)
|
|
1458
|
+
if not names:
|
|
1459
|
+
print("error: --skills names no skill", file=sys.stderr)
|
|
1460
|
+
return 1
|
|
1461
|
+
json.dump(compute_scope(names, _parse_explicit(args.in_scope)), sys.stdout, indent=2)
|
|
1462
|
+
sys.stdout.write("\n")
|
|
1463
|
+
return 0
|
|
1464
|
+
|
|
1465
|
+
|
|
666
1466
|
def _build_parser() -> argparse.ArgumentParser:
|
|
667
1467
|
parser = argparse.ArgumentParser(
|
|
668
1468
|
prog="skf-enumerate-stack-skills",
|
|
@@ -680,7 +1480,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
680
1480
|
)
|
|
681
1481
|
p_enum.add_argument(
|
|
682
1482
|
"skills_root",
|
|
683
|
-
help="directory
|
|
1483
|
+
help="directory holding skill folders; only packages SKF generated are enumerated",
|
|
684
1484
|
)
|
|
685
1485
|
p_enum.add_argument(
|
|
686
1486
|
"--pairs",
|
|
@@ -699,8 +1499,61 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
699
1499
|
f"`warning_count`; threshold {RELIABILITY_THRESHOLD})"
|
|
700
1500
|
),
|
|
701
1501
|
)
|
|
1502
|
+
p_enum.add_argument(
|
|
1503
|
+
"--expect-hashes",
|
|
1504
|
+
metavar="FILE",
|
|
1505
|
+
help=(
|
|
1506
|
+
"additionally compare each skill's metadata_hash with FILE "
|
|
1507
|
+
"(an earlier enumerate result, or an object mapping each skill "
|
|
1508
|
+
"name to its metadata_hash; `-` reads stdin) and emit "
|
|
1509
|
+
"`changed_skills`, `missing_skills` and `new_skills`"
|
|
1510
|
+
),
|
|
1511
|
+
)
|
|
702
1512
|
p_enum.set_defaults(func=_cmd_enumerate)
|
|
703
1513
|
|
|
1514
|
+
p_cand = sub.add_parser(
|
|
1515
|
+
"candidates",
|
|
1516
|
+
help="pick the compose-mode candidates and gate them against the roster",
|
|
1517
|
+
)
|
|
1518
|
+
p_cand.add_argument(
|
|
1519
|
+
"skills_root",
|
|
1520
|
+
help="directory holding skill folders; only packages SKF generated can be kept",
|
|
1521
|
+
)
|
|
1522
|
+
p_cand.add_argument(
|
|
1523
|
+
"--explicit",
|
|
1524
|
+
metavar="NAMES",
|
|
1525
|
+
help=(
|
|
1526
|
+
"comma-separated skill folder names or package paths to gate, "
|
|
1527
|
+
"instead of the export manifest keys or the folders holding an "
|
|
1528
|
+
"`active` package; a path names its first folder under skills-root"
|
|
1529
|
+
),
|
|
1530
|
+
)
|
|
1531
|
+
p_cand.set_defaults(func=_cmd_candidates)
|
|
1532
|
+
|
|
1533
|
+
p_scope = sub.add_parser(
|
|
1534
|
+
"scope",
|
|
1535
|
+
help="split the unique pairs of an inventory's skills by the skills in scope",
|
|
1536
|
+
)
|
|
1537
|
+
p_scope.add_argument(
|
|
1538
|
+
"--skills",
|
|
1539
|
+
required=True,
|
|
1540
|
+
metavar="NAMES",
|
|
1541
|
+
help=(
|
|
1542
|
+
"comma-separated names of the inventory's skills (the `name` of "
|
|
1543
|
+
"each skills[] entry of an enumerate result)"
|
|
1544
|
+
),
|
|
1545
|
+
)
|
|
1546
|
+
p_scope.add_argument(
|
|
1547
|
+
"--in-scope",
|
|
1548
|
+
required=True,
|
|
1549
|
+
metavar="NAMES",
|
|
1550
|
+
help=(
|
|
1551
|
+
"comma-separated names of the skills in scope; a name that is no "
|
|
1552
|
+
"inventory skill is listed in `unknown`"
|
|
1553
|
+
),
|
|
1554
|
+
)
|
|
1555
|
+
p_scope.set_defaults(func=_cmd_scope)
|
|
1556
|
+
|
|
704
1557
|
return parser
|
|
705
1558
|
|
|
706
1559
|
|
|
@@ -710,5 +1563,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
710
1563
|
return args.func(args)
|
|
711
1564
|
|
|
712
1565
|
|
|
1566
|
+
def _force_utf8(*streams) -> None:
|
|
1567
|
+
"""Reconfigure stdout and stderr to UTF-8, keeping each stream's error handler.
|
|
1568
|
+
|
|
1569
|
+
A Windows console pipes them as cp1252, which cannot print every character
|
|
1570
|
+
of the --help text, so --help would stop with UnicodeEncodeError.
|
|
1571
|
+
"""
|
|
1572
|
+
for stream in streams:
|
|
1573
|
+
if hasattr(stream, "reconfigure"):
|
|
1574
|
+
errors = getattr(stream, "errors", None)
|
|
1575
|
+
if errors is None:
|
|
1576
|
+
stream.reconfigure(encoding="utf-8")
|
|
1577
|
+
else:
|
|
1578
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1579
|
+
|
|
1580
|
+
|
|
713
1581
|
if __name__ == "__main__":
|
|
1582
|
+
_force_utf8(sys.stdout, sys.stderr)
|
|
714
1583
|
raise SystemExit(main())
|