bmad-module-skill-forge 2.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +49 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +143 -18
- package/docs/verifying-a-skill.md +87 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +3 -3
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +31 -59
- package/src/shared/references/description-guard-protocol.md +12 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +233 -64
- package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +772 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
- package/src/shared/scripts/skf-description-guard.py +99 -11
- package/src/shared/scripts/skf-detect-docs.py +694 -2
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
- package/src/shared/scripts/skf-detect-tools.py +209 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +5247 -96
- package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +700 -0
- package/src/shared/scripts/skf-hash-content.py +180 -20
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +161 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +176 -28
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +787 -121
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +120 -67
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
- package/src/shared/scripts/skf-write-skill-brief.py +406 -25
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +149 -210
- package/src/skf-audit-skill/references/re-index.md +103 -110
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +34 -25
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +80 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
- package/src/skf-brief-skill/SKILL.md +37 -26
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +18 -25
- package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +144 -127
- package/src/skf-brief-skill/references/confirm-brief.md +11 -9
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +7 -10
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
- package/src/skf-create-skill/assets/skill-sections.md +21 -11
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +119 -70
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
- package/src/skf-create-skill/references/extract.md +204 -162
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
- package/src/skf-create-skill/references/extraction-patterns.md +588 -201
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +37 -57
- package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +28 -19
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +83 -146
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +122 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -90
- package/src/skf-setup/references/detect-and-tier.md +58 -67
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +209 -264
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +164 -91
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +164 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +42 -155
- package/src/skf-test-skill/references/source-access-protocol.md +18 -94
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +380 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
- package/src/skf-test-skill/scripts/score-signatures.py +479 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +253 -223
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +254 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +182 -49
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +102 -35
- package/src/skf-update-skill/references/write.md +357 -155
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -679
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Run Lock: one lock file per run, held across tool calls.
|
|
6
|
+
|
|
7
|
+
Every Bash tool call runs in a fresh shell, and an SKF workflow spans many
|
|
8
|
+
calls and turns. A lock that lives in a process (a held `flock`, the `$$`
|
|
9
|
+
PID that a later `kill -0` tests) is gone by the next call, so it can never
|
|
10
|
+
serialize two runs. This helper keeps the lock in a file instead: acquire
|
|
11
|
+
writes the owner and the time into the lock file, release deletes it only
|
|
12
|
+
for the run that owns it, and a lock older than the stale timeout counts as
|
|
13
|
+
left by a run that ended without releasing it.
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
|
|
17
|
+
uv run skf-run-lock.py acquire --lock <path> \\
|
|
18
|
+
--owner <workflow>:<skill>:<run_id> [--stale-after <minutes>]
|
|
19
|
+
uv run skf-run-lock.py release --lock <path> \\
|
|
20
|
+
--owner <workflow>:<skill>:<run_id>
|
|
21
|
+
uv run skf-run-lock.py run-id
|
|
22
|
+
|
|
23
|
+
--lock is the full path of the lock file. Name it by its full path in every
|
|
24
|
+
call, never through a shell variable an earlier call set.
|
|
25
|
+
|
|
26
|
+
--owner names the run: the workflow, the skill and a run id, joined by
|
|
27
|
+
colons (`update-skill:cocoindex:20260930T101500Z-3f9a1c2e`). When the run id
|
|
28
|
+
is missing (`update-skill:cocoindex`), acquire adds a new one and prints the
|
|
29
|
+
whole owner; pass that owner to release.
|
|
30
|
+
|
|
31
|
+
acquire
|
|
32
|
+
|
|
33
|
+
Takes the lock when no lock file exists, when the lock is this owner's
|
|
34
|
+
own (its time is renewed: a run that may outlast the timeout re-runs
|
|
35
|
+
acquire to keep its lock), or when the lock is stale: older than
|
|
36
|
+
--stale-after minutes (60 by default, at most 525600: one year). A stale
|
|
37
|
+
lock is replaced, and stale_replaced says whose it was. Any other lock is
|
|
38
|
+
held by another run: nothing changes, and the output names that run.
|
|
39
|
+
Missing parent folders of --lock are created.
|
|
40
|
+
|
|
41
|
+
A lock file that is not this helper's record (the PID and time lines that
|
|
42
|
+
SKF versions before 3.0.0 wrote, the empty file an `flock` left) names no
|
|
43
|
+
owner: held_by is null, and its time is the ISO time on its second line
|
|
44
|
+
when there is one, else the file's modification time. It goes stale the
|
|
45
|
+
same way.
|
|
46
|
+
|
|
47
|
+
Output (one ASCII line; every key is always present):
|
|
48
|
+
acquired true | false
|
|
49
|
+
refreshed true when the lock was already this owner's
|
|
50
|
+
lock absolute path of the lock file
|
|
51
|
+
owner the owner this call used, with its run id
|
|
52
|
+
run_id the run id in owner
|
|
53
|
+
held_by the owner holding the lock after this call (this
|
|
54
|
+
owner when acquired), or null for a lock that names
|
|
55
|
+
no owner
|
|
56
|
+
held_since ISO UTC time the holder took or renewed the lock
|
|
57
|
+
stale_at ISO UTC time the holder's lock goes stale
|
|
58
|
+
stale_after_minutes the timeout this call applied
|
|
59
|
+
stale_replaced null | {"held_by": ..., "held_since": ...}: the
|
|
60
|
+
stale lock this call replaced
|
|
61
|
+
message one line for the user; when acquired is false it
|
|
62
|
+
names the lock file to delete when no run is active
|
|
63
|
+
|
|
64
|
+
release
|
|
65
|
+
|
|
66
|
+
Deletes the lock only when --owner holds it. A lock another run holds
|
|
67
|
+
(one that replaced this run's stale lock, or one taken while this run
|
|
68
|
+
never acquired) is left in place, so a halt path may always release.
|
|
69
|
+
--owner is the whole owner acquire printed: acquire always writes a run
|
|
70
|
+
id, so an owner without one could never match and is a bad argument.
|
|
71
|
+
|
|
72
|
+
Output (one ASCII line; every key is always present):
|
|
73
|
+
released true | false
|
|
74
|
+
reason null | "absent" (no lock file) | "not-owner"
|
|
75
|
+
lock absolute path of the lock file
|
|
76
|
+
owner the owner given
|
|
77
|
+
held_by the owner still holding the lock (not-owner), else null
|
|
78
|
+
held_since its time (not-owner), else null
|
|
79
|
+
message one line
|
|
80
|
+
|
|
81
|
+
run-id
|
|
82
|
+
|
|
83
|
+
Prints {"run_id": "<UTC stamp>-<8 hex>"}, for example
|
|
84
|
+
`20260930T101500Z-3f9a1c2e`: the time of the call and a random suffix,
|
|
85
|
+
safe in file names.
|
|
86
|
+
|
|
87
|
+
Lock file: one JSON line, {"owner": ..., "acquired_at": ..., "tool":
|
|
88
|
+
"skf-run-lock"}, written to `<lock>.skf-<8 hex>-tmp` and renamed over the
|
|
89
|
+
lock, so it is never seen half written. Each acquire and release reads and
|
|
90
|
+
changes the lock while it holds `<lock>.skf-guard`, an OS lock
|
|
91
|
+
(`fcntl.flock` on POSIX, `msvcrt.locking` on Windows) that lives only for
|
|
92
|
+
that one call and is released if the call dies, so two runs that start
|
|
93
|
+
together never both take the lock and a release never deletes a lock
|
|
94
|
+
another run just took. The guard file is removed when the call ends. Every
|
|
95
|
+
name this helper creates beside the lock carries `.skf-`, which
|
|
96
|
+
skf-skill-inventory.py counts as SKF's own.
|
|
97
|
+
|
|
98
|
+
Library use: skf-forge-tier-rw.py loads this file by its path and calls
|
|
99
|
+
acquire_within() and release() around one read-modify-write. It passes
|
|
100
|
+
empty_is_stale=True: an empty lock file is what an `flock` on the same
|
|
101
|
+
path leaves (this helper never writes one), so it is replaced at once
|
|
102
|
+
instead of when it goes stale.
|
|
103
|
+
|
|
104
|
+
Exit codes (1 and 2 print one JSON line on stderr, {"status": "error",
|
|
105
|
+
"message": ...}, and nothing on stdout):
|
|
106
|
+
0 acquire took or renewed the lock; release and run-id answered
|
|
107
|
+
1 bad arguments: a missing or unknown argument, an owner that is not
|
|
108
|
+
<workflow>:<skill>:<run_id> (acquire adds a missing run id, release
|
|
109
|
+
needs it), a --stale-after that is not a number of minutes above 0
|
|
110
|
+
and at most 525600, a --lock that is a folder
|
|
111
|
+
2 the lock could not be read or written
|
|
112
|
+
3 acquire found the lock held by another run; the output says which
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
from __future__ import annotations
|
|
116
|
+
|
|
117
|
+
import argparse
|
|
118
|
+
import errno
|
|
119
|
+
import json
|
|
120
|
+
import math
|
|
121
|
+
import os
|
|
122
|
+
import re
|
|
123
|
+
import secrets
|
|
124
|
+
import sys
|
|
125
|
+
import time
|
|
126
|
+
from datetime import datetime, timedelta, timezone
|
|
127
|
+
from pathlib import Path
|
|
128
|
+
|
|
129
|
+
if os.name == "nt":
|
|
130
|
+
import msvcrt
|
|
131
|
+
else:
|
|
132
|
+
import fcntl
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
DEFAULT_STALE_AFTER_MIN = 60
|
|
136
|
+
MAX_STALE_AFTER_MIN = 525600 # one year
|
|
137
|
+
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
138
|
+
RUN_ID_STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
139
|
+
RUN_ID_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{8}$")
|
|
140
|
+
OWNER_MAX_LEN = 200
|
|
141
|
+
GUARD_SUFFIX = ".skf-guard"
|
|
142
|
+
GUARD_WAIT_SEC = 10.0
|
|
143
|
+
EXIT_HELD = 3
|
|
144
|
+
TOOL = "skf-run-lock"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class LockBusy(Exception):
|
|
148
|
+
"""acquire_within() ran out of time; .result is the last acquire output."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, result: dict):
|
|
151
|
+
super().__init__(result["message"])
|
|
152
|
+
self.result = result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ─── Owner, time and run id ──────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def new_run_id(now: datetime | None = None) -> str:
|
|
159
|
+
"""A run id: the UTC time and 8 random hex characters."""
|
|
160
|
+
now = now or datetime.now(timezone.utc)
|
|
161
|
+
return f"{now.strftime(RUN_ID_STAMP_FORMAT)}-{secrets.token_hex(4)}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _stamp(t: datetime) -> str:
|
|
165
|
+
return t.astimezone(timezone.utc).strftime(TIME_FORMAT)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _stale_at(since: datetime, window: timedelta) -> str:
|
|
169
|
+
"""The time a lock taken at `since` goes stale, or the last time a datetime can hold."""
|
|
170
|
+
try:
|
|
171
|
+
return _stamp(since + window)
|
|
172
|
+
except OverflowError:
|
|
173
|
+
# A lock file dated near year 9999: it only goes stale when deleted.
|
|
174
|
+
return _stamp(datetime.max.replace(tzinfo=timezone.utc))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_time(text) -> datetime | None:
|
|
178
|
+
if not isinstance(text, str):
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return datetime.strptime(text.strip(), TIME_FORMAT).replace(tzinfo=timezone.utc)
|
|
182
|
+
except ValueError:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _split_owner(owner: str) -> tuple[str, str, str]:
|
|
187
|
+
"""(workflow, skill, run_id) of an owner; run_id is "" when missing.
|
|
188
|
+
|
|
189
|
+
Raises ValueError for an owner that is not `<workflow>:<skill>[:<run_id>]`.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(owner, str) or not owner or len(owner) > OWNER_MAX_LEN:
|
|
192
|
+
raise ValueError(f"--owner must be <workflow>:<skill>:<run_id>, at most "
|
|
193
|
+
f"{OWNER_MAX_LEN} characters with its run id")
|
|
194
|
+
if any(ch.isspace() or not ch.isprintable() for ch in owner):
|
|
195
|
+
raise ValueError(f"--owner must not hold spaces or control characters: {owner!r}")
|
|
196
|
+
parts = owner.split(":", 2)
|
|
197
|
+
if len(parts) < 2 or not parts[0] or not parts[1]:
|
|
198
|
+
raise ValueError(f"--owner must be <workflow>:<skill>:<run_id>, got {owner!r}")
|
|
199
|
+
return parts[0], parts[1], parts[2] if len(parts) == 3 else ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _lock_path(lock) -> Path:
|
|
203
|
+
path = Path(os.path.abspath(os.path.expanduser(str(lock))))
|
|
204
|
+
if path.is_dir():
|
|
205
|
+
raise ValueError(f"--lock names a folder, not a lock file: {path}")
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _minutes(seconds: float):
|
|
210
|
+
minutes = seconds / 60
|
|
211
|
+
return int(minutes) if minutes == int(minutes) else round(minutes, 4)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ─── The guard: an OS lock for one call ──────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _try_lock(fd: int) -> bool:
|
|
218
|
+
try:
|
|
219
|
+
if os.name == "nt":
|
|
220
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
221
|
+
else:
|
|
222
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
223
|
+
except OSError as e:
|
|
224
|
+
if e.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK, errno.EWOULDBLOCK):
|
|
225
|
+
return False
|
|
226
|
+
raise
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _unlock(fd: int) -> None:
|
|
231
|
+
try:
|
|
232
|
+
if os.name == "nt":
|
|
233
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
234
|
+
else:
|
|
235
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
236
|
+
except OSError:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _still_named(fd: int, guard: Path) -> bool:
|
|
241
|
+
"""True when `guard` still names the file fd holds.
|
|
242
|
+
|
|
243
|
+
On POSIX the holder removes the guard while it still holds it, so a call
|
|
244
|
+
that was waiting on the removed file must open the path again. Windows
|
|
245
|
+
refuses to remove a file another call has open, so there the path always
|
|
246
|
+
names the file.
|
|
247
|
+
"""
|
|
248
|
+
if os.name == "nt":
|
|
249
|
+
return True
|
|
250
|
+
try:
|
|
251
|
+
named = os.stat(guard)
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
return False
|
|
254
|
+
held = os.fstat(fd)
|
|
255
|
+
return (held.st_dev, held.st_ino) == (named.st_dev, named.st_ino)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _Guard:
|
|
259
|
+
"""Hold `<lock>.skf-guard` while one call reads and changes the lock."""
|
|
260
|
+
|
|
261
|
+
def __init__(self, lock: Path):
|
|
262
|
+
self.path = lock.with_name(lock.name + GUARD_SUFFIX)
|
|
263
|
+
self.fd: int | None = None
|
|
264
|
+
|
|
265
|
+
def __enter__(self) -> "_Guard":
|
|
266
|
+
deadline = time.monotonic() + GUARD_WAIT_SEC
|
|
267
|
+
while True:
|
|
268
|
+
try:
|
|
269
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o644)
|
|
270
|
+
except PermissionError:
|
|
271
|
+
# Windows: the file is being removed by the call that held it.
|
|
272
|
+
if os.name != "nt" or time.monotonic() >= deadline:
|
|
273
|
+
raise
|
|
274
|
+
time.sleep(0.01)
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
locked = _try_lock(fd)
|
|
278
|
+
if locked and _still_named(fd, self.path):
|
|
279
|
+
self.fd = fd
|
|
280
|
+
return self
|
|
281
|
+
except BaseException:
|
|
282
|
+
os.close(fd)
|
|
283
|
+
raise
|
|
284
|
+
if locked:
|
|
285
|
+
_unlock(fd)
|
|
286
|
+
os.close(fd)
|
|
287
|
+
if time.monotonic() >= deadline:
|
|
288
|
+
raise OSError(errno.EAGAIN, f"{self.path} stayed locked for "
|
|
289
|
+
f"{GUARD_WAIT_SEC:g} seconds")
|
|
290
|
+
time.sleep(0.01)
|
|
291
|
+
|
|
292
|
+
def __exit__(self, *exc) -> None:
|
|
293
|
+
fd, self.fd = self.fd, None
|
|
294
|
+
if os.name != "nt":
|
|
295
|
+
# Remove it while still holding it (see _still_named).
|
|
296
|
+
_unlink_quietly(self.path)
|
|
297
|
+
_unlock(fd)
|
|
298
|
+
os.close(fd)
|
|
299
|
+
else:
|
|
300
|
+
_unlock(fd)
|
|
301
|
+
os.close(fd)
|
|
302
|
+
# Refused while another call has it open; that call removes it.
|
|
303
|
+
_unlink_quietly(self.path)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _unlink_quietly(path: Path) -> None:
|
|
307
|
+
try:
|
|
308
|
+
os.unlink(path)
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _windows_retry(action) -> None:
|
|
314
|
+
"""Run action(); on Windows retry briefly while another process has the file open."""
|
|
315
|
+
for attempt in range(5):
|
|
316
|
+
try:
|
|
317
|
+
action()
|
|
318
|
+
return
|
|
319
|
+
except PermissionError:
|
|
320
|
+
if os.name != "nt" or attempt == 4:
|
|
321
|
+
raise
|
|
322
|
+
time.sleep(0.02 * (attempt + 1))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ─── The lock record ─────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Held:
|
|
329
|
+
"""What a lock file says: its owner (or None), since when, and whether it is empty."""
|
|
330
|
+
|
|
331
|
+
def __init__(self, owner: str | None, since: datetime, empty: bool = False):
|
|
332
|
+
self.owner = owner
|
|
333
|
+
self.since = since
|
|
334
|
+
self.empty = empty
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read(lock: Path) -> _Held | None:
|
|
338
|
+
"""The lock's holder, or None when there is no lock file."""
|
|
339
|
+
try:
|
|
340
|
+
raw = lock.read_bytes()
|
|
341
|
+
mtime = lock.stat().st_mtime
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
return None
|
|
344
|
+
text = raw.decode("utf-8", "replace")
|
|
345
|
+
try:
|
|
346
|
+
record = json.loads(text)
|
|
347
|
+
except ValueError:
|
|
348
|
+
record = None
|
|
349
|
+
if isinstance(record, dict):
|
|
350
|
+
owner = record.get("owner")
|
|
351
|
+
owner = owner if isinstance(owner, str) and owner else None
|
|
352
|
+
since = _parse_time(record.get("acquired_at"))
|
|
353
|
+
else:
|
|
354
|
+
# Not this helper's record: the PID and time lines older SKF versions
|
|
355
|
+
# wrote, an empty file an `flock` left, or anything else.
|
|
356
|
+
owner = None
|
|
357
|
+
lines = text.splitlines()
|
|
358
|
+
since = _parse_time(lines[1]) if len(lines) > 1 else None
|
|
359
|
+
if since is None:
|
|
360
|
+
since = datetime.fromtimestamp(mtime, timezone.utc)
|
|
361
|
+
return _Held(owner, since, empty=not raw)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _write(lock: Path, owner: str, now: datetime) -> None:
|
|
365
|
+
"""Write the record to a temp file beside the lock, then rename it over the lock."""
|
|
366
|
+
record = {"owner": owner, "acquired_at": _stamp(now), "tool": TOOL}
|
|
367
|
+
content = (json.dumps(record) + "\n").encode("utf-8")
|
|
368
|
+
tmp = lock.with_name(f"{lock.name}.skf-{secrets.token_hex(4)}-tmp")
|
|
369
|
+
# O_BINARY (Windows only; 0 elsewhere) keeps the bytes verbatim.
|
|
370
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
371
|
+
try:
|
|
372
|
+
fd = os.open(tmp, flags, 0o644)
|
|
373
|
+
try:
|
|
374
|
+
os.write(fd, content)
|
|
375
|
+
os.fsync(fd)
|
|
376
|
+
finally:
|
|
377
|
+
os.close(fd)
|
|
378
|
+
_windows_retry(lambda: os.replace(tmp, lock))
|
|
379
|
+
except OSError:
|
|
380
|
+
_unlink_quietly(tmp)
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ─── acquire / release ───────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def acquire(lock, owner: str, stale_after: float, now: datetime | None = None,
|
|
388
|
+
*, empty_is_stale: bool = False) -> dict:
|
|
389
|
+
"""Take the lock for owner, or report who holds it.
|
|
390
|
+
|
|
391
|
+
stale_after is in seconds, at most one year. `now` is for tests. With
|
|
392
|
+
empty_is_stale, an empty lock file counts as stale at once. Raises
|
|
393
|
+
ValueError for a bad owner, lock or timeout, OSError when the lock cannot
|
|
394
|
+
be read or written.
|
|
395
|
+
"""
|
|
396
|
+
if (not isinstance(stale_after, (int, float)) or not math.isfinite(stale_after)
|
|
397
|
+
or not 0 < stale_after <= MAX_STALE_AFTER_MIN * 60):
|
|
398
|
+
raise ValueError(f"stale_after must be a number of seconds above 0 and at most "
|
|
399
|
+
f"{MAX_STALE_AFTER_MIN * 60} (one year), got {stale_after!r}")
|
|
400
|
+
workflow, skill, run_id = _split_owner(owner)
|
|
401
|
+
if not run_id:
|
|
402
|
+
run_id = new_run_id(now)
|
|
403
|
+
owner = f"{workflow}:{skill}:{run_id}"
|
|
404
|
+
# Checked again with its run id, so release accepts every owner acquire prints.
|
|
405
|
+
_split_owner(owner)
|
|
406
|
+
path = _lock_path(lock)
|
|
407
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
window = timedelta(seconds=stale_after)
|
|
409
|
+
|
|
410
|
+
with _Guard(path):
|
|
411
|
+
held = _read(path)
|
|
412
|
+
t = now or datetime.now(timezone.utc)
|
|
413
|
+
refreshed = held is not None and held.owner == owner
|
|
414
|
+
replaced = None
|
|
415
|
+
if held is not None and not refreshed:
|
|
416
|
+
if t - held.since < window and not (empty_is_stale and held.empty):
|
|
417
|
+
who = held.owner or "an owner the lock file does not name"
|
|
418
|
+
stale_at = _stale_at(held.since, window)
|
|
419
|
+
return {
|
|
420
|
+
"acquired": False,
|
|
421
|
+
"refreshed": False,
|
|
422
|
+
"lock": str(path),
|
|
423
|
+
"owner": owner,
|
|
424
|
+
"run_id": run_id,
|
|
425
|
+
"held_by": held.owner,
|
|
426
|
+
"held_since": _stamp(held.since),
|
|
427
|
+
"stale_at": stale_at,
|
|
428
|
+
"stale_after_minutes": _minutes(stale_after),
|
|
429
|
+
"stale_replaced": None,
|
|
430
|
+
"message": (f"another run holds {path}: {who} since {_stamp(held.since)}. "
|
|
431
|
+
f"Wait for it to finish; if no run is active, delete {path} "
|
|
432
|
+
f"or wait until {stale_at}, when the lock goes stale."),
|
|
433
|
+
}
|
|
434
|
+
replaced = {"held_by": held.owner, "held_since": _stamp(held.since)}
|
|
435
|
+
# Computed before the write, so no error can follow a lock taken.
|
|
436
|
+
held_since, stale_at = _stamp(t), _stale_at(t, window)
|
|
437
|
+
_write(path, owner, t)
|
|
438
|
+
|
|
439
|
+
if refreshed:
|
|
440
|
+
message = f"renewed the run lock {path} for {owner}"
|
|
441
|
+
elif replaced:
|
|
442
|
+
message = (f"took the run lock {path} for {owner}, replacing the stale lock of "
|
|
443
|
+
f"{replaced['held_by'] or 'an unnamed owner'} (since {replaced['held_since']})")
|
|
444
|
+
else:
|
|
445
|
+
message = f"took the run lock {path} for {owner}"
|
|
446
|
+
return {
|
|
447
|
+
"acquired": True,
|
|
448
|
+
"refreshed": refreshed,
|
|
449
|
+
"lock": str(path),
|
|
450
|
+
"owner": owner,
|
|
451
|
+
"run_id": run_id,
|
|
452
|
+
"held_by": owner,
|
|
453
|
+
"held_since": held_since,
|
|
454
|
+
"stale_at": stale_at,
|
|
455
|
+
"stale_after_minutes": _minutes(stale_after),
|
|
456
|
+
"stale_replaced": replaced,
|
|
457
|
+
"message": message,
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def acquire_within(lock, owner: str, stale_after: float, wait: float,
|
|
462
|
+
*, empty_is_stale: bool = False) -> dict:
|
|
463
|
+
"""acquire(), retried until it takes the lock or `wait` seconds pass.
|
|
464
|
+
|
|
465
|
+
For a lock held inside one call. Raises LockBusy with the last output
|
|
466
|
+
when the time runs out.
|
|
467
|
+
"""
|
|
468
|
+
deadline = time.monotonic() + max(wait, 0)
|
|
469
|
+
delay = 0.05
|
|
470
|
+
while True:
|
|
471
|
+
result = acquire(lock, owner, stale_after, empty_is_stale=empty_is_stale)
|
|
472
|
+
if result["acquired"]:
|
|
473
|
+
return result
|
|
474
|
+
left = deadline - time.monotonic()
|
|
475
|
+
if left <= 0:
|
|
476
|
+
raise LockBusy(result)
|
|
477
|
+
time.sleep(min(delay, left))
|
|
478
|
+
delay = min(delay * 2, 0.5)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def release(lock, owner: str) -> dict:
|
|
482
|
+
"""Delete the lock when owner holds it; leave any other lock in place.
|
|
483
|
+
|
|
484
|
+
Raises ValueError for an owner that is not <workflow>:<skill>:<run_id>.
|
|
485
|
+
"""
|
|
486
|
+
if not _split_owner(owner)[2]:
|
|
487
|
+
raise ValueError(f"release needs the owner acquire printed, with its run id "
|
|
488
|
+
f"(<workflow>:<skill>:<run_id>), got {owner!r}")
|
|
489
|
+
path = _lock_path(lock)
|
|
490
|
+
out = {"released": False, "reason": None, "lock": str(path), "owner": owner,
|
|
491
|
+
"held_by": None, "held_since": None, "message": ""}
|
|
492
|
+
if not path.parent.is_dir() or not os.path.lexists(path):
|
|
493
|
+
out.update(reason="absent", message=f"no run lock at {path}; nothing to release")
|
|
494
|
+
return out
|
|
495
|
+
with _Guard(path):
|
|
496
|
+
held = _read(path)
|
|
497
|
+
if held is None:
|
|
498
|
+
out.update(reason="absent", message=f"no run lock at {path}; nothing to release")
|
|
499
|
+
return out
|
|
500
|
+
if held.owner != owner:
|
|
501
|
+
out.update(reason="not-owner", held_by=held.owner, held_since=_stamp(held.since),
|
|
502
|
+
message=(f"{path} belongs to {held.owner or 'an unnamed owner'}, "
|
|
503
|
+
f"not {owner}; left in place"))
|
|
504
|
+
return out
|
|
505
|
+
_windows_retry(lambda: os.unlink(path))
|
|
506
|
+
out.update(released=True, message=f"released the run lock {path}")
|
|
507
|
+
return out
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _error(message: str) -> None:
|
|
514
|
+
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class _JsonErrorParser(argparse.ArgumentParser):
|
|
518
|
+
"""Report a usage error as a JSON error with exit 1, like every other bad argument.
|
|
519
|
+
|
|
520
|
+
The subcommand parsers are made with this class too, so a missing --lock
|
|
521
|
+
or a --stale-after that is not a number never reaches argparse's own
|
|
522
|
+
plain-text usage and exit 2.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
def error(self, message: str) -> None:
|
|
526
|
+
_error(f"usage error: {self.prog}: {message}")
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
531
|
+
parser = _JsonErrorParser(
|
|
532
|
+
description="Take and release an SKF run lock that holds across tool calls.",
|
|
533
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
534
|
+
)
|
|
535
|
+
sub = parser.add_subparsers(dest="cmd", required=True, metavar="{acquire,release,run-id}")
|
|
536
|
+
|
|
537
|
+
p_acquire = sub.add_parser("acquire", help="take the lock, or report the run that holds it")
|
|
538
|
+
p_acquire.add_argument("--lock", required=True, help="full path of the lock file")
|
|
539
|
+
p_acquire.add_argument("--owner", required=True,
|
|
540
|
+
help="<workflow>:<skill>:<run_id>; a new run id is added when missing")
|
|
541
|
+
p_acquire.add_argument("--stale-after", type=float, default=DEFAULT_STALE_AFTER_MIN,
|
|
542
|
+
help="minutes after which another run's lock is stale "
|
|
543
|
+
"(default 60, at most 525600)")
|
|
544
|
+
|
|
545
|
+
p_release = sub.add_parser("release", help="delete the lock when this owner holds it")
|
|
546
|
+
p_release.add_argument("--lock", required=True, help="full path of the lock file")
|
|
547
|
+
p_release.add_argument("--owner", required=True, help="the owner acquire printed")
|
|
548
|
+
|
|
549
|
+
sub.add_parser("run-id", help="print a new run id")
|
|
550
|
+
return parser
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def main(argv: list[str] | None = None) -> int:
|
|
554
|
+
args = _build_parser().parse_args(argv)
|
|
555
|
+
try:
|
|
556
|
+
if args.cmd == "acquire":
|
|
557
|
+
if not math.isfinite(args.stale_after) or not 0 < args.stale_after <= MAX_STALE_AFTER_MIN:
|
|
558
|
+
raise ValueError(f"--stale-after must be a number of minutes above 0 and at "
|
|
559
|
+
f"most {MAX_STALE_AFTER_MIN} (one year), got {args.stale_after:g}")
|
|
560
|
+
out = acquire(args.lock, args.owner, args.stale_after * 60)
|
|
561
|
+
code = 0 if out["acquired"] else EXIT_HELD
|
|
562
|
+
elif args.cmd == "release":
|
|
563
|
+
out, code = release(args.lock, args.owner), 0
|
|
564
|
+
else:
|
|
565
|
+
out, code = {"run_id": new_run_id()}, 0
|
|
566
|
+
except ValueError as e:
|
|
567
|
+
_error(str(e))
|
|
568
|
+
return 1
|
|
569
|
+
except OSError as e:
|
|
570
|
+
_error(f"{type(e).__name__}: {e}")
|
|
571
|
+
return 2
|
|
572
|
+
print(json.dumps(out))
|
|
573
|
+
return code
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
raise SystemExit(main())
|