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,1128 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml", "jsonschema>=4.0"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Campaign State: every write to _campaign-state.yaml, and the resume point.
|
|
6
|
+
|
|
7
|
+
The campaign's progress lives in one YAML file that must survive a session
|
|
8
|
+
dying at any moment. The step files used to re-type the whole file on every
|
|
9
|
+
change: copy it to .bak, edit it in memory, type a timestamp with no clock to
|
|
10
|
+
read, write it back, and check it only when the next step loaded it. A bad
|
|
11
|
+
write for one skill was then copied over the last good .bak by the next
|
|
12
|
+
write, and resume found both files invalid. This script owns every write, so
|
|
13
|
+
the step files name an operation and its inputs and never touch the YAML.
|
|
14
|
+
|
|
15
|
+
Each write operation, in this order:
|
|
16
|
+
|
|
17
|
+
1. reads the state and validates it (campaign-validate-state.py's schema
|
|
18
|
+
check, date-time fields included): an invalid state is never rotated
|
|
19
|
+
into .bak, and nothing is written (exit 3);
|
|
20
|
+
2. applies the operation, sets `campaign.current_stage` when --stage is
|
|
21
|
+
given, and stamps `campaign.last_updated` (and any started_at or
|
|
22
|
+
completed_at the operation sets) from the system clock, in UTC;
|
|
23
|
+
3. validates the result: a change that would make the state invalid is
|
|
24
|
+
refused and nothing is written (exit 3);
|
|
25
|
+
4. copies the primary it read, which validated, to the .bak beside it,
|
|
26
|
+
then writes the new state; each file is written to a temporary file
|
|
27
|
+
and renamed over the old one, so a crash leaves the old file or the
|
|
28
|
+
new one, never half of one.
|
|
29
|
+
|
|
30
|
+
Operations:
|
|
31
|
+
|
|
32
|
+
init Create the state (step-01). Reads the parsed targets
|
|
33
|
+
(campaign-parse-manifest.py's JSON output, a file or `-`
|
|
34
|
+
for stdin) and settles the quality gate through
|
|
35
|
+
campaign-quality-gate.py's check (a brief's
|
|
36
|
+
quality_gate wins when --brief-file names one). Refuses
|
|
37
|
+
a state file or backup that already exists. With
|
|
38
|
+
--brief-out it also writes campaign-brief.yaml from
|
|
39
|
+
--brief-template (the template's mapping, its keys in
|
|
40
|
+
their order, after its leading comment block): the
|
|
41
|
+
campaign name, created_at (the state's started_at), the
|
|
42
|
+
same targets[] with every field each one carries
|
|
43
|
+
(repo_url, which the state has no place for, and any
|
|
44
|
+
hint), the settled quality_gate, architecture_doc_path
|
|
45
|
+
and notes (--notes, else the --brief-file's notes, else
|
|
46
|
+
empty). The template is read before anything is written;
|
|
47
|
+
the brief is written right after the state, and a resume
|
|
48
|
+
that finds the state without it halts (missing-brief).
|
|
49
|
+
set-stage Write current_stage alone: a stage's final write when it
|
|
50
|
+
records nothing else.
|
|
51
|
+
set-skill Set the status, quality_score, skill_path or brief_path
|
|
52
|
+
of one or more skills (--skill repeats). `active` stamps
|
|
53
|
+
started_at when it is not set yet (an interrupted run
|
|
54
|
+
keeps its start); `completed` stamps completed_at;
|
|
55
|
+
`pending` (a reset for a re-run) clears both and the
|
|
56
|
+
export outcome. --export records the Export stage's
|
|
57
|
+
outcome (step-10) as `export`: `exported`, or `failed`
|
|
58
|
+
with --export-halt-reason and --export-exit-code from
|
|
59
|
+
the skf-export-skill call.
|
|
60
|
+
apply-plan Write the execution order and the cycle flag that
|
|
61
|
+
campaign-deps.py --compute gives for the state (step-02);
|
|
62
|
+
refuses a plan the stages cannot follow (exit 4).
|
|
63
|
+
apply-pins Write the resolved pins from campaign-validate-pins.py's
|
|
64
|
+
output (step-03); refuses output with an invalid pin
|
|
65
|
+
(exit 5).
|
|
66
|
+
apply-provenance Write each commit SHA from campaign-provenance.py's
|
|
67
|
+
output (step-04); refuses output with an inaccessible
|
|
68
|
+
repository (exit 6).
|
|
69
|
+
apply-batch The Tier B batch (step-06), from the line-to-skill map
|
|
70
|
+
campaign-render-batch.py wrote: --start marks the map's
|
|
71
|
+
skipped_by_directive skills `skipped` and its batched
|
|
72
|
+
skills `active`; --results-file records the joined
|
|
73
|
+
results (campaign-render-batch.py --record output);
|
|
74
|
+
--no-results fails every batched skill.
|
|
75
|
+
append-workarounds
|
|
76
|
+
Append entries to one skill's workarounds_applied; an
|
|
77
|
+
entry it already holds is not added twice.
|
|
78
|
+
set-campaign Write campaign-level fields: the architecture document
|
|
79
|
+
path, and the capstone, verification or refinement
|
|
80
|
+
summary read from the sub-skill's result envelope line
|
|
81
|
+
(a file or `-`; the line's SKF_*_RESULT_JSON: prefix is
|
|
82
|
+
optional). A success envelope is recorded; an error
|
|
83
|
+
envelope, or --no-capstone and the like, records null.
|
|
84
|
+
A recorded verification also sets capstone.verified.
|
|
85
|
+
recover Copy a .bak that validates over the primary (step-resume).
|
|
86
|
+
archive Move the state, its .bak and the brief, each one that
|
|
87
|
+
exists, into archive/<campaign name>-<UTC stamp>/ beside
|
|
88
|
+
the state (SKILL.md's overwrite), so step-01's init can
|
|
89
|
+
start a new campaign. The name comes from the state, else
|
|
90
|
+
the .bak, else "campaign"; an existing folder is refused.
|
|
91
|
+
log Append one typed entry to the decision log: decision (an
|
|
92
|
+
operator's choice), auto (a default a headless run took)
|
|
93
|
+
or event (a failure, a recovery, a skip), stamped from
|
|
94
|
+
the clock. The log is append-only.
|
|
95
|
+
halt-payload Print the payload of a HARD HALT's error envelope for
|
|
96
|
+
skf-emit-result-envelope.py emit-halt (read-only): the
|
|
97
|
+
phase, the halt reason, the halt message read from stdin,
|
|
98
|
+
the completed and failed counts of the state (0 when it
|
|
99
|
+
cannot be read) and the decision log beside it (null
|
|
100
|
+
before the campaign workspace exists).
|
|
101
|
+
resume Compute the resume point (read-only): the stage and step
|
|
102
|
+
file to chain to, from the state and --from.
|
|
103
|
+
|
|
104
|
+
Resume rule. Without --from: an active skill resumes its own stage (Tier A
|
|
105
|
+
at stage 4, the skill loop; Tier B at stage 5, the batch, whose script
|
|
106
|
+
batches the active skills an interrupted batch left), with no +1; otherwise
|
|
107
|
+
the next stage is current_stage + 1, because current_stage is the highest
|
|
108
|
+
completed stage, capped at 10 (the report stage reruns, never a stage 11).
|
|
109
|
+
The campaign is complete when current_stage is 10 and no skill is pending or
|
|
110
|
+
active. With --from: an unknown skill is an error; a pending or active skill
|
|
111
|
+
resumes its tier's stage; a completed, failed or skipped one needs the
|
|
112
|
+
operator's choice (needs_choice), unless --next takes the next pending or
|
|
113
|
+
active skill after it in the execution order (complete when there is none).
|
|
114
|
+
|
|
115
|
+
CLI:
|
|
116
|
+
uv run campaign-state.py init --state-file <p> --targets-file <p|-> --name <name> \\
|
|
117
|
+
--hard <value> --soft-target <N> --soft-fallback <N> [--brief-file <p>] \\
|
|
118
|
+
[--directive-path <p>] [--architecture-doc-path <p>] \\
|
|
119
|
+
[--brief-out <p> [--brief-template <p>] [--notes <text>]]
|
|
120
|
+
uv run campaign-state.py set-stage --state-file <p> --stage <N>
|
|
121
|
+
uv run campaign-state.py set-skill --state-file <p> --skill <name> [--skill <name> ...] \\
|
|
122
|
+
[--status <status>] [--quality-score <N>] [--skill-path <p>] [--brief-path <p>] \\
|
|
123
|
+
[--export exported|failed [--export-halt-reason <r>] [--export-exit-code <N>]] [--stage <N>]
|
|
124
|
+
uv run campaign-state.py apply-plan --state-file <p> [--stage <N>]
|
|
125
|
+
uv run campaign-state.py apply-pins --state-file <p> --results-file <p|-> [--stage <N>]
|
|
126
|
+
uv run campaign-state.py apply-provenance --state-file <p> --results-file <p|-> [--stage <N>]
|
|
127
|
+
uv run campaign-state.py apply-batch --state-file <p> --map-file <p> \\
|
|
128
|
+
(--start | --results-file <p|-> | --no-results) [--stage <N>]
|
|
129
|
+
uv run campaign-state.py append-workarounds --state-file <p> --skill <name> \\
|
|
130
|
+
--entry <text> [--entry <text> ...] [--stage <N>]
|
|
131
|
+
uv run campaign-state.py set-campaign --state-file <p> [--architecture-doc-path <p>] \\
|
|
132
|
+
[--capstone <p|-> | --no-capstone] [--verification <p|-> | --no-verification] \\
|
|
133
|
+
[--refinement <p|-> | --no-refinement] [--stage <N>]
|
|
134
|
+
uv run campaign-state.py recover --state-file <p>
|
|
135
|
+
uv run campaign-state.py archive --state-file <p> --brief-file <p>
|
|
136
|
+
uv run campaign-state.py log --log-file <p> --type <decision|auto|event> --text <entry>
|
|
137
|
+
uv run campaign-state.py halt-payload --state-file <p> --phase <slug> --halt-reason <class> < <message>
|
|
138
|
+
uv run campaign-state.py resume --state-file <p> [--from <skill>] [--next]
|
|
139
|
+
|
|
140
|
+
The backup is always <state-file>.bak.
|
|
141
|
+
|
|
142
|
+
Output (one JSON object on stdout):
|
|
143
|
+
writes: {"op", "state_file", "backup_file", "backup_rotated",
|
|
144
|
+
"last_updated", "current_stage", "changed": [...]} plus the
|
|
145
|
+
operation's own keys (init: "quality_gate", "brief_file", null
|
|
146
|
+
without --brief-out; apply-batch: "activated", "skipped",
|
|
147
|
+
"completed", "failed"; set-campaign: "recorded", "not_recorded")
|
|
148
|
+
recover: {"op", "state_file", "backup_file", "recovered", "last_updated",
|
|
149
|
+
"current_stage"}
|
|
150
|
+
archive: {"op", "archive_dir", "moved": [...]}
|
|
151
|
+
log: {"op", "log_file", "entry"}
|
|
152
|
+
halt-payload:
|
|
153
|
+
{"phase", "reason", "halt_reason", "skills_completed",
|
|
154
|
+
"skills_failed", "decision_log"}
|
|
155
|
+
resume: {"complete", "stage", "step_file", "reason", "skill",
|
|
156
|
+
"needs_choice", "from_status", "active_other"}
|
|
157
|
+
Errors: {"error", "code", "errors": [...]} on stderr.
|
|
158
|
+
|
|
159
|
+
Exit codes (each is the campaign's HALT code for the same class):
|
|
160
|
+
0 written, recovered, archived, logged or computed
|
|
161
|
+
2 invalid input: a missing or unreadable input file, an unknown skill,
|
|
162
|
+
output from another script that holds an error, a gate the gate script
|
|
163
|
+
rejects, a state that init would overwrite, a brief template that is
|
|
164
|
+
no YAML mapping, export flags that do not go together, or an archive
|
|
165
|
+
folder that exists
|
|
166
|
+
3 invalid state: the state is missing, malformed or fails the schema, or
|
|
167
|
+
the change would make it fail (nothing written)
|
|
168
|
+
4 apply-plan: a plan the stages cannot follow (circular-deps)
|
|
169
|
+
5 apply-pins: an invalid pin (invalid-pin)
|
|
170
|
+
6 apply-provenance: an inaccessible repository (inaccessible-repo)
|
|
171
|
+
9 recover: the backup is missing or fails validation too
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
from __future__ import annotations
|
|
175
|
+
|
|
176
|
+
import argparse
|
|
177
|
+
import contextlib
|
|
178
|
+
import copy
|
|
179
|
+
import importlib.util
|
|
180
|
+
import io
|
|
181
|
+
import json
|
|
182
|
+
import os
|
|
183
|
+
import re
|
|
184
|
+
import secrets
|
|
185
|
+
import sys
|
|
186
|
+
import time
|
|
187
|
+
from datetime import datetime, timezone
|
|
188
|
+
from pathlib import Path
|
|
189
|
+
from typing import Any, Callable
|
|
190
|
+
|
|
191
|
+
import yaml
|
|
192
|
+
|
|
193
|
+
HERE = Path(__file__).resolve().parent
|
|
194
|
+
VALIDATE_SCRIPT = HERE / "campaign-validate-state.py"
|
|
195
|
+
DEPS_SCRIPT = HERE / "campaign-deps.py"
|
|
196
|
+
GATE_SCRIPT = HERE / "campaign-quality-gate.py"
|
|
197
|
+
BRIEF_TEMPLATE = HERE.parent / "templates" / "campaign-brief-template.yaml"
|
|
198
|
+
STATUSES = ("pending", "active", "completed", "failed", "skipped")
|
|
199
|
+
SETTLED = ("completed", "failed", "skipped")
|
|
200
|
+
OPEN = ("pending", "active")
|
|
201
|
+
LOG_TYPES = ("decision", "auto", "event")
|
|
202
|
+
EXPORT_STATUSES = ("exported", "failed")
|
|
203
|
+
FINAL_STAGE = 10
|
|
204
|
+
# Stage N runs step-(N+1); SKILL.md's Stages table.
|
|
205
|
+
STEP_FILES = {
|
|
206
|
+
0: "step-01-setup.md",
|
|
207
|
+
1: "step-02-strategy.md",
|
|
208
|
+
2: "step-03-pins.md",
|
|
209
|
+
3: "step-04-provenance.md",
|
|
210
|
+
4: "step-05-skill-loop.md",
|
|
211
|
+
5: "step-06-batch.md",
|
|
212
|
+
6: "step-07-capstone.md",
|
|
213
|
+
7: "step-08-verify.md",
|
|
214
|
+
8: "step-09-refine.md",
|
|
215
|
+
9: "step-10-export.md",
|
|
216
|
+
10: "step-11-maintenance.md",
|
|
217
|
+
}
|
|
218
|
+
TIER_STAGE = {"A": 4, "B": 5}
|
|
219
|
+
DECISION_LOG = "_campaign-decision-log.md"
|
|
220
|
+
EXIT_INPUT = 2
|
|
221
|
+
EXIT_STATE = 3
|
|
222
|
+
EXIT_CIRCULAR = 4
|
|
223
|
+
EXIT_PIN = 5
|
|
224
|
+
EXIT_REPO = 6
|
|
225
|
+
EXIT_NO_BACKUP = 9
|
|
226
|
+
_IS_WINDOWS = os.name == "nt"
|
|
227
|
+
_REPLACE_WAIT_SECONDS = 5.0
|
|
228
|
+
_REPLACE_POLL_SECONDS = 0.05
|
|
229
|
+
_modules: dict[str, Any] = {}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class StateError(Exception):
|
|
233
|
+
"""A failure reported as {"error", "code", "errors"} on stderr with its exit code."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, exit_code: int, code: str, message: str, errors: list | None = None) -> None:
|
|
236
|
+
super().__init__(message)
|
|
237
|
+
self.exit_code = exit_code
|
|
238
|
+
self.code = code
|
|
239
|
+
self.errors = errors or []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _module(path: Path) -> Any:
|
|
243
|
+
"""A sibling script, loaded once."""
|
|
244
|
+
key = path.name
|
|
245
|
+
if key not in _modules:
|
|
246
|
+
spec = importlib.util.spec_from_file_location(path.stem.replace("-", "_"), path)
|
|
247
|
+
module = importlib.util.module_from_spec(spec)
|
|
248
|
+
spec.loader.exec_module(module)
|
|
249
|
+
_modules[key] = module
|
|
250
|
+
return _modules[key]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def now() -> str:
|
|
254
|
+
"""The clock's time in UTC, as ISO-8601 with its offset."""
|
|
255
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --------------------------------------------------------------------------
|
|
259
|
+
# Reading, validating and writing the state
|
|
260
|
+
# --------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _schema() -> dict:
|
|
264
|
+
validator = _module(VALIDATE_SCRIPT)
|
|
265
|
+
return json.loads(validator.DEFAULT_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def validation_errors(state: Any) -> list[dict]:
|
|
269
|
+
"""campaign-validate-state.py's errors for a state already loaded."""
|
|
270
|
+
if not isinstance(state, dict):
|
|
271
|
+
return [{"field": "(root)", "message": "State root must be a mapping"}]
|
|
272
|
+
return _module(VALIDATE_SCRIPT).validate_state(state, _schema())["errors"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def read_state(path: Path) -> tuple[dict, bytes]:
|
|
276
|
+
"""The state and its bytes; StateError (exit 3) unless it validates."""
|
|
277
|
+
if not path.is_file():
|
|
278
|
+
raise StateError(EXIT_STATE, "state-missing", f"State not found at `{path.as_posix()}`.")
|
|
279
|
+
try:
|
|
280
|
+
raw = path.read_bytes()
|
|
281
|
+
text = raw.decode("utf-8")
|
|
282
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
283
|
+
raise StateError(EXIT_STATE, "state-malformed", f"State unreadable: {exc}") from exc
|
|
284
|
+
state, problem = _module(VALIDATE_SCRIPT)._load_yaml_text(text)
|
|
285
|
+
if problem is not None:
|
|
286
|
+
raise StateError(EXIT_STATE, "state-malformed", problem)
|
|
287
|
+
errors = validation_errors(state)
|
|
288
|
+
if errors:
|
|
289
|
+
raise StateError(EXIT_STATE, "state-invalid", "The state fails validation: nothing was written.", errors)
|
|
290
|
+
return state, raw
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def dump_state(state: dict) -> bytes:
|
|
294
|
+
text = yaml.safe_dump(state, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
295
|
+
return text.encode("utf-8")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _replace(source: Path, target: Path) -> None:
|
|
299
|
+
"""os.replace, retried on Windows while a reader holds either file open."""
|
|
300
|
+
deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
|
|
301
|
+
while True:
|
|
302
|
+
try:
|
|
303
|
+
os.replace(source, target)
|
|
304
|
+
return
|
|
305
|
+
except PermissionError:
|
|
306
|
+
if not _IS_WINDOWS or time.monotonic() >= deadline:
|
|
307
|
+
raise
|
|
308
|
+
time.sleep(_REPLACE_POLL_SECONDS)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def write_atomic(path: Path, data: bytes) -> None:
|
|
312
|
+
"""Write a temporary file beside path, then rename it over path.
|
|
313
|
+
|
|
314
|
+
A reader holding the file open on Windows makes the rename fail with
|
|
315
|
+
PermissionError for a moment, so the rename is retried there.
|
|
316
|
+
"""
|
|
317
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
|
|
319
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
320
|
+
fd = os.open(tmp, flags, 0o644)
|
|
321
|
+
try:
|
|
322
|
+
with open(fd, "wb") as fh:
|
|
323
|
+
fh.write(data)
|
|
324
|
+
fh.flush()
|
|
325
|
+
os.fsync(fh.fileno())
|
|
326
|
+
_replace(tmp, path)
|
|
327
|
+
except BaseException:
|
|
328
|
+
with contextlib.suppress(OSError):
|
|
329
|
+
tmp.unlink()
|
|
330
|
+
raise
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def backup_path(state_file: Path) -> Path:
|
|
334
|
+
return state_file.with_name(state_file.name + ".bak")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _set_stage(state: dict, stage: int | None, changed: list[str]) -> None:
|
|
338
|
+
if stage is None:
|
|
339
|
+
return
|
|
340
|
+
state["campaign"]["current_stage"] = stage
|
|
341
|
+
changed.append(f"campaign.current_stage={stage}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def mutate(
|
|
345
|
+
state_file: str,
|
|
346
|
+
op: str,
|
|
347
|
+
change: Callable[[dict, str, list[str]], dict | None],
|
|
348
|
+
stage: int | None = None,
|
|
349
|
+
) -> dict:
|
|
350
|
+
"""Read, validate, change, stamp, validate again, rotate .bak, write.
|
|
351
|
+
|
|
352
|
+
`change(state, stamp, changed)` edits the state in place, appends a short
|
|
353
|
+
description of each change to `changed`, and may return extra output
|
|
354
|
+
keys. It raises StateError to refuse the operation before anything is
|
|
355
|
+
written.
|
|
356
|
+
"""
|
|
357
|
+
path = Path(state_file)
|
|
358
|
+
state, raw = read_state(path)
|
|
359
|
+
new_state = copy.deepcopy(state)
|
|
360
|
+
stamp = now()
|
|
361
|
+
changed: list[str] = []
|
|
362
|
+
extra = change(new_state, stamp, changed) or {}
|
|
363
|
+
_set_stage(new_state, stage, changed)
|
|
364
|
+
new_state["campaign"]["last_updated"] = stamp
|
|
365
|
+
errors = validation_errors(new_state)
|
|
366
|
+
if errors:
|
|
367
|
+
raise StateError(EXIT_STATE, "state-change-invalid",
|
|
368
|
+
"The change would make the state invalid: nothing was written.", errors)
|
|
369
|
+
bak = backup_path(path)
|
|
370
|
+
write_atomic(bak, raw)
|
|
371
|
+
write_atomic(path, dump_state(new_state))
|
|
372
|
+
return {
|
|
373
|
+
"op": op,
|
|
374
|
+
"state_file": path.as_posix(),
|
|
375
|
+
"backup_file": bak.as_posix(),
|
|
376
|
+
"backup_rotated": True,
|
|
377
|
+
"last_updated": stamp,
|
|
378
|
+
"current_stage": new_state["campaign"]["current_stage"],
|
|
379
|
+
"changed": changed,
|
|
380
|
+
**extra,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------
|
|
385
|
+
# Input files
|
|
386
|
+
# --------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def read_text(source: str, label: str) -> str:
|
|
390
|
+
"""A file's text, or stdin's for `-`, in the encoding it was written in.
|
|
391
|
+
|
|
392
|
+
A shell redirect on Windows PowerShell writes UTF-16 with a byte-order
|
|
393
|
+
mark, so one is honoured.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
raw = sys.stdin.buffer.read() if source == "-" else Path(source).read_bytes()
|
|
397
|
+
except OSError as exc:
|
|
398
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} unreadable: {exc}") from exc
|
|
399
|
+
try:
|
|
400
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
401
|
+
return raw.decode("utf-16")
|
|
402
|
+
return raw.decode("utf-8-sig")
|
|
403
|
+
except UnicodeDecodeError as exc:
|
|
404
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not UTF-8 text: {exc}") from exc
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def read_json(source: str, label: str) -> dict:
|
|
408
|
+
text = read_text(source, label)
|
|
409
|
+
if not text.strip():
|
|
410
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is empty")
|
|
411
|
+
try:
|
|
412
|
+
data = json.loads(text)
|
|
413
|
+
except json.JSONDecodeError as exc:
|
|
414
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not JSON: {exc}") from exc
|
|
415
|
+
if not isinstance(data, dict):
|
|
416
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a JSON object")
|
|
417
|
+
return data
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def read_envelope(source: str, label: str) -> dict:
|
|
421
|
+
"""A sub-skill's result envelope: the last SKF_*_RESULT_JSON line, or bare JSON."""
|
|
422
|
+
text = read_text(source, label)
|
|
423
|
+
lines = [line for line in text.splitlines() if "_RESULT_JSON:" in line and "SKF_" in line]
|
|
424
|
+
payload = lines[-1].split("_RESULT_JSON:", 1)[1].strip() if lines else text.strip()
|
|
425
|
+
if not payload:
|
|
426
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} holds no result envelope")
|
|
427
|
+
try:
|
|
428
|
+
data = json.loads(payload)
|
|
429
|
+
except json.JSONDecodeError as exc:
|
|
430
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a result envelope: {exc}") from exc
|
|
431
|
+
if not isinstance(data, dict):
|
|
432
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a JSON object")
|
|
433
|
+
return data
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _skills_by_name(state: dict) -> dict[str, dict]:
|
|
437
|
+
return {s["name"]: s for s in state.get("skills", [])}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _known(state: dict, names: list[str]) -> dict[str, dict]:
|
|
441
|
+
skills = _skills_by_name(state)
|
|
442
|
+
unknown = [n for n in names if n not in skills]
|
|
443
|
+
if unknown:
|
|
444
|
+
raise StateError(EXIT_INPUT, "unknown-skill",
|
|
445
|
+
f"Unknown skill(s): {', '.join(unknown)}. Known skills: {', '.join(skills)}.")
|
|
446
|
+
return skills
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# --------------------------------------------------------------------------
|
|
450
|
+
# Operations
|
|
451
|
+
# --------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _brief_template(source: str) -> tuple[str, dict]:
|
|
455
|
+
"""The template's leading comment block and its mapping (empty for an empty template)."""
|
|
456
|
+
text = read_text(source, "Brief template")
|
|
457
|
+
try:
|
|
458
|
+
mapping = yaml.safe_load(text)
|
|
459
|
+
except yaml.YAMLError as exc:
|
|
460
|
+
raise StateError(EXIT_INPUT, "brief-template-invalid", f"Brief template is not YAML: {exc}") from exc
|
|
461
|
+
if mapping is None:
|
|
462
|
+
mapping = {}
|
|
463
|
+
if not isinstance(mapping, dict):
|
|
464
|
+
raise StateError(EXIT_INPUT, "brief-template-invalid", "Brief template is not a YAML mapping")
|
|
465
|
+
header = []
|
|
466
|
+
for line in text.splitlines():
|
|
467
|
+
if not line.startswith("#"):
|
|
468
|
+
break
|
|
469
|
+
header.append(line)
|
|
470
|
+
return "".join(f"{line}\n" for line in header), mapping
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def render_brief(template: tuple[str, dict], fields: dict) -> bytes:
|
|
474
|
+
"""campaign-brief.yaml: the template's header, then its mapping with `fields` laid over it."""
|
|
475
|
+
header, mapping = template
|
|
476
|
+
brief = {**copy.deepcopy(mapping), **fields}
|
|
477
|
+
body = yaml.safe_dump(brief, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
478
|
+
return ((header + "\n" if header else "") + body).encode("utf-8")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def op_init(args: argparse.Namespace) -> dict:
|
|
482
|
+
path = Path(args.state_file)
|
|
483
|
+
bak = backup_path(path)
|
|
484
|
+
for existing in (path, bak):
|
|
485
|
+
if existing.exists():
|
|
486
|
+
raise StateError(EXIT_INPUT, "state-exists",
|
|
487
|
+
f"`{existing.as_posix()}` already exists: archive the campaign before starting a new one.")
|
|
488
|
+
if not args.brief_out and (args.brief_template or args.notes is not None):
|
|
489
|
+
raise StateError(EXIT_INPUT, "input-invalid", "--brief-template and --notes go with --brief-out")
|
|
490
|
+
parsed = read_json(args.targets_file, "Targets")
|
|
491
|
+
if parsed.get("errors"):
|
|
492
|
+
raise StateError(EXIT_INPUT, "targets-invalid", "The targets hold errors: fix them and parse again.",
|
|
493
|
+
list(parsed["errors"]))
|
|
494
|
+
targets = parsed.get("targets")
|
|
495
|
+
if not isinstance(targets, list) or not targets:
|
|
496
|
+
raise StateError(EXIT_INPUT, "no-targets", "A campaign needs at least one target.")
|
|
497
|
+
|
|
498
|
+
gate_module = _module(GATE_SCRIPT)
|
|
499
|
+
try:
|
|
500
|
+
brief = gate_module._load_brief(args.brief_file) if args.brief_file else None
|
|
501
|
+
gate = gate_module.check(args.hard, args.soft_target, args.soft_fallback, brief)
|
|
502
|
+
except gate_module.GateError as exc:
|
|
503
|
+
raise StateError(EXIT_INPUT, exc.code, str(exc), list(exc.extra.get("errors", []))) from exc
|
|
504
|
+
|
|
505
|
+
template = _brief_template(args.brief_template or str(BRIEF_TEMPLATE)) if args.brief_out else None
|
|
506
|
+
stamp = now()
|
|
507
|
+
campaign: dict[str, Any] = {
|
|
508
|
+
"name": args.name,
|
|
509
|
+
"started_at": stamp,
|
|
510
|
+
"last_updated": stamp,
|
|
511
|
+
"current_stage": 0,
|
|
512
|
+
}
|
|
513
|
+
if args.directive_path:
|
|
514
|
+
campaign["directive_path"] = args.directive_path
|
|
515
|
+
if args.architecture_doc_path:
|
|
516
|
+
campaign["architecture_doc_path"] = args.architecture_doc_path
|
|
517
|
+
campaign["quality_gate"] = {key: gate[key] for key in ("hard", "soft_target", "soft_fallback")}
|
|
518
|
+
|
|
519
|
+
skills = []
|
|
520
|
+
for target in targets:
|
|
521
|
+
if not isinstance(target, dict) or not target.get("name"):
|
|
522
|
+
raise StateError(EXIT_INPUT, "targets-invalid", "A target has no name: parse the targets again.")
|
|
523
|
+
skills.append({
|
|
524
|
+
"name": target["name"],
|
|
525
|
+
"status": "pending",
|
|
526
|
+
"depends_on": list(target.get("depends_on") or []),
|
|
527
|
+
"tier": target.get("tier") or "A",
|
|
528
|
+
"pin": target.get("pin"),
|
|
529
|
+
"brief_path": None,
|
|
530
|
+
"skill_path": None,
|
|
531
|
+
"quality_score": None,
|
|
532
|
+
"workarounds_applied": [],
|
|
533
|
+
"started_at": None,
|
|
534
|
+
"completed_at": None,
|
|
535
|
+
"commit_sha": None,
|
|
536
|
+
})
|
|
537
|
+
state = {
|
|
538
|
+
"campaign": campaign,
|
|
539
|
+
"skills": skills,
|
|
540
|
+
"dependency_graph": {"execution_order": [], "circular_deps_detected": False},
|
|
541
|
+
}
|
|
542
|
+
errors = validation_errors(state)
|
|
543
|
+
if errors:
|
|
544
|
+
raise StateError(EXIT_STATE, "state-change-invalid",
|
|
545
|
+
"The new state would be invalid: nothing was written.", errors)
|
|
546
|
+
brief_data = None
|
|
547
|
+
if template is not None:
|
|
548
|
+
notes = args.notes if args.notes is not None else (brief or {}).get("notes")
|
|
549
|
+
brief_data = render_brief(template, {
|
|
550
|
+
"campaign_name": args.name,
|
|
551
|
+
"created_at": stamp,
|
|
552
|
+
"targets": copy.deepcopy(targets),
|
|
553
|
+
"quality_gate": dict(campaign["quality_gate"]),
|
|
554
|
+
"architecture_doc_path": args.architecture_doc_path or "",
|
|
555
|
+
"notes": notes if isinstance(notes, str) else "",
|
|
556
|
+
})
|
|
557
|
+
write_atomic(path, dump_state(state))
|
|
558
|
+
changed = [f"skills={len(skills)}", "campaign.quality_gate"]
|
|
559
|
+
brief_file = Path(args.brief_out) if brief_data is not None else None
|
|
560
|
+
if brief_file is not None:
|
|
561
|
+
write_atomic(brief_file, brief_data)
|
|
562
|
+
changed.append("brief")
|
|
563
|
+
return {
|
|
564
|
+
"op": "init",
|
|
565
|
+
"state_file": path.as_posix(),
|
|
566
|
+
"backup_file": bak.as_posix(),
|
|
567
|
+
"backup_rotated": False,
|
|
568
|
+
"last_updated": stamp,
|
|
569
|
+
"current_stage": 0,
|
|
570
|
+
"changed": changed,
|
|
571
|
+
"quality_gate": campaign["quality_gate"],
|
|
572
|
+
"brief_file": brief_file.as_posix() if brief_file else None,
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def op_set_stage(args: argparse.Namespace) -> dict:
|
|
577
|
+
return mutate(args.state_file, "set-stage", lambda state, stamp, changed: None, args.stage)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _export_outcome(args: argparse.Namespace) -> dict | None:
|
|
581
|
+
"""The `export` record --export gives, or None without it."""
|
|
582
|
+
detail = args.export_halt_reason is not None or args.export_exit_code is not None
|
|
583
|
+
if args.export is None:
|
|
584
|
+
if detail:
|
|
585
|
+
raise StateError(EXIT_INPUT, "input-invalid", "--export-halt-reason and --export-exit-code go with --export")
|
|
586
|
+
return None
|
|
587
|
+
if args.export == "exported":
|
|
588
|
+
if detail:
|
|
589
|
+
raise StateError(EXIT_INPUT, "input-invalid",
|
|
590
|
+
"--export-halt-reason and --export-exit-code go with --export failed")
|
|
591
|
+
return {"status": "exported", "halt_reason": None, "exit_code": 0}
|
|
592
|
+
return {"status": "failed", "halt_reason": args.export_halt_reason or None, "exit_code": args.export_exit_code}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def op_set_skill(args: argparse.Namespace) -> dict:
|
|
596
|
+
outcome = _export_outcome(args)
|
|
597
|
+
|
|
598
|
+
def change(state: dict, stamp: str, changed: list[str]) -> None:
|
|
599
|
+
skills = _known(state, args.skill)
|
|
600
|
+
for name in args.skill:
|
|
601
|
+
skill = skills[name]
|
|
602
|
+
if args.status is not None:
|
|
603
|
+
skill["status"] = args.status
|
|
604
|
+
changed.append(f"{name}.status={args.status}")
|
|
605
|
+
if args.status == "active" and not skill.get("started_at"):
|
|
606
|
+
skill["started_at"] = stamp
|
|
607
|
+
changed.append(f"{name}.started_at")
|
|
608
|
+
elif args.status == "completed":
|
|
609
|
+
skill["completed_at"] = stamp
|
|
610
|
+
changed.append(f"{name}.completed_at")
|
|
611
|
+
elif args.status == "pending":
|
|
612
|
+
skill["started_at"] = None
|
|
613
|
+
skill["completed_at"] = None
|
|
614
|
+
skill.pop("export", None)
|
|
615
|
+
for field in ("quality_score", "skill_path", "brief_path"):
|
|
616
|
+
value = getattr(args, field)
|
|
617
|
+
if value is not None:
|
|
618
|
+
skill[field] = value
|
|
619
|
+
changed.append(f"{name}.{field}")
|
|
620
|
+
if outcome is not None:
|
|
621
|
+
skill["export"] = dict(outcome)
|
|
622
|
+
changed.append(f"{name}.export={outcome['status']}")
|
|
623
|
+
|
|
624
|
+
return mutate(args.state_file, "set-skill", change, args.stage)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def computed_plan(state_file: str) -> list[str]:
|
|
628
|
+
"""campaign-deps.py --compute's execution order for the state file.
|
|
629
|
+
|
|
630
|
+
StateError (exit 4) when the graph cannot be followed: a cycle, a
|
|
631
|
+
dangling depends_on name or a Tier A skill on a Tier B one.
|
|
632
|
+
"""
|
|
633
|
+
out, err = io.StringIO(), io.StringIO()
|
|
634
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
635
|
+
rc = _module(DEPS_SCRIPT).compute(state_file)
|
|
636
|
+
if rc == 0:
|
|
637
|
+
return json.loads(out.getvalue())["execution_order"]
|
|
638
|
+
details = [line for line in (out.getvalue() + err.getvalue()).splitlines() if line.strip()]
|
|
639
|
+
raise StateError(EXIT_CIRCULAR, "plan-unorderable",
|
|
640
|
+
"The dependency graph cannot be followed: campaign-deps.py --compute says why.", details)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def op_apply_plan(args: argparse.Namespace) -> dict:
|
|
644
|
+
def change(state: dict, stamp: str, changed: list[str]) -> dict:
|
|
645
|
+
order = computed_plan(args.state_file)
|
|
646
|
+
state["dependency_graph"] = {"execution_order": order, "circular_deps_detected": False}
|
|
647
|
+
changed.append("dependency_graph.execution_order")
|
|
648
|
+
return {"execution_order": order}
|
|
649
|
+
|
|
650
|
+
return mutate(args.state_file, "apply-plan", change, args.stage)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _results(source: str, label: str) -> list[dict]:
|
|
654
|
+
data = read_json(source, label)
|
|
655
|
+
results = data.get("results")
|
|
656
|
+
if not isinstance(results, list) or not all(isinstance(r, dict) for r in results):
|
|
657
|
+
raise StateError(EXIT_INPUT, "input-unreadable", f"{label} holds no results[] list")
|
|
658
|
+
return results
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def op_apply_pins(args: argparse.Namespace) -> dict:
|
|
662
|
+
results = _results(args.results_file, "Pin results")
|
|
663
|
+
invalid = [r.get("name") for r in results if r.get("status") not in ("valid", "resolved")]
|
|
664
|
+
if invalid:
|
|
665
|
+
raise StateError(EXIT_PIN, "invalid-pin", f"Invalid pin(s): {', '.join(map(str, invalid))}: nothing was written.")
|
|
666
|
+
|
|
667
|
+
def change(state: dict, stamp: str, changed: list[str]) -> None:
|
|
668
|
+
skills = _known(state, [r.get("name") for r in results])
|
|
669
|
+
for result in results:
|
|
670
|
+
ref = result.get("resolved_ref")
|
|
671
|
+
skill = skills[result["name"]]
|
|
672
|
+
if ref and ref != skill.get("pin"):
|
|
673
|
+
skill["pin"] = ref
|
|
674
|
+
changed.append(f"{result['name']}.pin={ref}")
|
|
675
|
+
|
|
676
|
+
return mutate(args.state_file, "apply-pins", change, args.stage)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def op_apply_provenance(args: argparse.Namespace) -> dict:
|
|
680
|
+
results = _results(args.results_file, "Provenance results")
|
|
681
|
+
inaccessible = [r.get("name") for r in results if r.get("status") != "accessible" or not r.get("commit_sha")]
|
|
682
|
+
if inaccessible:
|
|
683
|
+
raise StateError(EXIT_REPO, "inaccessible-repo",
|
|
684
|
+
f"Inaccessible repositories: {', '.join(map(str, inaccessible))}: nothing was written.")
|
|
685
|
+
|
|
686
|
+
def change(state: dict, stamp: str, changed: list[str]) -> None:
|
|
687
|
+
skills = _known(state, [r.get("name") for r in results])
|
|
688
|
+
for result in results:
|
|
689
|
+
skills[result["name"]]["commit_sha"] = result["commit_sha"]
|
|
690
|
+
changed.append(f"{result['name']}.commit_sha")
|
|
691
|
+
|
|
692
|
+
return mutate(args.state_file, "apply-provenance", change, args.stage)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def op_apply_batch(args: argparse.Namespace) -> dict:
|
|
696
|
+
batch_map = read_json(args.map_file, "Batch map")
|
|
697
|
+
lines = batch_map.get("lines")
|
|
698
|
+
if not isinstance(lines, list):
|
|
699
|
+
raise StateError(EXIT_INPUT, "input-unreadable", "Batch map holds no lines[] list")
|
|
700
|
+
batched = [entry.get("skill") for entry in lines if isinstance(entry, dict)]
|
|
701
|
+
skipped = [e for e in batch_map.get("skipped_by_directive") or [] if isinstance(e, dict)]
|
|
702
|
+
rows: list[dict] = []
|
|
703
|
+
if args.results_file:
|
|
704
|
+
rows = _results(args.results_file, "Batch results")
|
|
705
|
+
unmapped = [r.get("skill") for r in rows if r.get("skill") not in batched]
|
|
706
|
+
if unmapped:
|
|
707
|
+
raise StateError(EXIT_INPUT, "input-unreadable",
|
|
708
|
+
f"Batch results name skills the map does not: {', '.join(map(str, unmapped))}")
|
|
709
|
+
|
|
710
|
+
def change(state: dict, stamp: str, changed: list[str]) -> dict:
|
|
711
|
+
skills = _known(state, batched + [e.get("name") for e in skipped])
|
|
712
|
+
out: dict[str, list] = {"activated": [], "skipped": [], "completed": [], "failed": []}
|
|
713
|
+
if args.start:
|
|
714
|
+
for entry in skipped:
|
|
715
|
+
skills[entry["name"]]["status"] = "skipped"
|
|
716
|
+
out["skipped"].append({"name": entry["name"], "reason": entry.get("reason")})
|
|
717
|
+
for name in batched:
|
|
718
|
+
skill = skills[name]
|
|
719
|
+
skill["status"] = "active"
|
|
720
|
+
if not skill.get("started_at"):
|
|
721
|
+
skill["started_at"] = stamp
|
|
722
|
+
out["activated"].append(name)
|
|
723
|
+
elif args.no_results:
|
|
724
|
+
for name in batched:
|
|
725
|
+
skills[name]["status"] = "failed"
|
|
726
|
+
out["failed"].append({"skill": name, "error_code": "no-batch-result"})
|
|
727
|
+
else:
|
|
728
|
+
for row in rows:
|
|
729
|
+
skill = skills[row["skill"]]
|
|
730
|
+
if row.get("status") == "completed":
|
|
731
|
+
skill["status"] = "completed"
|
|
732
|
+
skill["completed_at"] = stamp
|
|
733
|
+
skill["quality_score"] = row.get("quality_score")
|
|
734
|
+
skill["skill_path"] = row.get("skill_path")
|
|
735
|
+
out["completed"].append(row["skill"])
|
|
736
|
+
else:
|
|
737
|
+
skill["status"] = "failed"
|
|
738
|
+
out["failed"].append({"skill": row["skill"], "error_code": row.get("error_code")})
|
|
739
|
+
for key, values in out.items():
|
|
740
|
+
if values:
|
|
741
|
+
changed.append(f"{key}={len(values)}")
|
|
742
|
+
return out
|
|
743
|
+
|
|
744
|
+
return mutate(args.state_file, "apply-batch", change, args.stage)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def op_append_workarounds(args: argparse.Namespace) -> dict:
|
|
748
|
+
def change(state: dict, stamp: str, changed: list[str]) -> dict:
|
|
749
|
+
skill = _known(state, [args.skill])[args.skill]
|
|
750
|
+
held = skill.setdefault("workarounds_applied", [])
|
|
751
|
+
added = []
|
|
752
|
+
for entry in args.entry:
|
|
753
|
+
if entry.strip() and entry not in held:
|
|
754
|
+
held.append(entry)
|
|
755
|
+
added.append(entry)
|
|
756
|
+
changed.append(f"{args.skill}.workarounds_applied+{len(added)}")
|
|
757
|
+
return {"added": added}
|
|
758
|
+
|
|
759
|
+
return mutate(args.state_file, "append-workarounds", change, args.stage)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _capstone(envelope: dict, stamp: str) -> dict | None:
|
|
763
|
+
if envelope.get("status") != "success":
|
|
764
|
+
return None
|
|
765
|
+
return {
|
|
766
|
+
"skill_path": envelope.get("skill_package"),
|
|
767
|
+
"quality_score": envelope.get("quality_score"),
|
|
768
|
+
"verified": None,
|
|
769
|
+
"completed_at": stamp,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _verification(envelope: dict, stamp: str) -> dict | None:
|
|
774
|
+
if envelope.get("status") != "success" or envelope.get("exit_code", 0) != 0:
|
|
775
|
+
return None
|
|
776
|
+
return {key: envelope.get(key) for key in
|
|
777
|
+
("report_path", "overall_verdict", "coverage_percentage", "recommendation_count")}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _refinement(envelope: dict, stamp: str) -> dict | None:
|
|
781
|
+
if envelope.get("status") != "success" or envelope.get("exit_code", 0) != 0:
|
|
782
|
+
return None
|
|
783
|
+
return {key: envelope.get(key) for key in ("refined_path", "gap_count", "issue_count", "improvement_count")}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
SUMMARIES = (("capstone", _capstone), ("verification", _verification), ("refinement", _refinement))
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def op_set_campaign(args: argparse.Namespace) -> dict:
|
|
790
|
+
envelopes = {}
|
|
791
|
+
for field, _build in SUMMARIES:
|
|
792
|
+
source = getattr(args, field)
|
|
793
|
+
if source is not None:
|
|
794
|
+
envelopes[field] = read_envelope(source, f"The {field} envelope")
|
|
795
|
+
|
|
796
|
+
def change(state: dict, stamp: str, changed: list[str]) -> dict:
|
|
797
|
+
campaign = state["campaign"]
|
|
798
|
+
recorded, not_recorded = [], []
|
|
799
|
+
if args.architecture_doc_path:
|
|
800
|
+
campaign["architecture_doc_path"] = args.architecture_doc_path
|
|
801
|
+
changed.append("campaign.architecture_doc_path")
|
|
802
|
+
for field, build in SUMMARIES:
|
|
803
|
+
if field in envelopes:
|
|
804
|
+
envelope = envelopes[field]
|
|
805
|
+
value = build(envelope, stamp)
|
|
806
|
+
if value is None:
|
|
807
|
+
not_recorded.append({"field": field, "status": envelope.get("status"),
|
|
808
|
+
"halt_reason": envelope.get("halt_reason"),
|
|
809
|
+
"exit_code": envelope.get("exit_code")})
|
|
810
|
+
else:
|
|
811
|
+
recorded.append(field)
|
|
812
|
+
elif getattr(args, f"no_{field}"):
|
|
813
|
+
value = None
|
|
814
|
+
not_recorded.append({"field": field, "status": None, "halt_reason": None, "exit_code": None})
|
|
815
|
+
else:
|
|
816
|
+
continue
|
|
817
|
+
campaign[field] = value
|
|
818
|
+
changed.append(f"campaign.{field}")
|
|
819
|
+
if field == "verification" and isinstance(campaign.get("capstone"), dict):
|
|
820
|
+
verdict = (value or {}).get("overall_verdict")
|
|
821
|
+
campaign["capstone"]["verified"] = None if value is None else verdict == "FEASIBLE"
|
|
822
|
+
changed.append("campaign.capstone.verified")
|
|
823
|
+
return {"recorded": recorded, "not_recorded": not_recorded}
|
|
824
|
+
|
|
825
|
+
return mutate(args.state_file, "set-campaign", change, args.stage)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def op_recover(args: argparse.Namespace) -> dict:
|
|
829
|
+
path = Path(args.state_file)
|
|
830
|
+
bak = backup_path(path)
|
|
831
|
+
try:
|
|
832
|
+
state, raw = read_state(bak)
|
|
833
|
+
except StateError as exc:
|
|
834
|
+
raise StateError(EXIT_NO_BACKUP, "corrupt-state",
|
|
835
|
+
f"No usable backup at `{bak.as_posix()}`: {exc}", exc.errors) from exc
|
|
836
|
+
write_atomic(path, raw)
|
|
837
|
+
return {
|
|
838
|
+
"op": "recover",
|
|
839
|
+
"state_file": path.as_posix(),
|
|
840
|
+
"backup_file": bak.as_posix(),
|
|
841
|
+
"recovered": True,
|
|
842
|
+
"last_updated": state["campaign"]["last_updated"],
|
|
843
|
+
"current_stage": state["campaign"]["current_stage"],
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _loose_state(path: Path) -> dict | None:
|
|
848
|
+
"""The state as YAML, unvalidated: what an archive or a halt can still read."""
|
|
849
|
+
if not path.is_file():
|
|
850
|
+
return None
|
|
851
|
+
try:
|
|
852
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
853
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
854
|
+
return None
|
|
855
|
+
return data if isinstance(data, dict) else None
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _campaign_name(path: Path) -> str | None:
|
|
859
|
+
campaign = (_loose_state(path) or {}).get("campaign")
|
|
860
|
+
name = campaign.get("name") if isinstance(campaign, dict) else None
|
|
861
|
+
return name if isinstance(name, str) and name.strip() else None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def op_archive(args: argparse.Namespace) -> dict:
|
|
865
|
+
path = Path(args.state_file)
|
|
866
|
+
bak = backup_path(path)
|
|
867
|
+
sources = [p for p in (path, bak, Path(args.brief_file)) if p.is_file()]
|
|
868
|
+
if not sources:
|
|
869
|
+
raise StateError(EXIT_INPUT, "nothing-to-archive", "No campaign state, backup or brief to archive.")
|
|
870
|
+
name = _campaign_name(path) or _campaign_name(bak) or "campaign"
|
|
871
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "campaign"
|
|
872
|
+
# No colon in a folder name: Windows refuses one.
|
|
873
|
+
stamp = now().replace("+00:00", "Z").replace(":", "")
|
|
874
|
+
folder = path.parent / "archive" / f"{slug}-{stamp}"
|
|
875
|
+
if folder.exists():
|
|
876
|
+
raise StateError(EXIT_INPUT, "archive-exists", f"`{folder.as_posix()}` already exists: nothing was moved.")
|
|
877
|
+
folder.mkdir(parents=True)
|
|
878
|
+
moved = []
|
|
879
|
+
for source in sources:
|
|
880
|
+
target = folder / source.name
|
|
881
|
+
_replace(source, target)
|
|
882
|
+
moved.append(target.as_posix())
|
|
883
|
+
return {"op": "archive", "archive_dir": folder.as_posix(), "moved": moved}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def op_log(args: argparse.Namespace) -> dict:
|
|
887
|
+
text = " ".join(args.text.split())
|
|
888
|
+
if not text:
|
|
889
|
+
raise StateError(EXIT_INPUT, "input-unreadable", "The log entry is empty")
|
|
890
|
+
entry = f"- {now()} ({args.type}) {text}"
|
|
891
|
+
path = Path(args.log_file)
|
|
892
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
893
|
+
with open(path, "a", encoding="utf-8", newline="\n") as fh:
|
|
894
|
+
fh.write(entry + "\n")
|
|
895
|
+
fh.flush()
|
|
896
|
+
os.fsync(fh.fileno())
|
|
897
|
+
return {"op": "log", "log_file": path.as_posix(), "entry": entry}
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def op_halt_payload(args: argparse.Namespace) -> dict:
|
|
901
|
+
path = Path(args.state_file)
|
|
902
|
+
reason = " ".join(read_text("-", "The halt message").split())
|
|
903
|
+
if not reason:
|
|
904
|
+
raise StateError(EXIT_INPUT, "input-unreadable", "The halt message is empty")
|
|
905
|
+
skills = (_loose_state(path) or {}).get("skills")
|
|
906
|
+
skills = [s for s in skills if isinstance(s, dict)] if isinstance(skills, list) else []
|
|
907
|
+
return {
|
|
908
|
+
"phase": args.phase,
|
|
909
|
+
"reason": reason,
|
|
910
|
+
"halt_reason": args.halt_reason,
|
|
911
|
+
"skills_completed": sum(1 for s in skills if s.get("status") == "completed"),
|
|
912
|
+
"skills_failed": sum(1 for s in skills if s.get("status") == "failed"),
|
|
913
|
+
"decision_log": (path.parent / DECISION_LOG).as_posix() if path.parent.is_dir() else None,
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def resume_point(state: dict, from_skill: str | None = None, take_next: bool = False) -> dict:
|
|
918
|
+
"""Where a resumed campaign continues (see the module docstring)."""
|
|
919
|
+
campaign = state["campaign"]
|
|
920
|
+
skills = state.get("skills", [])
|
|
921
|
+
by_name = _skills_by_name(state)
|
|
922
|
+
result: dict[str, Any] = {
|
|
923
|
+
"complete": False, "stage": None, "step_file": None, "reason": None, "skill": None,
|
|
924
|
+
"needs_choice": False, "from_status": None, "active_other": None,
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
def route(stage: int, reason: str, skill: str | None = None) -> dict:
|
|
928
|
+
result.update(stage=stage, step_file=STEP_FILES[stage], reason=reason, skill=skill)
|
|
929
|
+
return result
|
|
930
|
+
|
|
931
|
+
if from_skill is not None:
|
|
932
|
+
if from_skill not in by_name:
|
|
933
|
+
raise StateError(EXIT_INPUT, "unknown-skill",
|
|
934
|
+
f"Unknown skill '{from_skill}'. Known skills: {', '.join(by_name)}.")
|
|
935
|
+
target = by_name[from_skill]
|
|
936
|
+
result["from_status"] = target["status"]
|
|
937
|
+
active = [s["name"] for s in skills if s["status"] == "active" and s["name"] != from_skill]
|
|
938
|
+
result["active_other"] = active[0] if active else None
|
|
939
|
+
if target["status"] in OPEN:
|
|
940
|
+
return route(TIER_STAGE[target["tier"]], "from-skill", from_skill)
|
|
941
|
+
if not take_next:
|
|
942
|
+
result.update(needs_choice=True, skill=from_skill)
|
|
943
|
+
return result
|
|
944
|
+
order = state["dependency_graph"].get("execution_order") or [s["name"] for s in skills]
|
|
945
|
+
after = order[order.index(from_skill) + 1:] if from_skill in order else []
|
|
946
|
+
for name in after:
|
|
947
|
+
skill = by_name.get(name)
|
|
948
|
+
if skill is not None and skill["status"] in OPEN:
|
|
949
|
+
return route(TIER_STAGE[skill["tier"]], "from-next", name)
|
|
950
|
+
result.update(complete=True, reason="nothing-after-from")
|
|
951
|
+
return result
|
|
952
|
+
|
|
953
|
+
active = [s for s in skills if s["status"] == "active"]
|
|
954
|
+
if active:
|
|
955
|
+
first = min(active, key=lambda s: TIER_STAGE[s["tier"]])
|
|
956
|
+
return route(TIER_STAGE[first["tier"]], "active-skill", first["name"])
|
|
957
|
+
current = campaign["current_stage"]
|
|
958
|
+
if current >= FINAL_STAGE and not any(s["status"] in OPEN for s in skills):
|
|
959
|
+
result.update(complete=True, reason="complete")
|
|
960
|
+
return result
|
|
961
|
+
if current >= FINAL_STAGE:
|
|
962
|
+
return route(FINAL_STAGE, "terminal-cap")
|
|
963
|
+
return route(current + 1, "next-stage")
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def op_resume(args: argparse.Namespace) -> dict:
|
|
967
|
+
state, _raw = read_state(Path(args.state_file))
|
|
968
|
+
if args.next and args.from_skill is None:
|
|
969
|
+
raise StateError(EXIT_INPUT, "input-invalid", "--next needs --from")
|
|
970
|
+
return resume_point(state, args.from_skill, args.next)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# --------------------------------------------------------------------------
|
|
974
|
+
# CLI
|
|
975
|
+
# --------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _stage(value: str) -> int:
|
|
979
|
+
stage = int(value)
|
|
980
|
+
if not 0 <= stage <= FINAL_STAGE:
|
|
981
|
+
raise argparse.ArgumentTypeError(f"a stage is 0 to {FINAL_STAGE}")
|
|
982
|
+
return stage
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _score(value: str) -> int | float:
|
|
986
|
+
number = float(value)
|
|
987
|
+
return int(number) if number.is_integer() else number
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
991
|
+
parser = argparse.ArgumentParser(
|
|
992
|
+
prog="campaign-state",
|
|
993
|
+
description="Write _campaign-state.yaml (validated before and after, .bak rotated from a valid "
|
|
994
|
+
"primary, timestamps from the clock, atomic) and compute the resume point.",
|
|
995
|
+
)
|
|
996
|
+
sub = parser.add_subparsers(dest="op", required=True)
|
|
997
|
+
|
|
998
|
+
def command(name: str, help_text: str, stage: str | None = "optional") -> argparse.ArgumentParser:
|
|
999
|
+
p = sub.add_parser(name, help=help_text)
|
|
1000
|
+
if name != "log":
|
|
1001
|
+
p.add_argument("--state-file", required=True, help="path to _campaign-state.yaml")
|
|
1002
|
+
if stage is not None:
|
|
1003
|
+
p.add_argument("--stage", type=_stage, required=stage == "required",
|
|
1004
|
+
help="write campaign.current_stage (0 to 10) in the same write")
|
|
1005
|
+
return p
|
|
1006
|
+
|
|
1007
|
+
p = command("init", "create the state from parsed targets (step-01)", stage=None)
|
|
1008
|
+
p.add_argument("--targets-file", required=True, help="campaign-parse-manifest.py output, or - for stdin")
|
|
1009
|
+
p.add_argument("--name", required=True, help="campaign name")
|
|
1010
|
+
p.add_argument("--hard", required=True, help="quality_gate_hard")
|
|
1011
|
+
p.add_argument("--soft-target", required=True, help="quality_gate_soft_target")
|
|
1012
|
+
p.add_argument("--soft-fallback", required=True, help="quality_gate_soft_fallback")
|
|
1013
|
+
p.add_argument("--brief-file", help="campaign brief whose quality_gate wins over the values above")
|
|
1014
|
+
p.add_argument("--directive-path", help="campaign directive file")
|
|
1015
|
+
p.add_argument("--architecture-doc-path", help="architecture document for verify and refine")
|
|
1016
|
+
p.add_argument("--brief-out", help="also write campaign-brief.yaml here, from the same targets")
|
|
1017
|
+
p.add_argument("--brief-template", help="the brief template (default: templates/campaign-brief-template.yaml)")
|
|
1018
|
+
p.add_argument("--notes", help="the brief's notes (default: the --brief-file's notes, else empty)")
|
|
1019
|
+
|
|
1020
|
+
command("set-stage", "write campaign.current_stage", stage="required")
|
|
1021
|
+
|
|
1022
|
+
p = command("set-skill", "set fields of one or more skills")
|
|
1023
|
+
p.add_argument("--skill", action="append", required=True, help="skill name (repeat for several)")
|
|
1024
|
+
p.add_argument("--status", choices=STATUSES)
|
|
1025
|
+
p.add_argument("--quality-score", type=_score)
|
|
1026
|
+
p.add_argument("--skill-path")
|
|
1027
|
+
p.add_argument("--brief-path")
|
|
1028
|
+
p.add_argument("--export", choices=EXPORT_STATUSES, help="the skill's skf-export-skill outcome (step-10)")
|
|
1029
|
+
p.add_argument("--export-halt-reason", help="with --export failed: the export envelope's halt_reason")
|
|
1030
|
+
p.add_argument("--export-exit-code", type=int, help="with --export failed: skf-export-skill's exit code")
|
|
1031
|
+
|
|
1032
|
+
command("apply-plan", "write the execution order campaign-deps.py computes (step-02)")
|
|
1033
|
+
|
|
1034
|
+
p = command("apply-pins", "write resolved pins (step-03)")
|
|
1035
|
+
p.add_argument("--results-file", required=True, help="campaign-validate-pins.py output, or -")
|
|
1036
|
+
|
|
1037
|
+
p = command("apply-provenance", "write commit SHAs (step-04)")
|
|
1038
|
+
p.add_argument("--results-file", required=True, help="campaign-provenance.py output, or -")
|
|
1039
|
+
|
|
1040
|
+
p = command("apply-batch", "record the Tier B batch (step-06)")
|
|
1041
|
+
p.add_argument("--map-file", required=True, help="the line-to-skill map campaign-render-batch.py wrote")
|
|
1042
|
+
mode = p.add_mutually_exclusive_group(required=True)
|
|
1043
|
+
mode.add_argument("--start", action="store_true", help="mark skipped and batched skills before the batch")
|
|
1044
|
+
mode.add_argument("--results-file", help="campaign-render-batch.py --record output, or -")
|
|
1045
|
+
mode.add_argument("--no-results", action="store_true", help="fail every batched skill")
|
|
1046
|
+
|
|
1047
|
+
p = command("append-workarounds", "append entries to a skill's workarounds_applied")
|
|
1048
|
+
p.add_argument("--skill", required=True)
|
|
1049
|
+
p.add_argument("--entry", action="append", required=True, help="one entry (repeat for several)")
|
|
1050
|
+
|
|
1051
|
+
p = command("set-campaign", "write campaign-level fields")
|
|
1052
|
+
p.add_argument("--architecture-doc-path")
|
|
1053
|
+
for field, _build in SUMMARIES:
|
|
1054
|
+
group = p.add_mutually_exclusive_group()
|
|
1055
|
+
group.add_argument(f"--{field}", help=f"the {field} sub-skill's result envelope line, a file or -")
|
|
1056
|
+
group.add_argument(f"--no-{field}", action="store_true", help=f"record campaign.{field} as null")
|
|
1057
|
+
|
|
1058
|
+
command("recover", "copy a valid .bak over the primary", stage=None)
|
|
1059
|
+
|
|
1060
|
+
p = command("archive", "move the state, its .bak and the brief into archive/ (overwrite)", stage=None)
|
|
1061
|
+
p.add_argument("--brief-file", required=True, help="path to campaign-brief.yaml")
|
|
1062
|
+
|
|
1063
|
+
p = command("log", "append a typed entry to the decision log", stage=None)
|
|
1064
|
+
p.add_argument("--log-file", required=True, help="path to _campaign-decision-log.md")
|
|
1065
|
+
p.add_argument("--type", required=True, choices=LOG_TYPES)
|
|
1066
|
+
p.add_argument("--text", required=True)
|
|
1067
|
+
|
|
1068
|
+
p = command("halt-payload", "print a HARD HALT's emit-halt payload; the message on stdin", stage=None)
|
|
1069
|
+
p.add_argument("--phase", required=True, help="the kebab slug of the step that halts")
|
|
1070
|
+
p.add_argument("--halt-reason", required=True, help="the Exit Codes meaning, such as circular-deps")
|
|
1071
|
+
|
|
1072
|
+
p = command("resume", "compute the resume point (read-only)", stage=None)
|
|
1073
|
+
p.add_argument("--from", dest="from_skill", help="resume at this skill")
|
|
1074
|
+
p.add_argument("--next", action="store_true", help="with --from at a finished skill: take the next open one")
|
|
1075
|
+
return parser
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
OPERATIONS = {
|
|
1079
|
+
"init": op_init,
|
|
1080
|
+
"set-stage": op_set_stage,
|
|
1081
|
+
"set-skill": op_set_skill,
|
|
1082
|
+
"apply-plan": op_apply_plan,
|
|
1083
|
+
"apply-pins": op_apply_pins,
|
|
1084
|
+
"apply-provenance": op_apply_provenance,
|
|
1085
|
+
"apply-batch": op_apply_batch,
|
|
1086
|
+
"append-workarounds": op_append_workarounds,
|
|
1087
|
+
"set-campaign": op_set_campaign,
|
|
1088
|
+
"recover": op_recover,
|
|
1089
|
+
"archive": op_archive,
|
|
1090
|
+
"log": op_log,
|
|
1091
|
+
"halt-payload": op_halt_payload,
|
|
1092
|
+
"resume": op_resume,
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1097
|
+
parser = build_parser()
|
|
1098
|
+
args = parser.parse_args(argv)
|
|
1099
|
+
try:
|
|
1100
|
+
result = OPERATIONS[args.op](args)
|
|
1101
|
+
except StateError as exc:
|
|
1102
|
+
sys.stderr.write(json.dumps({"error": str(exc), "code": exc.code, "errors": exc.errors}) + "\n")
|
|
1103
|
+
return exc.exit_code
|
|
1104
|
+
except OSError as exc:
|
|
1105
|
+
sys.stderr.write(json.dumps({"error": f"Write failed: {exc}", "code": "write-failed", "errors": []}) + "\n")
|
|
1106
|
+
return EXIT_INPUT
|
|
1107
|
+
sys.stdout.write(json.dumps(result, separators=(",", ":")) + "\n")
|
|
1108
|
+
return 0
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _force_utf8(*streams) -> None:
|
|
1112
|
+
"""Reconfigure the streams to UTF-8, keeping each one's error handler.
|
|
1113
|
+
|
|
1114
|
+
A Windows console pipes them as cp1252, which cannot print every
|
|
1115
|
+
character a path or a log entry may hold.
|
|
1116
|
+
"""
|
|
1117
|
+
for stream in streams:
|
|
1118
|
+
if hasattr(stream, "reconfigure"):
|
|
1119
|
+
errors = getattr(stream, "errors", None)
|
|
1120
|
+
if errors is None:
|
|
1121
|
+
stream.reconfigure(encoding="utf-8")
|
|
1122
|
+
else:
|
|
1123
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
if __name__ == "__main__":
|
|
1127
|
+
_force_utf8(sys.stdout, sys.stderr)
|
|
1128
|
+
sys.exit(main())
|