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
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml", "jsonschema>=4.0"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Quick Batch: the state of a --batch run, kept on disk.
|
|
6
|
+
|
|
7
|
+
A --batch run drives every target through its workflow's pipeline, one
|
|
8
|
+
after another, in one long session: each target of a skf-quick-skill batch
|
|
9
|
+
file, or each skill-brief.yaml of a skf-create-skill batch. This helper
|
|
10
|
+
keeps the batch's place and every target's outcome in the batch run
|
|
11
|
+
folder, not in the conversation, so a context compaction mid-batch loses
|
|
12
|
+
neither, and it computes the counts, the status and the exit code the
|
|
13
|
+
batch ends with.
|
|
14
|
+
|
|
15
|
+
CLI (--run-dir is the batch run folder: for skf-quick-skill the one SKILL.md
|
|
16
|
+
On Activation creates, {project-root}/_bmad-output/.skf-run/skf-quick-skill-<id>/;
|
|
17
|
+
for skf-create-skill the folder start --briefs names after its briefs,
|
|
18
|
+
<run-root>/skf-create-skill-batch-<hash>/, so a batch of the same briefs an
|
|
19
|
+
ended session left is found again, and a batch of other briefs is never
|
|
20
|
+
touched):
|
|
21
|
+
|
|
22
|
+
uv run skf-quick-batch.py parse <file>
|
|
23
|
+
uv run skf-quick-batch.py start <file> --run-dir <dir> [--fail-fast]
|
|
24
|
+
uv run skf-quick-batch.py start --briefs <path>... --run-root <dir> [--fail-fast]
|
|
25
|
+
uv run skf-quick-batch.py next --run-dir <dir>
|
|
26
|
+
uv run skf-quick-batch.py record --run-dir <dir> --batch <n> [--target stderr]
|
|
27
|
+
uv run skf-quick-batch.py summarize --run-dir <dir> --output-dir <dir> [--target stderr]
|
|
28
|
+
|
|
29
|
+
The batch file (UTF-8): one target per line. A blank line, and a line whose
|
|
30
|
+
first character after any spaces is `#`, is skipped. The first word of a
|
|
31
|
+
line is the target, kept whole: `cognee@0.5.0`, `requests==2.31.0` or
|
|
32
|
+
`https://github.com/foo/bar@2.1.0-beta` reach quick-skill's own target
|
|
33
|
+
parser (skf-resolve-package.py parse-target) as written, and it splits off
|
|
34
|
+
the version. Each later word is a modifier: `language=<lang>` sets the
|
|
35
|
+
target's language_hint and `scope=<path>` its scope_hint (the key in any
|
|
36
|
+
letter case, the value not empty, each at most once). A line with any other
|
|
37
|
+
word after its target is taken whole as the target, so step 1 cannot parse
|
|
38
|
+
it and that target fails on its own while the rest of the batch runs.
|
|
39
|
+
|
|
40
|
+
parse
|
|
41
|
+
Prints {"targets": [{"batch", "target", "language_hint", "scope_hint"}]}:
|
|
42
|
+
the file's targets numbered from 1, a hint null when the line sets none.
|
|
43
|
+
|
|
44
|
+
start
|
|
45
|
+
Parses the file and writes the batch file, <run-dir>/batch.jsonl: a start
|
|
46
|
+
line (status running, the workflow, the input file's absolute path,
|
|
47
|
+
--fail-fast) and one line per target, status pending. Prints {"status":
|
|
48
|
+
"running", "input_file", "fail_fast", "targets_total", "recorded",
|
|
49
|
+
"resumed"}. When the run folder already holds a batch of the same input
|
|
50
|
+
file, start changes nothing and reports it (resumed true, and the targets
|
|
51
|
+
already recorded), so a start run twice, from any working folder, never
|
|
52
|
+
loses an outcome. A batch of another file is refused. A file with no
|
|
53
|
+
target starts a batch that summarize ends at once, status success.
|
|
54
|
+
|
|
55
|
+
start --briefs (skf-create-skill)
|
|
56
|
+
The targets are skill-brief.yaml files: each path given is a brief, or a
|
|
57
|
+
folder whose skill-brief.yaml files (under it at any depth, hidden folders
|
|
58
|
+
left out, in path order) are briefs, each once, by its absolute path.
|
|
59
|
+
The batch run folder is <run-root>/skf-create-skill-batch-<hash>, <hash>
|
|
60
|
+
the first 12 hex digits of the SHA-256 of the brief paths, one per line:
|
|
61
|
+
each set of briefs has its own. Every brief is checked with
|
|
62
|
+
skf-validate-brief-schema.py, beside this script, before the first one
|
|
63
|
+
compiles: a brief it refuses (brief-missing, brief-malformed or
|
|
64
|
+
brief-invalid), and a brief whose `name` an earlier brief of the batch
|
|
65
|
+
already has (brief-invalid: both would compile into one skill folder), is
|
|
66
|
+
recorded at once as failed, with the exit code the skf-create-skill
|
|
67
|
+
envelope schema maps its halt_reason to, and next never hands it out.
|
|
68
|
+
Prints {"status": "running", "workflow", "batch_dir", "input_briefs",
|
|
69
|
+
"fail_fast", "targets_total", "recorded", "resumed", "rejected":
|
|
70
|
+
[{"batch", "target", "error_code", "message"}]}. When the folder holds an
|
|
71
|
+
unfinished batch of the same briefs, start changes nothing and reports it
|
|
72
|
+
(resumed true), so a batch a session ended mid-way goes on where it
|
|
73
|
+
stopped; a batch that ended (summarized) gives way to the new one, its
|
|
74
|
+
folder emptied first. No brief under the paths is refused. The pyyaml and
|
|
75
|
+
jsonschema the header names are the brief validator's.
|
|
76
|
+
|
|
77
|
+
next
|
|
78
|
+
Prints the next pending target, {"status": "next", "batch", "target",
|
|
79
|
+
"language_hint", "scope_hint", "run_dir"}, where run_dir is the target's
|
|
80
|
+
own run folder, created empty (a folder an earlier try of the target left
|
|
81
|
+
is emptied first): <run-dir>/<run-dir name>-<batch>, and for a brief
|
|
82
|
+
<run-dir>/skf-create-skill-batch-<YYYYMMDD-HHmmss>-<batch>, named after
|
|
83
|
+
the batch's start, so the run_id the emitter stamps from it is the
|
|
84
|
+
brief's own in every batch. It prints
|
|
85
|
+
the target's start event on stderr, one line:
|
|
86
|
+
{"batch":<n>,"target":"<target>","status":"start"}
|
|
87
|
+
Prints {"status": "record", "batch"} when that folder already staged
|
|
88
|
+
halt.json or result-context.json: the target ended but was never
|
|
89
|
+
recorded (a compaction between its envelope and record), so record it
|
|
90
|
+
instead of running it again. Prints {"status": "done",
|
|
91
|
+
"fail_fast_triggered"} when no target is pending, or when --fail-fast was
|
|
92
|
+
given and a target failed.
|
|
93
|
+
|
|
94
|
+
record
|
|
95
|
+
Records how target <n> ended, read from what its run folder staged for
|
|
96
|
+
the shared emitter: halt.json (a HARD HALT: status error, error_code its
|
|
97
|
+
halt_reason, exit_code the code the workflow's envelope schema maps
|
|
98
|
+
that reason to) or result-context.json (status success, exit_code 0,
|
|
99
|
+
quality_score from its summary). The outcome line {"batch", "target",
|
|
100
|
+
"status", "exit_code", "skill_package", "error_code", "quality_score"} is
|
|
101
|
+
appended to batch.jsonl, and a target that succeeded has its run folder
|
|
102
|
+
removed. Prints the target's end event, one line (on stderr under
|
|
103
|
+
--target stderr, on stdout otherwise):
|
|
104
|
+
{"batch":<n>,"target":"<target>","status":"done","exit":0}
|
|
105
|
+
{"batch":<n>,"target":"<target>","status":"fail","exit":<code>,"error_code":"<reason>"}
|
|
106
|
+
A target already recorded prints its event again and changes nothing.
|
|
107
|
+
|
|
108
|
+
summarize
|
|
109
|
+
Ends the batch: every target recorded, or --fail-fast stopped it after a
|
|
110
|
+
failure. It counts targets_total (the targets that ran), succeeded and
|
|
111
|
+
failed; sets status (success when none failed, a batch with no target
|
|
112
|
+
included; partial when some failed and some succeeded; failed when some
|
|
113
|
+
failed and none succeeded), fail_fast_triggered (--fail-fast stopped the
|
|
114
|
+
batch with targets left) and the exit code (0, else the highest exit
|
|
115
|
+
code of a failed target); writes the summary, with the batch's
|
|
116
|
+
input_file (skf-quick-skill) or input_briefs (skf-create-skill), to
|
|
117
|
+
<output-dir>/<stem>-<YYYYMMDD-HHmmss>.json, <stem> quick-skill-batch or
|
|
118
|
+
create-skill-batch (`-2`, `-3`, ... appended when another batch took
|
|
119
|
+
that second), and its copy <stem>-latest.json; appends the final status
|
|
120
|
+
line to batch.jsonl; and removes the batch run folder when no target failed and
|
|
121
|
+
the summary was written (a halted target's run folder stays for a look).
|
|
122
|
+
Prints the batch_summary event, one line (on stderr under --target
|
|
123
|
+
stderr, on stdout otherwise): {"batch_summary": true, "targets_total",
|
|
124
|
+
"succeeded", "failed", "status", "fail_fast_triggered", "exit_code",
|
|
125
|
+
"summary_path"}, with "warnings" when a summary file could not be
|
|
126
|
+
written. A batch already summarized prints its event again.
|
|
127
|
+
|
|
128
|
+
The batch file is one JSON object per line, so a write cut short spoils at
|
|
129
|
+
most its own line, which is skipped when read: the next write ends that
|
|
130
|
+
line first. A later outcome line for a target replaces an earlier one.
|
|
131
|
+
|
|
132
|
+
Every result and event is one line of ASCII JSON, with no space after a
|
|
133
|
+
`,` or `:`, so an event line is printed exactly as its format reads.
|
|
134
|
+
|
|
135
|
+
Exit codes (1 prints one JSON line on stderr, {"status": "error",
|
|
136
|
+
"message": ...}, and nothing on stdout; a refused start adds the
|
|
137
|
+
"halt_reason" the batch halts with, in its workflow's words: write-failure
|
|
138
|
+
(skf-create-skill: write-failed) when the batch file cannot be written, an
|
|
139
|
+
ended batch's folder cannot be emptied, or the briefs' folder holds an
|
|
140
|
+
unfinished batch of other briefs; for --briefs, brief-missing when
|
|
141
|
+
no brief is found and helper-missing when the validator cannot be loaded;
|
|
142
|
+
input-invalid otherwise):
|
|
143
|
+
0 the result was printed
|
|
144
|
+
1 a batch file that cannot be read or is not UTF-8, a run folder with no
|
|
145
|
+
batch (start first) or with a batch of another file or of other
|
|
146
|
+
briefs, a batch file that cannot be written, a --batch number the
|
|
147
|
+
batch does not hold, a target whose run folder staged neither halt.json
|
|
148
|
+
nor result-context.json, an unreadable staged payload, or a summarize
|
|
149
|
+
while targets are still pending
|
|
150
|
+
2 usage error (argparse)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
from __future__ import annotations
|
|
154
|
+
|
|
155
|
+
import argparse
|
|
156
|
+
import hashlib
|
|
157
|
+
import importlib.util
|
|
158
|
+
import json
|
|
159
|
+
import os
|
|
160
|
+
import shutil
|
|
161
|
+
import sys
|
|
162
|
+
import time
|
|
163
|
+
from pathlib import Path
|
|
164
|
+
|
|
165
|
+
WORKFLOW = "skf-quick-skill"
|
|
166
|
+
CREATE_WORKFLOW = "skf-create-skill"
|
|
167
|
+
BATCH_FILE = "batch.jsonl"
|
|
168
|
+
HALT_PAYLOAD = "halt.json"
|
|
169
|
+
RESULT_PAYLOAD = "result-context.json"
|
|
170
|
+
SUMMARY_STEM = "quick-skill-batch"
|
|
171
|
+
SUMMARY_STEMS = {WORKFLOW: SUMMARY_STEM, CREATE_WORKFLOW: "create-skill-batch"}
|
|
172
|
+
MODIFIERS = {"language": "language_hint", "scope": "scope_hint"}
|
|
173
|
+
BRIEF_FILE = "skill-brief.yaml"
|
|
174
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
175
|
+
# Each workflow's envelope schema, whose emitter settings map each halt_reason
|
|
176
|
+
# to its exit code; the emitter derives the envelope's exit_code from it too.
|
|
177
|
+
SCHEMA_FILE = SCRIPTS / "schemas" / "skf-quick-skill-result-envelope.v1.json"
|
|
178
|
+
SCHEMA_FILES = {WORKFLOW: SCHEMA_FILE, CREATE_WORKFLOW: SCRIPTS / "schemas" / "skf-create-skill-result-envelope.v1.json"}
|
|
179
|
+
# The halt_reason a refused start gives, in each workflow's envelope words.
|
|
180
|
+
WRITE_FAILURE = {WORKFLOW: "write-failure", CREATE_WORKFLOW: "write-failed"}
|
|
181
|
+
VALIDATOR = SCRIPTS / "skf-validate-brief-schema.py"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class BatchError(Exception):
|
|
185
|
+
"""A refusal: exit 1, the message on stderr, with the halt_reason of a refused start."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, message: str, halt_reason: str | None = None) -> None:
|
|
188
|
+
super().__init__(message)
|
|
189
|
+
self.halt_reason = halt_reason
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ─── the batch file grammar ─────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _modifier(word: str) -> tuple[str, str] | None:
|
|
196
|
+
"""(hint key, value) of a `language=` or `scope=` word, else None."""
|
|
197
|
+
key, sep, value = word.partition("=")
|
|
198
|
+
field = MODIFIERS.get(key.lower()) if sep else None
|
|
199
|
+
return (field, value) if field and value else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_line(line: str) -> dict | None:
|
|
203
|
+
"""The target a batch-file line names, or None for a blank or `#` line."""
|
|
204
|
+
text = line.strip()
|
|
205
|
+
if not text or text.startswith("#"):
|
|
206
|
+
return None
|
|
207
|
+
words = text.split()
|
|
208
|
+
hints = {"language_hint": None, "scope_hint": None}
|
|
209
|
+
for word in words[1:]:
|
|
210
|
+
found = _modifier(word)
|
|
211
|
+
if found is None or hints[found[0]] is not None:
|
|
212
|
+
# Not a modifier, or one given twice: the line is a target of its own.
|
|
213
|
+
return {"target": text, "language_hint": None, "scope_hint": None}
|
|
214
|
+
hints[found[0]] = found[1]
|
|
215
|
+
return {"target": words[0], **hints}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse_text(text: str) -> list[dict]:
|
|
219
|
+
"""The numbered targets of a batch file's text."""
|
|
220
|
+
targets = []
|
|
221
|
+
for line in text.splitlines():
|
|
222
|
+
entry = parse_line(line)
|
|
223
|
+
if entry is not None:
|
|
224
|
+
targets.append({"batch": len(targets) + 1, **entry})
|
|
225
|
+
return targets
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_batch_file(path: str) -> list[dict]:
|
|
229
|
+
try:
|
|
230
|
+
# utf-8-sig: a byte-order mark an editor wrote is no part of the first target.
|
|
231
|
+
text = Path(path).read_text(encoding="utf-8-sig")
|
|
232
|
+
except UnicodeDecodeError:
|
|
233
|
+
raise BatchError(f"the batch file {path} is not UTF-8 text") from None
|
|
234
|
+
except OSError as e:
|
|
235
|
+
raise BatchError(f"the batch file {path} cannot be read: {e.strerror or e}") from None
|
|
236
|
+
return parse_text(text)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ─── the batch state ────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _utc(seconds: float) -> tuple[str, str]:
|
|
243
|
+
"""(ISO-8601 UTC timestamp, YYYYMMDD-HHmmss file stamp) of a Unix time."""
|
|
244
|
+
parts = time.gmtime(seconds)
|
|
245
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", parts), time.strftime("%Y%m%d-%H%M%S", parts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _append(run_dir: Path, entry: dict) -> None:
|
|
249
|
+
"""Append one line to batch.jsonl, first ending a last line a write cut short."""
|
|
250
|
+
line = (json.dumps(entry, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
|
|
251
|
+
with open(run_dir / BATCH_FILE, "ab+") as fh:
|
|
252
|
+
end = fh.seek(0, os.SEEK_END)
|
|
253
|
+
if end:
|
|
254
|
+
fh.seek(end - 1)
|
|
255
|
+
if fh.read(1) != b"\n":
|
|
256
|
+
line = b"\n" + line # the torn line stays a line of its own, which load skips
|
|
257
|
+
fh.write(line)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load(run_dir: Path) -> dict:
|
|
261
|
+
"""The batch run folder's state: its start line, targets, outcomes and summary."""
|
|
262
|
+
try:
|
|
263
|
+
text = (run_dir / BATCH_FILE).read_text(encoding="utf-8")
|
|
264
|
+
except FileNotFoundError:
|
|
265
|
+
raise BatchError(f"no batch in {run_dir.as_posix()}: run start first") from None
|
|
266
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
267
|
+
raise BatchError(f"the batch in {run_dir.as_posix()} cannot be read: {e}") from None
|
|
268
|
+
state = {"start": None, "targets": {}, "results": {}, "summary": None}
|
|
269
|
+
for line in text.split("\n"):
|
|
270
|
+
try:
|
|
271
|
+
entry = json.loads(line) if line.strip() else None
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
entry = None # a write cut short
|
|
274
|
+
if not isinstance(entry, dict):
|
|
275
|
+
continue
|
|
276
|
+
event = entry.get("event")
|
|
277
|
+
if event == "start":
|
|
278
|
+
state["start"] = entry
|
|
279
|
+
elif event == "target" and isinstance(entry.get("batch"), int):
|
|
280
|
+
state["targets"][entry["batch"]] = entry
|
|
281
|
+
elif event == "result" and isinstance(entry.get("batch"), int):
|
|
282
|
+
state["results"][entry["batch"]] = entry
|
|
283
|
+
elif event == "summary":
|
|
284
|
+
state["summary"] = entry
|
|
285
|
+
if state["start"] is None:
|
|
286
|
+
raise BatchError(f"the batch in {run_dir.as_posix()} has no start line: run start first")
|
|
287
|
+
return state
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def workflow(state: dict) -> str:
|
|
291
|
+
"""The workflow a batch runs: its start line's, skf-quick-skill for a batch older than the field."""
|
|
292
|
+
name = state["start"].get("workflow")
|
|
293
|
+
return name if name in SCHEMA_FILES else WORKFLOW
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def pending(state: dict) -> list[dict]:
|
|
297
|
+
return [state["targets"][n] for n in sorted(state["targets"]) if n not in state["results"]]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _failed(state: dict) -> list[dict]:
|
|
301
|
+
return [r for r in state["results"].values() if r.get("status") != "success"]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _target_dir(run_dir: Path, number: int, started: dict | None = None) -> Path:
|
|
305
|
+
"""Target `number`'s run folder: named after the batch's start for a brief
|
|
306
|
+
batch, whose folder name repeats for the same briefs."""
|
|
307
|
+
batch_id = (started or {}).get("batch_id")
|
|
308
|
+
if isinstance(batch_id, str) and batch_id:
|
|
309
|
+
return run_dir / f"{CREATE_WORKFLOW}-{batch_id}-{number}"
|
|
310
|
+
return run_dir / f"{run_dir.name}-{number}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def brief_batch_dir(run_root: Path, briefs: list[str]) -> Path:
|
|
314
|
+
"""The batch run folder of a set of briefs: one per set, found again for the same set."""
|
|
315
|
+
digest = hashlib.sha256("\n".join(briefs).encode("utf-8")).hexdigest()[:12]
|
|
316
|
+
return run_root / f"{CREATE_WORKFLOW}-batch-{digest}"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _remove_tree(path: Path) -> None:
|
|
320
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ─── subcommands ────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_parse(file: str) -> dict:
|
|
327
|
+
return {"targets": read_batch_file(file)}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def expand_briefs(paths: list[str]) -> list[str]:
|
|
331
|
+
"""The briefs `paths` name, by absolute path with `/`, each once: a
|
|
332
|
+
folder gives its skill-brief.yaml files at any depth (hidden folders
|
|
333
|
+
left out) in path order, any other path is a brief itself."""
|
|
334
|
+
found: list[str] = []
|
|
335
|
+
for given in paths:
|
|
336
|
+
root = Path(given)
|
|
337
|
+
if root.is_dir():
|
|
338
|
+
briefs = sorted((p for p in root.rglob(BRIEF_FILE) if p.is_file()
|
|
339
|
+
and not any(part.startswith(".") for part in p.relative_to(root).parts)),
|
|
340
|
+
key=lambda p: p.relative_to(root).as_posix())
|
|
341
|
+
else:
|
|
342
|
+
briefs = [root]
|
|
343
|
+
for brief in briefs:
|
|
344
|
+
absolute = Path(os.path.abspath(brief)).as_posix()
|
|
345
|
+
if not any(_same_file(absolute, seen) for seen in found):
|
|
346
|
+
found.append(absolute)
|
|
347
|
+
return found
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _validator():
|
|
351
|
+
"""skf-validate-brief-schema.py, the sibling that checks a brief."""
|
|
352
|
+
try:
|
|
353
|
+
spec = importlib.util.spec_from_file_location("skf_validate_brief_schema", VALIDATOR)
|
|
354
|
+
if spec is None or spec.loader is None:
|
|
355
|
+
raise ImportError(VALIDATOR.as_posix())
|
|
356
|
+
module = importlib.util.module_from_spec(spec)
|
|
357
|
+
spec.loader.exec_module(module)
|
|
358
|
+
except (ImportError, OSError) as e:
|
|
359
|
+
raise BatchError(f"the brief validator cannot be loaded ({VALIDATOR.name} beside {Path(__file__).name}): "
|
|
360
|
+
f"{e}", "helper-missing") from None
|
|
361
|
+
return module
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def check_brief(validator, path: str) -> tuple[str | None, str | None, str | None]:
|
|
365
|
+
"""(halt_reason, message, None) of a brief the validator refuses, else (None, None, its name)."""
|
|
366
|
+
file = Path(path)
|
|
367
|
+
if not file.is_file():
|
|
368
|
+
return "brief-missing", (f"Brief not found at `{path}`. Run [BS] Brief Skill to create one, or use [QS] "
|
|
369
|
+
"Quick Skill for brief-less generation."), None
|
|
370
|
+
try:
|
|
371
|
+
text = file.read_text(encoding="utf-8")
|
|
372
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
373
|
+
return "brief-malformed", f"Brief validation failed: `{path}` cannot be read as UTF-8 text: {e}", None
|
|
374
|
+
brief, error = validator.load_brief_text(text)
|
|
375
|
+
if error is not None:
|
|
376
|
+
return "brief-malformed", error, None
|
|
377
|
+
result = validator.validate_brief(brief)
|
|
378
|
+
if not result["valid"]:
|
|
379
|
+
return "brief-invalid", result["errors"][0]["message"], None
|
|
380
|
+
return None, None, brief.get("name")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def check_briefs(validator, briefs: list[str]) -> list[tuple[int, str, str | None, str | None]]:
|
|
384
|
+
"""(batch, brief, halt_reason, message) of every brief: refused by the
|
|
385
|
+
validator, or by a name an earlier valid brief already has."""
|
|
386
|
+
checked = []
|
|
387
|
+
first: dict[str, str] = {}
|
|
388
|
+
for n, brief in enumerate(briefs, start=1):
|
|
389
|
+
reason, message, name = check_brief(validator, brief)
|
|
390
|
+
if reason is None and isinstance(name, str) and name in first:
|
|
391
|
+
reason, message = "brief-invalid", (f"Brief validation failed: `{brief}` has the name `{name}` of "
|
|
392
|
+
f"`{first[name]}`, and both would compile into one skill folder. "
|
|
393
|
+
"Give each brief its own name.")
|
|
394
|
+
elif reason is None and isinstance(name, str):
|
|
395
|
+
first[name] = brief
|
|
396
|
+
checked.append((n, brief, reason, message))
|
|
397
|
+
return checked
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _same_file(a, b) -> bool:
|
|
401
|
+
if not isinstance(a, str) or not isinstance(b, str):
|
|
402
|
+
return False
|
|
403
|
+
return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def cmd_start(file: str, run_dir: Path, fail_fast: bool) -> dict:
|
|
407
|
+
# The absolute path, so a start run again from another working folder finds the same batch.
|
|
408
|
+
path = os.path.abspath(file)
|
|
409
|
+
try:
|
|
410
|
+
if (run_dir / BATCH_FILE).exists():
|
|
411
|
+
state = load(run_dir)
|
|
412
|
+
started = state["start"]
|
|
413
|
+
if not _same_file(started.get("input_file"), path):
|
|
414
|
+
raise BatchError(f"{run_dir.as_posix()} holds the batch of {started.get('input_file')}, "
|
|
415
|
+
f"not {path}: give this batch a run folder of its own")
|
|
416
|
+
return {"status": "running", "input_file": started.get("input_file"),
|
|
417
|
+
"fail_fast": bool(started.get("fail_fast")), "targets_total": len(state["targets"]),
|
|
418
|
+
"recorded": len(state["results"]), "resumed": True}
|
|
419
|
+
targets = read_batch_file(path)
|
|
420
|
+
except BatchError as e:
|
|
421
|
+
raise BatchError(str(e), "input-invalid") from None
|
|
422
|
+
try:
|
|
423
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
_append(run_dir, {"event": "start", "status": "running", "input_file": path,
|
|
425
|
+
"fail_fast": fail_fast, "started_at": _utc(time.time())[0]})
|
|
426
|
+
for target in targets:
|
|
427
|
+
_append(run_dir, {"event": "target", **target, "status": "pending"})
|
|
428
|
+
except OSError as e:
|
|
429
|
+
raise BatchError(f"the batch file cannot be written in {run_dir.as_posix()}: {e.strerror or e}",
|
|
430
|
+
"write-failure") from None
|
|
431
|
+
return {"status": "running", "input_file": path, "fail_fast": fail_fast,
|
|
432
|
+
"targets_total": len(targets), "recorded": 0, "resumed": False}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cmd_start_briefs(paths: list[str], run_root: Path, fail_fast: bool) -> dict:
|
|
436
|
+
briefs = expand_briefs(paths)
|
|
437
|
+
if not briefs:
|
|
438
|
+
raise BatchError(f"no {BRIEF_FILE} under {', '.join(paths)}", "brief-missing")
|
|
439
|
+
run_dir = brief_batch_dir(run_root, briefs)
|
|
440
|
+
if (run_dir / BATCH_FILE).exists():
|
|
441
|
+
try:
|
|
442
|
+
state = load(run_dir)
|
|
443
|
+
except BatchError:
|
|
444
|
+
state = None # a batch file with no start line: nothing to resume
|
|
445
|
+
if state is not None and state["summary"] is None:
|
|
446
|
+
started = state["start"]
|
|
447
|
+
if workflow(state) != CREATE_WORKFLOW or started.get("input_briefs") != briefs:
|
|
448
|
+
raise BatchError(f"{run_dir.as_posix()} holds an unfinished batch of other briefs: "
|
|
449
|
+
"finish or remove it first", WRITE_FAILURE[CREATE_WORKFLOW])
|
|
450
|
+
return {"status": "running", "workflow": CREATE_WORKFLOW, "batch_dir": run_dir.as_posix(),
|
|
451
|
+
"input_briefs": briefs, "fail_fast": bool(started.get("fail_fast")),
|
|
452
|
+
"targets_total": len(state["targets"]), "recorded": len(state["results"]), "resumed": True,
|
|
453
|
+
"rejected": [{"batch": r["batch"], "target": r["target"], "error_code": r["error_code"],
|
|
454
|
+
"message": r.get("message")}
|
|
455
|
+
for _, r in sorted(state["results"].items()) if r.get("rejected")]}
|
|
456
|
+
validator = _validator()
|
|
457
|
+
codes = exit_codes(CREATE_WORKFLOW)
|
|
458
|
+
checked = check_briefs(validator, briefs)
|
|
459
|
+
timestamp, stamp = _utc(time.time())
|
|
460
|
+
try:
|
|
461
|
+
if run_dir.exists():
|
|
462
|
+
_remove_tree(run_dir) # an ended batch of these briefs gives way to this one
|
|
463
|
+
if (run_dir / BATCH_FILE).exists():
|
|
464
|
+
raise OSError(f"the ended batch's {BATCH_FILE} could not be removed")
|
|
465
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
466
|
+
_append(run_dir, {"event": "start", "status": "running", "workflow": CREATE_WORKFLOW,
|
|
467
|
+
"input_file": None, "input_briefs": briefs, "fail_fast": fail_fast,
|
|
468
|
+
"batch_id": f"batch-{stamp}", "started_at": timestamp})
|
|
469
|
+
for n, brief, _, _ in checked:
|
|
470
|
+
_append(run_dir, {"event": "target", "batch": n, "target": brief, "language_hint": None,
|
|
471
|
+
"scope_hint": None, "status": "pending"})
|
|
472
|
+
rejected = []
|
|
473
|
+
for n, brief, reason, message in checked:
|
|
474
|
+
if reason is None:
|
|
475
|
+
continue
|
|
476
|
+
_append(run_dir, {"event": "result", "batch": n, "target": brief, "status": "error",
|
|
477
|
+
"exit_code": codes.get(reason, 2), "skill_package": None, "error_code": reason,
|
|
478
|
+
"quality_score": None, "rejected": True, "message": message})
|
|
479
|
+
rejected.append({"batch": n, "target": brief, "error_code": reason, "message": message})
|
|
480
|
+
except OSError as e:
|
|
481
|
+
raise BatchError(f"the batch file cannot be written in {run_dir.as_posix()}: {e.strerror or e}",
|
|
482
|
+
WRITE_FAILURE[CREATE_WORKFLOW]) from None
|
|
483
|
+
return {"status": "running", "workflow": CREATE_WORKFLOW, "batch_dir": run_dir.as_posix(), "input_briefs": briefs,
|
|
484
|
+
"fail_fast": fail_fast, "targets_total": len(briefs), "recorded": len(rejected), "resumed": False,
|
|
485
|
+
"rejected": rejected}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _stop_early(state: dict) -> bool:
|
|
489
|
+
return bool(state["start"].get("fail_fast")) and bool(_failed(state))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def cmd_next(run_dir: Path) -> dict:
|
|
493
|
+
state = load(run_dir)
|
|
494
|
+
left = pending(state)
|
|
495
|
+
if state["summary"] is not None or not left or _stop_early(state):
|
|
496
|
+
return {"status": "done", "fail_fast_triggered": _stop_early(state) and bool(left)}
|
|
497
|
+
target = left[0]
|
|
498
|
+
folder = _target_dir(run_dir, target["batch"], state["start"])
|
|
499
|
+
if (folder / HALT_PAYLOAD).exists() or (folder / RESULT_PAYLOAD).exists():
|
|
500
|
+
return {"status": "record", "batch": target["batch"]} # it ended: record it, never run it twice
|
|
501
|
+
try:
|
|
502
|
+
if folder.exists():
|
|
503
|
+
_remove_tree(folder) # an earlier try of this target left it
|
|
504
|
+
folder.mkdir(parents=True)
|
|
505
|
+
except OSError as e:
|
|
506
|
+
raise BatchError(f"the target's run folder {folder.as_posix()} cannot be created: "
|
|
507
|
+
f"{e.strerror or e}") from None
|
|
508
|
+
return {"status": "next", "batch": target["batch"], "target": target["target"],
|
|
509
|
+
"language_hint": target.get("language_hint"), "scope_hint": target.get("scope_hint"),
|
|
510
|
+
"run_dir": folder.as_posix()}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def exit_codes(name: str = WORKFLOW) -> dict:
|
|
514
|
+
"""halt_reason -> exit code, from the workflow's envelope schema's emitter settings."""
|
|
515
|
+
try:
|
|
516
|
+
schema = json.loads(SCHEMA_FILES[name].read_text(encoding="utf-8"))
|
|
517
|
+
codes = schema["$defs"]["skf-envelope"]["const"]["exit_codes"]
|
|
518
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
519
|
+
return {}
|
|
520
|
+
return codes if isinstance(codes, dict) else {}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _read_payload(path: Path) -> dict:
|
|
524
|
+
try:
|
|
525
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
526
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
527
|
+
raise BatchError(f"{path.as_posix()} holds no JSON object: {e}") from None
|
|
528
|
+
if not isinstance(payload, dict):
|
|
529
|
+
raise BatchError(f"{path.as_posix()} holds no JSON object")
|
|
530
|
+
return payload
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def outcome(folder: Path, name: str = WORKFLOW) -> dict:
|
|
534
|
+
"""How the target whose run folder is `folder` ended, from its staged payload."""
|
|
535
|
+
halt, result = folder / HALT_PAYLOAD, folder / RESULT_PAYLOAD
|
|
536
|
+
if halt.exists():
|
|
537
|
+
payload = _read_payload(halt)
|
|
538
|
+
reason = payload.get("halt_reason")
|
|
539
|
+
code = exit_codes(name).get(reason) if isinstance(reason, str) else None
|
|
540
|
+
given = payload.get("exit_code")
|
|
541
|
+
if code is None and isinstance(given, int) and not isinstance(given, bool):
|
|
542
|
+
code = given
|
|
543
|
+
if code is None:
|
|
544
|
+
raise BatchError(f"{halt.as_posix()}: halt_reason {reason!r} maps to no exit code")
|
|
545
|
+
package = payload.get("skill_package")
|
|
546
|
+
return {"status": "error", "exit_code": code,
|
|
547
|
+
"skill_package": package if isinstance(package, str) else None,
|
|
548
|
+
"error_code": reason, "quality_score": None}
|
|
549
|
+
if result.exists():
|
|
550
|
+
payload = _read_payload(result)
|
|
551
|
+
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
552
|
+
score = summary.get("quality_score")
|
|
553
|
+
package = payload.get("skill_package")
|
|
554
|
+
return {"status": "success", "exit_code": 0,
|
|
555
|
+
"skill_package": package if isinstance(package, str) else None, "error_code": None,
|
|
556
|
+
"quality_score": score if isinstance(score, (int, float)) and not isinstance(score, bool) else None}
|
|
557
|
+
raise BatchError(f"{folder.as_posix()} staged neither {HALT_PAYLOAD} nor {RESULT_PAYLOAD}: stage the "
|
|
558
|
+
f"payload the target ended with ({HALT_PAYLOAD} or {RESULT_PAYLOAD}) and record it again")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def start_event(handed_out: dict) -> dict:
|
|
562
|
+
return {"batch": handed_out["batch"], "target": handed_out["target"], "status": "start"}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def end_event(entry: dict) -> dict:
|
|
566
|
+
if entry["status"] == "success":
|
|
567
|
+
return {"batch": entry["batch"], "target": entry["target"], "status": "done", "exit": 0}
|
|
568
|
+
return {"batch": entry["batch"], "target": entry["target"], "status": "fail",
|
|
569
|
+
"exit": entry["exit_code"], "error_code": entry["error_code"]}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cmd_record(run_dir: Path, number: int) -> dict:
|
|
573
|
+
state = load(run_dir)
|
|
574
|
+
target = state["targets"].get(number)
|
|
575
|
+
if target is None:
|
|
576
|
+
raise BatchError(f"the batch in {run_dir.as_posix()} has no target {number}")
|
|
577
|
+
known = state["results"].get(number)
|
|
578
|
+
if known is not None:
|
|
579
|
+
return end_event(known) # next never hands a recorded target out again
|
|
580
|
+
folder = _target_dir(run_dir, number, state["start"])
|
|
581
|
+
entry = {"event": "result", "batch": number, "target": target["target"], **outcome(folder, workflow(state))}
|
|
582
|
+
try:
|
|
583
|
+
_append(run_dir, entry)
|
|
584
|
+
except OSError as e:
|
|
585
|
+
raise BatchError(f"the outcome cannot be recorded in {run_dir.as_posix()}: {e.strerror or e}") from None
|
|
586
|
+
if entry["status"] == "success":
|
|
587
|
+
_remove_tree(folder) # a run folder is kept only for a halt
|
|
588
|
+
return end_event(entry)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _claim(folder: Path, stamp: str, stem: str = SUMMARY_STEM) -> Path:
|
|
592
|
+
"""Create the per-batch summary file, empty, under a name no batch holds yet."""
|
|
593
|
+
for n in range(1, 1000):
|
|
594
|
+
path = folder / (f"{stem}-{stamp}.json" if n == 1 else f"{stem}-{stamp}-{n}.json")
|
|
595
|
+
try:
|
|
596
|
+
os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
597
|
+
except FileExistsError:
|
|
598
|
+
continue
|
|
599
|
+
return path
|
|
600
|
+
raise OSError(f"no free file name for {stem}-{stamp}.json")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _write_json(path: Path, value: dict) -> None:
|
|
604
|
+
"""Write `value` as indented JSON through a temporary file and one rename."""
|
|
605
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
606
|
+
try:
|
|
607
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
608
|
+
json.dump(value, fh, indent=2, ensure_ascii=False)
|
|
609
|
+
fh.write("\n")
|
|
610
|
+
os.replace(tmp, path)
|
|
611
|
+
except OSError:
|
|
612
|
+
try:
|
|
613
|
+
tmp.unlink()
|
|
614
|
+
except OSError:
|
|
615
|
+
pass
|
|
616
|
+
raise
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def summary_event(summary: dict) -> dict:
|
|
620
|
+
event = {"batch_summary": True}
|
|
621
|
+
for key in ("targets_total", "succeeded", "failed", "status", "fail_fast_triggered", "exit_code",
|
|
622
|
+
"summary_path"):
|
|
623
|
+
event[key] = summary.get(key)
|
|
624
|
+
if summary.get("warnings"):
|
|
625
|
+
event["warnings"] = summary["warnings"]
|
|
626
|
+
return event
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def cmd_summarize(run_dir: Path, output_dir: Path) -> dict:
|
|
630
|
+
if not (run_dir / BATCH_FILE).exists() and not run_dir.exists():
|
|
631
|
+
stem = SUMMARY_STEMS[CREATE_WORKFLOW if run_dir.name.startswith(f"{CREATE_WORKFLOW}-") else WORKFLOW]
|
|
632
|
+
raise BatchError(f"no batch in {run_dir.as_posix()}: a batch whose targets all succeeded removes its "
|
|
633
|
+
f"run folder once summarized; its summary is "
|
|
634
|
+
f"{(output_dir / f'{stem}-latest.json').as_posix()}")
|
|
635
|
+
state = load(run_dir)
|
|
636
|
+
name = workflow(state)
|
|
637
|
+
if state["summary"] is not None:
|
|
638
|
+
return summary_event(state["summary"])
|
|
639
|
+
left, stopped = pending(state), _stop_early(state)
|
|
640
|
+
if left and not stopped:
|
|
641
|
+
raise BatchError(f"{len(left)} target(s) of the batch are still pending: run next and finish "
|
|
642
|
+
"them before summarize")
|
|
643
|
+
results = [state["results"][n] for n in sorted(state["results"])]
|
|
644
|
+
failed = [r for r in results if r.get("status") != "success"]
|
|
645
|
+
succeeded = len(results) - len(failed)
|
|
646
|
+
status = "success" if not failed else ("partial" if succeeded else "failed")
|
|
647
|
+
exit_code = max((r["exit_code"] for r in failed), default=0)
|
|
648
|
+
timestamp, stamp = _utc(time.time())
|
|
649
|
+
record = {
|
|
650
|
+
"skill": name,
|
|
651
|
+
"mode": "batch",
|
|
652
|
+
"status": status,
|
|
653
|
+
"timestamp": timestamp,
|
|
654
|
+
**({"input_briefs": state["start"].get("input_briefs")} if name == CREATE_WORKFLOW
|
|
655
|
+
else {"input_file": state["start"].get("input_file")}),
|
|
656
|
+
"targets_total": len(results),
|
|
657
|
+
"succeeded": succeeded,
|
|
658
|
+
"failed": len(failed),
|
|
659
|
+
"fail_fast_triggered": stopped and bool(left),
|
|
660
|
+
"exit_code": exit_code,
|
|
661
|
+
"results": [{key: r.get(key) for key in ("batch", "target", "status", "exit_code", "skill_package",
|
|
662
|
+
"error_code", "quality_score")} for r in results],
|
|
663
|
+
}
|
|
664
|
+
warnings, summary_path = [], None
|
|
665
|
+
try:
|
|
666
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
667
|
+
per_batch = _claim(output_dir, stamp, SUMMARY_STEMS[name])
|
|
668
|
+
_write_json(per_batch, record)
|
|
669
|
+
summary_path = per_batch.as_posix()
|
|
670
|
+
_write_json(output_dir / f"{SUMMARY_STEMS[name]}-latest.json", record)
|
|
671
|
+
except OSError as e:
|
|
672
|
+
warnings.append(f"summary_write_failed: {output_dir.as_posix()}: {e.strerror or e}")
|
|
673
|
+
final = {"event": "summary", **{k: v for k, v in record.items() if k != "results"},
|
|
674
|
+
"summary_path": summary_path, "warnings": warnings}
|
|
675
|
+
try:
|
|
676
|
+
_append(run_dir, final)
|
|
677
|
+
except OSError as e:
|
|
678
|
+
warnings.append(f"batch_file_write_failed: {run_dir.as_posix()}: {e.strerror or e}")
|
|
679
|
+
inside = output_dir.resolve().is_relative_to(run_dir.resolve())
|
|
680
|
+
if not failed and summary_path is not None and not inside and run_dir.name.startswith(f"{name}-"):
|
|
681
|
+
_remove_tree(run_dir) # kept when a target halted, or when batch.jsonl is the only record
|
|
682
|
+
return summary_event(final)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _error(error: BatchError) -> None:
|
|
689
|
+
body = {"status": "error", "message": str(error)}
|
|
690
|
+
if error.halt_reason:
|
|
691
|
+
body["halt_reason"] = error.halt_reason
|
|
692
|
+
print(json.dumps(body), file=sys.stderr)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _line(value: dict) -> str:
|
|
696
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
700
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
701
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
702
|
+
p_parse = sub.add_parser("parse", help="print the batch file's numbered targets")
|
|
703
|
+
p_parse.add_argument("file", help="the batch file")
|
|
704
|
+
p_start = sub.add_parser("start", help="write the batch file, every target pending")
|
|
705
|
+
p_start.add_argument("file", nargs="?", default=None, help="the batch file given to --batch (skf-quick-skill)")
|
|
706
|
+
p_start.add_argument("--briefs", nargs="+", default=None, metavar="PATH",
|
|
707
|
+
help="skf-create-skill: the briefs, or folders of briefs, given to --batch")
|
|
708
|
+
p_start.add_argument("--run-dir", default=None, help="the batch run folder (with the batch file)")
|
|
709
|
+
p_start.add_argument("--run-root", default=None,
|
|
710
|
+
help="with --briefs: the folder the briefs' batch run folder is made in")
|
|
711
|
+
p_start.add_argument("--fail-fast", action="store_true", help="end the batch at the first failed target")
|
|
712
|
+
p_next = sub.add_parser("next", help="print the next pending target, create its run folder and print its "
|
|
713
|
+
"start event on stderr")
|
|
714
|
+
p_next.add_argument("--run-dir", required=True, help="the batch run folder")
|
|
715
|
+
p_record = sub.add_parser("record", help="record how a target ended and print its end event")
|
|
716
|
+
p_record.add_argument("--run-dir", required=True, help="the batch run folder")
|
|
717
|
+
p_record.add_argument("--batch", type=int, required=True, help="the target's number, as next printed it")
|
|
718
|
+
p_summary = sub.add_parser("summarize", help="write the batch summary and print the batch_summary event")
|
|
719
|
+
p_summary.add_argument("--run-dir", required=True, help="the batch run folder")
|
|
720
|
+
p_summary.add_argument("--output-dir", required=True, help="the folder the summary files go to")
|
|
721
|
+
for p in (p_record, p_summary):
|
|
722
|
+
p.add_argument("--target", choices=["stdout", "stderr"], default="stdout",
|
|
723
|
+
help="the stream the event line goes to (default: stdout)")
|
|
724
|
+
return parser
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def main(argv: list[str] | None = None) -> int:
|
|
728
|
+
args = _build_parser().parse_args(argv)
|
|
729
|
+
try:
|
|
730
|
+
if args.cmd == "parse":
|
|
731
|
+
out = cmd_parse(args.file)
|
|
732
|
+
elif args.cmd == "start" and args.briefs is not None:
|
|
733
|
+
if args.file is not None:
|
|
734
|
+
raise BatchError("give start a batch file or --briefs, not both", "input-invalid")
|
|
735
|
+
if args.run_root is None or args.run_dir is not None:
|
|
736
|
+
raise BatchError("start --briefs takes --run-root, not --run-dir: the briefs name their batch "
|
|
737
|
+
"run folder", "input-invalid")
|
|
738
|
+
out = cmd_start_briefs(args.briefs, Path(args.run_root), args.fail_fast)
|
|
739
|
+
elif args.cmd == "start":
|
|
740
|
+
if args.file is None:
|
|
741
|
+
raise BatchError("start needs the batch file, or --briefs", "input-invalid")
|
|
742
|
+
if args.run_dir is None or args.run_root is not None:
|
|
743
|
+
raise BatchError("start with a batch file takes --run-dir, not --run-root", "input-invalid")
|
|
744
|
+
out = cmd_start(args.file, Path(args.run_dir), args.fail_fast)
|
|
745
|
+
elif args.cmd == "next":
|
|
746
|
+
out = cmd_next(Path(args.run_dir))
|
|
747
|
+
elif args.cmd == "record":
|
|
748
|
+
out = cmd_record(Path(args.run_dir), args.batch)
|
|
749
|
+
else:
|
|
750
|
+
out = cmd_summarize(Path(args.run_dir), Path(args.output_dir))
|
|
751
|
+
except BatchError as e:
|
|
752
|
+
_error(e)
|
|
753
|
+
return 1
|
|
754
|
+
if args.cmd == "next" and out["status"] == "next":
|
|
755
|
+
print(_line(start_event(out)), file=sys.stderr)
|
|
756
|
+
stream = sys.stderr if getattr(args, "target", "stdout") == "stderr" else sys.stdout
|
|
757
|
+
print(_line(out), file=stream)
|
|
758
|
+
return 0
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _force_utf8(*streams) -> None:
|
|
762
|
+
"""Reconfigure the JSON streams to UTF-8 (a Windows console uses cp1252)."""
|
|
763
|
+
for stream in streams:
|
|
764
|
+
if hasattr(stream, "reconfigure"):
|
|
765
|
+
errors = getattr(stream, "errors", None)
|
|
766
|
+
if errors is None:
|
|
767
|
+
stream.reconfigure(encoding="utf-8")
|
|
768
|
+
else:
|
|
769
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
if __name__ == "__main__":
|
|
773
|
+
_force_utf8(sys.stdout, sys.stderr)
|
|
774
|
+
sys.exit(main())
|