bmad-module-skill-forge 2.1.0 → 3.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 +51 -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 +135 -18
- package/docs/verifying-a-skill.md +83 -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 +2 -2
- 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 +32 -12
- package/src/shared/references/description-guard-protocol.md +14 -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 +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- 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 +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- 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 +3 -2
- package/src/shared/scripts/skf-description-guard.py +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- 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 +1 -1
- package/src/shared/scripts/skf-detect-tools.py +215 -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 +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -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 +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -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 +1 -1
- 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 +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- 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 +128 -13
- 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 +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- 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 +4 -1
- 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 +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- 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 +27 -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/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- 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 +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- 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 +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- 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 +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +17 -6
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- 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 +107 -29
- 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 +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- 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 +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- 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 +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- 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 +79 -146
- 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 +118 -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 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- 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 +217 -254
- 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 +163 -88
- 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 +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- 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 +287 -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 +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -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 +127 -52
- 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 +233 -222
- 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 +250 -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 +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- 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 -645
- 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,1052 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = []
|
|
5
|
+
# ///
|
|
6
|
+
"""The forger's pipeline journal: a chain's state on disk, and its resume.
|
|
7
|
+
|
|
8
|
+
Pipeline Mode (references/pipeline-mode.md) keeps a chain's state in a
|
|
9
|
+
journal rather than only in the conversation, so a closed session, a killed
|
|
10
|
+
terminal or a compacted context loses nothing, and a later activation picks
|
|
11
|
+
the chain up at the step it stopped on, with the plan the first parse
|
|
12
|
+
produced: its alias, bracket values and arguments. Re-parsing only the codes
|
|
13
|
+
still pending dropped the alias (forge-auto's TS[min:90] fell back to the
|
|
14
|
+
default threshold) and warned about prerequisites the chain had already run.
|
|
15
|
+
Which step a chain stopped on, what a gate's decision records, what a halt
|
|
16
|
+
offers, and the pipeline result's name and shape each have one correct
|
|
17
|
+
answer, so they run here, not in the prompt.
|
|
18
|
+
|
|
19
|
+
The journal is `pipeline-journal.json` in the run's own folder,
|
|
20
|
+
`<run-root>/skf-forger-<run_id>/`, under the run root every SKF workflow
|
|
21
|
+
keeps its run state in (`{project-root}/_bmad-output/.skf-run`). `run_id` is
|
|
22
|
+
the UTC time the chain started and a random suffix, so the folder names sort
|
|
23
|
+
by start time. `src/shared/references/pipeline-contracts.md` (Pipeline State)
|
|
24
|
+
describes its fields. Each step entry keeps one shape, in the journal and in
|
|
25
|
+
the pipeline result alike:
|
|
26
|
+
|
|
27
|
+
{"code": "TS", "min": 90, "mode": null, "target": null, "flags": [],
|
|
28
|
+
"status": "pending" | "completed" | "skipped" | "halted", "reason": null}
|
|
29
|
+
|
|
30
|
+
Subcommands:
|
|
31
|
+
|
|
32
|
+
start Read the whole invocation on stdin, parse it with
|
|
33
|
+
parse-pipeline.py (beside this script), and create the run folder
|
|
34
|
+
and its journal: every step pending, the alias, the first
|
|
35
|
+
workflow's args.
|
|
36
|
+
step Record the step the chain just finished: the first pending step,
|
|
37
|
+
whose code must be --code. After AN, TS, AS or VS, --gate reads
|
|
38
|
+
the JSON pipeline-gate.py printed on stdin: `continue` records
|
|
39
|
+
the step `completed`, `skip` also records the next step, the one
|
|
40
|
+
the gate passes over, `skipped`, and `halt` records the step
|
|
41
|
+
`halted` with the gate's reason (the journal is then `halted`).
|
|
42
|
+
The gate's `handoff` is recorded too, on a halt as well: AS's
|
|
43
|
+
`target_ref` when upstream moved, which a value given with --set
|
|
44
|
+
does not replace. Any other workflow, or a gate that printed no
|
|
45
|
+
JSON, takes --status `completed`, or `halted` with --reason.
|
|
46
|
+
--set records a value the step hands the next, by its Data Flow
|
|
47
|
+
name (a name given twice in one call holds a list): a completed
|
|
48
|
+
CS, QS or US must hand on `skill_name`, a completed AN or BS
|
|
49
|
+
`brief_path`. --output records a result or report path the
|
|
50
|
+
step's envelope names.
|
|
51
|
+
finish Write the pipeline result from the journal to --result-dir:
|
|
52
|
+
`pipeline-result-<YYYYMMDD-HHmmss>.json` (UTC; -2, -3 ... when a
|
|
53
|
+
run already took that second's name), then its copy
|
|
54
|
+
`pipeline-result-latest.json`, and print the next action the
|
|
55
|
+
stop leaves (below). The run folder is deleted when nothing is
|
|
56
|
+
left to resume (every step completed or skipped, or a halt no
|
|
57
|
+
resume fixes) and kept otherwise. A --halt-reason with no halted
|
|
58
|
+
step halts the first pending one. A journal that cannot be saved
|
|
59
|
+
or read still gets its result, with a warning, unless its run
|
|
60
|
+
already has one. Without --journal it records a chain that
|
|
61
|
+
stopped before its journal started (at its parse, or a `start`
|
|
62
|
+
that failed): --halt-reason and no step.
|
|
63
|
+
resume The offer a new activation makes, from the newest journal under
|
|
64
|
+
--run-root. No offer when no journal is left, when a later chain
|
|
65
|
+
recorded a result with a step in it (any record in --result-dir,
|
|
66
|
+
the per-run ones included), when the journal's skill was tested
|
|
67
|
+
(--forge-data-folder) or exported (--skills-output-folder) after
|
|
68
|
+
the journal's last write, or when its halt has nothing to resume.
|
|
69
|
+
reopen Apply the offer the user accepted: the halted step is pending
|
|
70
|
+
again (after AS CRITICAL the audit counts as done), a repair that
|
|
71
|
+
is a workflow of its own joins the plan right before the step it
|
|
72
|
+
repairs, and the first step to run takes the journal's skill as
|
|
73
|
+
its target when it takes a skill and names none. The journal runs
|
|
74
|
+
again. Prints the steps still to run.
|
|
75
|
+
discard Delete a stopped chain's run folder, so its offer is made no more.
|
|
76
|
+
|
|
77
|
+
Next action. A chain interrupted mid-step (its journal still `running`),
|
|
78
|
+
or halted by anything but a quality verdict, gets the offer `resume` at the
|
|
79
|
+
step it stopped on. A quality halt gets a `repair` (REPAIRS below), which
|
|
80
|
+
the user makes first unless it is a workflow of its own (USER_ACTIONS):
|
|
81
|
+
|
|
82
|
+
halted on reason repair picks up at
|
|
83
|
+
TS FAIL update-from-test-report TS, after
|
|
84
|
+
(US <skill> --from-test-report)
|
|
85
|
+
TS INCONCLUSIVE add-evidence TS
|
|
86
|
+
TS pass-with-drift,
|
|
87
|
+
workspace-drift retest-at-pinned-commit TS
|
|
88
|
+
AS CRITICAL review-drift-report the step
|
|
89
|
+
after AS
|
|
90
|
+
VS zero-coverage create-missing-skills VS
|
|
91
|
+
AN no-skillable-units,
|
|
92
|
+
units-below-min, skipped new-target no resume
|
|
93
|
+
AN redirect update-existing-skill no resume
|
|
94
|
+
|
|
95
|
+
`finish` and `resume` print it in one shape: `offer` (`resume`, `repair`,
|
|
96
|
+
or null when nothing is left to resume), `kind` (`interrupted`, `error` or
|
|
97
|
+
`quality`), `repair`, `user_action` (what the user does first, or null),
|
|
98
|
+
`route` (the next action in one line, such as `US hono --from-test-report,
|
|
99
|
+
then TS EX at the recorded threshold of 90`), `halted_on`, `resume_at` and
|
|
100
|
+
`threshold` (the TS `min` the rest of the plan carries). Every value is
|
|
101
|
+
null for a chain that nothing stopped.
|
|
102
|
+
|
|
103
|
+
The journal's skill is the handoff `skill_name`, else the alias argument of
|
|
104
|
+
that name (`forge`, `maintain`), else the bracket name of a TS, EX, US or AS
|
|
105
|
+
step. It counts as tested after the journal when one of its
|
|
106
|
+
`skf-test-skill-result-latest.json` records (flat, or in a version folder)
|
|
107
|
+
started later than the journal's `updated_at`: the UTC time that leads its
|
|
108
|
+
`runId`, which the run took from the clock, or its `timestamp` when it has
|
|
109
|
+
no run id. It counts as exported after it when its export manifest entry
|
|
110
|
+
records a later `last_exported` day, or that same day with the manifest's
|
|
111
|
+
`updated_at` later (the chain's own EX excepted). The manifest dates an
|
|
112
|
+
export by its day only, and any skill's export moves its `updated_at`, so
|
|
113
|
+
a same-day export of another skill after the journal counts too.
|
|
114
|
+
|
|
115
|
+
Shared formats. The run id is skf-run-lock.py's (`new_run_id`), and the
|
|
116
|
+
per-run record's name with its -2, -3 suffix is the shared emitter's
|
|
117
|
+
(skf-emit-result-envelope.py `_claim_result_path`). This script ships with
|
|
118
|
+
the forger and loads no shared script, so it keeps its own copy of both;
|
|
119
|
+
test/test-skf-pipeline-journal.py checks that they name the same files for
|
|
120
|
+
the same clock.
|
|
121
|
+
|
|
122
|
+
CLI usage (from the skf-forger skill root):
|
|
123
|
+
uv run scripts/pipeline-journal.py start --run-root <dir> <<'SKF_PIPELINE'
|
|
124
|
+
forge-auto https://github.com/honojs/hono --pin v4.6.0
|
|
125
|
+
SKF_PIPELINE
|
|
126
|
+
uv run scripts/pipeline-journal.py step --journal <file> --code CS \\
|
|
127
|
+
--status completed --set skill_name=hono
|
|
128
|
+
uv run scripts/pipeline-journal.py step --journal <file> --code TS --gate <<'SKF_GATE'
|
|
129
|
+
{"code": "TS", "decision": "halt", "reason": "FAIL", "skip": null, "message": "..."}
|
|
130
|
+
SKF_GATE
|
|
131
|
+
uv run scripts/pipeline-journal.py finish --journal <file> --result-dir <dir> \\
|
|
132
|
+
[--halt-reason <reason>]
|
|
133
|
+
uv run scripts/pipeline-journal.py finish --result-dir <dir> --halt-reason <reason>
|
|
134
|
+
uv run scripts/pipeline-journal.py resume --run-root <dir> --result-dir <dir> \\
|
|
135
|
+
[--forge-data-folder <dir>] [--skills-output-folder <dir>]
|
|
136
|
+
uv run scripts/pipeline-journal.py reopen --journal <file>
|
|
137
|
+
uv run scripts/pipeline-journal.py discard --journal <file>
|
|
138
|
+
|
|
139
|
+
Output (stdout): one JSON object. `status` is `ok` (start, step, finish,
|
|
140
|
+
reopen, discard), `offer` or `none` (resume), or `error`, with `error`
|
|
141
|
+
naming the problem, `message` saying it in one line, and `retry` true when
|
|
142
|
+
the call itself lacked what the message names (a handoff value, a reason,
|
|
143
|
+
the gate's decision): the same call with it goes through.
|
|
144
|
+
|
|
145
|
+
Exit codes:
|
|
146
|
+
0 done: read `status`
|
|
147
|
+
1 error: the journal or a folder cannot be read or written, the
|
|
148
|
+
invocation does not parse to a runnable plan, a step out of order or
|
|
149
|
+
without the value it hands on, a gate decision that does not fit the
|
|
150
|
+
step, a halt with no reason, nothing to resume
|
|
151
|
+
2 usage error (argparse: usage on stderr, no JSON)
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
from __future__ import annotations
|
|
155
|
+
|
|
156
|
+
import argparse
|
|
157
|
+
import importlib.util
|
|
158
|
+
import json
|
|
159
|
+
import os
|
|
160
|
+
import re
|
|
161
|
+
import secrets
|
|
162
|
+
import shutil
|
|
163
|
+
import sys
|
|
164
|
+
from datetime import datetime, timezone
|
|
165
|
+
from pathlib import Path
|
|
166
|
+
|
|
167
|
+
PARSER = Path(__file__).resolve().parent / "parse-pipeline.py"
|
|
168
|
+
|
|
169
|
+
JOURNAL = "pipeline-journal.json"
|
|
170
|
+
RUN_PREFIX = "skf-forger-"
|
|
171
|
+
RESULT_STEM = "pipeline-result"
|
|
172
|
+
TEST_RESULT = "skf-test-skill-result-latest.json"
|
|
173
|
+
EXPORT_MANIFEST = ".export-manifest.json"
|
|
174
|
+
JOURNAL_VERSION = 1
|
|
175
|
+
|
|
176
|
+
STEP_STATUSES = ("pending", "completed", "skipped", "halted")
|
|
177
|
+
STATUS_CHOICES = ("completed", "halted") # what `step --status` records; a skip comes from a gate
|
|
178
|
+
DECISIONS = ("continue", "skip", "halt") # pipeline-gate.py's `decision`
|
|
179
|
+
|
|
180
|
+
RUN_ID_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
181
|
+
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
182
|
+
FILE_STAMP_FORMAT = "%Y%m%d-%H%M%S"
|
|
183
|
+
_RUN_ID_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{8}$")
|
|
184
|
+
_RUN_TIME_RE = re.compile(r"^(\d{8}T\d{6}Z)")
|
|
185
|
+
_FILE_STAMP_RE = re.compile(r"^\d{8}-\d{6}$")
|
|
186
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
187
|
+
|
|
188
|
+
# The codes pipeline-gate.py decides for: their step records the gate's
|
|
189
|
+
# decision (--gate), not a status given by hand.
|
|
190
|
+
GATED = frozenset({"AN", "TS", "AS", "VS"})
|
|
191
|
+
# The value a completed step hands the next workflow, by its Data Flow name
|
|
192
|
+
# in pipeline-contracts.md. A resumed chain passes it on, and the offer's
|
|
193
|
+
# later-test and later-export checks look the skill up by it.
|
|
194
|
+
HANDOFFS = {"AN": "brief_path", "BS": "brief_path", "CS": "skill_name", "QS": "skill_name", "US": "skill_name"}
|
|
195
|
+
|
|
196
|
+
# A quality halt, keyed by the halted code and its reason (lower case, `-`
|
|
197
|
+
# for `_`): the repair the offer names, and where the chain picks up again
|
|
198
|
+
# ("same" re-runs the halted step, "next" the one after it, None: no resume).
|
|
199
|
+
# Mirrors the Repair Routes table in pipeline-contracts.md. Any other halt is
|
|
200
|
+
# an error the user fixes, and the chain re-runs the step that halted.
|
|
201
|
+
REPAIRS = {
|
|
202
|
+
("TS", "fail"): ("update-from-test-report", "same"),
|
|
203
|
+
("TS", "inconclusive"): ("add-evidence", "same"),
|
|
204
|
+
("TS", "pass-with-drift"): ("retest-at-pinned-commit", "same"),
|
|
205
|
+
("TS", "workspace-drift"): ("retest-at-pinned-commit", "same"),
|
|
206
|
+
("AS", "critical"): ("review-drift-report", "next"),
|
|
207
|
+
("VS", "zero-coverage"): ("create-missing-skills", "same"),
|
|
208
|
+
("AN", "no-skillable-units"): ("new-target", None),
|
|
209
|
+
("AN", "units-below-min"): ("new-target", None),
|
|
210
|
+
("AN", "skipped"): ("new-target", None),
|
|
211
|
+
("AN", "redirect"): ("update-existing-skill", None),
|
|
212
|
+
}
|
|
213
|
+
# A repair that is a workflow of its own: it joins the plan right before the
|
|
214
|
+
# step it repairs, with the journal's skill as its target.
|
|
215
|
+
REPAIR_STEPS = {"update-from-test-report": ("US", ("--from-test-report",))}
|
|
216
|
+
# What the user does before a repair picks the chain up again, or instead of
|
|
217
|
+
# a resume when there is none (None: the chain runs the repair itself). The
|
|
218
|
+
# Repair Routes table in pipeline-contracts.md shows the same texts.
|
|
219
|
+
USER_ACTIONS = {
|
|
220
|
+
"update-from-test-report": None,
|
|
221
|
+
"add-evidence": "add evidence: install skill-check, or move up a tier (the tier's tools, then SF)",
|
|
222
|
+
"retest-at-pinned-commit": "put the source back at the commit the skill pins",
|
|
223
|
+
"review-drift-report": "review the drift report",
|
|
224
|
+
"create-missing-skills": "create skills for the architecture's technologies (for example forge-quick <package>)",
|
|
225
|
+
"new-target": "start a new chain with another target, or with a scope hint",
|
|
226
|
+
"update-existing-skill": "run US on the skill the target already has",
|
|
227
|
+
}
|
|
228
|
+
# The codes whose bracket target is a skill name (CS's names a brief, QS's a
|
|
229
|
+
# package or URL).
|
|
230
|
+
SKILL_TARGET_CODES = frozenset({"TS", "EX", "US", "AS"})
|
|
231
|
+
# The keys of the next action `finish` and `resume` print.
|
|
232
|
+
NEXT_ACTION_KEYS = ("offer", "kind", "repair", "user_action", "route", "halted_on", "resume_at", "threshold")
|
|
233
|
+
# The errors a call causes by lacking something its message names: the same
|
|
234
|
+
# call with it goes through, so a chain need not stop on them.
|
|
235
|
+
RETRYABLE = frozenset({"bad-value", "gate-unreadable", "gate-mismatch", "gate-required", "reason-missing",
|
|
236
|
+
"handoff-missing", "unfinished"})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class JournalError(Exception):
|
|
240
|
+
"""A request the journal cannot serve: printed as a `status: error` object."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, error: str, message: str):
|
|
243
|
+
super().__init__(message)
|
|
244
|
+
self.error = error
|
|
245
|
+
self.message = message
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
# Time, run ids and files
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _now() -> datetime:
|
|
254
|
+
return datetime.now(timezone.utc)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _stamp(t: datetime) -> str:
|
|
258
|
+
return t.strftime(TIME_FORMAT)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def new_run_id(now: datetime | None = None) -> str:
|
|
262
|
+
"""A run id: the UTC time and 8 random hex characters (skf-run-lock.py's format)."""
|
|
263
|
+
return f"{(now or _now()).strftime(RUN_ID_FORMAT)}-{secrets.token_hex(4)}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _file_stamp(run_id: str) -> str:
|
|
267
|
+
"""A run id's start time as a result file stamp (YYYYMMDD-HHmmss), or "" for another shape."""
|
|
268
|
+
return f"{run_id[:8]}-{run_id[9:15]}" if _RUN_ID_RE.match(run_id) else ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_time(value) -> datetime | None:
|
|
272
|
+
"""A UTC time from an ISO-8601 time or a run id's leading stamp, else None."""
|
|
273
|
+
if not isinstance(value, str) or not value.strip():
|
|
274
|
+
return None
|
|
275
|
+
text = value.strip()
|
|
276
|
+
m = _RUN_TIME_RE.match(text)
|
|
277
|
+
try:
|
|
278
|
+
if m:
|
|
279
|
+
return datetime.strptime(m.group(1), RUN_ID_FORMAT).replace(tzinfo=timezone.utc)
|
|
280
|
+
t = datetime.fromisoformat(text)
|
|
281
|
+
except ValueError:
|
|
282
|
+
return None
|
|
283
|
+
return t.replace(tzinfo=timezone.utc) if t.tzinfo is None else t.astimezone(timezone.utc)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _read_json(path: Path):
|
|
287
|
+
"""The JSON value in `path`, or None when it is missing or not JSON."""
|
|
288
|
+
try:
|
|
289
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
290
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _write_json(path: Path, value) -> None:
|
|
295
|
+
"""Write `value` as indented JSON through a temporary file and one rename.
|
|
296
|
+
|
|
297
|
+
newline="\\n" keeps LF endings on Windows too.
|
|
298
|
+
"""
|
|
299
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}-{secrets.token_hex(2)}.tmp")
|
|
300
|
+
try:
|
|
301
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
302
|
+
json.dump(value, fh, indent=2, ensure_ascii=False)
|
|
303
|
+
fh.write("\n")
|
|
304
|
+
fh.flush()
|
|
305
|
+
os.fsync(fh.fileno())
|
|
306
|
+
os.replace(tmp, path)
|
|
307
|
+
except OSError:
|
|
308
|
+
try:
|
|
309
|
+
tmp.unlink()
|
|
310
|
+
except OSError:
|
|
311
|
+
pass
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _save(path: Path, journal: dict) -> None:
|
|
316
|
+
try:
|
|
317
|
+
_write_json(path, journal)
|
|
318
|
+
except OSError as e:
|
|
319
|
+
raise JournalError("write-failed", f"The journal {path} cannot be written: {e.strerror or e}") from None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_journal(value) -> bool:
|
|
323
|
+
return (
|
|
324
|
+
isinstance(value, dict)
|
|
325
|
+
and isinstance(value.get("run_id"), str)
|
|
326
|
+
and isinstance(value.get("data"), dict)
|
|
327
|
+
and isinstance(value.get("steps"), list)
|
|
328
|
+
and all(
|
|
329
|
+
isinstance(s, dict) and isinstance(s.get("code"), str) and s.get("status") in STEP_STATUSES
|
|
330
|
+
for s in value["steps"]
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def load_journal(path: Path) -> dict:
|
|
336
|
+
journal = _read_json(path)
|
|
337
|
+
if not _is_journal(journal):
|
|
338
|
+
raise JournalError("journal-unreadable", f"{path} is missing, not JSON, or not a pipeline journal.")
|
|
339
|
+
for key, empty in (("args", {}), ("outputs", []), ("history", [])):
|
|
340
|
+
if not isinstance(journal.get(key), type(empty)):
|
|
341
|
+
journal[key] = empty
|
|
342
|
+
return journal
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _folder_run_id(path: Path) -> str | None:
|
|
346
|
+
"""The run id the name of the journal's folder carries, or None for any other file or folder."""
|
|
347
|
+
name = path.parent.name
|
|
348
|
+
run_id = name[len(RUN_PREFIX):] if name.startswith(RUN_PREFIX) else ""
|
|
349
|
+
return run_id if path.name == JOURNAL and _RUN_ID_RE.match(run_id) else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _delete_run_folder(path: Path, run_id: str) -> str | None:
|
|
353
|
+
"""Delete the run folder of the journal at `path`; a warning when it is not deleted.
|
|
354
|
+
|
|
355
|
+
Only the journal's own folder, `skf-forger-<run_id>/`, is ever deleted:
|
|
356
|
+
a journal given from anywhere else leaves its folder in place.
|
|
357
|
+
"""
|
|
358
|
+
folder = path.parent
|
|
359
|
+
if path.name != JOURNAL or folder.name != f"{RUN_PREFIX}{run_id}":
|
|
360
|
+
return f"run_dir_not_deleted: {folder.as_posix()}: not the journal's run folder"
|
|
361
|
+
try:
|
|
362
|
+
shutil.rmtree(folder)
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
pass
|
|
365
|
+
except OSError as e:
|
|
366
|
+
return f"run_dir_not_deleted: {folder.as_posix()}: {e.strerror or e}"
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# --------------------------------------------------------------------------
|
|
371
|
+
# Steps and offers
|
|
372
|
+
# --------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _step_entry(code: str, min_n=None, mode=None, target=None, flags=()) -> dict:
|
|
376
|
+
return {"code": code, "min": min_n, "mode": mode, "target": target,
|
|
377
|
+
"flags": list(flags), "status": "pending", "reason": None}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _first(steps: list, status: str) -> int | None:
|
|
381
|
+
return next((i for i, s in enumerate(steps) if s["status"] == status), None)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _indexed(steps: list, index: int) -> dict:
|
|
385
|
+
return {"index": index, **steps[index]}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _reason_key(reason) -> str:
|
|
389
|
+
return str(reason or "").strip().lower().replace("_", "-")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _repair_rule(step: dict) -> tuple[str, str | None] | None:
|
|
393
|
+
return REPAIRS.get((step["code"].upper(), _reason_key(step.get("reason"))))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def offer_for(journal: dict) -> dict | None:
|
|
397
|
+
"""What a stopped chain offers, from its journal alone; None: nothing to resume.
|
|
398
|
+
|
|
399
|
+
{"offer": "resume" | "repair", "kind": "interrupted" | "error" | "quality",
|
|
400
|
+
"repair": <REPAIRS key> | None, "halted_on": <index>, "resume_at": <index>}
|
|
401
|
+
"""
|
|
402
|
+
steps = journal["steps"]
|
|
403
|
+
halted = _first(steps, "halted")
|
|
404
|
+
if halted is None:
|
|
405
|
+
pending = _first(steps, "pending")
|
|
406
|
+
if pending is None:
|
|
407
|
+
return None
|
|
408
|
+
return {"offer": "resume", "kind": "interrupted", "repair": None, "halted_on": pending, "resume_at": pending}
|
|
409
|
+
rule = _repair_rule(steps[halted])
|
|
410
|
+
if rule is None:
|
|
411
|
+
return {"offer": "resume", "kind": "error", "repair": None, "halted_on": halted, "resume_at": halted}
|
|
412
|
+
repair, pickup = rule
|
|
413
|
+
if pickup is None:
|
|
414
|
+
return None
|
|
415
|
+
resume_at = halted + 1 if pickup == "next" else halted
|
|
416
|
+
if resume_at >= len(steps):
|
|
417
|
+
return None
|
|
418
|
+
return {"offer": "repair", "kind": "quality", "repair": repair, "halted_on": halted, "resume_at": resume_at}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def pipeline_status(steps: list) -> str:
|
|
422
|
+
"""success: nothing halted; partial: a halt after a completed step; failed: before any."""
|
|
423
|
+
if _first(steps, "halted") is None:
|
|
424
|
+
return "success"
|
|
425
|
+
return "partial" if _first(steps, "completed") is not None else "failed"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _skill_name(journal: dict) -> str | None:
|
|
429
|
+
"""The skill the chain works on, or None while no step has named it."""
|
|
430
|
+
candidates = [journal["data"].get("skill_name"), journal["args"].get("skill_name")]
|
|
431
|
+
candidates += [s.get("target") for s in journal["steps"] if s["code"] in SKILL_TARGET_CODES]
|
|
432
|
+
for value in candidates:
|
|
433
|
+
if isinstance(value, list):
|
|
434
|
+
value = next((v for v in value if isinstance(v, str) and v.strip()), None)
|
|
435
|
+
if isinstance(value, str) and value.strip():
|
|
436
|
+
return value.strip()
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _threshold(steps: list, start: int) -> int | None:
|
|
441
|
+
"""The TS `min` the rest of the plan carries: the recorded threshold."""
|
|
442
|
+
return next((s["min"] for s in steps[start:] if s["code"] == "TS" and s["min"] is not None), None)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _halted_on(steps: list, index: int) -> dict:
|
|
446
|
+
return {"index": index, "code": steps[index]["code"], "reason": steps[index]["reason"]}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def next_action(journal: dict) -> dict:
|
|
450
|
+
"""The next action a stopped chain leaves, in NEXT_ACTION_KEYS; every value None when nothing stopped it."""
|
|
451
|
+
steps = journal["steps"]
|
|
452
|
+
action = dict.fromkeys(NEXT_ACTION_KEYS)
|
|
453
|
+
offer = offer_for(journal)
|
|
454
|
+
if offer is None:
|
|
455
|
+
halted = _first(steps, "halted")
|
|
456
|
+
rule = None if halted is None else _repair_rule(steps[halted])
|
|
457
|
+
if rule is None:
|
|
458
|
+
return action
|
|
459
|
+
user = USER_ACTIONS.get(rule[0])
|
|
460
|
+
action.update(kind="quality", repair=rule[0], user_action=user,
|
|
461
|
+
route=f"no resume: {user}" if user else "no resume", halted_on=_halted_on(steps, halted))
|
|
462
|
+
return action
|
|
463
|
+
at, repair = offer["resume_at"], offer["repair"]
|
|
464
|
+
user = USER_ACTIONS.get(repair) if repair else None
|
|
465
|
+
run = " ".join(s["code"] for s in steps[at:])
|
|
466
|
+
if repair in REPAIR_STEPS:
|
|
467
|
+
code, flags = REPAIR_STEPS[repair]
|
|
468
|
+
route = f"{' '.join([code, _skill_name(journal) or '<skill>', *flags])}, then {run}"
|
|
469
|
+
elif user:
|
|
470
|
+
route = f"{user}, then {run}"
|
|
471
|
+
else:
|
|
472
|
+
route = f"resume at {steps[at]['code']}: {run}"
|
|
473
|
+
threshold = _threshold(steps, at)
|
|
474
|
+
if threshold is not None:
|
|
475
|
+
route += f" at the recorded threshold of {threshold}"
|
|
476
|
+
action.update(offer=offer["offer"], kind=offer["kind"], repair=repair, user_action=user, route=route,
|
|
477
|
+
halted_on=_halted_on(steps, offer["halted_on"]),
|
|
478
|
+
resume_at={"index": at, "code": steps[at]["code"]}, threshold=threshold)
|
|
479
|
+
return action
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# --------------------------------------------------------------------------
|
|
483
|
+
# Did the user move on? (resume)
|
|
484
|
+
# --------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _record_time(path: Path) -> datetime | None:
|
|
488
|
+
"""When a test run started: its clock-stamped run id, else its `timestamp`."""
|
|
489
|
+
record = _read_json(path)
|
|
490
|
+
if not isinstance(record, dict):
|
|
491
|
+
return None
|
|
492
|
+
return parse_time(record.get("runId")) or parse_time(record.get("run_id")) or parse_time(record.get("timestamp"))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def tested_after(forge_data_folder: Path, skill: str, since: datetime) -> bool:
|
|
496
|
+
"""A test result record of `skill`, flat or in a version folder, later than `since`."""
|
|
497
|
+
base = forge_data_folder / skill
|
|
498
|
+
try:
|
|
499
|
+
records = [base / TEST_RESULT, *sorted(base.glob(f"*/{TEST_RESULT}"))]
|
|
500
|
+
except OSError:
|
|
501
|
+
return False
|
|
502
|
+
return any(path.is_file() and (when := _record_time(path)) is not None and when > since for path in records)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def exported_after(skills_output_folder: Path, skill: str, since: datetime) -> bool:
|
|
506
|
+
"""An export of `skill` later than `since`: a later day, or that day with a later manifest write.
|
|
507
|
+
|
|
508
|
+
`last_exported` holds a day only; the manifest's `updated_at` tells the
|
|
509
|
+
time of its last write, whichever skill it was for, so a same-day export
|
|
510
|
+
of another skill after `since` counts too.
|
|
511
|
+
"""
|
|
512
|
+
manifest = _read_json(skills_output_folder / EXPORT_MANIFEST)
|
|
513
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get("exports"), dict):
|
|
514
|
+
return False
|
|
515
|
+
entry = manifest["exports"].get(skill)
|
|
516
|
+
if not isinstance(entry, dict):
|
|
517
|
+
return False
|
|
518
|
+
versions = entry.get("versions")
|
|
519
|
+
if isinstance(versions, dict):
|
|
520
|
+
days = [r.get("last_exported") for r in versions.values() if isinstance(r, dict)]
|
|
521
|
+
else: # a v1 entry lists its versions; the manifest's own time dates them
|
|
522
|
+
days = [manifest.get("updated_at")] if isinstance(versions, list) else []
|
|
523
|
+
written = parse_time(manifest.get("updated_at"))
|
|
524
|
+
since_day = since.date().isoformat()
|
|
525
|
+
for day in days:
|
|
526
|
+
if not isinstance(day, str) or len(day) < 10:
|
|
527
|
+
continue
|
|
528
|
+
day = day[:10]
|
|
529
|
+
if day > since_day or (day == since_day and written is not None and written > since):
|
|
530
|
+
return True
|
|
531
|
+
return False
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _records(result_dir: Path, since: str = ""):
|
|
535
|
+
"""Each pipeline result record in `result_dir` as (path, record), newest first.
|
|
536
|
+
|
|
537
|
+
The -latest copy comes first. A per-run record whose file stamp is
|
|
538
|
+
older than `since` (a file stamp) is passed over without being read.
|
|
539
|
+
"""
|
|
540
|
+
try:
|
|
541
|
+
paths = sorted(result_dir.glob(f"{RESULT_STEM}-*.json"), key=lambda p: p.name, reverse=True)
|
|
542
|
+
except OSError:
|
|
543
|
+
return
|
|
544
|
+
for path in paths:
|
|
545
|
+
stamp = path.name[len(RESULT_STEM) + 1:][:15]
|
|
546
|
+
if since and _FILE_STAMP_RE.match(stamp) and stamp < since:
|
|
547
|
+
continue
|
|
548
|
+
record = _read_json(path)
|
|
549
|
+
if isinstance(record, dict):
|
|
550
|
+
yield path, record
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _later_result(result_dir: Path, run_id: str) -> str | None:
|
|
554
|
+
"""The run id of a later chain's pipeline result that recorded a step, else None.
|
|
555
|
+
|
|
556
|
+
A record with no step, such as a parse halt's, supersedes no journal,
|
|
557
|
+
but its copy replaces `pipeline-result-latest.json`: the per-run records
|
|
558
|
+
are read too. One named before the journal's chain started is older.
|
|
559
|
+
"""
|
|
560
|
+
for _, record in _records(result_dir, _file_stamp(run_id)):
|
|
561
|
+
later = record.get("run_id")
|
|
562
|
+
summary = record.get("summary") if isinstance(record.get("summary"), dict) else {}
|
|
563
|
+
steps = summary.get("steps")
|
|
564
|
+
if isinstance(later, str) and _RUN_ID_RE.match(later) and later > run_id and isinstance(steps, list) and steps:
|
|
565
|
+
return later
|
|
566
|
+
return None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _recorded(result_dir: Path, run_id: str) -> dict | None:
|
|
570
|
+
"""The pipeline result already written for `run_id`, else None."""
|
|
571
|
+
return next((record for _, record in _records(result_dir, _file_stamp(run_id))
|
|
572
|
+
if record.get("run_id") == run_id), None)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _newest_journal(run_root: Path, warnings: list) -> tuple[Path, dict] | None:
|
|
576
|
+
"""The newest readable journal under `run_root`; an unreadable one is named in `warnings`."""
|
|
577
|
+
try:
|
|
578
|
+
folders = sorted((p for p in run_root.glob(f"{RUN_PREFIX}*") if p.is_dir()), key=lambda p: p.name, reverse=True)
|
|
579
|
+
except OSError:
|
|
580
|
+
return None
|
|
581
|
+
for folder in folders:
|
|
582
|
+
path = folder / JOURNAL
|
|
583
|
+
try:
|
|
584
|
+
return path, load_journal(path)
|
|
585
|
+
except JournalError:
|
|
586
|
+
warnings.append(f"journal_unreadable: {path.as_posix()}")
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# --------------------------------------------------------------------------
|
|
591
|
+
# Subcommands
|
|
592
|
+
# --------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _load_parser():
|
|
596
|
+
"""Load parse-pipeline.py, which ships in this script's folder."""
|
|
597
|
+
try:
|
|
598
|
+
spec = importlib.util.spec_from_file_location("skf_parse_pipeline", PARSER)
|
|
599
|
+
if spec is None or spec.loader is None:
|
|
600
|
+
raise ImportError("no import spec")
|
|
601
|
+
module = importlib.util.module_from_spec(spec)
|
|
602
|
+
spec.loader.exec_module(module)
|
|
603
|
+
except (OSError, ImportError) as e:
|
|
604
|
+
raise JournalError("parser-missing", f"parse-pipeline.py cannot be loaded from {PARSER.parent}: {e}") from None
|
|
605
|
+
return module
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _read_stdin() -> str:
|
|
609
|
+
# A Windows console reads cp1252: the arrows, paths and gate messages need UTF-8.
|
|
610
|
+
if hasattr(sys.stdin, "reconfigure"):
|
|
611
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
612
|
+
return sys.stdin.read()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def cmd_start(args) -> dict:
|
|
616
|
+
raw = _read_stdin().strip()
|
|
617
|
+
if not raw:
|
|
618
|
+
raise JournalError("no-invocation", "No invocation on stdin: pass the whole pipeline invocation.")
|
|
619
|
+
parsed = _load_parser().parse_pipeline(raw)
|
|
620
|
+
if parsed["removed_alias"] or not parsed["valid"]:
|
|
621
|
+
raise JournalError("not-runnable", "The invocation does not parse to a runnable plan: "
|
|
622
|
+
"step 1 halts on it before any journal starts.")
|
|
623
|
+
now = _now()
|
|
624
|
+
run_id = new_run_id(now)
|
|
625
|
+
run_dir = Path(args.run_root) / f"{RUN_PREFIX}{run_id}"
|
|
626
|
+
try:
|
|
627
|
+
run_dir.mkdir(parents=True)
|
|
628
|
+
except OSError as e:
|
|
629
|
+
raise JournalError("write-failed", f"The run folder {run_dir} cannot be created: {e.strerror or e}") from None
|
|
630
|
+
journal = {
|
|
631
|
+
"journal_version": JOURNAL_VERSION,
|
|
632
|
+
"run_id": run_id,
|
|
633
|
+
"status": "running",
|
|
634
|
+
"started_at": _stamp(now),
|
|
635
|
+
"updated_at": _stamp(now),
|
|
636
|
+
"invocation": raw,
|
|
637
|
+
"alias": parsed["alias"],
|
|
638
|
+
"args": parsed["args"],
|
|
639
|
+
"steps": [_step_entry(p["code"], p["min"], p["mode"], p["target"]) for p in parsed["plan"]],
|
|
640
|
+
"data": {},
|
|
641
|
+
"outputs": [],
|
|
642
|
+
"history": [],
|
|
643
|
+
}
|
|
644
|
+
path = run_dir / JOURNAL
|
|
645
|
+
_save(path, journal)
|
|
646
|
+
return {"status": "ok", "run_id": run_id, "run_dir": str(run_dir), "journal": str(path),
|
|
647
|
+
"alias": journal["alias"], "args": journal["args"], "steps": journal["steps"]}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _set_values(pairs: list[str]) -> dict:
|
|
651
|
+
"""`name=value` pairs; a name given twice holds a list, in order."""
|
|
652
|
+
values: dict = {}
|
|
653
|
+
for pair in pairs:
|
|
654
|
+
name, eq, value = pair.partition("=")
|
|
655
|
+
name, value = name.strip(), value.strip()
|
|
656
|
+
if not eq or not _NAME_RE.match(name) or not value:
|
|
657
|
+
raise JournalError("bad-value", f"--set takes <name>=<value> with a lower-case name: {pair!r}")
|
|
658
|
+
if name in values:
|
|
659
|
+
held = values[name]
|
|
660
|
+
values[name] = [*held, value] if isinstance(held, list) else [held, value]
|
|
661
|
+
else:
|
|
662
|
+
values[name] = value
|
|
663
|
+
return values
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _gate_decision(text: str, code: str) -> dict:
|
|
667
|
+
"""The decision pipeline-gate.py printed for `code`, read from `text`."""
|
|
668
|
+
text = text.strip()
|
|
669
|
+
try:
|
|
670
|
+
value = json.loads(text)
|
|
671
|
+
except ValueError:
|
|
672
|
+
start, end = text.find("{"), text.rfind("}")
|
|
673
|
+
try:
|
|
674
|
+
value = json.loads(text[start:end + 1]) if 0 <= start < end else None
|
|
675
|
+
except ValueError:
|
|
676
|
+
value = None
|
|
677
|
+
if not isinstance(value, dict) or value.get("decision") not in DECISIONS:
|
|
678
|
+
raise JournalError("gate-unreadable", "--gate reads the JSON object pipeline-gate.py printed, with its "
|
|
679
|
+
"`decision`, on stdin.")
|
|
680
|
+
if str(value.get("code") or "").strip().upper() != code:
|
|
681
|
+
raise JournalError("gate-mismatch", f"The gate decided for {value.get('code')!r}, not {code}.")
|
|
682
|
+
named = {"continue": (), "skip": ("reason", "skip"), "halt": ("reason",)}[value["decision"]]
|
|
683
|
+
for key in named:
|
|
684
|
+
if not (isinstance(value.get(key), str) and value[key].strip()):
|
|
685
|
+
raise JournalError("gate-unreadable", f"The gate's {value['decision']} decision names no `{key}`.")
|
|
686
|
+
return value
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _gate_handoff(decision: dict) -> dict:
|
|
690
|
+
"""The values the gate's `handoff` names (AS's `target_ref`), each a lower-case name and a non-empty string."""
|
|
691
|
+
handoff = decision.get("handoff")
|
|
692
|
+
if not isinstance(handoff, dict):
|
|
693
|
+
return {}
|
|
694
|
+
return {name: value.strip() for name, value in handoff.items()
|
|
695
|
+
if isinstance(name, str) and _NAME_RE.match(name) and isinstance(value, str) and value.strip()}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def cmd_step(args) -> dict:
|
|
699
|
+
path = Path(args.journal)
|
|
700
|
+
journal = load_journal(path)
|
|
701
|
+
if journal.get("status") != "running":
|
|
702
|
+
raise JournalError("not-running", f"The journal is {journal.get('status')}, not running: reopen it before "
|
|
703
|
+
"recording a step.")
|
|
704
|
+
steps = journal["steps"]
|
|
705
|
+
index = _first(steps, "pending")
|
|
706
|
+
if index is None:
|
|
707
|
+
raise JournalError("no-step-pending", "Every step of the journal is already recorded.")
|
|
708
|
+
if steps[index]["code"] != args.code:
|
|
709
|
+
raise JournalError("out-of-order", f"The next step in the journal is {steps[index]['code']} "
|
|
710
|
+
f"(step {index + 1}), not {args.code}.")
|
|
711
|
+
skipped = skip_reason = None
|
|
712
|
+
gate_handoff: dict = {}
|
|
713
|
+
if args.gate:
|
|
714
|
+
decision = _gate_decision(_read_stdin(), args.code)
|
|
715
|
+
gate_handoff = _gate_handoff(decision)
|
|
716
|
+
status = "halted" if decision["decision"] == "halt" else "completed"
|
|
717
|
+
reason = decision["reason"].strip() if status == "halted" else None
|
|
718
|
+
if decision["decision"] == "skip":
|
|
719
|
+
skipped, skip_reason = index + 1, decision["reason"].strip()
|
|
720
|
+
following = steps[skipped]["code"] if skipped < len(steps) else None
|
|
721
|
+
if following != decision["skip"].strip().upper():
|
|
722
|
+
raise JournalError("skip-mismatch", f"The gate passes over {decision['skip']}, but the step after "
|
|
723
|
+
f"{args.code} is {following or 'none'}.")
|
|
724
|
+
else:
|
|
725
|
+
status = args.status
|
|
726
|
+
reason = ((args.reason or "").strip() or None) if status == "halted" else None
|
|
727
|
+
if status == "completed" and args.code in GATED:
|
|
728
|
+
raise JournalError("gate-required", f"{args.code} has a gate: record its decision with --gate and the "
|
|
729
|
+
"JSON pipeline-gate.py printed on stdin.")
|
|
730
|
+
if status == "halted" and reason is None:
|
|
731
|
+
raise JournalError("reason-missing", "A halted step needs --reason: the workflow's halt reason.")
|
|
732
|
+
# The gate read the envelope the step printed, so its value wins over one typed with --set.
|
|
733
|
+
values = {**_set_values(args.set or []), **gate_handoff}
|
|
734
|
+
need = HANDOFFS.get(args.code)
|
|
735
|
+
if status == "completed" and need and need not in values:
|
|
736
|
+
raise JournalError("handoff-missing", f"A completed {args.code} hands `{need}` to the next workflow: "
|
|
737
|
+
f"pass --set \"{need}=<value>\".")
|
|
738
|
+
steps[index].update(status=status, reason=reason)
|
|
739
|
+
if skipped is not None:
|
|
740
|
+
steps[skipped].update(status="skipped", reason=skip_reason)
|
|
741
|
+
journal["data"].update(values)
|
|
742
|
+
journal["outputs"].extend({"code": args.code, "path": p} for p in args.output or [])
|
|
743
|
+
if status == "halted":
|
|
744
|
+
journal["status"] = "halted"
|
|
745
|
+
journal["updated_at"] = _stamp(_now())
|
|
746
|
+
_save(path, journal)
|
|
747
|
+
following = None if status == "halted" else _first(steps, "pending")
|
|
748
|
+
return {"status": "ok", "journal": str(path), "recorded": _indexed(steps, index),
|
|
749
|
+
"skipped": None if skipped is None else _indexed(steps, skipped),
|
|
750
|
+
"next": None if following is None else _indexed(steps, following), "data": journal["data"]}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _claim_result_path(result_dir: Path, stamp: str) -> Path:
|
|
754
|
+
"""Create the per-run record, empty, under a name no run holds yet (-2, -3 ... on a clash)."""
|
|
755
|
+
for n in range(1, 1000):
|
|
756
|
+
path = result_dir / (f"{RESULT_STEM}-{stamp}.json" if n == 1 else f"{RESULT_STEM}-{stamp}-{n}.json")
|
|
757
|
+
try:
|
|
758
|
+
os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
759
|
+
except FileExistsError:
|
|
760
|
+
continue
|
|
761
|
+
return path
|
|
762
|
+
raise OSError(f"no free file name for {RESULT_STEM}-{stamp}.json")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _write_result(result_dir: Path, record: dict, now: datetime) -> tuple[Path, Path]:
|
|
766
|
+
try:
|
|
767
|
+
result_dir.mkdir(parents=True, exist_ok=True)
|
|
768
|
+
per_run = _claim_result_path(result_dir, now.strftime(FILE_STAMP_FORMAT))
|
|
769
|
+
_write_json(per_run, record)
|
|
770
|
+
latest = result_dir / f"{RESULT_STEM}-latest.json"
|
|
771
|
+
_write_json(latest, record)
|
|
772
|
+
except OSError as e:
|
|
773
|
+
raise JournalError("write-failed", f"The pipeline result cannot be written to {result_dir}: "
|
|
774
|
+
f"{e.strerror or e}") from None
|
|
775
|
+
return per_run, latest
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def result_record(*, run_id: str, now: datetime, alias, steps: list, outputs: list,
|
|
779
|
+
halt: str | None = None, warnings=()) -> dict:
|
|
780
|
+
"""The pipeline result in output-contract-schema.md's shape, with the steps in `summary`.
|
|
781
|
+
|
|
782
|
+
`halt` is the reason of a chain recorded with no step: one that stopped
|
|
783
|
+
before its journal started, or whose journal is lost. Its status is
|
|
784
|
+
`failed`. `warnings` are what `finish` could not do.
|
|
785
|
+
"""
|
|
786
|
+
halted = _first(steps, "halted")
|
|
787
|
+
if halt is not None:
|
|
788
|
+
status, halt_reason = "failed", halt
|
|
789
|
+
else:
|
|
790
|
+
status, halt_reason = pipeline_status(steps), None if halted is None else steps[halted]["reason"]
|
|
791
|
+
return {
|
|
792
|
+
"skill": "skf-forger",
|
|
793
|
+
"status": status,
|
|
794
|
+
"timestamp": _stamp(now),
|
|
795
|
+
"run_id": run_id,
|
|
796
|
+
"outputs": [{"type": "report", "path": o["path"]} for o in outputs if isinstance(o, dict) and o.get("path")],
|
|
797
|
+
"summary": {
|
|
798
|
+
"status": status,
|
|
799
|
+
"halt_reason": halt_reason,
|
|
800
|
+
"alias": alias,
|
|
801
|
+
"steps": [dict(s) for s in steps],
|
|
802
|
+
},
|
|
803
|
+
"headless_decisions": [],
|
|
804
|
+
"warnings": list(warnings),
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _finished(record: dict, per_run: Path, latest: Path, kept: bool, action: dict, warnings: list) -> dict:
|
|
809
|
+
result = {"status": "ok", "pipeline_status": record["status"], "halt_reason": record["summary"]["halt_reason"],
|
|
810
|
+
"result_path": str(per_run), "latest_path": str(latest), "run_dir_kept": kept, **action}
|
|
811
|
+
if warnings:
|
|
812
|
+
result["warnings"] = warnings
|
|
813
|
+
return result
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _finish_with_no_step(result_dir: Path, run_id: str, now: datetime, reason: str, warnings: list,
|
|
817
|
+
kept: bool = False) -> dict:
|
|
818
|
+
record = result_record(run_id=run_id, now=now, alias=None, steps=[], outputs=[], halt=reason, warnings=warnings)
|
|
819
|
+
per_run, latest = _write_result(result_dir, record, now)
|
|
820
|
+
return _finished(record, per_run, latest, kept, dict.fromkeys(NEXT_ACTION_KEYS), warnings)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def cmd_finish(args) -> dict:
|
|
824
|
+
now = _now()
|
|
825
|
+
result_dir = Path(args.result_dir)
|
|
826
|
+
halt_reason = (args.halt_reason or "").strip() or None
|
|
827
|
+
if args.journal is None:
|
|
828
|
+
if halt_reason is None:
|
|
829
|
+
raise JournalError("reason-missing", "Without --journal, finish records a chain that stopped before its "
|
|
830
|
+
"journal started: pass --halt-reason.")
|
|
831
|
+
return _finish_with_no_step(result_dir, new_run_id(now), now, halt_reason, [])
|
|
832
|
+
path = Path(args.journal)
|
|
833
|
+
try:
|
|
834
|
+
journal = load_journal(path)
|
|
835
|
+
except JournalError:
|
|
836
|
+
# The chain's state is lost, but its stop is still recorded: the
|
|
837
|
+
# latest result must never stay the previous run's.
|
|
838
|
+
run_id = _folder_run_id(path)
|
|
839
|
+
if run_id is not None and _recorded(result_dir, run_id) is not None:
|
|
840
|
+
raise JournalError("already-finished", f"The pipeline result of run {run_id} is already written.") from None
|
|
841
|
+
lost = [f"journal_unreadable: {path.as_posix()}"]
|
|
842
|
+
kept = run_id is not None and path.parent.is_dir()
|
|
843
|
+
return _finish_with_no_step(result_dir, run_id or new_run_id(now), now, halt_reason or "journal-unreadable",
|
|
844
|
+
lost, kept)
|
|
845
|
+
warnings: list = []
|
|
846
|
+
steps = journal["steps"]
|
|
847
|
+
if _first(steps, "halted") is None and (pending := _first(steps, "pending")) is not None:
|
|
848
|
+
if halt_reason is None:
|
|
849
|
+
raise JournalError("unfinished", f"Step {pending + 1} ({steps[pending]['code']}) is still pending and no "
|
|
850
|
+
"step halted: record the step that stopped, or pass --halt-reason.")
|
|
851
|
+
steps[pending].update(status="halted", reason=halt_reason)
|
|
852
|
+
journal["status"] = "halted"
|
|
853
|
+
journal["updated_at"] = _stamp(now)
|
|
854
|
+
try:
|
|
855
|
+
_save(path, journal)
|
|
856
|
+
except JournalError as e:
|
|
857
|
+
warnings.append(f"journal_not_saved: {e.message}")
|
|
858
|
+
action = next_action(journal)
|
|
859
|
+
record = result_record(run_id=journal["run_id"], now=now, alias=journal.get("alias"),
|
|
860
|
+
steps=steps, outputs=journal["outputs"], warnings=warnings)
|
|
861
|
+
per_run, latest = _write_result(result_dir, record, now)
|
|
862
|
+
kept = action["offer"] is not None
|
|
863
|
+
if not kept:
|
|
864
|
+
problem = _delete_run_folder(path, journal["run_id"])
|
|
865
|
+
if problem:
|
|
866
|
+
kept = True
|
|
867
|
+
warnings.append(problem)
|
|
868
|
+
record["warnings"] = list(warnings)
|
|
869
|
+
try:
|
|
870
|
+
_write_json(per_run, record)
|
|
871
|
+
_write_json(latest, record)
|
|
872
|
+
except OSError:
|
|
873
|
+
pass # the record stands without it; the output below still names the warning
|
|
874
|
+
return _finished(record, per_run, latest, kept, action, warnings)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _no_offer(why: str, message: str, warnings: list, journal: Path | None = None) -> dict:
|
|
878
|
+
result = {"status": "none", "why": why, "message": message, "journal": None if journal is None else str(journal)}
|
|
879
|
+
if warnings:
|
|
880
|
+
result["warnings"] = warnings
|
|
881
|
+
return result
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cmd_resume(args) -> dict:
|
|
885
|
+
warnings: list = []
|
|
886
|
+
found = _newest_journal(Path(args.run_root), warnings)
|
|
887
|
+
if found is None:
|
|
888
|
+
return _no_offer("no-journal", "No stopped pipeline left a journal.", warnings)
|
|
889
|
+
path, journal = found
|
|
890
|
+
later = _later_result(Path(args.result_dir), journal["run_id"])
|
|
891
|
+
if later:
|
|
892
|
+
return _no_offer("later-pipeline", f"A later pipeline ({later}) recorded its result.", warnings, path)
|
|
893
|
+
action = next_action(journal)
|
|
894
|
+
if action["offer"] is None:
|
|
895
|
+
return _no_offer("nothing-to-resume", "The journal's chain has nothing left to resume.", warnings, path)
|
|
896
|
+
skill = _skill_name(journal)
|
|
897
|
+
since = parse_time(journal.get("updated_at"))
|
|
898
|
+
if skill and since:
|
|
899
|
+
ran_ex = any(s["code"] == "EX" and s["status"] == "completed" for s in journal["steps"])
|
|
900
|
+
if args.forge_data_folder and tested_after(Path(args.forge_data_folder), skill, since):
|
|
901
|
+
return _no_offer("tested-after", f"{skill} was tested after the journal's last write.", warnings, path)
|
|
902
|
+
if args.skills_output_folder and not ran_ex and exported_after(Path(args.skills_output_folder), skill, since):
|
|
903
|
+
return _no_offer("exported-after", f"{skill} was exported after the journal's last write.", warnings, path)
|
|
904
|
+
steps = journal["steps"]
|
|
905
|
+
at = action["resume_at"]["index"]
|
|
906
|
+
result = {
|
|
907
|
+
"status": "offer",
|
|
908
|
+
**action,
|
|
909
|
+
"run_id": journal["run_id"],
|
|
910
|
+
"journal": str(path),
|
|
911
|
+
"invocation": journal.get("invocation"),
|
|
912
|
+
"alias": journal.get("alias"),
|
|
913
|
+
"skill_name": skill,
|
|
914
|
+
"remaining": [_indexed(steps, i) for i in range(at, len(steps))],
|
|
915
|
+
"args": journal["args"],
|
|
916
|
+
"data": journal["data"],
|
|
917
|
+
}
|
|
918
|
+
if warnings:
|
|
919
|
+
result["warnings"] = warnings
|
|
920
|
+
return result
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def cmd_reopen(args) -> dict:
|
|
924
|
+
path = Path(args.journal)
|
|
925
|
+
journal = load_journal(path)
|
|
926
|
+
offer = offer_for(journal)
|
|
927
|
+
if offer is None:
|
|
928
|
+
raise JournalError("nothing-to-resume", "The journal's chain has nothing left to resume.")
|
|
929
|
+
steps = journal["steps"]
|
|
930
|
+
now = _stamp(_now())
|
|
931
|
+
halted, at = offer["halted_on"], offer["resume_at"]
|
|
932
|
+
if offer["kind"] != "interrupted":
|
|
933
|
+
step = steps[halted]
|
|
934
|
+
journal["history"].append({"code": step["code"], "reason": step["reason"], "repair": offer["repair"], "at": now})
|
|
935
|
+
if at == halted:
|
|
936
|
+
step.update(status="pending", reason=None)
|
|
937
|
+
else:
|
|
938
|
+
step["status"] = "completed" # AS CRITICAL: the review stands in for the audit's go-ahead
|
|
939
|
+
skill = _skill_name(journal)
|
|
940
|
+
if offer["repair"] in REPAIR_STEPS:
|
|
941
|
+
code, flags = REPAIR_STEPS[offer["repair"]]
|
|
942
|
+
steps.insert(at, _step_entry(code, target=skill, flags=flags))
|
|
943
|
+
first = steps[at]
|
|
944
|
+
if first["code"] in SKILL_TARGET_CODES and first["target"] is None and skill:
|
|
945
|
+
first["target"] = skill # no step before it hands the skill on in this run
|
|
946
|
+
journal["status"] = "running"
|
|
947
|
+
journal["updated_at"] = now
|
|
948
|
+
_save(path, journal)
|
|
949
|
+
return {"status": "ok", "journal": str(path), "offer": offer["offer"], "repair": offer["repair"],
|
|
950
|
+
"alias": journal.get("alias"), "skill_name": skill,
|
|
951
|
+
"run": [_indexed(steps, i) for i in range(at, len(steps)) if steps[i]["status"] == "pending"],
|
|
952
|
+
"args": journal["args"], "data": journal["data"]}
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def cmd_discard(args) -> dict:
|
|
956
|
+
path = Path(args.journal)
|
|
957
|
+
try:
|
|
958
|
+
run_id = load_journal(path)["run_id"]
|
|
959
|
+
except JournalError:
|
|
960
|
+
run_id = _folder_run_id(path) # a journal that no longer loads can still be discarded
|
|
961
|
+
if run_id is None:
|
|
962
|
+
raise
|
|
963
|
+
problem = _delete_run_folder(path, run_id)
|
|
964
|
+
if problem:
|
|
965
|
+
raise JournalError("not-deleted", problem)
|
|
966
|
+
return {"status": "ok", "journal": str(path), "run_dir": str(path.parent), "deleted": True}
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# --------------------------------------------------------------------------
|
|
970
|
+
# CLI
|
|
971
|
+
# --------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
975
|
+
parser = argparse.ArgumentParser(
|
|
976
|
+
prog="pipeline-journal",
|
|
977
|
+
description=(
|
|
978
|
+
"The forger's pipeline journal (references/pipeline-mode.md): start it from the "
|
|
979
|
+
"invocation, record each step, write the pipeline result, and work out the resume "
|
|
980
|
+
"or repair offer a stopped chain leaves."
|
|
981
|
+
),
|
|
982
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
983
|
+
)
|
|
984
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
985
|
+
|
|
986
|
+
start = sub.add_parser("start", help="Parse the invocation on stdin and create the run folder and its journal.")
|
|
987
|
+
start.add_argument("--run-root", required=True, help="The folder that holds the runs' folders.")
|
|
988
|
+
start.set_defaults(func=cmd_start)
|
|
989
|
+
|
|
990
|
+
step = sub.add_parser("step", help="Record the step the chain just finished.")
|
|
991
|
+
step.add_argument("--journal", required=True, help="The journal `start` created.")
|
|
992
|
+
step.add_argument("--code", required=True, type=str.upper, help="The code of the step that finished.")
|
|
993
|
+
how = step.add_mutually_exclusive_group(required=True)
|
|
994
|
+
how.add_argument("--gate", action="store_true",
|
|
995
|
+
help="Read the JSON pipeline-gate.py printed for the step on stdin and record its decision.")
|
|
996
|
+
how.add_argument("--status", choices=STATUS_CHOICES, help="How a step with no gate decision ended.")
|
|
997
|
+
step.add_argument("--reason", help="The halt reason, with --status halted.")
|
|
998
|
+
step.add_argument("--set", action="append", metavar="NAME=VALUE",
|
|
999
|
+
help="A value the step hands the next, by its Data Flow name (skill_name, brief_path).")
|
|
1000
|
+
step.add_argument("--output", action="append", metavar="PATH",
|
|
1001
|
+
help="A result or report path the step's envelope names.")
|
|
1002
|
+
step.set_defaults(func=cmd_step)
|
|
1003
|
+
|
|
1004
|
+
finish = sub.add_parser("finish", help="Write the pipeline result; delete the run folder when nothing is left.")
|
|
1005
|
+
finish.add_argument("--result-dir", required=True, help="The folder of the pipeline result files (the sidecar).")
|
|
1006
|
+
finish.add_argument("--journal", help="The run's journal; omitted for a chain that stopped before it started.")
|
|
1007
|
+
finish.add_argument("--halt-reason", help="Why the chain stopped, when no step call recorded it.")
|
|
1008
|
+
finish.set_defaults(func=cmd_finish)
|
|
1009
|
+
|
|
1010
|
+
resume = sub.add_parser("resume", help="The resume or repair offer the newest journal leaves.")
|
|
1011
|
+
resume.add_argument("--run-root", required=True, help="The folder that holds the runs' folders.")
|
|
1012
|
+
resume.add_argument("--result-dir", required=True, help="The folder of the pipeline result files (the sidecar).")
|
|
1013
|
+
resume.add_argument("--forge-data-folder", help="Where test results live: a later test drops the offer.")
|
|
1014
|
+
resume.add_argument("--skills-output-folder", help="Where the export manifest lives: a later export drops the offer.")
|
|
1015
|
+
resume.set_defaults(func=cmd_resume)
|
|
1016
|
+
|
|
1017
|
+
reopen = sub.add_parser("reopen", help="Apply the accepted offer and print the steps still to run.")
|
|
1018
|
+
reopen.add_argument("--journal", required=True, help="The journal `resume` named.")
|
|
1019
|
+
reopen.set_defaults(func=cmd_reopen)
|
|
1020
|
+
|
|
1021
|
+
discard = sub.add_parser("discard", help="Delete a stopped chain's run folder, so its offer is made no more.")
|
|
1022
|
+
discard.add_argument("--journal", required=True, help="The journal the offer named.")
|
|
1023
|
+
discard.set_defaults(func=cmd_discard)
|
|
1024
|
+
return parser
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _force_utf8(*streams) -> None:
|
|
1028
|
+
"""Reconfigure the output streams to UTF-8 (a Windows console uses cp1252)."""
|
|
1029
|
+
for stream in streams:
|
|
1030
|
+
if hasattr(stream, "reconfigure"):
|
|
1031
|
+
errors = getattr(stream, "errors", None)
|
|
1032
|
+
if errors is None:
|
|
1033
|
+
stream.reconfigure(encoding="utf-8")
|
|
1034
|
+
else:
|
|
1035
|
+
stream.reconfigure(encoding="utf-8", errors=errors)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1039
|
+
args = _build_parser().parse_args(argv)
|
|
1040
|
+
try:
|
|
1041
|
+
result = args.func(args)
|
|
1042
|
+
except JournalError as e:
|
|
1043
|
+
print(json.dumps({"status": "error", "error": e.error, "message": e.message, "retry": e.error in RETRYABLE},
|
|
1044
|
+
indent=2))
|
|
1045
|
+
return 1
|
|
1046
|
+
print(json.dumps(result, indent=2))
|
|
1047
|
+
return 0
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
if __name__ == "__main__":
|
|
1051
|
+
_force_utf8(sys.stdout, sys.stderr)
|
|
1052
|
+
sys.exit(main())
|