bmad-module-skill-forge 2.2.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 +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 +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 +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 +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 +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 +1 -1
- 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 +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 +15 -5
- 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 +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 +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 -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 +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 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -1,38 +1,210 @@
|
|
|
1
1
|
# /// script
|
|
2
|
-
# requires-python = ">=3.
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# ///
|
|
5
|
-
"""SKF Emit Result Envelope
|
|
6
|
-
|
|
7
|
-
Replaces the prose-driven envelope assembly in `src/skf-setup/references/
|
|
8
|
-
report.md` §4 with one Python invocation. The envelope contract
|
|
9
|
-
(SKF_SETUP_RESULT_JSON) was added to step 4 in PR #247, extended in
|
|
10
|
-
PR #248 (header / outputs / failure modes), and extended again in this
|
|
11
|
-
PR (previous_tier, tier_changed, tools_added, tools_removed, error).
|
|
12
|
-
|
|
13
|
-
LLM-rendered envelopes risk silent schema drift on every invocation —
|
|
14
|
-
a pipeline that grep's `SKF_SETUP_RESULT_JSON: {…}` out of the workflow
|
|
15
|
-
log can break if the model decides to rename a key or rearrange a
|
|
16
|
-
nested structure. This script is the single source of truth: it takes
|
|
17
|
-
a context payload as JSON on stdin, computes derived fields
|
|
18
|
-
deterministically, and emits the envelope in a fixed shape that
|
|
19
|
-
matches the JSON Schema at
|
|
20
|
-
`src/shared/scripts/schemas/skf-setup-result-envelope.v1.json`.
|
|
5
|
+
"""SKF Emit Result Envelope: one schema-checked emitter for every workflow.
|
|
21
6
|
|
|
22
|
-
|
|
7
|
+
A workflow with a headless contract ends each run, and each HARD HALT, with
|
|
8
|
+
one prefixed line that pipelines grep out of the workflow log:
|
|
9
|
+
`SKF_<NAME>_RESULT_JSON: {one-line JSON}`. The model never types that line.
|
|
10
|
+
It stages a payload file and runs this script, which fills the derived
|
|
11
|
+
fields, folds in the run's auto-decisions and warnings, checks the envelope
|
|
12
|
+
against the workflow's JSON Schema, writes the run's result files and
|
|
13
|
+
prints the line:
|
|
23
14
|
|
|
24
|
-
emit
|
|
25
|
-
|
|
26
|
-
envelope, emit `SKF_SETUP_RESULT_JSON: {one-line JSON}`
|
|
27
|
-
on stdout. Default subcommand.
|
|
15
|
+
uv run {helper} emit --workflow <name> --run-dir <run_dir> --result-dir <dir> < <run_dir>/result-context.json
|
|
16
|
+
uv run {helper} emit-halt --workflow <name> --run-dir <run_dir> < <run_dir>/halt.json
|
|
28
17
|
|
|
29
|
-
|
|
30
|
-
and verify it against the documented schema. No stdout
|
|
31
|
-
on success; non-zero exit + stderr error on failure.
|
|
32
|
-
Useful for paranoid pipelines that want to validate a
|
|
33
|
-
received envelope before consuming it.
|
|
18
|
+
Subcommands:
|
|
34
19
|
|
|
35
|
-
|
|
20
|
+
emit Build the envelope that ends a run from the context payload
|
|
21
|
+
on stdin. Without --workflow it builds skf-setup's, from
|
|
22
|
+
setup's own payload (see "skf-setup" below). Default
|
|
23
|
+
subcommand.
|
|
24
|
+
|
|
25
|
+
emit-halt Build the envelope of a HARD HALT from the halt payload on
|
|
26
|
+
stdin: {"phase", "reason", "halt_reason"?, "exit_code"?,
|
|
27
|
+
"path"?, "status"?} plus any envelope field the halt already
|
|
28
|
+
knows (a skill name, a version). Needs --workflow.
|
|
29
|
+
|
|
30
|
+
emit-blocked
|
|
31
|
+
skf-setup's halt: `emit-halt --workflow skf-setup` under its
|
|
32
|
+
older name. Reads {"phase", "reason", "path"?} on stdin, or
|
|
33
|
+
takes them from --phase, --reason and --path, and emits a
|
|
34
|
+
status 'blocked' envelope, with placeholders for the fields a
|
|
35
|
+
halt does not know (tier 'Quick', no tools, config_path set to
|
|
36
|
+
the path, files_written []). With the options the emitter
|
|
37
|
+
writes the JSON itself, so a step types no payload. With
|
|
38
|
+
--stderr-from (a file, or `-` for stdin) the failed command's
|
|
39
|
+
stderr fills `<message>` in --reason: the `message` of its
|
|
40
|
+
JSON error object, else its first non-blank line, folded to
|
|
41
|
+
one line (`(no error message)` when it holds neither). A
|
|
42
|
+
`<message>` with no --stderr-from is refused. A non-empty
|
|
43
|
+
--customization-resolver-unavailable is the payload key of
|
|
44
|
+
that name: the envelope warns that the `_bmad/custom/`
|
|
45
|
+
overrides were not applied.
|
|
46
|
+
|
|
47
|
+
record Append one entry to the run's sink: --decision reads one
|
|
48
|
+
auto-decision (a JSON object) on stdin, --warning takes the
|
|
49
|
+
warning text. With --workflow a decision is checked against
|
|
50
|
+
that workflow's headless_decisions item schema first.
|
|
51
|
+
|
|
52
|
+
validate Read an envelope (without the prefix) as JSON on stdin and
|
|
53
|
+
verify it against the workflow's schema (skf-setup's without
|
|
54
|
+
--workflow). No stdout on success; non-zero exit + stderr
|
|
55
|
+
error on failure. Useful for paranoid pipelines that want to
|
|
56
|
+
validate a received envelope before consuming it.
|
|
57
|
+
|
|
58
|
+
render-report
|
|
59
|
+
skf-setup's interactive FORGE STATUS banner: reads the same
|
|
60
|
+
payload as `emit` on stdin (plus the banner keys below),
|
|
61
|
+
folds in the helper outputs --run-dir holds and the paths
|
|
62
|
+
--project-root, --sidecar-path and --forge-data-folder give,
|
|
63
|
+
and prints the banner's lines in English, one per line,
|
|
64
|
+
ending with the REQUIRED TIER NOT MET block when
|
|
65
|
+
--require-tier was not met. --tier-rules names skf-setup's
|
|
66
|
+
references/tier-rules.md, which holds the tier descriptions
|
|
67
|
+
and re-run messages (default: the copy beside this script's
|
|
68
|
+
folder, in the source tree and in an installed project
|
|
69
|
+
alike).
|
|
70
|
+
|
|
71
|
+
Workflow schemas. A workflow's envelope schema is
|
|
72
|
+
`schemas/skf-<name>-result-envelope.v<N>.json`, found beside this script in
|
|
73
|
+
both the source tree and an installed project. --workflow takes the
|
|
74
|
+
workflow's folder name (`skf-update-skill`) or the schema's stem
|
|
75
|
+
(`skf-update`); two versions of one schema resolve to the higher. The
|
|
76
|
+
schema tells the emitter what JSON Schema cannot in its `skf-envelope`
|
|
77
|
+
settings: a `$defs` entry that holds them as a `const` and that nothing
|
|
78
|
+
references. `$defs` and `const` are standard keywords, so a strict
|
|
79
|
+
validator (Ajv's default mode) still compiles the schema:
|
|
80
|
+
|
|
81
|
+
"$defs": {"skf-envelope": {"const": {
|
|
82
|
+
"workflow": "skf-update-skill",
|
|
83
|
+
"prefix": "SKF_UPDATE_RESULT_JSON",
|
|
84
|
+
"wrapper": "skf_update" | null,
|
|
85
|
+
"halt_status": "blocked",
|
|
86
|
+
"exit_codes": {"<halt_reason>": <exit code>, ...},
|
|
87
|
+
"success_exit_code": 0,
|
|
88
|
+
"result_file": "update-skill-result" | null,
|
|
89
|
+
"result_stamp": "run_id"
|
|
90
|
+
}}}
|
|
91
|
+
|
|
92
|
+
wrapper names the one property that wraps the envelope (null for a flat
|
|
93
|
+
envelope); halt_status is the status a halt envelope carries unless its
|
|
94
|
+
payload names one; exit_codes maps each halt_reason to its exit code, and
|
|
95
|
+
success_exit_code is the code a null halt_reason carries (both optional);
|
|
96
|
+
result_file is the stem of the run's result files (null: none);
|
|
97
|
+
result_stamp "run_id" (optional) names the per-run result file after the
|
|
98
|
+
run's own stamp instead of the clock (see "Result files").
|
|
99
|
+
|
|
100
|
+
Building an envelope (every workflow but skf-setup). Each payload key is an
|
|
101
|
+
envelope field of the same name, except `result_contract` and
|
|
102
|
+
`customization_resolver_unavailable` (below). Then:
|
|
103
|
+
|
|
104
|
+
- A halt payload's phase, reason, halt_reason, exit_code, path and
|
|
105
|
+
details become the fields of the same name the schema declares, and
|
|
106
|
+
fill its `error` object: phase, reason, path and details by name,
|
|
107
|
+
`code` from halt_reason, `message` from reason, and '<n/a>' for a
|
|
108
|
+
required path the halt has none for. A key the schema has no place for
|
|
109
|
+
is dropped. A payload that gives `error` itself keeps it whole.
|
|
110
|
+
- exit_code, when the payload has none, is exit_codes[halt_reason], or
|
|
111
|
+
for a finished run with a null halt_reason success_exit_code (else 0);
|
|
112
|
+
a halt without a mapped halt_reason must give its exit_code. A status
|
|
113
|
+
equal to halt_status needs a halt_reason and any other status a null
|
|
114
|
+
one, and exit_code must match the mapping, when the schema declares
|
|
115
|
+
them.
|
|
116
|
+
- Stamped from the run, never typed: `timestamp` (ISO-8601 UTC, from the
|
|
117
|
+
clock), `run_id` (the --run-dir folder name without its
|
|
118
|
+
`<workflow>-` prefix) and `result_path` (the per-run result file this
|
|
119
|
+
call wrote, or null), each when the schema declares it.
|
|
120
|
+
- headless_decisions and warnings: the payload's own entries, then the
|
|
121
|
+
sink's, duplicates dropped. A `customization_resolver_unavailable`
|
|
122
|
+
reason becomes the warning `customization_resolver_unavailable:
|
|
123
|
+
<reason>`. An optional list stays out of the envelope while empty.
|
|
124
|
+
- A required field the payload leaves out gets its schema `default`,
|
|
125
|
+
else null when the schema allows it; in a halt, else [], {}, false or
|
|
126
|
+
0 by its type.
|
|
127
|
+
|
|
128
|
+
The envelope's keys follow the schema's property order, and the line is
|
|
129
|
+
ASCII JSON, non-ASCII text escaped, as skf-brief-skill's helper always
|
|
130
|
+
printed it: no character in a value can break the line for a reader.
|
|
131
|
+
|
|
132
|
+
Run sink. Every run owns a folder, `_bmad-output/.skf-run/<workflow>-<run_id>/`,
|
|
133
|
+
passed as --run-dir. Each gate records its auto-decision there the moment
|
|
134
|
+
it decides, and each warning as it is raised, with `record`:
|
|
135
|
+
|
|
136
|
+
{run_dir}/headless-decisions.jsonl one decision object per line
|
|
137
|
+
{run_dir}/warnings.jsonl one warning string per line
|
|
138
|
+
|
|
139
|
+
so the decision trail survives context compaction and a halt reports the
|
|
140
|
+
decisions taken before it. `record` writes each line as ASCII JSON, so no
|
|
141
|
+
U+2028, U+2029 or U+0085 inside a value can split it for a reader that
|
|
142
|
+
breaks lines there, and the emitter splits the files on newlines alone. A
|
|
143
|
+
line that is not JSON (a write cut short) or a decision the schema rejects
|
|
144
|
+
is left out and named in the warnings (`sink_line_unreadable:
|
|
145
|
+
<file>:<line>`, `headless_decision_invalid: <file>:<line>: <error>`), so a
|
|
146
|
+
halt still emits.
|
|
147
|
+
|
|
148
|
+
Result files. With --result-dir naming a folder that exists (the version
|
|
149
|
+
folder), the call writes `<result_file>-<YYYYMMDD-HHmmss>.json` (UTC; `-2`,
|
|
150
|
+
`-3`, ... appended when a run already took that second's name) and then
|
|
151
|
+
the copy `<result_file>-latest.json`, each atomically. The stamp is the
|
|
152
|
+
clock's, unless the settings give `result_stamp: "run_id"` and the run's
|
|
153
|
+
run_id is itself a `YYYYMMDD-HHmmss` stamp (a run folder named
|
|
154
|
+
`<workflow>-<YYYYMMDD-HHmmss>`): the file then carries the run's stamp, the
|
|
155
|
+
one its run_id and the report files the run wrote carry. The file holds the
|
|
156
|
+
payload's `result_contract` object (see
|
|
157
|
+
`shared/references/output-contract-schema.md`) with `timestamp`, `run_id`,
|
|
158
|
+
`headless_decisions` and `warnings` stamped in, and the payload's own
|
|
159
|
+
`status` and `summary` where the contract leaves them out, or the envelope
|
|
160
|
+
itself when the payload has none. When --result-dir names no folder, nothing is
|
|
161
|
+
written: a halt before the version folder exists reports on stdout only.
|
|
162
|
+
A write that fails adds the warning `result_file_write_failed: <path>:
|
|
163
|
+
<reason>`, and a failed per-run record leaves `result_path` null. A
|
|
164
|
+
workflow whose `result_file` is null writes none: it ignores --result-dir
|
|
165
|
+
with the warning `result_dir_ignored: <workflow> writes no result file`.
|
|
166
|
+
|
|
167
|
+
Clock. The timestamp is the system clock's time, read with time.time().
|
|
168
|
+
The helper imports only argparse, json, os, pathlib, sys and time, all in
|
|
169
|
+
the standard library of every Python the halt contract may run it under,
|
|
170
|
+
so reading the clock needs no folder: a halt emits before any run folder
|
|
171
|
+
exists, and the call creates none.
|
|
172
|
+
|
|
173
|
+
skf-setup. setup keeps its own payload, which the emitter turns into the
|
|
174
|
+
envelope below (`emit` and `emit-blocked` keep working without --workflow).
|
|
175
|
+
Its envelope follows `schemas/skf-setup-result-envelope.v1.json` and prints
|
|
176
|
+
with sorted keys and raw UTF-8, except that U+2028, U+2029 and U+0085 are
|
|
177
|
+
escaped so the line stays one line. --run-dir appends the sink's warnings
|
|
178
|
+
that setup's own list does not already hold.
|
|
179
|
+
|
|
180
|
+
A setup run also stages each helper's JSON output in its run folder, as
|
|
181
|
+
the step that runs the helper writes it, so no step types those values
|
|
182
|
+
back into a payload:
|
|
183
|
+
|
|
184
|
+
detect-tools.json step 1, skf-detect-tools.py
|
|
185
|
+
ccc-exclusions.json step 1b, skf-merge-ccc-exclusions.py --build-index
|
|
186
|
+
(its --result-to file: the result, or its error)
|
|
187
|
+
qmd-classify.json step 3, skf-qmd-classify-collections.py classify
|
|
188
|
+
qmd-remove.json step 3, skf-qmd-classify-collections.py remove-orphans
|
|
189
|
+
clean-stale.json step 3, skf-forge-tier-rw.py clean-stale
|
|
190
|
+
|
|
191
|
+
With --run-dir, `emit` and `render-report` fold them into the payload:
|
|
192
|
+
a staged output replaces the payload's own value of each field it is the
|
|
193
|
+
source of (fold_staged lists them). A file that holds no JSON object means
|
|
194
|
+
its helper failed (the classifier then reads as qmd unavailable); a file
|
|
195
|
+
that is absent means its step did not run the helper, except a missing
|
|
196
|
+
ccc-exclusions.json when ccc is available, which reads as a failed index.
|
|
197
|
+
|
|
198
|
+
The run's paths come from options, not the payload. With --project-root,
|
|
199
|
+
--sidecar-path and --forge-data-folder (the `{sidecar_path}` and
|
|
200
|
+
`{forge_data_folder}` setup's On Activation bound from skf-preflight.py's
|
|
201
|
+
sidecar_path_resolved and forge_data_folder_resolved), `emit` and
|
|
202
|
+
`render-report` derive `project_root`, `config_path` (the forge-tier.yaml
|
|
203
|
+
in the sidecar folder) and `forge_data_folder`, each written with `/`.
|
|
204
|
+
The emitter reads no config.yaml value itself, so preflight stays the one
|
|
205
|
+
place that resolves them. The derived values replace the payload's own.
|
|
206
|
+
|
|
207
|
+
Context payload shape (consumed by `emit` for skf-setup):
|
|
36
208
|
|
|
37
209
|
{
|
|
38
210
|
"tier": "Quick|Forge|Forge+|Deep",
|
|
@@ -51,31 +223,82 @@ Context payload shape (consumed by `emit`):
|
|
|
51
223
|
"require_tier_satisfied": bool|null,
|
|
52
224
|
"require_tier_failure_missing": ["ccc", ...],
|
|
53
225
|
"qmd_status": "absent|daemon_stopped|healthy",
|
|
226
|
+
"tools_below_minimum": [{"tool": "ast_grep", "name": "ast-grep", "version": "0.42.2",
|
|
227
|
+
"minimum": "0.45.3", "upgrade": "string|null",
|
|
228
|
+
"tier": "Forge|Forge+|Deep|null"}, ...],
|
|
54
229
|
"ccc_exclusion_warnings": ["string", ...],
|
|
55
230
|
"ccc_registry_stale_removed": ["/path", ...],
|
|
56
231
|
"ccc_indexing_failed_reason": "string|null",
|
|
57
|
-
"orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|orphan-action-flag"},
|
|
232
|
+
"orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|quiet-default|orphan-action-flag", "removed": ["name", ...], "failed": ["name", ...]},
|
|
233
|
+
"customization_resolver_unavailable": "string|null",
|
|
58
234
|
"error": null|{"phase","path","reason"}
|
|
59
235
|
}
|
|
60
236
|
|
|
237
|
+
The `tools` values may also be skf-detect-tools.py's own objects
|
|
238
|
+
({"available": bool, "version": ..., ...}), as detect-tools.json holds
|
|
239
|
+
them. Without `files_written`, the emitter derives it: forge-tier.yaml,
|
|
240
|
+
then settings.yml and ccc_index when `settings_yml_written` and a
|
|
241
|
+
ccc_index status of "created" say so (none when `error` is set).
|
|
242
|
+
|
|
61
243
|
When the step-3 orphan-removal gate is resolved non-interactively
|
|
62
|
-
(headless default Keep, or an explicit --orphan-action), pass
|
|
63
|
-
`orphan_auto_resolution` so the audit trail lands in `warnings
|
|
64
|
-
|
|
244
|
+
(headless or quiet default Keep, or an explicit --orphan-action), pass
|
|
245
|
+
`orphan_auto_resolution` so the audit trail lands in `warnings`: most
|
|
246
|
+
importantly when the destructive `remove` ran headlessly, which a
|
|
65
247
|
pipeline otherwise could not distinguish from a no-op by reading the
|
|
66
|
-
envelope alone.
|
|
248
|
+
envelope alone. Its `removed` and `failed` lists name the collections
|
|
249
|
+
the removal deleted and the ones it could not, and each becomes its own
|
|
250
|
+
warning (`orphan_removed: <name>`, `orphan_remove_failed: <name>`).
|
|
251
|
+
With --run-dir, the staged classification supplies `count` and the
|
|
252
|
+
staged removal supplies `removed` and `failed`, so setup's payload
|
|
253
|
+
carries only `action` and `source`.
|
|
254
|
+
|
|
255
|
+
`tools_below_minimum` is skf-detect-tools.py's list of the tools below
|
|
256
|
+
their minimum version, which detect-tools.json supplies. Each becomes the
|
|
257
|
+
warning `tool_below_minimum: <name> <version> (minimum <minimum>)`, and
|
|
258
|
+
the banner's Tool upgrades line; a tier tool among them already reads
|
|
259
|
+
false under `tools`, so no envelope field changes.
|
|
260
|
+
|
|
261
|
+
When the On Activation customization resolver was missing or failed,
|
|
262
|
+
pass its one-line reason as `customization_resolver_unavailable`: the
|
|
263
|
+
run then used only the skill's own customize.toml, and the warning tells
|
|
264
|
+
a pipeline that the `_bmad/custom/` overrides were not applied. The
|
|
265
|
+
emit-blocked payload takes the same key.
|
|
266
|
+
|
|
267
|
+
The banner keys, which `render-report` reads (`emit` reads only
|
|
268
|
+
"settings_yml_written", from which it derives files_written):
|
|
269
|
+
"project_root" and "forge_data_folder" (resolved paths),
|
|
270
|
+
"settings_yml_written" and "gitignore_updated" (bool),
|
|
271
|
+
"settings_yml_patterns_added" and "settings_yml_patterns_removed"
|
|
272
|
+
(int), "hygiene_result" ("completed|qmd_unavailable|skipped"),
|
|
273
|
+
"hygiene_healthy", "hygiene_orphaned_removed", "hygiene_orphaned_kept",
|
|
274
|
+
"hygiene_stale_cleaned" and "ccc_registry_stale_cleaned" (int),
|
|
275
|
+
"ccc_index_deferred" (bool: --defer-index skipped a due index build),
|
|
276
|
+
and "require_tier" (the tier --require-tier named, or null). The staged
|
|
277
|
+
helper outputs supply every one of them but the two resolved paths,
|
|
278
|
+
which the path options give with `config_path`: setup's
|
|
279
|
+
step 4 stages only the orphan decision, the resolver reason and a null
|
|
280
|
+
`error`.
|
|
67
281
|
|
|
68
282
|
Caller does NOT need to compute warnings, tools_added/removed, or
|
|
69
|
-
tier_changed
|
|
283
|
+
tier_changed: the script derives them from the inputs above.
|
|
70
284
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
285
|
+
Setup's step 4 always passes `"error": null`: every halt that names a
|
|
286
|
+
phase emits through `emit-blocked` instead. A non-null error still
|
|
287
|
+
yields status 'blocked'.
|
|
288
|
+
|
|
289
|
+
CLI: the step files invoke it via `uv run`, like every sibling helper.
|
|
290
|
+
It imports only the standard library (dependencies = []) and must stay
|
|
291
|
+
that way: the setup halt contract runs `emit-blocked` for the On
|
|
292
|
+
Activation halts, which can fire before `uv` is proven present, under
|
|
293
|
+
the first of `uv run`, `python3`, `python` and `py -3` that works.
|
|
76
294
|
|
|
77
295
|
echo '{...context payload...}' | uv run skf-emit-result-envelope.py emit
|
|
296
|
+
uv run skf-emit-result-envelope.py emit --run-dir "{run_dir}" --project-root "{project-root}" --sidecar-path "{sidecar_path}" --forge-data-folder "{forge_data_folder}" < "{run_dir}/report-context.json"
|
|
297
|
+
uv run skf-emit-result-envelope.py render-report --run-dir "{run_dir}" --project-root "{project-root}" --sidecar-path "{sidecar_path}" --forge-data-folder "{forge_data_folder}" --tier-rules "<skill>/references/tier-rules.md" < "{run_dir}/report-context.json"
|
|
298
|
+
echo '{"phase":"...","reason":"...","path":"..."}' | uv run skf-emit-result-envelope.py emit-blocked
|
|
299
|
+
uv run skf-emit-result-envelope.py emit-blocked --phase "step 2:write-tools" --path "<path>" --reason "<message>" --stderr-from "{run_dir}/write-tools.err"
|
|
78
300
|
echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
|
|
301
|
+
uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warning "customization_resolver_unavailable: <reason>"
|
|
79
302
|
|
|
80
303
|
Exit codes:
|
|
81
304
|
0 success
|
|
@@ -89,6 +312,7 @@ import argparse
|
|
|
89
312
|
import json
|
|
90
313
|
import os
|
|
91
314
|
import sys
|
|
315
|
+
import time
|
|
92
316
|
from pathlib import Path
|
|
93
317
|
|
|
94
318
|
|
|
@@ -96,8 +320,52 @@ ENVELOPE_PREFIX = "SKF_SETUP_RESULT_JSON: "
|
|
|
96
320
|
SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.json"
|
|
97
321
|
TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
|
|
98
322
|
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
99
|
-
VALID_FILES = ("forge-tier.yaml", "
|
|
323
|
+
VALID_FILES = ("forge-tier.yaml", "settings.yml", "ccc_index")
|
|
100
324
|
VALID_CCC_STATUS = ("fresh", "created", "failed", "none", "skipped")
|
|
325
|
+
# The helper outputs a skf-setup run stages in its run folder, each by the
|
|
326
|
+
# step that runs the helper; `emit` and `render-report` read them from
|
|
327
|
+
# --run-dir (see "skf-setup" in the module docstring).
|
|
328
|
+
STAGED_DETECT = "detect-tools.json"
|
|
329
|
+
STAGED_CCC = "ccc-exclusions.json"
|
|
330
|
+
STAGED_CLASSIFY = "qmd-classify.json"
|
|
331
|
+
STAGED_REMOVE = "qmd-remove.json"
|
|
332
|
+
STAGED_CLEAN_STALE = "clean-stale.json"
|
|
333
|
+
STAGED_FILES = (STAGED_DETECT, STAGED_CCC, STAGED_CLASSIFY, STAGED_REMOVE, STAGED_CLEAN_STALE)
|
|
334
|
+
# The index failure a run reports when ccc is available and step 1b staged no result.
|
|
335
|
+
CCC_NO_RESULT = "the ccc settings helper wrote no result"
|
|
336
|
+
|
|
337
|
+
SCHEMA_DIR = SCHEMA_FILE.parent
|
|
338
|
+
SETUP_WORKFLOW = "skf-setup"
|
|
339
|
+
# The `$defs` entry whose `const` holds a schema's emitter settings.
|
|
340
|
+
META_KEY = "skf-envelope"
|
|
341
|
+
META_FIELDS = ("workflow", "prefix", "wrapper", "halt_status", "exit_codes",
|
|
342
|
+
"success_exit_code", "result_file", "result_stamp")
|
|
343
|
+
SINK_DECISIONS = "headless-decisions.jsonl"
|
|
344
|
+
SINK_WARNINGS = "warnings.jsonl"
|
|
345
|
+
# The halt payload's own keys; each lands only where the schema has a place for it.
|
|
346
|
+
HALT_KEYS = ("phase", "reason", "halt_reason", "exit_code", "path", "details")
|
|
347
|
+
# Payload keys the emitter reads and never copies into the envelope.
|
|
348
|
+
PAYLOAD_ONLY_KEYS = ("result_contract", "customization_resolver_unavailable")
|
|
349
|
+
# The fields of a schema's `error` object a halt fills, and the halt key each reads.
|
|
350
|
+
ERROR_FIELDS = {"phase": "phase", "reason": "reason", "path": "path", "code": "halt_reason",
|
|
351
|
+
"message": "reason", "halt_reason": "halt_reason", "exit_code": "exit_code",
|
|
352
|
+
"details": "details"}
|
|
353
|
+
# The record fields a result_contract may leave out, taken from the payload's own.
|
|
354
|
+
CONTRACT_DEFAULTS = ("status", "summary")
|
|
355
|
+
# Every JSON Schema keyword _validate_against_schema enforces, and the ones
|
|
356
|
+
# it may skip: annotations, and `$defs`, which holds only the emitter's
|
|
357
|
+
# settings (no envelope schema has a `$ref`). An envelope schema uses no
|
|
358
|
+
# other keyword, so the built-in validator never passes what the schema
|
|
359
|
+
# forbids.
|
|
360
|
+
VALIDATOR_KEYWORDS = frozenset({"type", "enum", "const", "oneOf", "anyOf", "properties",
|
|
361
|
+
"additionalProperties", "required", "items", "uniqueItems",
|
|
362
|
+
"minLength", "minimum"})
|
|
363
|
+
ANNOTATION_KEYWORDS = frozenset({"$schema", "$id", "$defs", "title", "description", "default",
|
|
364
|
+
"examples"})
|
|
365
|
+
# What str.splitlines() and some log readers break a line at, and json.dumps
|
|
366
|
+
# leaves raw when it keeps non-ASCII text (it escapes every control character).
|
|
367
|
+
RAW_LINE_BREAKS = ("\x85", "\u2028", "\u2029")
|
|
368
|
+
_MISSING = object()
|
|
101
369
|
|
|
102
370
|
|
|
103
371
|
def _die(code: int, message: str) -> None:
|
|
@@ -152,13 +420,29 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
|
|
|
152
420
|
return added, removed
|
|
153
421
|
|
|
154
422
|
|
|
423
|
+
def _resolver_warning(payload: dict) -> list[str]:
|
|
424
|
+
"""The customization_resolver_unavailable warning, when the payload reports one.
|
|
425
|
+
|
|
426
|
+
Every workflow's On Activation runs resolve_customization.py. When that
|
|
427
|
+
script is missing or fails, the run falls back to the skill's own
|
|
428
|
+
customize.toml, so the team and user overrides under `_bmad/custom/`
|
|
429
|
+
silently do nothing; the payload key carries the resolver's reason so
|
|
430
|
+
the envelope says so.
|
|
431
|
+
"""
|
|
432
|
+
reason = payload.get("customization_resolver_unavailable")
|
|
433
|
+
if not reason:
|
|
434
|
+
return []
|
|
435
|
+
text = reason.strip() if isinstance(reason, str) else ""
|
|
436
|
+
return [f"customization_resolver_unavailable: {text or '<unknown>'}"]
|
|
437
|
+
|
|
438
|
+
|
|
155
439
|
def _assemble_warnings(payload: dict) -> list[str]:
|
|
156
440
|
"""Fold every documented warning source into the envelope's warnings array.
|
|
157
441
|
|
|
158
442
|
Matches the field-rules block in step 4 §4 — pipelines should only need
|
|
159
443
|
to consult `warnings` to surface non-fatal issues.
|
|
160
444
|
"""
|
|
161
|
-
warnings: list[str] =
|
|
445
|
+
warnings: list[str] = _resolver_warning(payload)
|
|
162
446
|
if payload.get("tier_override_invalid"):
|
|
163
447
|
bad = payload.get("tier_override_invalid_value")
|
|
164
448
|
suggestion = payload.get("tier_override_invalid_suggestion")
|
|
@@ -176,6 +460,9 @@ def _assemble_warnings(payload: dict) -> list[str]:
|
|
|
176
460
|
warnings.append(f"ccc_registry_stale_removed: {p}")
|
|
177
461
|
if payload.get("qmd_status") == "daemon_stopped":
|
|
178
462
|
warnings.append("qmd_daemon_stopped")
|
|
463
|
+
for tool in _objects(payload.get("tools_below_minimum")):
|
|
464
|
+
warnings.append(f"tool_below_minimum: {_tool_name(tool)} {tool.get('version')} "
|
|
465
|
+
f"(minimum {tool.get('minimum')})")
|
|
179
466
|
failure_reason = payload.get("ccc_indexing_failed_reason")
|
|
180
467
|
if failure_reason:
|
|
181
468
|
warnings.append(f"ccc_indexing_failed: {failure_reason}")
|
|
@@ -193,9 +480,30 @@ def _assemble_warnings(payload: dict) -> list[str]:
|
|
|
193
480
|
f"orphan_auto_resolution: {action} {count} orphaned collection(s) "
|
|
194
481
|
f"(non-interactive, {source})"
|
|
195
482
|
)
|
|
483
|
+
# A removal nobody confirmed names each collection it deleted, and
|
|
484
|
+
# each it could not, so the audit trail says what to rebuild.
|
|
485
|
+
warnings += [f"orphan_removed: {name}" for name in _strings(orphan.get("removed"))]
|
|
486
|
+
warnings += [f"orphan_remove_failed: {name}" for name in _strings(orphan.get("failed"))]
|
|
196
487
|
return warnings
|
|
197
488
|
|
|
198
489
|
|
|
490
|
+
def _dict(value) -> dict:
|
|
491
|
+
return value if isinstance(value, dict) else {}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _strings(value) -> list[str]:
|
|
495
|
+
return [str(item) for item in value] if isinstance(value, list) else []
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _objects(value) -> list[dict]:
|
|
499
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _tool_name(tool: dict) -> str:
|
|
503
|
+
"""A tools_below_minimum entry's name, as the docs write it."""
|
|
504
|
+
return str(tool.get("name") or tool.get("tool"))
|
|
505
|
+
|
|
506
|
+
|
|
199
507
|
def _normalize_files_written(maybe_files) -> list[str]:
|
|
200
508
|
"""Accept either a list of names or a dict {name: bool}; emit sorted-canonical list."""
|
|
201
509
|
if maybe_files is None:
|
|
@@ -212,6 +520,24 @@ def _normalize_files_written(maybe_files) -> list[str]:
|
|
|
212
520
|
return [name for name in VALID_FILES if name in names]
|
|
213
521
|
|
|
214
522
|
|
|
523
|
+
def _files_written(payload: dict, error) -> list[str]:
|
|
524
|
+
"""files_written as the payload gives it, else derived from the run's flags.
|
|
525
|
+
|
|
526
|
+
A finished run always wrote forge-tier.yaml; settings.yml and the ccc
|
|
527
|
+
index count when settings_yml_written and a ccc_index status of
|
|
528
|
+
"created" say so. An envelope with an error reports no file.
|
|
529
|
+
"""
|
|
530
|
+
if "files_written" in payload:
|
|
531
|
+
return _normalize_files_written(payload["files_written"])
|
|
532
|
+
if error is not None:
|
|
533
|
+
return []
|
|
534
|
+
return _normalize_files_written({
|
|
535
|
+
"forge-tier.yaml": True,
|
|
536
|
+
"settings.yml": payload.get("settings_yml_written") is True,
|
|
537
|
+
"ccc_index": _dict(payload.get("ccc_index")).get("status") == "created",
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
|
|
215
541
|
def _normalize_ccc_index(maybe_idx) -> dict:
|
|
216
542
|
"""Coerce caller's ccc_index dict to the envelope shape (drops extras)."""
|
|
217
543
|
if maybe_idx is None:
|
|
@@ -284,7 +610,7 @@ def assemble_envelope(payload: dict) -> dict:
|
|
|
284
610
|
"tools_removed": tools_removed,
|
|
285
611
|
"config_path": config_path,
|
|
286
612
|
"ccc_index": ccc_index,
|
|
287
|
-
"files_written":
|
|
613
|
+
"files_written": _files_written(payload, error),
|
|
288
614
|
"tier_override_active": bool(payload.get("tier_override_active", False)),
|
|
289
615
|
"tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
|
|
290
616
|
"require_tier_satisfied": require_tier_satisfied,
|
|
@@ -297,15 +623,16 @@ def assemble_envelope(payload: dict) -> dict:
|
|
|
297
623
|
def _compute_status(error: dict | None, require_tier_satisfied) -> str:
|
|
298
624
|
"""Derive the single-field status from error + require_tier_satisfied.
|
|
299
625
|
|
|
300
|
-
- '
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
- '
|
|
626
|
+
- 'blocked' when error is non-null: a halt produced the envelope,
|
|
627
|
+
and error.phase names it (a write failure is
|
|
628
|
+
'step 2:write-tools' or 'step 2:forge-data-dir')
|
|
629
|
+
- 'tier_failure' when require_tier_satisfied is False
|
|
630
|
+
- 'success' otherwise
|
|
631
|
+
|
|
632
|
+
Both envelope builders derive status here, so every value it returns
|
|
633
|
+
is one the schema's status enum lists.
|
|
304
634
|
"""
|
|
305
635
|
if error is not None:
|
|
306
|
-
phase = (error.get("phase") or "").lower()
|
|
307
|
-
if "write" in phase or phase.endswith("forge-tier.yaml") or phase.endswith("preferences.yaml"):
|
|
308
|
-
return "write_failure"
|
|
309
636
|
return "blocked"
|
|
310
637
|
if require_tier_satisfied is False:
|
|
311
638
|
return "tier_failure"
|
|
@@ -313,13 +640,460 @@ def _compute_status(error: dict | None, require_tier_satisfied) -> str:
|
|
|
313
640
|
|
|
314
641
|
|
|
315
642
|
def emit_envelope_line(envelope: dict) -> str:
|
|
316
|
-
"""Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism.
|
|
643
|
+
"""Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism.
|
|
644
|
+
|
|
645
|
+
The body keeps non-ASCII text raw, except the RAW_LINE_BREAKS, which only
|
|
646
|
+
a string can hold and which are escaped there so the line stays one line.
|
|
647
|
+
"""
|
|
317
648
|
body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
649
|
+
for char in RAW_LINE_BREAKS:
|
|
650
|
+
body = body.replace(char, f"\\u{ord(char):04x}")
|
|
318
651
|
if "\n" in body:
|
|
319
652
|
_die(2, "envelope serialization produced embedded newline (should be impossible)")
|
|
320
653
|
return ENVELOPE_PREFIX + body
|
|
321
654
|
|
|
322
655
|
|
|
656
|
+
# ─── skf-setup: staged helper outputs ───────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def read_staged(run_dir: Path | None) -> dict:
|
|
660
|
+
"""The helper outputs a setup run staged in its run folder, by file name.
|
|
661
|
+
|
|
662
|
+
Each value is the helper's JSON object, or None when the file holds
|
|
663
|
+
none: the helper exited non-zero, so its redirected stdout stayed
|
|
664
|
+
empty. A file that is absent is left out: its step never ran the
|
|
665
|
+
helper (step 1b runs none without ccc, and step 3 none below Deep tier
|
|
666
|
+
without ccc), or, for ccc-exclusions.json, the helper wrote nothing
|
|
667
|
+
(fold_staged).
|
|
668
|
+
"""
|
|
669
|
+
staged: dict = {}
|
|
670
|
+
if run_dir is None:
|
|
671
|
+
return staged
|
|
672
|
+
for name in STAGED_FILES:
|
|
673
|
+
try:
|
|
674
|
+
# utf-8-sig: a shell that writes a byte-order mark still stages JSON.
|
|
675
|
+
text = (run_dir / name).read_text(encoding="utf-8-sig")
|
|
676
|
+
except FileNotFoundError:
|
|
677
|
+
continue
|
|
678
|
+
except (OSError, UnicodeDecodeError):
|
|
679
|
+
text = ""
|
|
680
|
+
try:
|
|
681
|
+
value = json.loads(text) if text.strip() else None
|
|
682
|
+
except json.JSONDecodeError:
|
|
683
|
+
value = None
|
|
684
|
+
staged[name] = value if isinstance(value, dict) else None
|
|
685
|
+
return staged
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def fold_staged(payload: dict, staged: dict) -> dict:
|
|
689
|
+
"""The setup payload with the fields each staged helper output is the source of.
|
|
690
|
+
|
|
691
|
+
A staged output replaces the payload's own value of every field it
|
|
692
|
+
holds: the step files no longer type those values, so the helper's are
|
|
693
|
+
the ones that count.
|
|
694
|
+
|
|
695
|
+
detect-tools.json tier, previous_tier, tools, previous_tools, the
|
|
696
|
+
tier_override_* and require_tier* fields,
|
|
697
|
+
qmd_status and tools_below_minimum
|
|
698
|
+
ccc-exclusions.json settings_yml_written, settings_yml_patterns_added
|
|
699
|
+
and _removed, gitignore_updated,
|
|
700
|
+
ccc_exclusion_warnings (each entry verbatim),
|
|
701
|
+
ccc_index, ccc_indexing_failed_reason and
|
|
702
|
+
ccc_index_deferred (index_action "defer"); a file
|
|
703
|
+
that holds the helper's error, or no JSON
|
|
704
|
+
object, reads as nothing written and the index
|
|
705
|
+
failed, with the error as its reason, and so
|
|
706
|
+
does no file at all when detect-tools.json
|
|
707
|
+
reports ccc available (step 1b ran the helper)
|
|
708
|
+
qmd-classify.json hygiene_result ("completed", or "qmd_unavailable"
|
|
709
|
+
when the classifier failed), hygiene_healthy,
|
|
710
|
+
hygiene_orphaned_kept (every orphan, while no
|
|
711
|
+
removal ran) and orphan_auto_resolution's count
|
|
712
|
+
qmd-remove.json hygiene_orphaned_removed, hygiene_orphaned_kept
|
|
713
|
+
(the orphans the removal could not delete) and
|
|
714
|
+
orphan_auto_resolution's removed and failed
|
|
715
|
+
clean-stale.json hygiene_stale_cleaned, ccc_registry_stale_cleaned
|
|
716
|
+
and ccc_registry_stale_removed (none when it failed)
|
|
717
|
+
"""
|
|
718
|
+
out = dict(payload)
|
|
719
|
+
detect = staged.get(STAGED_DETECT)
|
|
720
|
+
if detect is not None:
|
|
721
|
+
tier, prior = _dict(detect.get("tier")), _dict(detect.get("prior"))
|
|
722
|
+
required, tools = _dict(detect.get("require_tier")), _dict(detect.get("tools"))
|
|
723
|
+
out.update({
|
|
724
|
+
"tier": tier.get("calculated"),
|
|
725
|
+
"previous_tier": prior.get("previous_tier"),
|
|
726
|
+
"tools": tools,
|
|
727
|
+
"previous_tools": prior.get("previous_tools") or None,
|
|
728
|
+
"tier_override_active": tier.get("override_applied") is True,
|
|
729
|
+
"tier_override_invalid": tier.get("override_invalid") is True,
|
|
730
|
+
"tier_override_invalid_value": tier.get("override_invalid_value"),
|
|
731
|
+
"tier_override_invalid_suggestion": tier.get("override_invalid_suggestion"),
|
|
732
|
+
"tier_override_unsafe": tier.get("override_unsafe") is True,
|
|
733
|
+
"tier_override_unsafe_missing": _strings(tier.get("override_unsafe_missing")),
|
|
734
|
+
"require_tier": required.get("requested"),
|
|
735
|
+
"require_tier_satisfied": required.get("satisfied"),
|
|
736
|
+
"require_tier_failure_missing": _strings(required.get("missing_tools")),
|
|
737
|
+
"qmd_status": _dict(tools.get("qmd")).get("status"),
|
|
738
|
+
"tools_below_minimum": _objects(detect.get("tools_below_minimum")),
|
|
739
|
+
})
|
|
740
|
+
if STAGED_CCC in staged:
|
|
741
|
+
result = _dict(staged[STAGED_CCC])
|
|
742
|
+
ok = result.get("status") == "ok"
|
|
743
|
+
out.update({
|
|
744
|
+
"settings_yml_written": ok and result.get("written") is True,
|
|
745
|
+
"settings_yml_patterns_added": _count(result.get("patterns_added")) if ok else 0,
|
|
746
|
+
"settings_yml_patterns_removed": _count(result.get("patterns_removed")) if ok else 0,
|
|
747
|
+
"gitignore_updated": ok and result.get("gitignore_updated") is True,
|
|
748
|
+
"ccc_exclusion_warnings": _strings(result.get("warnings")) if ok else [],
|
|
749
|
+
"ccc_index_deferred": ok and result.get("index_action") == "defer",
|
|
750
|
+
})
|
|
751
|
+
index = _dict(result.get("index"))
|
|
752
|
+
if not ok:
|
|
753
|
+
out["ccc_index"] = {"status": "failed", "indexed_path": None, "file_count": None}
|
|
754
|
+
out["ccc_indexing_failed_reason"] = (result.get("message")
|
|
755
|
+
or "the ccc settings helper returned no result")
|
|
756
|
+
elif index:
|
|
757
|
+
out["ccc_index"] = {key: index.get(key) for key in ("status", "indexed_path", "file_count")}
|
|
758
|
+
out["ccc_indexing_failed_reason"] = index.get("failed_reason")
|
|
759
|
+
elif detect is not None and _dict(_dict(detect.get("tools")).get("ccc")).get("available") is True:
|
|
760
|
+
# Step 1b ran the helper, which wrote nothing: a usage error, or a
|
|
761
|
+
# call its host stopped before the first write.
|
|
762
|
+
out["ccc_index"] = {"status": "failed", "indexed_path": None, "file_count": None}
|
|
763
|
+
out["ccc_indexing_failed_reason"] = CCC_NO_RESULT
|
|
764
|
+
orphaned = _strings(_dict(staged.get(STAGED_CLASSIFY)).get("orphaned"))
|
|
765
|
+
resolution = out.get("orphan_auto_resolution")
|
|
766
|
+
if STAGED_CLASSIFY in staged:
|
|
767
|
+
healthy = _dict(staged[STAGED_CLASSIFY]).get("healthy")
|
|
768
|
+
completed = isinstance(healthy, list)
|
|
769
|
+
out["hygiene_result"] = "completed" if completed else "qmd_unavailable"
|
|
770
|
+
out["hygiene_healthy"] = len(healthy) if completed else 0
|
|
771
|
+
out["hygiene_orphaned_removed"], out["hygiene_orphaned_kept"] = 0, len(orphaned)
|
|
772
|
+
if isinstance(resolution, dict) and "count" not in resolution:
|
|
773
|
+
resolution = {**resolution, "count": len(orphaned)}
|
|
774
|
+
if STAGED_REMOVE in staged:
|
|
775
|
+
removal = staged[STAGED_REMOVE]
|
|
776
|
+
removed = _strings(_dict(removal).get("removed"))
|
|
777
|
+
# A removal that printed nothing deleted nothing anyone can name.
|
|
778
|
+
failed = _strings(removal.get("failed")) if removal is not None else orphaned
|
|
779
|
+
out["hygiene_orphaned_removed"], out["hygiene_orphaned_kept"] = len(removed), len(failed)
|
|
780
|
+
if isinstance(resolution, dict):
|
|
781
|
+
resolution = {**resolution, "removed": removed, "failed": failed}
|
|
782
|
+
if isinstance(resolution, dict):
|
|
783
|
+
out["orphan_auto_resolution"] = resolution
|
|
784
|
+
if STAGED_CLEAN_STALE in staged:
|
|
785
|
+
cleaned = _dict(staged[STAGED_CLEAN_STALE])
|
|
786
|
+
pruned = _strings(cleaned.get("ccc_removed"))
|
|
787
|
+
out["hygiene_stale_cleaned"] = len(_strings(cleaned.get("qmd_removed")))
|
|
788
|
+
out["ccc_registry_stale_cleaned"] = len(pruned)
|
|
789
|
+
out["ccc_registry_stale_removed"] = pruned
|
|
790
|
+
return out
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# ─── skf-setup: the run's paths ─────────────────────────────────────────────
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def setup_paths(project_root: str | None, sidecar_path: str | None, forge_data_folder: str | None) -> dict:
|
|
797
|
+
"""project_root, config_path and forge_data_folder from the path options.
|
|
798
|
+
|
|
799
|
+
--sidecar-path and --forge-data-folder take the folders setup's On
|
|
800
|
+
Activation bound from skf-preflight.py (its sidecar_path_resolved and
|
|
801
|
+
forge_data_folder_resolved), so the emitter resolves no config.yaml
|
|
802
|
+
value itself and cannot drift from preflight's rules. Each path is
|
|
803
|
+
written with `/`: the project root resolved, the forge-tier.yaml in the
|
|
804
|
+
sidecar folder, and the forge data folder (left out when empty). A
|
|
805
|
+
relative folder is joined to the project root. {} without
|
|
806
|
+
--project-root.
|
|
807
|
+
"""
|
|
808
|
+
if project_root is None:
|
|
809
|
+
if sidecar_path is not None or forge_data_folder is not None:
|
|
810
|
+
_die(1, "--sidecar-path and --forge-data-folder need --project-root")
|
|
811
|
+
return {}
|
|
812
|
+
root = Path(project_root).resolve()
|
|
813
|
+
paths = {"project_root": root.as_posix()}
|
|
814
|
+
|
|
815
|
+
def folder(value: str) -> Path:
|
|
816
|
+
path = Path(value)
|
|
817
|
+
return path if path.is_absolute() else root / path
|
|
818
|
+
|
|
819
|
+
if sidecar_path is not None:
|
|
820
|
+
if not sidecar_path.strip():
|
|
821
|
+
_die(1, "--sidecar-path must be non-empty")
|
|
822
|
+
paths["config_path"] = (folder(sidecar_path) / "forge-tier.yaml").as_posix()
|
|
823
|
+
if forge_data_folder and forge_data_folder.strip():
|
|
824
|
+
paths["forge_data_folder"] = folder(forge_data_folder).as_posix()
|
|
825
|
+
return paths
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# ─── skf-setup: FORGE STATUS banner ─────────────────────────────────────────
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
BANNER_RULE = "═" * 39
|
|
832
|
+
# The name the banner gives each tool key.
|
|
833
|
+
TOOL_NAMES = {"ast_grep": "ast-grep", "gh_cli": "gh", "qmd": "qmd", "ccc": "ccc"}
|
|
834
|
+
# tier-rules.md sits beside this script's folder in the source tree
|
|
835
|
+
# (src/skf-setup/) and in an installed project (_bmad/skf/skf-setup/).
|
|
836
|
+
TIER_RULES_FILE = Path(__file__).resolve().parent.parent.parent / "skf-setup" / "references" / "tier-rules.md"
|
|
837
|
+
# Each tier-rules.md heading the banner reads, and the key its copy goes under.
|
|
838
|
+
TIER_RULES_HEADINGS = {"Quick Tier": "Quick", "Forge Tier": "Forge", "Forge+ Tier": "Forge+",
|
|
839
|
+
"Deep Tier": "Deep", "Upgrade": "upgrade", "Downgrade": "downgrade",
|
|
840
|
+
"Same": "same"}
|
|
841
|
+
NEXT_STEPS = (
|
|
842
|
+
" Next: the fastest start is `@Ferris forge-auto <repo-or-doc-url>`: one command auto-scopes, "
|
|
843
|
+
"briefs, compiles, tests at a 90% quality gate, and exports a verified skill with zero "
|
|
844
|
+
"configuration. Prefer to scope by hand? `/skf-brief-skill` scopes your first compilation "
|
|
845
|
+
"target, or `/skf-quick-skill` is a fast template-driven path. Already have a skill? "
|
|
846
|
+
"`/skf-audit-skill` drift-checks an existing skill against current sources."
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def load_tier_rules(path: Path) -> dict:
|
|
851
|
+
"""The tier descriptions and re-run messages of tier-rules.md, by key.
|
|
852
|
+
|
|
853
|
+
Each is the first non-blank line under its `### <heading>`, without its
|
|
854
|
+
surrounding double quotes, so the banner's tier wording lives in that
|
|
855
|
+
one file.
|
|
856
|
+
"""
|
|
857
|
+
try:
|
|
858
|
+
text = path.read_text(encoding="utf-8")
|
|
859
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
860
|
+
_die(1, f"render-report: cannot read the tier copy {path.as_posix()}: {e}")
|
|
861
|
+
copy: dict[str, str] = {}
|
|
862
|
+
key = None
|
|
863
|
+
for line in text.splitlines():
|
|
864
|
+
stripped = line.strip()
|
|
865
|
+
if stripped.startswith("#"):
|
|
866
|
+
key = TIER_RULES_HEADINGS.get(stripped[4:].strip()) if stripped.startswith("### ") else None
|
|
867
|
+
elif key and stripped:
|
|
868
|
+
if len(stripped) > 1 and stripped[0] == stripped[-1] == '"':
|
|
869
|
+
stripped = stripped[1:-1]
|
|
870
|
+
copy.setdefault(key, stripped)
|
|
871
|
+
key = None
|
|
872
|
+
missing = [heading for heading, name in TIER_RULES_HEADINGS.items() if name not in copy]
|
|
873
|
+
if missing:
|
|
874
|
+
_die(1, f"render-report: {path.as_posix()} has no copy under: {', '.join(missing)}")
|
|
875
|
+
return copy
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _count(value) -> int:
|
|
879
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 0
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _names(keys) -> str:
|
|
883
|
+
return ", ".join(TOOL_NAMES.get(key, key) for key in keys)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _tool_line(key: str, probe, ccc_daemon) -> str:
|
|
887
|
+
"""One Tools Detected line: the tool and its version, the probe's version
|
|
888
|
+
line without the tool's name or a leading "version" (gh prints
|
|
889
|
+
`gh version 2.91.0 (...)`). ccc also shows its daemon; its version is the
|
|
890
|
+
one `uv tool list` gave, when uv installed it."""
|
|
891
|
+
name = TOOL_NAMES[key]
|
|
892
|
+
version = str(_dict(probe).get("version") or "").strip()
|
|
893
|
+
for word in (name, "version"):
|
|
894
|
+
if version.lower().startswith(word + " "):
|
|
895
|
+
version = version[len(word) + 1:].lstrip()
|
|
896
|
+
line = f" - {name} {version}" if version else f" - {name}"
|
|
897
|
+
return f"{line} (daemon {ccc_daemon})" if key == "ccc" and ccc_daemon else line
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _upgrade_line(tool: dict) -> str:
|
|
901
|
+
"""One Tool upgrades line: the version found, the minimum, how to upgrade
|
|
902
|
+
and, for a tier tool, the tier it counts toward once upgraded."""
|
|
903
|
+
line = f"- Upgrade {_tool_name(tool)} {tool.get('version')} to {tool.get('minimum')} or newer"
|
|
904
|
+
if tool.get("upgrade"):
|
|
905
|
+
line += f" ({tool['upgrade']})"
|
|
906
|
+
return line + (f" to use the {tool['tier']} tier" if tool.get("tier") else "")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _tier_change_message(copy: dict, previous: str, current: str, added, removed) -> str:
|
|
910
|
+
"""tier-rules.md's upgrade or downgrade message, filled in.
|
|
911
|
+
|
|
912
|
+
With no tool to name (a tier_override moved the tier), the message
|
|
913
|
+
stops after its first sentence.
|
|
914
|
+
"""
|
|
915
|
+
upgrade = VALID_TIERS.index(current) > VALID_TIERS.index(previous)
|
|
916
|
+
message = copy["upgrade" if upgrade else "downgrade"]
|
|
917
|
+
if not (added if upgrade else removed):
|
|
918
|
+
head, sep, _ = message.partition("{current}.")
|
|
919
|
+
message = head + sep
|
|
920
|
+
return (message.replace("{previous}", previous).replace("{current}", current)
|
|
921
|
+
.replace("{newly available tool(s)}", _names(added)).replace("{tool}", _names(removed)))
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def render_report(payload: dict, copy: dict) -> list[str]:
|
|
925
|
+
"""The FORGE STATUS banner of a finished setup run, one string per line.
|
|
926
|
+
|
|
927
|
+
Each `{if ...}` condition of the banner template is one test below, on
|
|
928
|
+
the payload (with the staged helper outputs folded in) and the envelope
|
|
929
|
+
fields derived from it; the tests keep the template. The lines are
|
|
930
|
+
English; the step translates them.
|
|
931
|
+
"""
|
|
932
|
+
inner = assemble_envelope(payload)["skf_setup"]
|
|
933
|
+
tier, previous, tools = inner["tier"], inner["previous_tier"], inner["tools"]
|
|
934
|
+
added, removed, changed = inner["tools_added"], inner["tools_removed"], inner["tier_changed"]
|
|
935
|
+
index = inner["ccc_index"]["status"]
|
|
936
|
+
probes = _dict(payload.get("tools"))
|
|
937
|
+
ccc_daemon = _dict(probes.get("ccc")).get("daemon")
|
|
938
|
+
root = str(payload.get("project_root") or "{project-root}").rstrip("/\\")
|
|
939
|
+
hygiene = payload.get("hygiene_result")
|
|
940
|
+
settings_written = payload.get("settings_yml_written")
|
|
941
|
+
# A run that missed --require-tier skipped a due index build: not --ccc-skip-index.
|
|
942
|
+
deferred = payload.get("ccc_index_deferred") is True
|
|
943
|
+
# A tool below its minimum is installed: its Tool upgrades line replaces
|
|
944
|
+
# an install hint, and it is never called "no longer detected".
|
|
945
|
+
below = _objects(payload.get("tools_below_minimum"))
|
|
946
|
+
below_keys = {str(tool.get("tool")) for tool in below}
|
|
947
|
+
removed_shown = [key for key in removed if key not in below_keys]
|
|
948
|
+
|
|
949
|
+
blocks = [[BANNER_RULE, " FORGE STATUS", BANNER_RULE], [f" Tier: {tier}", f" {copy[tier]}"]]
|
|
950
|
+
|
|
951
|
+
detected = [" Tools Detected:"]
|
|
952
|
+
detected += [_tool_line(key, probes.get(key), ccc_daemon) for key in TOOL_KEYS if tools[key]]
|
|
953
|
+
if not any(tools.values()):
|
|
954
|
+
# With no tool counted, the only climb hint is ast-grep's install line.
|
|
955
|
+
section = "Tool upgrades" if "ast_grep" in below_keys else "Climb to next tier"
|
|
956
|
+
detected.append(f' (none yet, see "{section}" below)')
|
|
957
|
+
blocks.append(detected)
|
|
958
|
+
|
|
959
|
+
if below:
|
|
960
|
+
blocks.append([" Tool upgrades:"] + [f" {_upgrade_line(tool)}" for tool in below])
|
|
961
|
+
|
|
962
|
+
if tier != "Deep":
|
|
963
|
+
ast_grep, qmd_status = tools["ast_grep"], payload.get("qmd_status")
|
|
964
|
+
hints = [hint for holds, hint in (
|
|
965
|
+
(not ast_grep and "ast_grep" not in below_keys,
|
|
966
|
+
"- Install ast-grep (https://ast-grep.github.io): unlocks AST-backed code analysis (Forge tier)"),
|
|
967
|
+
(ast_grep and not tools["ccc"] and "ccc" not in below_keys,
|
|
968
|
+
"- Install cocoindex-code (https://github.com/cocoindex-io/cocoindex-code): adds "
|
|
969
|
+
"semantic-guided precision compilation (Forge+ tier)"),
|
|
970
|
+
(ast_grep and not tools["gh_cli"] and "gh_cli" not in below_keys,
|
|
971
|
+
"- Install GitHub CLI (https://cli.github.com): required for Deep tier "
|
|
972
|
+
"(cross-repository synthesis)"),
|
|
973
|
+
(ast_grep and not tools["qmd"] and qmd_status == "absent",
|
|
974
|
+
"- Install qmd (https://github.com/tobi/qmd): required for Deep tier (knowledge search)"),
|
|
975
|
+
(ast_grep and not tools["qmd"] and qmd_status == "daemon_stopped" and "qmd" not in below_keys,
|
|
976
|
+
"- Start the qmd daemon (already installed): run `qmd start` (or your distribution's "
|
|
977
|
+
"qmd service command) to unlock Deep tier (knowledge search)"),
|
|
978
|
+
(tools["ccc"] and ccc_daemon == "error",
|
|
979
|
+
"- The ccc daemon is reporting errors: run `ccc doctor` to diagnose. CCC index will "
|
|
980
|
+
"fail until resolved"),
|
|
981
|
+
) if holds]
|
|
982
|
+
if hints:
|
|
983
|
+
blocks.append([" Climb to next tier:"] + [f" {hint}" for hint in hints])
|
|
984
|
+
|
|
985
|
+
if hygiene == "completed":
|
|
986
|
+
qmd = [" QMD Registry:", f" {_count(payload.get('hygiene_healthy'))} collection(s) healthy"]
|
|
987
|
+
for key, what in (("hygiene_orphaned_removed", "orphaned collection(s) removed"),
|
|
988
|
+
("hygiene_orphaned_kept", "orphaned collection(s) kept"),
|
|
989
|
+
("hygiene_stale_cleaned", "stale QMD registry entry/entries cleaned")):
|
|
990
|
+
if _count(payload.get(key)) > 0:
|
|
991
|
+
qmd.append(f" {_count(payload.get(key))} {what}")
|
|
992
|
+
blocks.append(qmd)
|
|
993
|
+
if _count(payload.get("ccc_registry_stale_cleaned")) > 0:
|
|
994
|
+
blocks.append([f" CCC Registry: {_count(payload.get('ccc_registry_stale_cleaned'))} stale "
|
|
995
|
+
"entry/entries cleaned"])
|
|
996
|
+
if hygiene == "completed" and _count(payload.get("hygiene_healthy")) == 0:
|
|
997
|
+
blocks.append([" QMD Registry: empty. Collections are created automatically when you run "
|
|
998
|
+
"/skf-create-skill."])
|
|
999
|
+
if hygiene == "qmd_unavailable":
|
|
1000
|
+
blocks.append([" QMD Registry: skipped (qmd unavailable; if the daemon is stopped, "
|
|
1001
|
+
"`qmd start` restores it)."])
|
|
1002
|
+
|
|
1003
|
+
if tools["ccc"]:
|
|
1004
|
+
ccc = [" CCC Index:"]
|
|
1005
|
+
# A failed `ccc index` can report on several lines; its line stays one line.
|
|
1006
|
+
reason = " ".join(str(payload.get("ccc_indexing_failed_reason") or "").split())
|
|
1007
|
+
ccc += [f" {line}" for status, line in (
|
|
1008
|
+
("fresh", "up to date, semantic discovery ready"),
|
|
1009
|
+
("created", "indexed this run, semantic discovery ready"),
|
|
1010
|
+
("skipped", "skipped (--require-tier not met). The next `/skf-setup` run that meets the "
|
|
1011
|
+
"required tier builds or refreshes the index" if deferred else
|
|
1012
|
+
"skipped (--ccc-skip-index). Run `/skf-setup` without --ccc-skip-index to "
|
|
1013
|
+
"build or refresh the index when you're ready"),
|
|
1014
|
+
("failed", "indexing failed, semantic discovery unavailable this session"
|
|
1015
|
+
+ (f" ({reason})" if reason else "")),
|
|
1016
|
+
) if index == status]
|
|
1017
|
+
notes = _strings(payload.get("ccc_exclusion_warnings"))
|
|
1018
|
+
if notes:
|
|
1019
|
+
# A note that names the project root in SKF's words writes the
|
|
1020
|
+
# placeholder, shown here as the resolved root; the envelope keeps it.
|
|
1021
|
+
ccc.append(" CCC exclusion notes:")
|
|
1022
|
+
ccc += [f" - {note.replace('{project-root}', root)}" for note in notes]
|
|
1023
|
+
blocks.append(ccc)
|
|
1024
|
+
|
|
1025
|
+
config_path = inner["config_path"]
|
|
1026
|
+
forge_data = str(payload.get("forge_data_folder") or "{forge_data_folder}").rstrip("/\\")
|
|
1027
|
+
files = [" Files written this run:", f" - forge-tier.yaml: {config_path}",
|
|
1028
|
+
f" - {forge_data}/ (directory ensured)"]
|
|
1029
|
+
if settings_written is True:
|
|
1030
|
+
pruned = _count(payload.get("settings_yml_patterns_removed"))
|
|
1031
|
+
files.append(f" - .cocoindex_code/settings.yml: {root}/.cocoindex_code/settings.yml "
|
|
1032
|
+
f"({_count(payload.get('settings_yml_patterns_added'))} SKF exclusion pattern(s) merged"
|
|
1033
|
+
+ (f", {pruned} stale SKF pattern(s) removed" if pruned > 0 else "") + ")")
|
|
1034
|
+
if payload.get("gitignore_updated") is True:
|
|
1035
|
+
files.append(f" - .gitignore: {root}/.gitignore (`/.cocoindex_code/` added by `ccc init`)")
|
|
1036
|
+
if index == "created":
|
|
1037
|
+
count = inner["ccc_index"]["file_count"]
|
|
1038
|
+
files.append(" - .cocoindex_code/ ccc index" + (f": {count} files indexed" if count is not None else ""))
|
|
1039
|
+
blocks.append(files)
|
|
1040
|
+
|
|
1041
|
+
if inner["tier_override_active"]:
|
|
1042
|
+
blocks.append([" Note: Tier override active (set in preferences.yaml)"])
|
|
1043
|
+
if inner["tier_override_invalid"]:
|
|
1044
|
+
suggestion = payload.get("tier_override_invalid_suggestion")
|
|
1045
|
+
blocks.append([
|
|
1046
|
+
f' Note: tier_override value "{payload.get("tier_override_invalid_value")}" in '
|
|
1047
|
+
"preferences.yaml is not valid.",
|
|
1048
|
+
*([f' Did you mean "{suggestion}"?'] if suggestion is not None else []),
|
|
1049
|
+
" Valid values are case-sensitive: Quick, Forge, Forge+, Deep. "
|
|
1050
|
+
f"Using detected tier {tier}.",
|
|
1051
|
+
])
|
|
1052
|
+
if payload.get("tier_override_unsafe") is True:
|
|
1053
|
+
missing = ", ".join(_strings(payload.get("tier_override_unsafe_missing")))
|
|
1054
|
+
blocks.append([
|
|
1055
|
+
f" Warning: tier_override is forcing {tier} but the underlying tool prerequisites are "
|
|
1056
|
+
"not satisfied.",
|
|
1057
|
+
f" Missing: {missing}. The override is honored, but downstream skills that",
|
|
1058
|
+
" rely on the missing tool(s) will fail at runtime. Install the missing tool(s) "
|
|
1059
|
+
"or remove",
|
|
1060
|
+
" the override from preferences.yaml.",
|
|
1061
|
+
])
|
|
1062
|
+
|
|
1063
|
+
if previous is None:
|
|
1064
|
+
blocks.append([f" Initial detection: {tier} tier established."])
|
|
1065
|
+
if changed:
|
|
1066
|
+
blocks.append([f" {_tier_change_message(copy, previous, tier, added, removed_shown)}"])
|
|
1067
|
+
if not changed and not added and not removed and previous is not None:
|
|
1068
|
+
same = [f" {copy['same'].replace('{current}', tier)}"]
|
|
1069
|
+
if settings_written is False and index == "fresh":
|
|
1070
|
+
same.append(" Your ccc settings were left untouched, and the ccc index was already current.")
|
|
1071
|
+
if settings_written is False and index == "skipped" and not deferred:
|
|
1072
|
+
same.append(" Your ccc settings were left untouched; the ccc index was not checked "
|
|
1073
|
+
"(--ccc-skip-index).")
|
|
1074
|
+
blocks.append(same)
|
|
1075
|
+
if not changed and (added or removed) and previous is not None:
|
|
1076
|
+
delta = [f" Tier unchanged: {tier}."]
|
|
1077
|
+
if added:
|
|
1078
|
+
deep_ccc = " ccc enhances Deep tier transparently." if "ccc" in added and tier == "Deep" else ""
|
|
1079
|
+
delta.append(f" Newly detected: {_names(added)}.{deep_ccc}")
|
|
1080
|
+
if removed_shown:
|
|
1081
|
+
delta.append(f" No longer detected: {_names(removed_shown)}. Re-install to restore those capabilities.")
|
|
1082
|
+
blocks.append(delta)
|
|
1083
|
+
|
|
1084
|
+
blocks += [[BANNER_RULE, f" Forge ready. {tier} tier active.", BANNER_RULE], [NEXT_STEPS]]
|
|
1085
|
+
if inner["require_tier_satisfied"] is False:
|
|
1086
|
+
missing = ", ".join(_strings(payload.get("require_tier_failure_missing"))) or "<none>"
|
|
1087
|
+
blocks += [[BANNER_RULE, " REQUIRED TIER NOT MET", BANNER_RULE],
|
|
1088
|
+
[f" Required: {payload.get('require_tier') or '<unknown>'}", f" Detected: {tier}",
|
|
1089
|
+
f" Missing: {missing}"],
|
|
1090
|
+
[" Install or upgrade the missing tool(s) and re-run, or relax `--require-tier`.", BANNER_RULE]]
|
|
1091
|
+
lines: list[str] = []
|
|
1092
|
+
for block in blocks:
|
|
1093
|
+
lines += ([""] if lines else []) + block
|
|
1094
|
+
return lines
|
|
1095
|
+
|
|
1096
|
+
|
|
323
1097
|
# ─── minimal stdlib JSON Schema validator ───────────────────────────────────
|
|
324
1098
|
|
|
325
1099
|
|
|
@@ -332,10 +1106,11 @@ def _load_schema() -> dict:
|
|
|
332
1106
|
def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
|
|
333
1107
|
"""Return a list of error strings (empty = valid).
|
|
334
1108
|
|
|
335
|
-
Implements only the subset of Draft 2020-12 features
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
1109
|
+
Implements only the subset of Draft 2020-12 features the envelope
|
|
1110
|
+
schemas use, the VALIDATOR_KEYWORDS: type, enum, const, oneOf, anyOf,
|
|
1111
|
+
properties, additionalProperties, required, items, uniqueItems,
|
|
1112
|
+
minLength, minimum. Sufficient to catch every violation those schemas
|
|
1113
|
+
can express; not a general-purpose validator.
|
|
339
1114
|
"""
|
|
340
1115
|
errors: list[str] = []
|
|
341
1116
|
|
|
@@ -345,6 +1120,11 @@ def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
|
|
|
345
1120
|
errors.append(f"{path}: matched {matches} of {len(schema['oneOf'])} oneOf branches (expected exactly 1)")
|
|
346
1121
|
return errors
|
|
347
1122
|
|
|
1123
|
+
if "anyOf" in schema:
|
|
1124
|
+
if all(_validate_against_schema(value, sub, path) for sub in schema["anyOf"]):
|
|
1125
|
+
errors.append(f"{path}: matched none of {len(schema['anyOf'])} anyOf branches")
|
|
1126
|
+
return errors
|
|
1127
|
+
|
|
348
1128
|
expected_type = schema.get("type")
|
|
349
1129
|
if expected_type is not None:
|
|
350
1130
|
if not _matches_type(value, expected_type):
|
|
@@ -355,6 +1135,9 @@ def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
|
|
|
355
1135
|
if value not in schema["enum"]:
|
|
356
1136
|
errors.append(f"{path}: value {value!r} not in enum {schema['enum']}")
|
|
357
1137
|
|
|
1138
|
+
if "const" in schema and value != schema["const"]:
|
|
1139
|
+
errors.append(f"{path}: value {value!r} is not {schema['const']!r}")
|
|
1140
|
+
|
|
358
1141
|
if isinstance(value, dict):
|
|
359
1142
|
props = schema.get("properties", {})
|
|
360
1143
|
required = set(schema.get("required", []))
|
|
@@ -402,6 +1185,8 @@ def _matches_type(value, expected) -> bool:
|
|
|
402
1185
|
return isinstance(value, bool)
|
|
403
1186
|
if expected == "integer":
|
|
404
1187
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
1188
|
+
if expected == "number":
|
|
1189
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
405
1190
|
if expected == "null":
|
|
406
1191
|
return value is None
|
|
407
1192
|
return False
|
|
@@ -416,39 +1201,661 @@ def _freeze(v):
|
|
|
416
1201
|
return v
|
|
417
1202
|
|
|
418
1203
|
|
|
419
|
-
# ───
|
|
1204
|
+
# ─── workflow schemas ───────────────────────────────────────────────────────
|
|
420
1205
|
|
|
421
1206
|
|
|
422
|
-
def
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1207
|
+
def _schema_version(path: Path, suffix: str = "-result-envelope.v") -> tuple[str, int] | None:
|
|
1208
|
+
"""(stem, version) of an envelope schema file name, or None for another file."""
|
|
1209
|
+
stem, sep, version = path.name[: -len(".json")].rpartition(suffix)
|
|
1210
|
+
if not sep or not version.isdigit():
|
|
1211
|
+
return None
|
|
1212
|
+
return stem, int(version)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _read_schema(path: Path) -> dict | None:
|
|
1216
|
+
try:
|
|
1217
|
+
schema = json.loads(path.read_text(encoding="utf-8"))
|
|
1218
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
1219
|
+
return None
|
|
1220
|
+
return schema if isinstance(schema, dict) else None
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def load_workflow_schema(workflow: str) -> tuple[dict, Path]:
|
|
1224
|
+
"""Return (schema, path) of the newest envelope schema of `workflow`.
|
|
1225
|
+
|
|
1226
|
+
A schema belongs to the workflow its settings name (`skf-update-skill`),
|
|
1227
|
+
and answers to its file stem too (`skf-update` for
|
|
1228
|
+
skf-update-result-envelope.v1.json). The stem is tried first, so an
|
|
1229
|
+
unreadable sibling schema never stops another workflow's halt.
|
|
1230
|
+
"""
|
|
1231
|
+
candidates = sorted(SCHEMA_DIR.glob(f"{workflow}-result-envelope.v*.json"))
|
|
1232
|
+
found = []
|
|
1233
|
+
for path in candidates:
|
|
1234
|
+
parsed = _schema_version(path)
|
|
1235
|
+
if parsed is None or parsed[0] != workflow:
|
|
1236
|
+
continue
|
|
1237
|
+
schema = _read_schema(path)
|
|
1238
|
+
if schema is None:
|
|
1239
|
+
_die(2, f"schema file unreadable: {path.name}")
|
|
1240
|
+
found.append((parsed[1], schema, path))
|
|
1241
|
+
if not found:
|
|
1242
|
+
for path in sorted(SCHEMA_DIR.glob("*-result-envelope.v*.json")):
|
|
1243
|
+
parsed = _schema_version(path)
|
|
1244
|
+
schema = _read_schema(path) if parsed else None
|
|
1245
|
+
meta = _meta_of(schema)
|
|
1246
|
+
if meta is not None and meta.get("workflow") == workflow:
|
|
1247
|
+
found.append((parsed[1], schema, path))
|
|
1248
|
+
if not found:
|
|
1249
|
+
_die(1, f"no result-envelope schema for workflow {workflow!r} in {SCHEMA_DIR.as_posix()}")
|
|
1250
|
+
_, schema, path = max(found, key=lambda item: item[0])
|
|
1251
|
+
_meta(schema, path)
|
|
1252
|
+
return schema, path
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _meta_of(schema) -> dict | None:
|
|
1256
|
+
"""A schema's emitter settings, the `const` of its `$defs` entry META_KEY, or None."""
|
|
1257
|
+
defs = schema.get("$defs") if isinstance(schema, dict) else None
|
|
1258
|
+
entry = defs.get(META_KEY) if isinstance(defs, dict) else None
|
|
1259
|
+
meta = entry.get("const") if isinstance(entry, dict) else None
|
|
1260
|
+
return meta if isinstance(meta, dict) else None
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _meta(schema: dict, path: Path | None = None) -> dict:
|
|
1264
|
+
"""The schema's emitter settings, checked for the fields the emitter needs."""
|
|
1265
|
+
meta = _meta_of(schema)
|
|
1266
|
+
name = path.name if path else "schema"
|
|
1267
|
+
if meta is None:
|
|
1268
|
+
_die(2, f"{name}: no $defs.{META_KEY}.const object")
|
|
1269
|
+
for key in ("workflow", "prefix"):
|
|
1270
|
+
if not isinstance(meta.get(key), str) or not meta[key]:
|
|
1271
|
+
_die(2, f"{name}: {META_KEY}.{key} must be a non-empty string")
|
|
1272
|
+
wrapper = meta.get("wrapper")
|
|
1273
|
+
if wrapper is not None and not isinstance(schema.get("properties", {}).get(wrapper), dict):
|
|
1274
|
+
_die(2, f"{name}: {META_KEY}.wrapper {wrapper!r} is not a property of the schema")
|
|
1275
|
+
return meta
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _inner_schema(schema: dict) -> dict:
|
|
1279
|
+
"""The schema of the envelope's fields: under the wrapper, or the whole schema."""
|
|
1280
|
+
wrapper = (_meta_of(schema) or {}).get("wrapper")
|
|
1281
|
+
return schema["properties"][wrapper] if wrapper else schema
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _allows(prop: dict, type_name: str) -> bool:
|
|
1285
|
+
"""True when the property schema accepts a value of JSON type `type_name`."""
|
|
1286
|
+
branches = prop.get("oneOf") or prop.get("anyOf")
|
|
1287
|
+
if branches:
|
|
1288
|
+
return any(_allows(branch, type_name) for branch in branches)
|
|
1289
|
+
declared = prop.get("type")
|
|
1290
|
+
if isinstance(declared, list):
|
|
1291
|
+
return type_name in declared
|
|
1292
|
+
return declared == type_name
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _object_branch(prop: dict) -> dict | None:
|
|
1296
|
+
"""The object schema of a property that may also be null (`error`)."""
|
|
1297
|
+
if prop.get("type") == "object" or "properties" in prop:
|
|
1298
|
+
return prop
|
|
1299
|
+
for branch in prop.get("oneOf") or prop.get("anyOf") or []:
|
|
1300
|
+
if isinstance(branch, dict) and branch.get("type") == "object":
|
|
1301
|
+
return branch
|
|
1302
|
+
return None
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _placeholder(prop: dict, halt: bool):
|
|
1306
|
+
"""The value a required field the payload left out takes, or _MISSING.
|
|
1307
|
+
|
|
1308
|
+
The schema's `default` first, then null where the schema allows it. A
|
|
1309
|
+
halt knows little about its run, so it also takes an empty list, false
|
|
1310
|
+
or 0 by type; a finished run must supply those itself.
|
|
1311
|
+
"""
|
|
1312
|
+
if "default" in prop:
|
|
1313
|
+
return json.loads(json.dumps(prop["default"]))
|
|
1314
|
+
if _allows(prop, "null"):
|
|
1315
|
+
return None
|
|
1316
|
+
if halt:
|
|
1317
|
+
if _allows(prop, "array"):
|
|
1318
|
+
return []
|
|
1319
|
+
if _allows(prop, "boolean"):
|
|
1320
|
+
return False
|
|
1321
|
+
if _allows(prop, "integer") or _allows(prop, "number"):
|
|
1322
|
+
return 0
|
|
1323
|
+
if _allows(prop, "object") and not prop.get("required"):
|
|
1324
|
+
return {}
|
|
1325
|
+
return _MISSING
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _ordered(value, schema):
|
|
1329
|
+
"""Order an object's keys as its schema lists them (unknown keys last)."""
|
|
1330
|
+
if isinstance(value, dict) and isinstance(schema, dict):
|
|
1331
|
+
branch = _object_branch(schema) or schema
|
|
1332
|
+
props = branch.get("properties") or {}
|
|
1333
|
+
keys = [k for k in props if k in value] + [k for k in value if k not in props]
|
|
1334
|
+
return {k: _ordered(value[k], props.get(k, {})) for k in keys}
|
|
1335
|
+
if isinstance(value, list) and isinstance(schema, dict) and isinstance(schema.get("items"), dict):
|
|
1336
|
+
return [_ordered(item, schema["items"]) for item in value]
|
|
1337
|
+
return value
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
# ─── run sink ───────────────────────────────────────────────────────────────
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def read_sink(run_dir: Path | None) -> tuple[list[tuple[str, dict]], list[str], list[str]]:
|
|
1344
|
+
"""Return (decisions, warnings, problems) the run recorded in its sink.
|
|
1345
|
+
|
|
1346
|
+
decisions pairs each object with its `<file>:<line>` location. A line
|
|
1347
|
+
that is not a JSON object (decisions) or a non-empty string (warnings),
|
|
1348
|
+
such as a write cut short, is left out and named in problems. Lines end
|
|
1349
|
+
at a newline only: str.splitlines() would also cut a line at a raw
|
|
1350
|
+
RAW_LINE_BREAKS character inside a string, which a sink line written by
|
|
1351
|
+
hand may hold.
|
|
1352
|
+
"""
|
|
1353
|
+
decisions: list[tuple[str, dict]] = []
|
|
1354
|
+
warnings: list[str] = []
|
|
1355
|
+
problems: list[str] = []
|
|
1356
|
+
if run_dir is None:
|
|
1357
|
+
return decisions, warnings, problems
|
|
1358
|
+
for name, kind in ((SINK_DECISIONS, dict), (SINK_WARNINGS, str)):
|
|
1359
|
+
try:
|
|
1360
|
+
text = (run_dir / name).read_text(encoding="utf-8")
|
|
1361
|
+
except FileNotFoundError:
|
|
1362
|
+
continue
|
|
1363
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
1364
|
+
problems.append(f"sink_line_unreadable: {name}: {e}")
|
|
1365
|
+
continue
|
|
1366
|
+
for number, line in enumerate(text.split("\n"), 1):
|
|
1367
|
+
if not line.strip():
|
|
1368
|
+
continue
|
|
1369
|
+
try:
|
|
1370
|
+
value = json.loads(line)
|
|
1371
|
+
except json.JSONDecodeError:
|
|
1372
|
+
value = None
|
|
1373
|
+
if not isinstance(value, kind) or not value:
|
|
1374
|
+
problems.append(f"sink_line_unreadable: {name}:{number}")
|
|
1375
|
+
elif kind is dict:
|
|
1376
|
+
decisions.append((f"{name}:{number}", value))
|
|
1377
|
+
else:
|
|
1378
|
+
warnings.append(value.strip())
|
|
1379
|
+
return decisions, warnings, problems
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _decision_schema(schema: dict) -> dict | None:
|
|
1383
|
+
"""The item schema of the envelope's headless_decisions, when it has one."""
|
|
1384
|
+
prop = _inner_schema(schema).get("properties", {}).get("headless_decisions")
|
|
1385
|
+
if not isinstance(prop, dict):
|
|
1386
|
+
return None
|
|
1387
|
+
items = prop.get("items")
|
|
1388
|
+
return items if isinstance(items, dict) else {}
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _merged(*lists) -> list:
|
|
1392
|
+
"""Concatenate the lists, keeping the first of any duplicate entries."""
|
|
1393
|
+
out, seen = [], set()
|
|
1394
|
+
for items in lists:
|
|
1395
|
+
for item in items:
|
|
1396
|
+
key = json.dumps(item, sort_keys=True, ensure_ascii=False)
|
|
1397
|
+
if key not in seen:
|
|
1398
|
+
seen.add(key)
|
|
1399
|
+
out.append(item)
|
|
1400
|
+
return out
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _payload_list(payload: dict, key: str, kind: type) -> list:
|
|
1404
|
+
value = payload.get(key)
|
|
1405
|
+
if value is None:
|
|
1406
|
+
return []
|
|
1407
|
+
if not isinstance(value, list) or not all(isinstance(item, kind) for item in value):
|
|
1408
|
+
_die(1, f"{key} must be a list of {'objects' if kind is dict else 'strings'}")
|
|
1409
|
+
return value
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
# ─── clock ──────────────────────────────────────────────────────────────────
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def _utc_parts(seconds: int) -> tuple[int, int, int, int, int, int]:
|
|
1416
|
+
"""(year, month, day, hour, minute, second) of a Unix time, in UTC."""
|
|
1417
|
+
days, rest = divmod(seconds, 86400)
|
|
1418
|
+
# Civil date from a day count (Howard Hinnant's days_from_civil inverse).
|
|
1419
|
+
days += 719468
|
|
1420
|
+
era = days // 146097
|
|
1421
|
+
doe = days - era * 146097
|
|
1422
|
+
yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
|
|
1423
|
+
doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
|
|
1424
|
+
mp = (5 * doy + 2) // 153
|
|
1425
|
+
day = doy - (153 * mp + 2) // 5 + 1
|
|
1426
|
+
month = mp + 3 if mp < 10 else mp - 9
|
|
1427
|
+
year = yoe + era * 400 + (1 if month <= 2 else 0)
|
|
1428
|
+
return year, month, day, rest // 3600, rest % 3600 // 60, rest % 60
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _is_file_stamp(value) -> bool:
|
|
1432
|
+
"""True for a `YYYYMMDD-HHmmss` stamp, the form a run folder's name may end with."""
|
|
1433
|
+
if not isinstance(value, str) or len(value) != 15 or value[8] != "-":
|
|
1434
|
+
return False
|
|
1435
|
+
digits = value[:8] + value[9:]
|
|
1436
|
+
return digits.isascii() and digits.isdigit()
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _stamps(seconds: int) -> tuple[str, str]:
|
|
1440
|
+
"""(ISO-8601 UTC timestamp, YYYYMMDD-HHmmss file stamp) of a Unix time."""
|
|
1441
|
+
y, mo, d, h, mi, s = _utc_parts(seconds)
|
|
1442
|
+
return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{s:02d}Z", f"{y:04d}{mo:02d}{d:02d}-{h:02d}{mi:02d}{s:02d}"
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
# ─── generic envelope assembly ──────────────────────────────────────────────
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _derived_exit_code(meta: dict, halt_reason):
|
|
1449
|
+
"""The exit code the settings map `halt_reason` to, or None when they map none."""
|
|
1450
|
+
if halt_reason is None:
|
|
1451
|
+
return meta.get("success_exit_code", 0)
|
|
1452
|
+
if not isinstance(halt_reason, str):
|
|
1453
|
+
# Not a key the map can hold; the schema check names the bad value.
|
|
1454
|
+
return None
|
|
1455
|
+
return (meta.get("exit_codes") or {}).get(halt_reason)
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def tolerant_payload(schema: dict, payload: dict) -> tuple[dict, list[str]]:
|
|
1459
|
+
"""The payload without what its envelope cannot take, and a warning for each drop.
|
|
1460
|
+
|
|
1461
|
+
For a caller that keeps an older helper's tolerance: a key the envelope
|
|
1462
|
+
has no field for is dropped (`payload_key_ignored: <key>`), and so is an
|
|
1463
|
+
exit_code the schema's exit_codes map decides, which the emitter then
|
|
1464
|
+
derives from halt_reason (`exit_code_overridden: <given> (halt_reason
|
|
1465
|
+
<reason> maps to <code>)` when the two differ). Such a payload still
|
|
1466
|
+
yields an envelope instead of a refusal.
|
|
1467
|
+
"""
|
|
1468
|
+
meta = _meta_of(schema) or {}
|
|
1469
|
+
props = _inner_schema(schema).get("properties", {})
|
|
1470
|
+
kept = {k: v for k, v in payload.items() if k in props or k in PAYLOAD_ONLY_KEYS}
|
|
1471
|
+
notes = [f"payload_key_ignored: {k}" for k in payload if k not in kept]
|
|
1472
|
+
if meta.get("exit_codes") and "exit_code" in kept:
|
|
1473
|
+
reason = kept.get("halt_reason")
|
|
1474
|
+
code = _derived_exit_code(meta, reason)
|
|
1475
|
+
if code is not None:
|
|
1476
|
+
given = kept.pop("exit_code")
|
|
1477
|
+
if json.dumps(given) != json.dumps(code):
|
|
1478
|
+
notes.append(f"exit_code_overridden: {json.dumps(given)} "
|
|
1479
|
+
f"(halt_reason {reason if reason is not None else 'null'} maps to {code})")
|
|
1480
|
+
return kept, notes
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def build_envelope(schema: dict, payload: dict, *, halt: bool, stamps: dict | None = None,
|
|
1484
|
+
decisions: list | None = None, warnings: list | None = None) -> dict:
|
|
1485
|
+
"""Assemble a workflow's envelope from its payload (see the module docstring).
|
|
1486
|
+
|
|
1487
|
+
`stamps` holds the timestamp, run_id and result_path the run supplies,
|
|
1488
|
+
None where it has none; `decisions` and `warnings` are the run's full
|
|
1489
|
+
lists, the payload's own entries included. Pure apart from _die.
|
|
1490
|
+
"""
|
|
1491
|
+
meta = _meta_of(schema)
|
|
1492
|
+
stamps = stamps or {}
|
|
1493
|
+
decisions = decisions if decisions is not None else _payload_list(payload, "headless_decisions", dict)
|
|
1494
|
+
warnings = warnings if warnings is not None else _merged(
|
|
1495
|
+
_payload_list(payload, "warnings", str), _resolver_warning(payload))
|
|
1496
|
+
if meta["workflow"] == SETUP_WORKFLOW:
|
|
1497
|
+
return _setup_envelope(payload, halt, warnings)
|
|
1498
|
+
|
|
1499
|
+
inner_schema = _inner_schema(schema)
|
|
1500
|
+
props = inner_schema.get("properties", {})
|
|
1501
|
+
required = inner_schema.get("required", [])
|
|
1502
|
+
skip = PAYLOAD_ONLY_KEYS + (HALT_KEYS if halt else ())
|
|
1503
|
+
body = {k: v for k, v in payload.items() if k not in skip}
|
|
1504
|
+
if halt:
|
|
1505
|
+
for key in HALT_KEYS:
|
|
1506
|
+
if key in props and key in payload:
|
|
1507
|
+
body[key] = payload[key]
|
|
1508
|
+
if "error" in props and "error" not in body:
|
|
1509
|
+
branch = _object_branch(props["error"])
|
|
1510
|
+
if branch is not None:
|
|
1511
|
+
error = {name: payload[ERROR_FIELDS[name]] for name in branch.get("properties", {})
|
|
1512
|
+
if name in ERROR_FIELDS and payload.get(ERROR_FIELDS[name]) is not None}
|
|
1513
|
+
if "path" in branch.get("required", []) and "path" not in error:
|
|
1514
|
+
error["path"] = "<n/a>"
|
|
1515
|
+
body["error"] = error
|
|
1516
|
+
if "status" in props and "status" not in body and meta.get("halt_status"):
|
|
1517
|
+
body["status"] = meta["halt_status"]
|
|
1518
|
+
|
|
1519
|
+
for key in ("timestamp", "run_id", "result_path"):
|
|
1520
|
+
if key not in props:
|
|
1521
|
+
continue
|
|
1522
|
+
value = stamps.get(key)
|
|
1523
|
+
if value is not None:
|
|
1524
|
+
body[key] = value
|
|
1525
|
+
elif _allows(props[key], "null"):
|
|
1526
|
+
body[key] = None
|
|
1527
|
+
else:
|
|
1528
|
+
body.pop(key, None)
|
|
1529
|
+
for key, values in (("headless_decisions", decisions), ("warnings", warnings)):
|
|
1530
|
+
body.pop(key, None)
|
|
1531
|
+
if key in props and (values or key in required):
|
|
1532
|
+
body[key] = list(values)
|
|
1533
|
+
if "exit_code" in props and "exit_code" not in body:
|
|
1534
|
+
# A halt reads its halt_reason even where the schema has no field for
|
|
1535
|
+
# it, and never falls back to the success code.
|
|
1536
|
+
reason = body["halt_reason"] if "halt_reason" in body else payload.get("halt_reason")
|
|
1537
|
+
code = _derived_exit_code(meta, reason) if (reason is not None or not halt) else None
|
|
1538
|
+
if code is not None:
|
|
1539
|
+
body["exit_code"] = code
|
|
1540
|
+
for key in required:
|
|
1541
|
+
# exit_code is given or derived above, never a placeholder.
|
|
1542
|
+
if key not in body and key != "exit_code":
|
|
1543
|
+
value = _placeholder(props.get(key, {}), halt)
|
|
1544
|
+
if value is not _MISSING:
|
|
1545
|
+
body[key] = value
|
|
1546
|
+
body = _ordered(body, inner_schema)
|
|
1547
|
+
return {meta["wrapper"]: body} if meta.get("wrapper") else body
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _setup_envelope(payload: dict, halt: bool, warnings: list) -> dict:
|
|
1551
|
+
"""skf-setup's envelope through its own builders, with the run's warnings added.
|
|
1552
|
+
|
|
1553
|
+
Setup's own warnings stay as its builders made them, repeats included;
|
|
1554
|
+
the run's are appended when setup's list does not already hold them.
|
|
1555
|
+
"""
|
|
1556
|
+
if halt:
|
|
1557
|
+
_check_halt_payload(payload, "emit-halt")
|
|
1558
|
+
envelope = assemble_blocked_envelope(payload["phase"], payload["reason"], payload.get("path"))
|
|
1559
|
+
else:
|
|
1560
|
+
envelope = assemble_envelope(payload)
|
|
1561
|
+
inner = envelope["skf_setup"]
|
|
1562
|
+
inner["warnings"] += [w for w in warnings if w not in inner["warnings"]]
|
|
1563
|
+
return envelope
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def _check_halt_payload(payload: dict, label: str) -> None:
|
|
1567
|
+
for key in ("phase", "reason"):
|
|
1568
|
+
value = payload.get(key)
|
|
1569
|
+
if not isinstance(value, str) or not value:
|
|
1570
|
+
_die(1, f"{label}: {key!r} must be a non-empty string")
|
|
1571
|
+
path = payload.get("path")
|
|
1572
|
+
if path is not None and not isinstance(path, str):
|
|
1573
|
+
_die(1, f"{label}: 'path' must be a string or omitted")
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def contract_errors(schema: dict, envelope) -> list[str]:
|
|
1577
|
+
"""Schema violations, then the rules of the emitter settings JSON Schema cannot state."""
|
|
426
1578
|
errors = _validate_against_schema(envelope, schema)
|
|
427
1579
|
if errors:
|
|
428
|
-
|
|
429
|
-
|
|
1580
|
+
return errors
|
|
1581
|
+
meta = _meta_of(schema) or {}
|
|
1582
|
+
wrapper = meta.get("wrapper")
|
|
1583
|
+
inner = envelope.get(wrapper) if wrapper else envelope
|
|
1584
|
+
props = _inner_schema(schema).get("properties", {}) if meta else {}
|
|
1585
|
+
if not isinstance(inner, dict):
|
|
1586
|
+
return errors
|
|
1587
|
+
halt_status = meta.get("halt_status")
|
|
1588
|
+
if halt_status and "status" in props and "halt_reason" in props:
|
|
1589
|
+
status, reason = inner.get("status"), inner.get("halt_reason")
|
|
1590
|
+
if status == halt_status and reason is None:
|
|
1591
|
+
errors.append(f"halt_reason must be set when status is {halt_status!r}")
|
|
1592
|
+
if status != halt_status and reason is not None:
|
|
1593
|
+
errors.append(f"halt_reason must be null when status is {status!r}; got {reason!r}")
|
|
1594
|
+
if meta.get("exit_codes") and "exit_code" in inner:
|
|
1595
|
+
reason = inner.get("halt_reason")
|
|
1596
|
+
expected = _derived_exit_code(meta, reason) if (reason is not None or "success_exit_code" in meta) else None
|
|
1597
|
+
if expected is not None and inner["exit_code"] != expected:
|
|
1598
|
+
errors.append(f"exit_code {inner['exit_code']!r} does not match canonical mapping "
|
|
1599
|
+
f"for halt_reason {reason!r} (expected {expected})")
|
|
1600
|
+
return errors
|
|
1601
|
+
|
|
430
1602
|
|
|
1603
|
+
def envelope_line(schema: dict, envelope: dict) -> str:
|
|
1604
|
+
"""The prefixed one-line form: ASCII JSON, as skf-brief-skill's helper printed it.
|
|
431
1605
|
|
|
432
|
-
|
|
1606
|
+
skf-setup's keeps its sorted keys and raw UTF-8 (emit_envelope_line).
|
|
1607
|
+
"""
|
|
1608
|
+
meta = _meta_of(schema)
|
|
1609
|
+
if meta["workflow"] == SETUP_WORKFLOW:
|
|
1610
|
+
return emit_envelope_line(envelope)
|
|
1611
|
+
return f"{meta['prefix']}: " + json.dumps(envelope, separators=(",", ":"), ensure_ascii=True)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
# ─── result files ───────────────────────────────────────────────────────────
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _is_dir(path: Path | None) -> bool:
|
|
1618
|
+
"""Path.is_dir() that reads a folder it may not stat as no folder."""
|
|
1619
|
+
try:
|
|
1620
|
+
return path is not None and path.is_dir()
|
|
1621
|
+
except OSError:
|
|
1622
|
+
return False
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _claim_result_path(result_dir: Path, stem: str, stamp: str) -> Path:
|
|
1626
|
+
"""Create the run's per-run record, empty, under a name no run holds yet.
|
|
1627
|
+
|
|
1628
|
+
`<stem>-<stamp>.json` when it is free, else `-2`, `-3`, ... before
|
|
1629
|
+
`.json`: two runs that end in the same second never overwrite each
|
|
1630
|
+
other's record. The exclusive create makes each claim atomic.
|
|
1631
|
+
"""
|
|
1632
|
+
for n in range(1, 1000):
|
|
1633
|
+
path = result_dir / (f"{stem}-{stamp}.json" if n == 1 else f"{stem}-{stamp}-{n}.json")
|
|
1634
|
+
try:
|
|
1635
|
+
os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
|
|
1636
|
+
except FileExistsError:
|
|
1637
|
+
continue
|
|
1638
|
+
return path
|
|
1639
|
+
raise OSError(f"no free file name for {stem}-{stamp}.json")
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _write_json_atomic(path: Path, value) -> None:
|
|
1643
|
+
"""Write `value` as indented JSON through a temporary file and one rename."""
|
|
1644
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
1645
|
+
try:
|
|
1646
|
+
with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
|
|
1647
|
+
json.dump(value, fh, indent=2, ensure_ascii=False)
|
|
1648
|
+
fh.write("\n")
|
|
1649
|
+
os.replace(tmp, path)
|
|
1650
|
+
except OSError:
|
|
1651
|
+
try:
|
|
1652
|
+
tmp.unlink()
|
|
1653
|
+
except OSError:
|
|
1654
|
+
pass
|
|
1655
|
+
raise
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def _result_record(payload: dict, envelope: dict, timestamp, run_id, decisions: list, warnings: list):
|
|
1659
|
+
"""The result file's content: the stamped result contract, or the envelope."""
|
|
1660
|
+
contract = payload.get("result_contract")
|
|
1661
|
+
if contract is None:
|
|
1662
|
+
return envelope
|
|
1663
|
+
record = dict(contract)
|
|
1664
|
+
for key in CONTRACT_DEFAULTS:
|
|
1665
|
+
if key not in record and key in payload:
|
|
1666
|
+
record[key] = payload[key]
|
|
1667
|
+
record["timestamp"] = timestamp
|
|
1668
|
+
if run_id is not None:
|
|
1669
|
+
record["run_id"] = run_id
|
|
1670
|
+
record["headless_decisions"] = list(decisions)
|
|
1671
|
+
record["warnings"] = list(warnings)
|
|
1672
|
+
return record
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
# ─── subcommands ────────────────────────────────────────────────────────────
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def run_emit(workflow: str | None, *, halt: bool, label: str, run_dir: str | None = None,
|
|
1679
|
+
result_dir: str | None = None, target: str = "stdout", tolerant: bool = False,
|
|
1680
|
+
payload: dict | None = None, project_root: str | None = None,
|
|
1681
|
+
sidecar_path: str | None = None, forge_data_folder: str | None = None) -> None:
|
|
1682
|
+
"""Read the payload on stdin, build, check and print the envelope, write the result files.
|
|
1683
|
+
|
|
1684
|
+
`tolerant` passes the payload through tolerant_payload() first, for a
|
|
1685
|
+
caller that keeps an older helper's tolerance. A `payload` given here
|
|
1686
|
+
is used instead of stdin (emit-blocked's options). `project_root`,
|
|
1687
|
+
`sidecar_path` and `forge_data_folder` give skf-setup's paths
|
|
1688
|
+
(setup_paths()).
|
|
1689
|
+
"""
|
|
1690
|
+
schema, schema_path = load_workflow_schema(workflow or SETUP_WORKFLOW)
|
|
1691
|
+
meta = _meta_of(schema)
|
|
1692
|
+
workflow = meta["workflow"]
|
|
1693
|
+
if (project_root, sidecar_path, forge_data_folder) != (None, None, None) and (
|
|
1694
|
+
workflow != SETUP_WORKFLOW or halt):
|
|
1695
|
+
_die(1, f"{label}: --project-root, --sidecar-path and --forge-data-folder give the paths of a "
|
|
1696
|
+
"finished skf-setup run")
|
|
1697
|
+
if payload is None:
|
|
1698
|
+
payload = _read_stdin_json(label)
|
|
1699
|
+
if not isinstance(payload, dict):
|
|
1700
|
+
_die(1, f"{label}: the payload must be a JSON object")
|
|
1701
|
+
if halt:
|
|
1702
|
+
_check_halt_payload(payload, label)
|
|
1703
|
+
contract = payload.get("result_contract")
|
|
1704
|
+
if contract is not None and not isinstance(contract, dict):
|
|
1705
|
+
_die(1, f"{label}: result_contract must be an object")
|
|
1706
|
+
tolerated: list[str] = []
|
|
1707
|
+
if tolerant:
|
|
1708
|
+
payload, tolerated = tolerant_payload(schema, payload)
|
|
1709
|
+
|
|
1710
|
+
extra: list[str] = []
|
|
1711
|
+
stem = meta.get("result_file")
|
|
1712
|
+
run_path = Path(run_dir) if run_dir else None
|
|
1713
|
+
if workflow == SETUP_WORKFLOW and not halt:
|
|
1714
|
+
payload = {**fold_staged(payload, read_staged(run_path)),
|
|
1715
|
+
**setup_paths(project_root, sidecar_path, forge_data_folder)}
|
|
1716
|
+
result_path_dir = Path(result_dir) if result_dir else None
|
|
1717
|
+
if result_path_dir is not None and not stem:
|
|
1718
|
+
# Refusing would leave a halt that passed the flag by mistake with no line.
|
|
1719
|
+
extra.append(f"result_dir_ignored: {workflow} writes no result file")
|
|
1720
|
+
result_path_dir = None
|
|
1721
|
+
writes = _is_dir(result_path_dir)
|
|
1722
|
+
run_id = None
|
|
1723
|
+
if run_path is not None:
|
|
1724
|
+
name, prefix = run_path.name, f"{workflow}-"
|
|
1725
|
+
run_id = name[len(prefix):] if name.startswith(prefix) and len(name) > len(prefix) else name
|
|
1726
|
+
|
|
1727
|
+
timestamp = file_stamp = None
|
|
1728
|
+
if writes or "timestamp" in _inner_schema(schema).get("properties", {}):
|
|
1729
|
+
timestamp, file_stamp = _stamps(int(time.time()))
|
|
1730
|
+
if meta.get("result_stamp") == "run_id" and _is_file_stamp(run_id):
|
|
1731
|
+
file_stamp = run_id
|
|
1732
|
+
|
|
1733
|
+
sink_decisions, sink_warnings, problems = read_sink(run_path)
|
|
1734
|
+
item_schema = _decision_schema(schema)
|
|
1735
|
+
valid = []
|
|
1736
|
+
for where, decision in sink_decisions:
|
|
1737
|
+
errors = _validate_against_schema(decision, item_schema) if item_schema else []
|
|
1738
|
+
if errors:
|
|
1739
|
+
problems.append(f"headless_decision_invalid: {where}: {errors[0]}")
|
|
1740
|
+
else:
|
|
1741
|
+
valid.append(decision)
|
|
1742
|
+
decisions = _merged(_payload_list(payload, "headless_decisions", dict), valid)
|
|
1743
|
+
|
|
1744
|
+
per_run = None
|
|
1745
|
+
if writes:
|
|
1746
|
+
try:
|
|
1747
|
+
per_run = _claim_result_path(result_path_dir, stem, file_stamp)
|
|
1748
|
+
except OSError as e:
|
|
1749
|
+
extra.append(f"result_file_write_failed: {result_path_dir.as_posix()}: {e.strerror or e}")
|
|
1750
|
+
|
|
1751
|
+
def assemble() -> tuple[dict, list]:
|
|
1752
|
+
warnings = _merged(_payload_list(payload, "warnings", str), _resolver_warning(payload),
|
|
1753
|
+
sink_warnings, problems, tolerated, extra)
|
|
1754
|
+
stamps = {"timestamp": timestamp, "run_id": run_id,
|
|
1755
|
+
"result_path": per_run.as_posix() if per_run else None}
|
|
1756
|
+
envelope = build_envelope(schema, payload, halt=halt, stamps=stamps,
|
|
1757
|
+
decisions=decisions, warnings=warnings)
|
|
1758
|
+
errors = contract_errors(schema, envelope)
|
|
1759
|
+
if errors:
|
|
1760
|
+
if per_run is not None:
|
|
1761
|
+
try:
|
|
1762
|
+
per_run.unlink()
|
|
1763
|
+
except OSError:
|
|
1764
|
+
pass
|
|
1765
|
+
code = 2 if workflow == SETUP_WORKFLOW else 1
|
|
1766
|
+
_die(code, f"{label}: the {workflow} envelope fails {schema_path.name}: {'; '.join(errors)}")
|
|
1767
|
+
return envelope, warnings
|
|
1768
|
+
|
|
1769
|
+
envelope, warnings = assemble()
|
|
1770
|
+
if per_run is not None:
|
|
1771
|
+
record = _result_record(payload, envelope, timestamp, run_id, decisions, warnings)
|
|
1772
|
+
latest = result_path_dir / f"{stem}-latest.json"
|
|
1773
|
+
for path in (per_run, latest):
|
|
1774
|
+
try:
|
|
1775
|
+
_write_json_atomic(path, record)
|
|
1776
|
+
except OSError as e:
|
|
1777
|
+
extra.append(f"result_file_write_failed: {path.as_posix()}: {e.strerror or e}")
|
|
1778
|
+
if path == per_run:
|
|
1779
|
+
try:
|
|
1780
|
+
per_run.unlink()
|
|
1781
|
+
except OSError:
|
|
1782
|
+
pass
|
|
1783
|
+
per_run = None
|
|
1784
|
+
break
|
|
1785
|
+
if extra:
|
|
1786
|
+
envelope, _ = assemble()
|
|
1787
|
+
print(envelope_line(schema, envelope), file=sys.stderr if target == "stderr" else sys.stdout)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def cmd_record(args) -> None:
|
|
1791
|
+
"""Append one decision or one warning to the run's sink, one JSON value per line."""
|
|
1792
|
+
run_dir = Path(args.run_dir)
|
|
1793
|
+
if args.warning is not None:
|
|
1794
|
+
value = args.warning.strip()
|
|
1795
|
+
if not value:
|
|
1796
|
+
_die(1, "record: --warning must be a non-empty string")
|
|
1797
|
+
name = SINK_WARNINGS
|
|
1798
|
+
else:
|
|
1799
|
+
value = _read_stdin_json("record")
|
|
1800
|
+
if not isinstance(value, dict) or not value:
|
|
1801
|
+
_die(1, "record: --decision reads one non-empty JSON object on stdin")
|
|
1802
|
+
if args.workflow:
|
|
1803
|
+
schema, schema_path = load_workflow_schema(args.workflow)
|
|
1804
|
+
item_schema = _decision_schema(schema)
|
|
1805
|
+
if item_schema is None:
|
|
1806
|
+
_die(1, f"record: {schema_path.name} has no headless_decisions")
|
|
1807
|
+
errors = _validate_against_schema(value, item_schema)
|
|
1808
|
+
if errors:
|
|
1809
|
+
_die(1, f"record: the decision fails {schema_path.name}: {'; '.join(errors)}")
|
|
1810
|
+
name = SINK_DECISIONS
|
|
1811
|
+
# ASCII only: a raw RAW_LINE_BREAKS character would split the line for a
|
|
1812
|
+
# reader that breaks lines there.
|
|
1813
|
+
line = json.dumps(value, separators=(",", ":"), ensure_ascii=True) + "\n"
|
|
1814
|
+
try:
|
|
1815
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1816
|
+
with open(run_dir / name, "a", encoding="utf-8", newline="\n") as fh:
|
|
1817
|
+
fh.write(line)
|
|
1818
|
+
except OSError as e:
|
|
1819
|
+
_die(1, f"record: cannot append to {(run_dir / name).as_posix()}: {e.strerror or e}")
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def cmd_validate(workflow: str | None) -> None:
|
|
433
1823
|
envelope = _read_stdin_json("validate")
|
|
434
|
-
schema =
|
|
435
|
-
errors =
|
|
1824
|
+
schema, _ = load_workflow_schema(workflow or SETUP_WORKFLOW)
|
|
1825
|
+
errors = contract_errors(schema, envelope)
|
|
436
1826
|
if errors:
|
|
437
1827
|
_die(1, "; ".join(errors))
|
|
438
1828
|
|
|
439
1829
|
|
|
1830
|
+
def cmd_render_report(run_dir: str | None, tier_rules: str | None, project_root: str | None = None,
|
|
1831
|
+
sidecar_path: str | None = None, forge_data_folder: str | None = None) -> None:
|
|
1832
|
+
"""Print skf-setup's FORGE STATUS banner for the payload on stdin."""
|
|
1833
|
+
payload = _read_stdin_json("render-report")
|
|
1834
|
+
if not isinstance(payload, dict):
|
|
1835
|
+
_die(1, "render-report: the payload must be a JSON object")
|
|
1836
|
+
payload = {**fold_staged(payload, read_staged(Path(run_dir) if run_dir else None)),
|
|
1837
|
+
**setup_paths(project_root, sidecar_path, forge_data_folder)}
|
|
1838
|
+
copy = load_tier_rules(Path(tier_rules) if tier_rules else TIER_RULES_FILE)
|
|
1839
|
+
print("\n".join(render_report(payload, copy)))
|
|
1840
|
+
|
|
1841
|
+
|
|
440
1842
|
def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None) -> dict:
|
|
441
|
-
"""Assemble a minimal status='blocked' envelope for
|
|
1843
|
+
"""Assemble a minimal status='blocked' envelope for a halt.
|
|
442
1844
|
|
|
443
|
-
Used
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
1845
|
+
Used by every halt that names a phase (On Activation and steps 1, 1b,
|
|
1846
|
+
2 and 3), which has no complete run to report. Every other required
|
|
1847
|
+
field gets a placeholder, as the schema describes, so the envelope
|
|
1848
|
+
still passes schema validation and pipelines can branch on
|
|
1849
|
+
`status: "blocked"` and inspect `error` for context.
|
|
448
1850
|
"""
|
|
1851
|
+
error = {
|
|
1852
|
+
"phase": phase,
|
|
1853
|
+
"path": path or "<n/a>",
|
|
1854
|
+
"reason": reason,
|
|
1855
|
+
}
|
|
449
1856
|
return {
|
|
450
1857
|
"skf_setup": {
|
|
451
|
-
"status":
|
|
1858
|
+
"status": _compute_status(error, None),
|
|
452
1859
|
"tier": "Quick",
|
|
453
1860
|
"previous_tier": None,
|
|
454
1861
|
"tier_changed": False,
|
|
@@ -462,42 +1869,84 @@ def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None)
|
|
|
462
1869
|
"tier_override_invalid": False,
|
|
463
1870
|
"require_tier_satisfied": None,
|
|
464
1871
|
"warnings": [],
|
|
465
|
-
"error":
|
|
466
|
-
"phase": phase,
|
|
467
|
-
"path": path or "<n/a>",
|
|
468
|
-
"reason": reason,
|
|
469
|
-
},
|
|
1872
|
+
"error": error,
|
|
470
1873
|
}
|
|
471
1874
|
}
|
|
472
1875
|
|
|
473
1876
|
|
|
474
|
-
|
|
475
|
-
|
|
1877
|
+
# The --reason placeholder --stderr-from fills, and its text when stderr holds no diagnostic.
|
|
1878
|
+
STDERR_MESSAGE = "<message>"
|
|
1879
|
+
NO_STDERR_MESSAGE = "(no error message)"
|
|
1880
|
+
|
|
476
1881
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1882
|
+
def stderr_message(text: str) -> str:
|
|
1883
|
+
"""The diagnostic a failed command's stderr holds, folded to one line.
|
|
1884
|
+
|
|
1885
|
+
The `message` of a JSON error object (the whole text, else the last line
|
|
1886
|
+
that holds one: a helper prints its error after any line `uv` printed),
|
|
1887
|
+
else the first non-blank line, else NO_STDERR_MESSAGE.
|
|
480
1888
|
"""
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1889
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
1890
|
+
for candidate in [text, *reversed(lines)]:
|
|
1891
|
+
try:
|
|
1892
|
+
value = json.loads(candidate)
|
|
1893
|
+
except json.JSONDecodeError:
|
|
1894
|
+
continue
|
|
1895
|
+
message = value.get("message") if isinstance(value, dict) else None
|
|
1896
|
+
if isinstance(message, str) and message.strip():
|
|
1897
|
+
return " ".join(message.split())
|
|
1898
|
+
return " ".join(lines[0].split()) if lines else NO_STDERR_MESSAGE
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _read_stderr(source: str) -> str:
|
|
1902
|
+
"""The text of --stderr-from: stdin for `-`, else the file; bytes that are not UTF-8 replaced."""
|
|
1903
|
+
try:
|
|
1904
|
+
raw = sys.stdin.buffer.read() if source == "-" else Path(source).read_bytes()
|
|
1905
|
+
except OSError:
|
|
1906
|
+
return ""
|
|
1907
|
+
return raw.decode("utf-8", "replace").lstrip("\ufeff")
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def blocked_payload(phase: str | None, reason: str | None, path: str | None,
|
|
1911
|
+
stderr_from: str | None, resolver_reason: str | None = None) -> dict | None:
|
|
1912
|
+
"""emit-blocked's halt payload from its options; None without --phase (stdin carries it)."""
|
|
1913
|
+
if phase is None:
|
|
1914
|
+
if (reason, path, stderr_from, resolver_reason) != (None, None, None, None):
|
|
1915
|
+
_die(1, "emit-blocked: --reason, --path, --stderr-from and --customization-resolver-unavailable "
|
|
1916
|
+
"need --phase")
|
|
1917
|
+
return None
|
|
1918
|
+
if not phase.strip() or not (reason or "").strip():
|
|
1919
|
+
_die(1, "emit-blocked: --phase and --reason must be non-empty")
|
|
1920
|
+
if stderr_from is None:
|
|
1921
|
+
if STDERR_MESSAGE in reason:
|
|
1922
|
+
_die(1, f"emit-blocked: {STDERR_MESSAGE} in --reason needs --stderr-from to fill it")
|
|
1923
|
+
else:
|
|
1924
|
+
if STDERR_MESSAGE not in reason:
|
|
1925
|
+
_die(1, f"emit-blocked: --stderr-from fills {STDERR_MESSAGE} in --reason, which has none")
|
|
1926
|
+
reason = reason.replace(STDERR_MESSAGE, stderr_message(_read_stderr(stderr_from)))
|
|
1927
|
+
payload = {"phase": phase, "reason": " ".join(reason.split())}
|
|
1928
|
+
if path:
|
|
1929
|
+
payload["path"] = path
|
|
1930
|
+
if resolver_reason and resolver_reason.strip():
|
|
1931
|
+
payload["customization_resolver_unavailable"] = " ".join(resolver_reason.split())
|
|
1932
|
+
return payload
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
def cmd_emit_blocked(args) -> None:
|
|
1936
|
+
"""Emit skf-setup's blocked envelope: `emit-halt --workflow skf-setup`.
|
|
1937
|
+
|
|
1938
|
+
Designed for the halts (uv missing, config.yaml missing, a failed write,
|
|
1939
|
+
etc.) where the regular `emit` subcommand can't run because the run has
|
|
1940
|
+
no complete tier/tools/config_path to report. The payload comes from
|
|
1941
|
+
the options when --phase is given, else from stdin.
|
|
1942
|
+
"""
|
|
1943
|
+
payload = blocked_payload(args.phase, args.reason, args.path, args.stderr_from,
|
|
1944
|
+
args.customization_resolver_unavailable)
|
|
1945
|
+
run_emit(SETUP_WORKFLOW, halt=True, label="emit-blocked", payload=payload)
|
|
497
1946
|
|
|
498
1947
|
|
|
499
1948
|
def _force_utf8(*streams) -> None:
|
|
500
|
-
"""Reconfigure JSON-carrying streams to UTF-8
|
|
1949
|
+
"""Reconfigure JSON-carrying streams to UTF-8.
|
|
501
1950
|
|
|
502
1951
|
A default Windows console decodes stdio as cp1252, which cannot carry
|
|
503
1952
|
non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
|
|
@@ -516,24 +1965,73 @@ def _force_utf8(*streams) -> None:
|
|
|
516
1965
|
|
|
517
1966
|
|
|
518
1967
|
def main() -> None:
|
|
519
|
-
_force_utf8(sys.stdin, sys.stdout)
|
|
1968
|
+
_force_utf8(sys.stdin, sys.stdout, sys.stderr)
|
|
520
1969
|
parser = argparse.ArgumentParser(
|
|
521
1970
|
description=__doc__,
|
|
522
1971
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
523
1972
|
)
|
|
524
1973
|
sub = parser.add_subparsers(dest="cmd")
|
|
525
|
-
sub.add_parser("emit", help="Build envelope from context payload (default).")
|
|
526
|
-
sub.add_parser("emit-
|
|
527
|
-
sub.add_parser("
|
|
1974
|
+
p_emit = sub.add_parser("emit", help="Build a run's envelope from its context payload (default).")
|
|
1975
|
+
p_halt = sub.add_parser("emit-halt", help="Build a HARD HALT's envelope from its halt payload.")
|
|
1976
|
+
p_blocked = sub.add_parser("emit-blocked", help="Emit skf-setup's status='blocked' envelope for early-halt paths.")
|
|
1977
|
+
p_record = sub.add_parser("record", help="Append an auto-decision or a warning to the run's sink.")
|
|
1978
|
+
p_validate = sub.add_parser("validate", help="Validate an envelope payload against the schema.")
|
|
1979
|
+
p_render = sub.add_parser("render-report", help="Print skf-setup's FORGE STATUS banner for its payload.")
|
|
1980
|
+
for p in (p_emit, p_halt):
|
|
1981
|
+
p.add_argument("--workflow", required=p is p_halt, default=None,
|
|
1982
|
+
help="Workflow folder name or schema stem (emit: skf-setup when omitted).")
|
|
1983
|
+
p.add_argument("--run-dir", default=None,
|
|
1984
|
+
help="The run folder that holds the sink (and skf-setup's staged helper outputs).")
|
|
1985
|
+
p.add_argument("--result-dir", default=None,
|
|
1986
|
+
help="Write the per-run and -latest result files here when the folder exists.")
|
|
1987
|
+
p.add_argument("--target", choices=["stdout", "stderr"], default="stdout",
|
|
1988
|
+
help="Stream for the envelope line (default stdout).")
|
|
1989
|
+
p_record.add_argument("--run-dir", required=True, help="The run folder that holds the sink.")
|
|
1990
|
+
p_record.add_argument("--workflow", default=None,
|
|
1991
|
+
help="Check a decision against this workflow's headless_decisions schema.")
|
|
1992
|
+
kind = p_record.add_mutually_exclusive_group(required=True)
|
|
1993
|
+
kind.add_argument("--decision", action="store_true", help="Read one decision object on stdin.")
|
|
1994
|
+
kind.add_argument("--warning", default=None, help="The warning text.")
|
|
1995
|
+
p_validate.add_argument("--workflow", default=None,
|
|
1996
|
+
help="Workflow folder name or schema stem (skf-setup when omitted).")
|
|
1997
|
+
p_render.add_argument("--run-dir", default=None, help="The setup run folder that holds the staged helper outputs.")
|
|
1998
|
+
p_render.add_argument("--tier-rules", default=None,
|
|
1999
|
+
help="skf-setup's references/tier-rules.md (default: beside this script's folder).")
|
|
2000
|
+
for p in (p_emit, p_render):
|
|
2001
|
+
p.add_argument("--project-root", default=None, help="skf-setup: the project root (project_root).")
|
|
2002
|
+
p.add_argument("--sidecar-path", default=None,
|
|
2003
|
+
help="skf-setup: the sidecar folder activation bound (needs --project-root); "
|
|
2004
|
+
"config_path is the forge-tier.yaml in it.")
|
|
2005
|
+
p.add_argument("--forge-data-folder", default=None,
|
|
2006
|
+
help="skf-setup: the forge data folder activation bound (needs --project-root).")
|
|
2007
|
+
p_blocked.add_argument("--phase", default=None,
|
|
2008
|
+
help="The halt's phase; with it the payload comes from these options, not stdin.")
|
|
2009
|
+
p_blocked.add_argument("--reason", default=None, help="The halt's reason (with --phase).")
|
|
2010
|
+
p_blocked.add_argument("--path", default=None, help="The file or folder the halt concerns (optional).")
|
|
2011
|
+
p_blocked.add_argument("--stderr-from", default=None, metavar="FILE|-",
|
|
2012
|
+
help="The failed command's stderr, a file or - for stdin: its diagnostic fills "
|
|
2013
|
+
"<message> in --reason.")
|
|
2014
|
+
p_blocked.add_argument("--customization-resolver-unavailable", default=None, metavar="REASON",
|
|
2015
|
+
help="The On Activation resolver's failure reason (with --phase): the envelope warns "
|
|
2016
|
+
"that the _bmad/custom/ overrides were not applied.")
|
|
528
2017
|
args = parser.parse_args()
|
|
529
2018
|
|
|
530
2019
|
cmd = args.cmd or "emit"
|
|
531
|
-
if cmd
|
|
532
|
-
|
|
2020
|
+
if cmd in ("emit", "emit-halt"):
|
|
2021
|
+
run_emit(getattr(args, "workflow", None), halt=cmd == "emit-halt", label=cmd,
|
|
2022
|
+
run_dir=getattr(args, "run_dir", None), result_dir=getattr(args, "result_dir", None),
|
|
2023
|
+
target=getattr(args, "target", "stdout"), project_root=getattr(args, "project_root", None),
|
|
2024
|
+
sidecar_path=getattr(args, "sidecar_path", None),
|
|
2025
|
+
forge_data_folder=getattr(args, "forge_data_folder", None))
|
|
533
2026
|
elif cmd == "emit-blocked":
|
|
534
|
-
cmd_emit_blocked()
|
|
2027
|
+
cmd_emit_blocked(args)
|
|
2028
|
+
elif cmd == "record":
|
|
2029
|
+
cmd_record(args)
|
|
535
2030
|
elif cmd == "validate":
|
|
536
|
-
cmd_validate()
|
|
2031
|
+
cmd_validate(args.workflow)
|
|
2032
|
+
elif cmd == "render-report":
|
|
2033
|
+
cmd_render_report(args.run_dir, args.tier_rules, args.project_root, args.sidecar_path,
|
|
2034
|
+
args.forge_data_folder)
|
|
537
2035
|
|
|
538
2036
|
|
|
539
2037
|
if __name__ == "__main__":
|