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,29 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# /// script
|
|
3
|
-
# requires-python = ">=3.
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
4
|
# dependencies = []
|
|
5
5
|
# ///
|
|
6
6
|
"""SKF Find Cycles — enumerate the simple directed cycles in a pair graph.
|
|
7
7
|
|
|
8
|
-
`skf-verify-stack`'s `integrations.md`
|
|
9
|
-
Pair
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
cycles
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
`skf-verify-stack`'s `integrations.md` (Cross-Reference Each Integration
|
|
9
|
+
Pair, Cycle detection) reads a directed pair graph: an edge `A -> B` exists
|
|
10
|
+
when skill A literally cites skill B (Check 4), one edge per citing direction
|
|
11
|
+
of the SKILL.md scanner's citations file. It appends one synthetic `Risky`
|
|
12
|
+
verdict row per cycle this helper lists. analyze-source runs it over import
|
|
13
|
+
edges, where two units that import each other are a mutual dependency.
|
|
14
|
+
|
|
15
|
+
Deciding *which* edges exist is a semantic judgment (Check 4 drops a direction
|
|
16
|
+
whose hits name only a common word) and stays in the prompt, which hands it
|
|
17
|
+
over as --exclude-edges. Once the edge set is fixed, the set of directed
|
|
18
|
+
cycles is fully determined: a graph traversal with exactly one correct answer
|
|
19
|
+
per edge set. In-prompt DFS silently misses real multi-hop cycles or invents
|
|
20
|
+
spurious ones as the pair count grows; this helper makes the traversal
|
|
21
|
+
deterministic and unit-testable, so nothing is traversed or filtered by hand.
|
|
21
22
|
|
|
22
23
|
Subcommand:
|
|
23
|
-
find --edges <json-file-or-'-'>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
find --edges <json-file-or-'-'> [--exclude-edges <json-file-or-'-'>]
|
|
25
|
+
[--skip-mutual]
|
|
26
|
+
--edges path to the edges JSON, or '-' to read from stdin.
|
|
27
|
+
--exclude-edges path to a JSON of the same shape, or '-' to read it
|
|
28
|
+
from stdin (not both inputs): each edge it lists is
|
|
29
|
+
left out of --edges before the traversal (an edge it
|
|
30
|
+
lists that --edges lacks changes nothing).
|
|
31
|
+
--skip-mutual leave out every cycle whose edges all have their
|
|
32
|
+
reverse in the edge set: two skills that cite each
|
|
33
|
+
other (["A", "B", "A"]), or a loop of such pairs,
|
|
34
|
+
give Check 4 evidence for their pairs, not a circular
|
|
35
|
+
dependency. A cycle with a one-way edge, and a
|
|
36
|
+
self-loop, stays.
|
|
37
|
+
|
|
38
|
+
Input shape (the Check-4 citation edges, one [from, to] pair each;
|
|
39
|
+
other keys, such as the citations file's, are ignored):
|
|
27
40
|
{"edges": [["A", "B"], ["B", "C"], ["C", "A"], ...]}
|
|
28
41
|
|
|
29
42
|
Emit JSON:
|
|
@@ -41,6 +54,8 @@ Subcommand:
|
|
|
41
54
|
|
|
42
55
|
CLI examples:
|
|
43
56
|
uv run skf-find-cycles.py find --edges edges.json
|
|
57
|
+
uv run skf-find-cycles.py find --edges citations.json \\
|
|
58
|
+
--exclude-edges rejected.json --skip-mutual
|
|
44
59
|
echo '{"edges": [["A","B"],["B","A"]]}' | uv run skf-find-cycles.py find --edges -
|
|
45
60
|
|
|
46
61
|
Exit codes:
|
|
@@ -114,14 +129,30 @@ def _canonicalize(cycle_nodes: list[str]) -> tuple[str, ...]:
|
|
|
114
129
|
return min(rotations)
|
|
115
130
|
|
|
116
131
|
|
|
117
|
-
def
|
|
132
|
+
def _mutual_only(cycle: tuple[str, ...], linked: set[tuple[str, str]]) -> bool:
|
|
133
|
+
"""True when every edge of the cycle has its reverse in the edge set."""
|
|
134
|
+
n = len(cycle)
|
|
135
|
+
return all((cycle[(i + 1) % n], cycle[i]) in linked for i in range(n))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def exclude_edges(
|
|
139
|
+
edges: list[tuple[str, str]], excluded: list[tuple[str, str]]
|
|
140
|
+
) -> list[tuple[str, str]]:
|
|
141
|
+
"""Drop every occurrence of each excluded (from, to) edge, keeping order."""
|
|
142
|
+
drop = set(excluded)
|
|
143
|
+
return [edge for edge in edges if edge not in drop]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def find_cycles(edges: list[tuple[str, str]], *, skip_mutual: bool = False) -> dict:
|
|
118
147
|
"""Enumerate every simple directed cycle in the edge set.
|
|
119
148
|
|
|
120
149
|
Deterministic: same edge set → byte-identical output. Runs a DFS from each
|
|
121
150
|
node (in sorted order) tracking the current recursion path; whenever the
|
|
122
151
|
walk returns to its start node a cycle is recorded, canonicalised to its
|
|
123
152
|
minimal rotation, and de-duplicated. Graphs here are small pair graphs, so
|
|
124
|
-
the exhaustive enumeration is inexpensive.
|
|
153
|
+
the exhaustive enumeration is inexpensive. With `skip_mutual`, a cycle
|
|
154
|
+
built only from mutual edges (A -> B -> A, or a longer loop whose every
|
|
155
|
+
edge has its reverse) is not recorded; a self-loop is.
|
|
125
156
|
"""
|
|
126
157
|
# Build sorted, de-duplicated adjacency for deterministic traversal.
|
|
127
158
|
adj: dict[str, list[str]] = {}
|
|
@@ -152,6 +183,10 @@ def find_cycles(edges: list[tuple[str, str]]) -> dict:
|
|
|
152
183
|
for start in sorted(nodes):
|
|
153
184
|
dfs(start, start, [start], {start})
|
|
154
185
|
|
|
186
|
+
if skip_mutual:
|
|
187
|
+
linked = set(edges)
|
|
188
|
+
seen = {c for c in seen if len(c) == 1 or not _mutual_only(c, linked)}
|
|
189
|
+
|
|
155
190
|
# Stable output order: by cycle length, then lexicographically.
|
|
156
191
|
ordered = sorted(seen, key=lambda c: (len(c), c))
|
|
157
192
|
cycles = [list(c) + [c[0]] for c in ordered]
|
|
@@ -163,30 +198,45 @@ def find_cycles(edges: list[tuple[str, str]]) -> dict:
|
|
|
163
198
|
# --------------------------------------------------------------------------
|
|
164
199
|
|
|
165
200
|
|
|
166
|
-
def _read_source(source: str) -> str:
|
|
201
|
+
def _read_source(source: str, flag: str = "--edges") -> str:
|
|
167
202
|
"""Read text from a file path or stdin (if source == '-')."""
|
|
168
203
|
if source == "-":
|
|
169
204
|
try:
|
|
170
205
|
return sys.stdin.read()
|
|
171
206
|
except OSError as exc:
|
|
172
|
-
raise InputError(f"failed to read
|
|
207
|
+
raise InputError(f"failed to read {flag} from stdin: {exc}") from exc
|
|
173
208
|
path = Path(source)
|
|
174
209
|
if not path.is_file():
|
|
175
|
-
raise InputError(f"
|
|
210
|
+
raise InputError(f"{flag} file not found: {path}")
|
|
176
211
|
try:
|
|
177
212
|
return path.read_text(encoding="utf-8")
|
|
178
213
|
except OSError as exc:
|
|
179
|
-
raise InputError(f"failed to read
|
|
214
|
+
raise InputError(f"failed to read {flag} file {path}: {exc}") from exc
|
|
180
215
|
|
|
181
216
|
|
|
182
|
-
def
|
|
183
|
-
text = _read_source(
|
|
217
|
+
def _load_edges(source: str, flag: str) -> list[tuple[str, str]]:
|
|
218
|
+
text = _read_source(source, flag)
|
|
184
219
|
try:
|
|
185
220
|
payload = json.loads(text)
|
|
186
221
|
except json.JSONDecodeError as exc:
|
|
187
|
-
raise InputError(f"malformed JSON in
|
|
188
|
-
|
|
189
|
-
|
|
222
|
+
raise InputError(f"malformed JSON in {flag} input: {exc}") from exc
|
|
223
|
+
try:
|
|
224
|
+
return parse_edges(payload)
|
|
225
|
+
except InputError as exc:
|
|
226
|
+
if flag == "--edges":
|
|
227
|
+
raise
|
|
228
|
+
raise InputError(f"{flag}: {exc}") from exc
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _cmd_find(args: argparse.Namespace) -> int:
|
|
232
|
+
if args.edges == "-" and args.exclude_edges == "-":
|
|
233
|
+
raise InputError(
|
|
234
|
+
"--edges and --exclude-edges cannot both read from stdin ('-')"
|
|
235
|
+
)
|
|
236
|
+
edges = _load_edges(args.edges, "--edges")
|
|
237
|
+
if args.exclude_edges is not None:
|
|
238
|
+
edges = exclude_edges(edges, _load_edges(args.exclude_edges, "--exclude-edges"))
|
|
239
|
+
result = find_cycles(edges, skip_mutual=args.skip_mutual)
|
|
190
240
|
if args.verbose:
|
|
191
241
|
print(
|
|
192
242
|
f"analyzed {len(edges)} edge(s); found {result['cycle_count']} cycle(s)",
|
|
@@ -219,6 +269,24 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
219
269
|
'Shape: {"edges": [["<from>", "<to>"], ...]}'
|
|
220
270
|
),
|
|
221
271
|
)
|
|
272
|
+
p_find.add_argument(
|
|
273
|
+
"--exclude-edges",
|
|
274
|
+
default=None,
|
|
275
|
+
help=(
|
|
276
|
+
"path to a JSON of the same shape, or '-' for stdin (not with "
|
|
277
|
+
"--edges -): each edge it lists is left out of --edges before "
|
|
278
|
+
"the traversal"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
p_find.add_argument(
|
|
282
|
+
"--skip-mutual",
|
|
283
|
+
action="store_true",
|
|
284
|
+
help=(
|
|
285
|
+
"leave out every cycle whose edges all have their reverse in the "
|
|
286
|
+
"edge set (A cites B and B cites A, or a loop of such pairs); a "
|
|
287
|
+
"cycle with a one-way edge stays"
|
|
288
|
+
),
|
|
289
|
+
)
|
|
222
290
|
p_find.add_argument(
|
|
223
291
|
"--verbose",
|
|
224
292
|
action="store_true",
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Find Test Report: locate a skill's newest test report and read its verdict.
|
|
6
|
+
|
|
7
|
+
skf-export-skill (load-skill.md section 4b) and skf-update-skill (init.md,
|
|
8
|
+
the --from-test-report lookup, and in a normal run the check for a failing
|
|
9
|
+
report the skill has not been repaired from) both find the test report by
|
|
10
|
+
hand: glob, sort, frontmatter read, with a fallback to the stable result
|
|
11
|
+
file. The same three lookups run in the prompt for every skill of a batch.
|
|
12
|
+
This helper runs them once, the same way for both workflows.
|
|
13
|
+
|
|
14
|
+
Lookup order (the first one that finds a finished report wins):
|
|
15
|
+
|
|
16
|
+
1. versioned-glob {forge_data_folder}/{skill_name}/{version}/test-report-{skill_name}-*.md
|
|
17
|
+
(only when --version is given)
|
|
18
|
+
2. flat-glob {forge_data_folder}/{skill_name}/test-report-{skill_name}-*.md
|
|
19
|
+
3. latest-json skf-test-skill-result-latest.json in the versioned folder,
|
|
20
|
+
then the flat folder: the report named in its outputs[]
|
|
21
|
+
|
|
22
|
+
Within a glob, reports sort newest first by the run id in the file name
|
|
23
|
+
(`test-report-{skill_name}-{YYYYMMDDTHHMMSSZ}-...md`; the timestamp is the
|
|
24
|
+
first part, so the name order is the time order). A name whose part after
|
|
25
|
+
`test-report-{skill_name}-` does not start with a digit belongs to another
|
|
26
|
+
skill (`test-report-foo-bar-...` is not a report of `foo`) and is ignored.
|
|
27
|
+
A report whose frontmatter `testResult` is empty is unfinished (a run that
|
|
28
|
+
stopped before its verdict): it is listed in `skipped[]` and the next older
|
|
29
|
+
report is read instead.
|
|
30
|
+
|
|
31
|
+
Subcommand:
|
|
32
|
+
|
|
33
|
+
find --forge-data-folder <dir> --skill-name <name> [--version <version>]
|
|
34
|
+
[--newer-than <time>] [--provenance-map <provenance-map.json>]
|
|
35
|
+
|
|
36
|
+
Output (stdout):
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"status": "found" | "not-found",
|
|
40
|
+
"skill_name": "<name>",
|
|
41
|
+
"version": "<version>" | null,
|
|
42
|
+
"path": "<report path>" | null,
|
|
43
|
+
"report_exists": true | false | null,
|
|
44
|
+
"source": "versioned-glob" | "flat-glob" | "latest-json" | null,
|
|
45
|
+
"testResult": "pass" | "fail" | "inconclusive" | "pass-with-drift" | null,
|
|
46
|
+
"score": <number> | null,
|
|
47
|
+
"run_id": "<run id>" | null,
|
|
48
|
+
"test_date": "<testDate>" | null,
|
|
49
|
+
"result_path": "<skf-test-skill-result-latest.json>" | null,
|
|
50
|
+
"skipped": [{"path", "reason": "unfinished"}],
|
|
51
|
+
"warnings": ["..."]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
`testResult` and `score` come from the report frontmatter. For latest-json,
|
|
55
|
+
when the report the result file names is gone, they come from the result
|
|
56
|
+
file's `summary` (`result`, `score`), `report_exists` is false (true for
|
|
57
|
+
every report the helper read, null when none was found) and a warning says
|
|
58
|
+
so. `testResult` is lower case with hyphens (`PASS_WITH_DRIFT` reads as
|
|
59
|
+
`pass-with-drift`).
|
|
60
|
+
|
|
61
|
+
With --newer-than (update-skill passes the skill's metadata.json
|
|
62
|
+
`generation_date`), the output also holds `"newer": true | false | null`:
|
|
63
|
+
true when the report's time is later than <time>, false when it is not,
|
|
64
|
+
null when no report was found or either time cannot be read. The report's
|
|
65
|
+
time is the later of the `YYYYMMDDTHHMMSSZ` its run id starts with and its
|
|
66
|
+
`test_date`, a time without a zone there read as UTC. <time> is an ISO-8601
|
|
67
|
+
date or date-time: with a zone it is that instant, and a date alone or a
|
|
68
|
+
time without a zone is the earliest instant it can name, 14 hours before
|
|
69
|
+
its UTC reading (the day starts first at UTC+14), so a report from the day
|
|
70
|
+
the skill was generated counts as newer rather than hiding.
|
|
71
|
+
|
|
72
|
+
With --provenance-map (update-skill passes the map it loaded), the output
|
|
73
|
+
also holds `"applied": true | false | null`: true when the map's update
|
|
74
|
+
block says a gap-driven update applied this report (top-level `update_type`
|
|
75
|
+
`gap-driven` and `test_report_run_id` equal to the report's `run_id`),
|
|
76
|
+
false when it does not, null when no report was found or the map cannot be
|
|
77
|
+
read as a JSON object.
|
|
78
|
+
|
|
79
|
+
Exit codes:
|
|
80
|
+
0 a result was printed (found or not-found)
|
|
81
|
+
1 --skill-name or --version is not a single folder name (message on
|
|
82
|
+
stderr)
|
|
83
|
+
2 usage error (argparse: a missing or unknown argument, usage on stderr)
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
import argparse
|
|
89
|
+
import json
|
|
90
|
+
import math
|
|
91
|
+
import re
|
|
92
|
+
import sys
|
|
93
|
+
from datetime import datetime, timedelta, timezone
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
|
|
96
|
+
LATEST_RESULT_NAME = "skf-test-skill-result-latest.json"
|
|
97
|
+
_RUN_ID_RE = re.compile(r"^\d{8}T\d{6}Z(?:-[A-Za-z0-9]+)*$")
|
|
98
|
+
_RUN_ID_STAMP_RE = re.compile(r"^(\d{8}T\d{6}Z)")
|
|
99
|
+
# The easternmost zone (UTC+14): a day there starts 14 hours before UTC's.
|
|
100
|
+
_EARLIEST_ZONE_OFFSET = timedelta(hours=14)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------
|
|
104
|
+
# Frontmatter
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_frontmatter(text: str) -> dict[str, str]:
|
|
109
|
+
"""Top-level scalar keys of a `---` frontmatter block, quotes removed.
|
|
110
|
+
|
|
111
|
+
Nested blocks and comments are skipped and a flow list comes back as its
|
|
112
|
+
text: the verdict keys are scalars.
|
|
113
|
+
|
|
114
|
+
>>> read_frontmatter("---\\ntestResult: 'fail'\\nscore: 72\\nsteps: ['a']\\n---\\n# R")
|
|
115
|
+
{'testResult': 'fail', 'score': '72', 'steps': "['a']"}
|
|
116
|
+
>>> read_frontmatter("# no frontmatter")
|
|
117
|
+
{}
|
|
118
|
+
"""
|
|
119
|
+
lines = text.lstrip("\ufeff").splitlines()
|
|
120
|
+
if not lines or lines[0].strip() != "---":
|
|
121
|
+
return {}
|
|
122
|
+
out: dict[str, str] = {}
|
|
123
|
+
for line in lines[1:]:
|
|
124
|
+
if line.strip() == "---":
|
|
125
|
+
return out
|
|
126
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*?)\s*$", line)
|
|
127
|
+
if not m:
|
|
128
|
+
continue
|
|
129
|
+
value = m.group(2)
|
|
130
|
+
if value.startswith("#"):
|
|
131
|
+
value = ""
|
|
132
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
133
|
+
value = value[1:-1]
|
|
134
|
+
out[m.group(1)] = value
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def normalize_result(value: object) -> str | None:
|
|
139
|
+
"""`PASS_WITH_DRIFT` -> `pass-with-drift`; empty -> None.
|
|
140
|
+
|
|
141
|
+
>>> normalize_result("PASS_WITH_DRIFT"), normalize_result(" Fail "), normalize_result("")
|
|
142
|
+
('pass-with-drift', 'fail', None)
|
|
143
|
+
"""
|
|
144
|
+
if not isinstance(value, str):
|
|
145
|
+
return None
|
|
146
|
+
text = value.strip().lower().replace("_", "-")
|
|
147
|
+
return text or None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def normalize_score(value: object) -> int | float | None:
|
|
151
|
+
"""A frontmatter or summary score as a number, or None.
|
|
152
|
+
|
|
153
|
+
>>> normalize_score("85"), normalize_score("72.5"), normalize_score(""), normalize_score("N/A")
|
|
154
|
+
(85, 72.5, None, None)
|
|
155
|
+
>>> normalize_score("nan"), normalize_score(True)
|
|
156
|
+
(None, None)
|
|
157
|
+
"""
|
|
158
|
+
if isinstance(value, bool):
|
|
159
|
+
return None
|
|
160
|
+
if isinstance(value, (int, float)):
|
|
161
|
+
number = float(value)
|
|
162
|
+
elif isinstance(value, str):
|
|
163
|
+
try:
|
|
164
|
+
number = float(value.strip().rstrip("%"))
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None
|
|
167
|
+
else:
|
|
168
|
+
return None
|
|
169
|
+
if not math.isfinite(number):
|
|
170
|
+
return None
|
|
171
|
+
return int(number) if number.is_integer() else number
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_time(value: object, *, earliest: bool = False) -> datetime | None:
|
|
175
|
+
"""An ISO-8601 date or date-time, or a run id's leading stamp, as a UTC instant.
|
|
176
|
+
|
|
177
|
+
A time with a zone is that instant. A date alone or a time without a
|
|
178
|
+
zone reads as UTC, or with `earliest` as the earliest instant it can
|
|
179
|
+
name, 14 hours before its UTC reading.
|
|
180
|
+
|
|
181
|
+
>>> parse_time("2026-09-30T10:10:10Z").isoformat()
|
|
182
|
+
'2026-09-30T10:10:10+00:00'
|
|
183
|
+
>>> parse_time("20260930T101010Z-22-bbbb").isoformat()
|
|
184
|
+
'2026-09-30T10:10:10+00:00'
|
|
185
|
+
>>> parse_time("2026-09-30", earliest=True).isoformat()
|
|
186
|
+
'2026-09-29T10:00:00+00:00'
|
|
187
|
+
>>> parse_time("yesterday") is None, parse_time(None) is None
|
|
188
|
+
(True, True)
|
|
189
|
+
"""
|
|
190
|
+
if not isinstance(value, str) or not value.strip():
|
|
191
|
+
return None
|
|
192
|
+
text = value.strip()
|
|
193
|
+
stamp = _RUN_ID_STAMP_RE.match(text)
|
|
194
|
+
try:
|
|
195
|
+
if stamp:
|
|
196
|
+
return datetime.strptime(stamp.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
|
197
|
+
moment = datetime.fromisoformat(text)
|
|
198
|
+
except (ValueError, OverflowError):
|
|
199
|
+
return None
|
|
200
|
+
if moment.tzinfo is not None:
|
|
201
|
+
return moment.astimezone(timezone.utc)
|
|
202
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
203
|
+
return moment - _EARLIEST_ZONE_OFFSET if earliest else moment
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def report_is_newer(result: dict, newer_than: str) -> bool | None:
|
|
207
|
+
"""Whether the found report's time is later than `newer_than` (None:
|
|
208
|
+
no report, or a time that cannot be read). The report's time is the
|
|
209
|
+
later of its run id's stamp and its test_date, so a date-only test_date
|
|
210
|
+
(midnight) never hides a report the run id dates later that day."""
|
|
211
|
+
if result["status"] != "found":
|
|
212
|
+
return None
|
|
213
|
+
times = [t for t in (parse_time(result["run_id"]), parse_time(result["test_date"])) if t is not None]
|
|
214
|
+
bound = parse_time(newer_than, earliest=True)
|
|
215
|
+
if not times or bound is None:
|
|
216
|
+
return None
|
|
217
|
+
return max(times) > bound
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def report_was_applied(result: dict, provenance_map: Path) -> bool | None:
|
|
221
|
+
"""Whether the provenance map's update block names the found report as
|
|
222
|
+
the one a gap-driven update applied (None: no report, or a map that
|
|
223
|
+
cannot be read as a JSON object)."""
|
|
224
|
+
if result["status"] != "found":
|
|
225
|
+
return None
|
|
226
|
+
try:
|
|
227
|
+
data = json.loads(provenance_map.read_text(encoding="utf-8"))
|
|
228
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
229
|
+
return None
|
|
230
|
+
if not isinstance(data, dict):
|
|
231
|
+
return None
|
|
232
|
+
applied = data.get("test_report_run_id")
|
|
233
|
+
return bool(
|
|
234
|
+
data.get("update_type") == "gap-driven" and isinstance(applied, str) and applied
|
|
235
|
+
and applied == result["run_id"]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --------------------------------------------------------------------------
|
|
240
|
+
# Lookups
|
|
241
|
+
# --------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def report_candidates(folder: Path, skill_name: str) -> list[tuple[str, Path]]:
|
|
245
|
+
"""(run id, path) for each report of `skill_name` in `folder`, newest first."""
|
|
246
|
+
if not folder.is_dir():
|
|
247
|
+
return []
|
|
248
|
+
prefix = f"test-report-{skill_name}-"
|
|
249
|
+
found: list[tuple[str, Path]] = []
|
|
250
|
+
for path in folder.glob(f"{glob_escape(prefix)}*.md"):
|
|
251
|
+
rest = path.name[len(prefix):-len(".md")]
|
|
252
|
+
if path.is_file() and rest[:1].isdigit():
|
|
253
|
+
found.append((rest, path))
|
|
254
|
+
found.sort(key=lambda item: item[0], reverse=True)
|
|
255
|
+
return found
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def glob_escape(text: str) -> str:
|
|
259
|
+
"""Escape glob metacharacters in a literal name part."""
|
|
260
|
+
return re.sub(r"([*?\[])", r"[\1]", text)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_report(path: Path) -> dict:
|
|
264
|
+
try:
|
|
265
|
+
text = path.read_text(encoding="utf-8")
|
|
266
|
+
except (OSError, UnicodeDecodeError):
|
|
267
|
+
return {}
|
|
268
|
+
return read_frontmatter(text)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _run_id(frontmatter: dict, path: Path, skill_name: str) -> str | None:
|
|
272
|
+
if frontmatter.get("runId"):
|
|
273
|
+
return frontmatter["runId"]
|
|
274
|
+
prefix = f"test-report-{skill_name}-"
|
|
275
|
+
if path.name.startswith(prefix) and path.name.endswith(".md"):
|
|
276
|
+
rest = path.name[len(prefix):-len(".md")]
|
|
277
|
+
if _RUN_ID_RE.match(rest):
|
|
278
|
+
return rest
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _found(result: dict, *, path: Path, source: str, frontmatter: dict, skill_name: str) -> dict:
|
|
283
|
+
result.update(
|
|
284
|
+
{
|
|
285
|
+
"status": "found",
|
|
286
|
+
"path": str(path),
|
|
287
|
+
"report_exists": True,
|
|
288
|
+
"source": source,
|
|
289
|
+
"testResult": normalize_result(frontmatter.get("testResult")),
|
|
290
|
+
"score": normalize_score(frontmatter.get("score")),
|
|
291
|
+
"run_id": _run_id(frontmatter, path, skill_name),
|
|
292
|
+
"test_date": frontmatter.get("testDate") or None,
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _glob_lookup(result: dict, folder: Path, skill_name: str, source: str) -> bool:
|
|
299
|
+
for _, path in report_candidates(folder, skill_name):
|
|
300
|
+
frontmatter = read_report(path)
|
|
301
|
+
if not normalize_result(frontmatter.get("testResult")):
|
|
302
|
+
result["skipped"].append({"path": str(path), "reason": "unfinished"})
|
|
303
|
+
continue
|
|
304
|
+
_found(result, path=path, source=source, frontmatter=frontmatter, skill_name=skill_name)
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _report_from_outputs(outputs: object) -> str | None:
|
|
310
|
+
if not isinstance(outputs, list):
|
|
311
|
+
return None
|
|
312
|
+
paths = [o.get("path") for o in outputs if isinstance(o, dict) and isinstance(o.get("path"), str)]
|
|
313
|
+
for entry in outputs:
|
|
314
|
+
if isinstance(entry, dict) and entry.get("type") == "report" and isinstance(entry.get("path"), str):
|
|
315
|
+
return entry["path"]
|
|
316
|
+
for path in paths:
|
|
317
|
+
if Path(path).name.startswith("test-report-") and path.endswith(".md"):
|
|
318
|
+
return path
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def resolve_output_path(recorded: str, result_file: Path) -> Path | None:
|
|
323
|
+
"""Where a path from outputs[] points: absolute as written; a relative one
|
|
324
|
+
beside the result file (the report is written in the same folder), then as
|
|
325
|
+
written from the working directory."""
|
|
326
|
+
path = Path(recorded)
|
|
327
|
+
if path.is_absolute():
|
|
328
|
+
return path if path.is_file() else None
|
|
329
|
+
for candidate in (result_file.parent / path, result_file.parent / path.name, Path.cwd() / path):
|
|
330
|
+
if candidate.is_file():
|
|
331
|
+
return candidate
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _latest_json_lookup(result: dict, folder: Path, skill_name: str) -> bool:
|
|
336
|
+
result_file = folder / LATEST_RESULT_NAME
|
|
337
|
+
if not result_file.is_file():
|
|
338
|
+
return False
|
|
339
|
+
try:
|
|
340
|
+
data = json.loads(result_file.read_text(encoding="utf-8"))
|
|
341
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
342
|
+
result["warnings"].append(f"cannot read {result_file}: {exc}")
|
|
343
|
+
return False
|
|
344
|
+
if not isinstance(data, dict):
|
|
345
|
+
result["warnings"].append(f"{result_file} is not a JSON object")
|
|
346
|
+
return False
|
|
347
|
+
recorded = _report_from_outputs(data.get("outputs"))
|
|
348
|
+
if recorded is None:
|
|
349
|
+
result["warnings"].append(f"{result_file} names no report in outputs[]")
|
|
350
|
+
return False
|
|
351
|
+
result["result_path"] = str(result_file)
|
|
352
|
+
report = resolve_output_path(recorded, result_file)
|
|
353
|
+
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
|
|
354
|
+
if report is not None:
|
|
355
|
+
frontmatter = read_report(report)
|
|
356
|
+
_found(result, path=report, source="latest-json", frontmatter=frontmatter, skill_name=skill_name)
|
|
357
|
+
if result["testResult"] is None:
|
|
358
|
+
result["testResult"] = normalize_result(summary.get("result"))
|
|
359
|
+
if result["score"] is None:
|
|
360
|
+
result["score"] = normalize_score(summary.get("score"))
|
|
361
|
+
return True
|
|
362
|
+
result["warnings"].append(
|
|
363
|
+
f"the report {recorded} named in {result_file} is missing; the verdict comes from its summary"
|
|
364
|
+
)
|
|
365
|
+
result.update(
|
|
366
|
+
{
|
|
367
|
+
"status": "found",
|
|
368
|
+
"path": recorded,
|
|
369
|
+
"report_exists": False,
|
|
370
|
+
"source": "latest-json",
|
|
371
|
+
"testResult": normalize_result(summary.get("result")),
|
|
372
|
+
"score": normalize_score(summary.get("score")),
|
|
373
|
+
"run_id": data.get("runId") if isinstance(data.get("runId"), str) else None,
|
|
374
|
+
"test_date": data.get("timestamp") if isinstance(data.get("timestamp"), str) else None,
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def find_test_report(forge_data_folder: Path, skill_name: str, version: str | None) -> dict:
|
|
381
|
+
"""Run the three lookups in order and return the result object."""
|
|
382
|
+
result: dict = {
|
|
383
|
+
"status": "not-found",
|
|
384
|
+
"skill_name": skill_name,
|
|
385
|
+
"version": version,
|
|
386
|
+
"path": None,
|
|
387
|
+
"report_exists": None,
|
|
388
|
+
"source": None,
|
|
389
|
+
"testResult": None,
|
|
390
|
+
"score": None,
|
|
391
|
+
"run_id": None,
|
|
392
|
+
"test_date": None,
|
|
393
|
+
"result_path": None,
|
|
394
|
+
"skipped": [],
|
|
395
|
+
"warnings": [],
|
|
396
|
+
}
|
|
397
|
+
flat = forge_data_folder / skill_name
|
|
398
|
+
versioned = flat / version if version else None
|
|
399
|
+
if versioned is not None and _glob_lookup(result, versioned, skill_name, "versioned-glob"):
|
|
400
|
+
return result
|
|
401
|
+
if _glob_lookup(result, flat, skill_name, "flat-glob"):
|
|
402
|
+
return result
|
|
403
|
+
for folder in (versioned, flat):
|
|
404
|
+
if folder is not None and _latest_json_lookup(result, folder, skill_name):
|
|
405
|
+
return result
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# --------------------------------------------------------------------------
|
|
410
|
+
# CLI
|
|
411
|
+
# --------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _valid_segment(value: str) -> bool:
|
|
415
|
+
return bool(value) and value not in (".", "..") and "/" not in value and "\\" not in value
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _cmd_find(args: argparse.Namespace) -> int:
|
|
419
|
+
for flag, value in (("--skill-name", args.skill_name), ("--version", args.version)):
|
|
420
|
+
if value is not None and not _valid_segment(value):
|
|
421
|
+
print(f"error: {flag} must be a single folder name, got {value!r}", file=sys.stderr)
|
|
422
|
+
return 1
|
|
423
|
+
result = find_test_report(Path(args.forge_data_folder), args.skill_name, args.version)
|
|
424
|
+
if args.newer_than is not None:
|
|
425
|
+
result["newer"] = report_is_newer(result, args.newer_than)
|
|
426
|
+
if args.provenance_map is not None:
|
|
427
|
+
result["applied"] = report_was_applied(result, Path(args.provenance_map))
|
|
428
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
429
|
+
return 0
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
433
|
+
parser = argparse.ArgumentParser(
|
|
434
|
+
prog="skf-find-test-report",
|
|
435
|
+
description="Locate a skill's newest finished test report and read its verdict.",
|
|
436
|
+
)
|
|
437
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
438
|
+
|
|
439
|
+
p = sub.add_parser("find", help="run the versioned, flat and latest-json lookups")
|
|
440
|
+
p.add_argument("--forge-data-folder", required=True)
|
|
441
|
+
p.add_argument("--skill-name", required=True)
|
|
442
|
+
p.add_argument("--version", help="the version folder to search first (the active version)")
|
|
443
|
+
p.add_argument(
|
|
444
|
+
"--newer-than",
|
|
445
|
+
metavar="TIME",
|
|
446
|
+
help="also say whether the report is newer than this ISO-8601 time (the skill's generation_date)",
|
|
447
|
+
)
|
|
448
|
+
p.add_argument(
|
|
449
|
+
"--provenance-map",
|
|
450
|
+
metavar="FILE",
|
|
451
|
+
help="also say whether this map's update block names the report as applied",
|
|
452
|
+
)
|
|
453
|
+
p.set_defaults(func=_cmd_find)
|
|
454
|
+
|
|
455
|
+
return parser
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _force_utf8(*streams) -> None:
|
|
459
|
+
"""Reconfigure the JSON streams to UTF-8 (a Windows console uses cp1252)."""
|
|
460
|
+
for stream in streams:
|
|
461
|
+
if hasattr(stream, "reconfigure"):
|
|
462
|
+
errors = getattr(stream, "errors", None)
|
|
463
|
+
if errors is None:
|
|
464
|
+
stream.reconfigure(encoding="utf-8")
|
|
465
|
+
else:
|
|
466
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main(argv: list[str] | None = None) -> int:
|
|
470
|
+
_force_utf8(sys.stdout)
|
|
471
|
+
args = _build_parser().parse_args(argv)
|
|
472
|
+
return args.func(args)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
raise SystemExit(main())
|