bmad-module-skill-forge 1.4.1 → 1.5.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 +0 -8
- package/docs/_data/pinned.yaml +1 -1
- package/docs/_internal/STABILITY.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/examples.md +1 -1
- package/docs/workflows.md +5 -5
- package/package.json +2 -2
- package/src/README.md +1 -1
- package/src/knowledge/ccc-bridge.md +12 -12
- package/src/knowledge/qmd-registry.md +5 -5
- package/src/shared/health-check.md +5 -5
- package/src/shared/references/description-guard-protocol.md +100 -0
- package/src/shared/references/output-contract-schema.md +1 -1
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
- package/src/shared/scripts/skf-build-change-manifest.py +420 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
- package/src/shared/scripts/skf-description-guard.py +359 -0
- package/src/shared/scripts/skf-detect-language.py +2 -2
- package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
- package/src/shared/scripts/skf-detect-tools.py +97 -8
- package/src/shared/scripts/skf-detect-workspaces.py +1 -1
- package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
- package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
- package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
- package/src/shared/scripts/skf-hash-content.py +284 -0
- package/src/shared/scripts/skf-load-provenance.py +295 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +34 -4
- package/src/shared/scripts/skf-pair-intersect.py +250 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
- package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
- package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
- package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
- package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
- package/src/shared/scripts/skf-scan-manifests.py +738 -0
- package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
- package/src/shared/scripts/skf-update-active-symlink.py +315 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
- package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
- package/src/shared/scripts/skf-write-skill-brief.py +61 -5
- package/src/skf-analyze-source/SKILL.md +69 -16
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +55 -0
- package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
- package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
- package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
- package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
- package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
- package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
- package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
- package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
- package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
- package/src/skf-audit-skill/SKILL.md +66 -15
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
- package/src/skf-audit-skill/customize.toml +49 -0
- package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
- package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +57 -19
- package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
- package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
- package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
- package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
- package/src/skf-audit-skill/references/severity-rules.md +2 -0
- package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -17
- package/src/skf-brief-skill/SKILL.md +48 -18
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
- package/src/skf-brief-skill/customize.toml +44 -0
- package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
- package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
- package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
- package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
- package/src/skf-brief-skill/references/headless-args.md +9 -9
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
- package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
- package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
- package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
- package/src/skf-brief-skill/references/version-resolution.md +2 -2
- package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
- package/src/skf-create-skill/SKILL.md +25 -19
- package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
- package/src/skf-create-skill/assets/skill-sections.md +5 -5
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
- package/src/skf-create-skill/customize.toml +35 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
- package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
- package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
- package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
- package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
- package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
- package/src/skf-create-skill/references/extraction-patterns.md +8 -8
- package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
- package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
- package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
- package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
- package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
- package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
- package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
- package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
- package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
- package/src/skf-create-stack-skill/SKILL.md +76 -17
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +46 -0
- package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
- package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
- package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
- package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
- package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
- package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
- package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
- package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
- package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
- package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
- package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
- package/src/skf-drop-skill/SKILL.md +84 -12
- package/src/skf-drop-skill/customize.toml +57 -0
- package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
- package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
- package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
- package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
- package/src/skf-export-skill/SKILL.md +85 -16
- package/src/skf-export-skill/assets/managed-section-format.md +3 -3
- package/src/skf-export-skill/assets/snippet-format.md +2 -2
- package/src/skf-export-skill/customize.toml +49 -0
- package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
- package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
- package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
- package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
- package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
- package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
- package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
- package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
- package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +20 -8
- package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
- package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
- package/src/skf-quick-skill/SKILL.md +63 -123
- package/src/skf-quick-skill/customize.toml +44 -0
- package/src/skf-quick-skill/references/batch-mode.md +102 -0
- package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
- package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
- package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
- package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
- package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
- package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
- package/src/skf-refine-architecture/SKILL.md +86 -16
- package/src/skf-refine-architecture/customize.toml +49 -0
- package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
- package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
- package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
- package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
- package/src/skf-refine-architecture/references/init.md +144 -0
- package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
- package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
- package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
- package/src/skf-rename-skill/SKILL.md +82 -12
- package/src/skf-rename-skill/customize.toml +52 -0
- package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
- package/src/skf-rename-skill/references/health-check.md +30 -0
- package/src/skf-rename-skill/references/rebuild-context.md +110 -0
- package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
- package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
- package/src/skf-setup/SKILL.md +35 -35
- package/src/skf-setup/customize.toml +33 -0
- package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
- package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
- package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
- package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
- package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
- package/src/skf-test-skill/SKILL.md +77 -15
- package/src/skf-test-skill/customize.toml +54 -0
- package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
- package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
- package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
- package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
- package/src/skf-test-skill/references/health-check.md +14 -0
- package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
- package/src/skf-test-skill/references/migration-section-rules.md +4 -2
- package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
- package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +7 -5
- package/src/skf-test-skill/scripts/compute-score.py +88 -17
- package/src/skf-test-skill/templates/test-report-template.md +13 -13
- package/src/skf-update-skill/SKILL.md +23 -14
- package/src/skf-update-skill/customize.toml +44 -0
- package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
- package/src/skf-update-skill/references/health-check.md +32 -0
- package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
- package/src/skf-update-skill/references/manual-section-rules.md +4 -0
- package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
- package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
- package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
- package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
- package/src/skf-update-skill/references/report.md +214 -0
- package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
- package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
- package/src/skf-verify-stack/SKILL.md +89 -17
- package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
- package/src/skf-verify-stack/customize.toml +50 -0
- package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
- package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
- package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
- package/src/skf-verify-stack/references/init.md +166 -0
- package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
- package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
- package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
- package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
- package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
- package/tools/validate-docs-drift.js +1 -1
- package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
- package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
- package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
- package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
- package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
- package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Scan Skill.md Structure — deterministic structural checks for SKILL.md.
|
|
6
|
+
|
|
7
|
+
Replaces the synonym-grep loops, bash fence recipes, and inline Python
|
|
8
|
+
table-parser in `references/coherence-check.md` §§2.1 / 2.2 / 2.3 / 2.6 with
|
|
9
|
+
a single subprocess invocation that emits JSON.
|
|
10
|
+
|
|
11
|
+
Subcommands:
|
|
12
|
+
scan <skill-md>
|
|
13
|
+
Emit JSON describing fence balance, bare opening fences (no language
|
|
14
|
+
tag), and table column-count drift:
|
|
15
|
+
{
|
|
16
|
+
"unbalanced_fences": <bool>,
|
|
17
|
+
"fence_count": N,
|
|
18
|
+
"bare_opening_fences": [{"line": N, "text": "..."}, ...],
|
|
19
|
+
"table_drift": [{"line": N, "section": "<heading>",
|
|
20
|
+
"expected_cols": N, "actual_cols": N,
|
|
21
|
+
"row": "..."}]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
scan <skill-md> --required-sections
|
|
25
|
+
Emit JSON describing which of the three required section families
|
|
26
|
+
(description / usage / api_surface) are present, and which synonym
|
|
27
|
+
satisfied the requirement (case-insensitive, `##`/`###` tolerated):
|
|
28
|
+
{
|
|
29
|
+
"description": {"satisfied": <bool>,
|
|
30
|
+
"matched_synonym": "<heading>" | null,
|
|
31
|
+
"tried": ["Description", "Overview", ...]},
|
|
32
|
+
"usage": {"satisfied": <bool>,
|
|
33
|
+
"matched_synonym": "..." | null,
|
|
34
|
+
"tried": [...]},
|
|
35
|
+
"api_surface": {"satisfied": <bool>,
|
|
36
|
+
"matched_synonym": "..." | null,
|
|
37
|
+
"tried": [...]}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Heading match rule:
|
|
41
|
+
Match the first `^#+\\s+<heading>$` (any number of `#`, case-insensitive,
|
|
42
|
+
surrounding whitespace trimmed) that matches any synonym in a family.
|
|
43
|
+
The reported `matched_synonym` is the canonical synonym from the list,
|
|
44
|
+
not the heading text as it appears in the file.
|
|
45
|
+
|
|
46
|
+
Fence balance:
|
|
47
|
+
Count triple-backtick (```) fence lines (`^```` at start of line, ignoring
|
|
48
|
+
leading whitespace). `unbalanced_fences=true` iff the count is odd.
|
|
49
|
+
|
|
50
|
+
Bare opening fence:
|
|
51
|
+
A stateful open/close scan toggles `in_code` on each fence line. A bare
|
|
52
|
+
opening fence is one where `in_code` transitions 0→1 and the line, with
|
|
53
|
+
the leading ``` stripped, has no language tag (empty or whitespace-only
|
|
54
|
+
remainder). Closing fences are never flagged — they are bare by markdown
|
|
55
|
+
convention. This mirrors the Python recipe in coherence-check.md §2.3.
|
|
56
|
+
|
|
57
|
+
Table drift:
|
|
58
|
+
Walks markdown table blocks. A block starts when a `^\\|.*\\|$` line is
|
|
59
|
+
found; subsequent contiguous `^\\|.*\\|$` lines are part of the same
|
|
60
|
+
block. The first row is the header. The second row, if it matches the
|
|
61
|
+
separator pattern (cells made of `-`, `:`, and whitespace), is ignored
|
|
62
|
+
in the drift count. For every other row, normalize escaped pipes (`\\|`)
|
|
63
|
+
to a sentinel before splitting on `|`, then drop the empty leading and
|
|
64
|
+
trailing fields produced by the bracketing pipes; flag rows whose column
|
|
65
|
+
count differs from the header's. Each flag includes the line number,
|
|
66
|
+
the most-recently-seen `^#+\\s+` heading, expected/actual column counts,
|
|
67
|
+
and the raw row text.
|
|
68
|
+
|
|
69
|
+
Escaped pipes appear inside TypeScript union types (e.g.
|
|
70
|
+
`string \\| undefined`). Normalizing prevents one false drift finding
|
|
71
|
+
per union-typed cell.
|
|
72
|
+
|
|
73
|
+
Empty SKILL.md:
|
|
74
|
+
An empty file yields `fence_count: 0`, `unbalanced_fences: false`,
|
|
75
|
+
empty `bare_opening_fences`, empty `table_drift`; for
|
|
76
|
+
`--required-sections`, all three families have `satisfied: false`,
|
|
77
|
+
`matched_synonym: null`.
|
|
78
|
+
|
|
79
|
+
Exit codes:
|
|
80
|
+
0 — operation succeeded
|
|
81
|
+
1 — user error (file not found, can't read)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
from __future__ import annotations
|
|
85
|
+
|
|
86
|
+
import argparse
|
|
87
|
+
import json
|
|
88
|
+
import re
|
|
89
|
+
import sys
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
# Required-section synonym constants
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# These mirror the canonical synonyms documented in
|
|
99
|
+
# `src/skf-test-skill/references/coherence-check.md` §2.1, with the
|
|
100
|
+
# SKF-template-specific headings (`Quick Start`, `Common Workflows`,
|
|
101
|
+
# `Key API Summary`) folded in so they are first-class matches rather
|
|
102
|
+
# than literal-name misses (per the §2.1 "Note" paragraph).
|
|
103
|
+
REQUIRED_SYNONYMS: dict[str, list[str]] = {
|
|
104
|
+
"description": ["Description", "Overview", "Purpose", "Summary"],
|
|
105
|
+
"usage": [
|
|
106
|
+
"Usage",
|
|
107
|
+
"Examples",
|
|
108
|
+
"How to use",
|
|
109
|
+
"Quickstart",
|
|
110
|
+
"Quick Start",
|
|
111
|
+
"Getting Started",
|
|
112
|
+
"Common Workflows",
|
|
113
|
+
],
|
|
114
|
+
"api_surface": [
|
|
115
|
+
"API",
|
|
116
|
+
"API Surface",
|
|
117
|
+
"Exports",
|
|
118
|
+
"Public API",
|
|
119
|
+
"Interface",
|
|
120
|
+
"Reference",
|
|
121
|
+
"Key API Summary",
|
|
122
|
+
],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --------------------------------------------------------------------------
|
|
127
|
+
# Required-section presence
|
|
128
|
+
# --------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_HEADING_RE = re.compile(r"^\s*(#+)\s+(.*?)\s*$")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_required_sections(text: str) -> dict[str, dict]:
|
|
135
|
+
"""For each family, find the first matching heading.
|
|
136
|
+
|
|
137
|
+
Walks every line once, lower-cases the heading text, and looks it up
|
|
138
|
+
against pre-lowered synonym sets. Returns the structure described in
|
|
139
|
+
the module docstring.
|
|
140
|
+
"""
|
|
141
|
+
# Build a lookup: lowered-heading → (family, canonical_synonym)
|
|
142
|
+
# Multiple families can never share a synonym, so a flat dict is fine.
|
|
143
|
+
lookup: dict[str, tuple[str, str]] = {}
|
|
144
|
+
for family, synonyms in REQUIRED_SYNONYMS.items():
|
|
145
|
+
for syn in synonyms:
|
|
146
|
+
lookup[syn.lower()] = (family, syn)
|
|
147
|
+
|
|
148
|
+
matched: dict[str, str] = {}
|
|
149
|
+
for line in text.splitlines():
|
|
150
|
+
m = _HEADING_RE.match(line)
|
|
151
|
+
if not m:
|
|
152
|
+
continue
|
|
153
|
+
heading_text = m.group(2).strip().lower()
|
|
154
|
+
hit = lookup.get(heading_text)
|
|
155
|
+
if hit is None:
|
|
156
|
+
continue
|
|
157
|
+
family, canonical = hit
|
|
158
|
+
if family in matched:
|
|
159
|
+
# first match wins
|
|
160
|
+
continue
|
|
161
|
+
matched[family] = canonical
|
|
162
|
+
|
|
163
|
+
result: dict[str, dict] = {}
|
|
164
|
+
for family, synonyms in REQUIRED_SYNONYMS.items():
|
|
165
|
+
if family in matched:
|
|
166
|
+
result[family] = {
|
|
167
|
+
"satisfied": True,
|
|
168
|
+
"matched_synonym": matched[family],
|
|
169
|
+
"tried": list(synonyms),
|
|
170
|
+
}
|
|
171
|
+
else:
|
|
172
|
+
result[family] = {
|
|
173
|
+
"satisfied": False,
|
|
174
|
+
"matched_synonym": None,
|
|
175
|
+
"tried": list(synonyms),
|
|
176
|
+
}
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
# Fence balance + bare opening fences
|
|
182
|
+
# --------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def scan_fences(text: str) -> tuple[int, bool, list[dict]]:
|
|
186
|
+
"""Count fences, decide balance, collect bare opening fences.
|
|
187
|
+
|
|
188
|
+
Uses a stateful toggle so closing fences are never flagged.
|
|
189
|
+
Returns (fence_count, unbalanced, bare_opening_fences[]).
|
|
190
|
+
"""
|
|
191
|
+
fence_count = 0
|
|
192
|
+
bare: list[dict] = []
|
|
193
|
+
in_code = False
|
|
194
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
195
|
+
# match lines that start with ``` (allow leading whitespace,
|
|
196
|
+
# which markdown tolerates in some renderers)
|
|
197
|
+
stripped = raw.lstrip()
|
|
198
|
+
if not stripped.startswith("```"):
|
|
199
|
+
continue
|
|
200
|
+
fence_count += 1
|
|
201
|
+
# the part of the fence line after the opening ```
|
|
202
|
+
tail = stripped[3:].strip()
|
|
203
|
+
if not in_code:
|
|
204
|
+
# opening fence: a bare opening fence has empty tail
|
|
205
|
+
if tail == "":
|
|
206
|
+
bare.append({"line": lineno, "text": raw})
|
|
207
|
+
in_code = True
|
|
208
|
+
else:
|
|
209
|
+
# closing fence — never flagged
|
|
210
|
+
in_code = False
|
|
211
|
+
return fence_count, (fence_count % 2 == 1), bare
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# Table column drift
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
|
|
220
|
+
_TABLE_SEP_CELL_RE = re.compile(r"^\s*:?-+:?\s*$")
|
|
221
|
+
_PIPE_SENTINEL = "\x00"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _split_row_cells(row_text: str) -> list[str]:
|
|
225
|
+
"""Split a markdown table row into cells.
|
|
226
|
+
|
|
227
|
+
Normalizes escaped pipes to a sentinel before splitting, then drops
|
|
228
|
+
the empty leading/trailing fields produced by the bracketing pipes.
|
|
229
|
+
"""
|
|
230
|
+
normalized = row_text.strip().replace("\\|", _PIPE_SENTINEL)
|
|
231
|
+
parts = normalized.split("|")
|
|
232
|
+
# parts looks like ["", "cell1", "cell2", ..., ""] for `|a|b|`;
|
|
233
|
+
# drop bracketing empties.
|
|
234
|
+
if parts and parts[0].strip() == "":
|
|
235
|
+
parts = parts[1:]
|
|
236
|
+
if parts and parts[-1].strip() == "":
|
|
237
|
+
parts = parts[:-1]
|
|
238
|
+
return [p.replace(_PIPE_SENTINEL, "|") for p in parts]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_separator_row(cells: list[str]) -> bool:
|
|
242
|
+
"""True if every cell looks like a table separator (`---`, `:---:`, etc.)."""
|
|
243
|
+
if not cells:
|
|
244
|
+
return False
|
|
245
|
+
return all(_TABLE_SEP_CELL_RE.match(c) is not None for c in cells)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def find_table_drift(text: str) -> list[dict]:
|
|
249
|
+
"""Walk table blocks, flag rows whose column count differs from the header.
|
|
250
|
+
|
|
251
|
+
Tracks the most-recently-seen heading text so each finding can name the
|
|
252
|
+
section it's in. The heading text reported is the raw text after the `#`
|
|
253
|
+
characters, with surrounding whitespace stripped.
|
|
254
|
+
"""
|
|
255
|
+
findings: list[dict] = []
|
|
256
|
+
lines = text.splitlines()
|
|
257
|
+
current_section = ""
|
|
258
|
+
i = 0
|
|
259
|
+
while i < len(lines):
|
|
260
|
+
line = lines[i]
|
|
261
|
+
heading = _HEADING_RE.match(line)
|
|
262
|
+
if heading is not None:
|
|
263
|
+
current_section = heading.group(2).strip()
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
if not _TABLE_ROW_RE.match(line):
|
|
267
|
+
i += 1
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Start of a table block. Collect contiguous rows.
|
|
271
|
+
block_start = i
|
|
272
|
+
block_rows: list[tuple[int, str]] = []
|
|
273
|
+
while i < len(lines) and _TABLE_ROW_RE.match(lines[i]):
|
|
274
|
+
block_rows.append((i + 1, lines[i])) # 1-based line numbers
|
|
275
|
+
i += 1
|
|
276
|
+
|
|
277
|
+
if not block_rows:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
header_lineno, header_text = block_rows[0]
|
|
281
|
+
header_cells = _split_row_cells(header_text)
|
|
282
|
+
expected = len(header_cells)
|
|
283
|
+
|
|
284
|
+
# If the second row is a separator, skip it from drift checking.
|
|
285
|
+
body_rows = block_rows[1:]
|
|
286
|
+
if body_rows:
|
|
287
|
+
_, second_text = body_rows[0]
|
|
288
|
+
if _is_separator_row(_split_row_cells(second_text)):
|
|
289
|
+
body_rows = body_rows[1:]
|
|
290
|
+
|
|
291
|
+
for row_lineno, row_text in body_rows:
|
|
292
|
+
cells = _split_row_cells(row_text)
|
|
293
|
+
actual = len(cells)
|
|
294
|
+
if actual != expected:
|
|
295
|
+
findings.append({
|
|
296
|
+
"line": row_lineno,
|
|
297
|
+
"section": current_section,
|
|
298
|
+
"expected_cols": expected,
|
|
299
|
+
"actual_cols": actual,
|
|
300
|
+
"row": row_text,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
# if the block ended at a non-row line, fall through to advance i;
|
|
304
|
+
# i already points past the block.
|
|
305
|
+
_ = block_start # explicitly unused, kept for readability
|
|
306
|
+
return findings
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --------------------------------------------------------------------------
|
|
310
|
+
# CLI
|
|
311
|
+
# --------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _read_text(path: Path) -> str:
|
|
315
|
+
return path.read_text(encoding="utf-8")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
319
|
+
skill_md = Path(args.skill_md)
|
|
320
|
+
if not skill_md.is_file():
|
|
321
|
+
print(f"error: file not found: {skill_md}", file=sys.stderr)
|
|
322
|
+
return 1
|
|
323
|
+
try:
|
|
324
|
+
text = _read_text(skill_md)
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
print(f"error: cannot read {skill_md}: {exc}", file=sys.stderr)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
if args.required_sections:
|
|
330
|
+
payload = find_required_sections(text)
|
|
331
|
+
else:
|
|
332
|
+
fence_count, unbalanced, bare = scan_fences(text)
|
|
333
|
+
payload = {
|
|
334
|
+
"unbalanced_fences": unbalanced,
|
|
335
|
+
"fence_count": fence_count,
|
|
336
|
+
"bare_opening_fences": bare,
|
|
337
|
+
"table_drift": find_table_drift(text),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
341
|
+
sys.stdout.write("\n")
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
346
|
+
parser = argparse.ArgumentParser(
|
|
347
|
+
prog="skf-scan-skill-md-structure",
|
|
348
|
+
description=(
|
|
349
|
+
"Deterministic structural scans for SKILL.md: fence balance, "
|
|
350
|
+
"bare opening fences, table column drift, and required-section "
|
|
351
|
+
"presence (case-insensitive synonym match)."
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
355
|
+
|
|
356
|
+
p_scan = sub.add_parser("scan", help="emit structural scan JSON")
|
|
357
|
+
p_scan.add_argument("skill_md", help="path to a SKILL.md file")
|
|
358
|
+
p_scan.add_argument(
|
|
359
|
+
"--required-sections",
|
|
360
|
+
action="store_true",
|
|
361
|
+
help="emit required-section presence JSON instead of fence/table data",
|
|
362
|
+
)
|
|
363
|
+
p_scan.set_defaults(func=_cmd_scan)
|
|
364
|
+
|
|
365
|
+
return parser
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main(argv: list[str] | None = None) -> int:
|
|
369
|
+
parser = _build_parser()
|
|
370
|
+
args = parser.parse_args(argv)
|
|
371
|
+
return args.func(args)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
if __name__ == "__main__":
|
|
375
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Update Active Symlink — atomic + idempotent + verified flip of
|
|
6
|
+
{skill_group}/active to point at a target version directory.
|
|
7
|
+
|
|
8
|
+
`skf-update-skill/references/write.md §5b` previously asked the LLM to
|
|
9
|
+
"create or update the active symlink at {skill_group}/active pointing
|
|
10
|
+
to the new {version}; if the symlink already exists, remove it first
|
|
11
|
+
and recreate". That two-step (`rm` then `ln`) leaves a brief window
|
|
12
|
+
where the symlink doesn't exist — readers that resolve `active`
|
|
13
|
+
mid-update see a `FileNotFoundError`. §6 then re-reads the symlink
|
|
14
|
+
to verify it points where expected and halts on divergence.
|
|
15
|
+
|
|
16
|
+
This helper consolidates the two steps into one workflow-specific
|
|
17
|
+
invocation:
|
|
18
|
+
|
|
19
|
+
- detects whether a flip is required (idempotent: already pointing
|
|
20
|
+
at the target → no-op)
|
|
21
|
+
- performs the flip atomically (symlink → temp name → os.replace)
|
|
22
|
+
so concurrent readers never see a missing symlink
|
|
23
|
+
- verifies the post-state and surfaces ok / flipped / mismatch
|
|
24
|
+
/ missing-target
|
|
25
|
+
|
|
26
|
+
Two subcommands:
|
|
27
|
+
|
|
28
|
+
update --skill-group <path> --version <name>
|
|
29
|
+
Make {skill-group}/active point at {version}. Atomic.
|
|
30
|
+
Idempotent: returns status=ok with action=no-op when the link
|
|
31
|
+
already points there.
|
|
32
|
+
|
|
33
|
+
verify --skill-group <path> --version <name>
|
|
34
|
+
Read-only: check that {skill-group}/active currently points at
|
|
35
|
+
{version}. Used by step-06 derived-artifact verification.
|
|
36
|
+
|
|
37
|
+
The target version directory (`{skill-group}/{version}/`) MUST exist
|
|
38
|
+
on disk before this script is called. The helper refuses to point
|
|
39
|
+
the symlink at a path that doesn't resolve — a dangling `active`
|
|
40
|
+
silently breaks downstream consumers that expect it to resolve.
|
|
41
|
+
|
|
42
|
+
Output JSON (stdout):
|
|
43
|
+
{
|
|
44
|
+
"status": "ok" | "flipped" | "mismatch" | "missing-target",
|
|
45
|
+
"skill_group": "<abs path>",
|
|
46
|
+
"active_link": "<abs path to active>",
|
|
47
|
+
"expected_target": "<version>",
|
|
48
|
+
"current_target": "<previous target>" | null,
|
|
49
|
+
"action_taken": "no-op" | "flipped" | "halt",
|
|
50
|
+
"log_message": "...",
|
|
51
|
+
"halt_message": "<multi-line user message>" | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Exit codes:
|
|
55
|
+
0 — ok (already correct) or flipped (just updated)
|
|
56
|
+
1 — script error (bad args, group path invalid)
|
|
57
|
+
2 — mismatch (verify mode and target diverges) OR missing-target
|
|
58
|
+
(the {version} directory doesn't exist; caller must halt)
|
|
59
|
+
|
|
60
|
+
Platform: POSIX symlinks. On Windows this script intentionally
|
|
61
|
+
exits 1 with a clear error — update-skill's documented supported
|
|
62
|
+
platform is WSL2 (same as skf-atomic-write.py's flip-link).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import argparse
|
|
68
|
+
import json
|
|
69
|
+
import os
|
|
70
|
+
import sys
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ACTIVE_NAME = "active"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------
|
|
78
|
+
# Symlink primitives
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_link_target(link_path: Path) -> str | None:
|
|
83
|
+
"""Return the symlink target as a relative string, or None if the link
|
|
84
|
+
doesn't exist. Raises ValueError if `link_path` exists but isn't a symlink
|
|
85
|
+
(someone replaced it with a regular dir/file)."""
|
|
86
|
+
if not link_path.exists() and not link_path.is_symlink():
|
|
87
|
+
return None
|
|
88
|
+
if not link_path.is_symlink():
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"{link_path} exists but is not a symlink — refusing to overwrite "
|
|
91
|
+
"(manual recovery required)"
|
|
92
|
+
)
|
|
93
|
+
return os.readlink(link_path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def atomic_flip_symlink(link_path: Path, target_name: str) -> None:
|
|
97
|
+
"""Atomically point `link_path` at `target_name` using the
|
|
98
|
+
create-temp + rename pattern. `target_name` is a relative path
|
|
99
|
+
interpreted in the parent of `link_path` (same convention as
|
|
100
|
+
{skill_group}/active → version-dir-sibling).
|
|
101
|
+
|
|
102
|
+
Implementation: os.symlink writes a new link at link_path.tmp,
|
|
103
|
+
then os.replace atomically renames it over link_path. POSIX
|
|
104
|
+
rename is atomic for symlinks, so concurrent readers either see
|
|
105
|
+
the old target or the new one, never a missing file.
|
|
106
|
+
"""
|
|
107
|
+
parent = link_path.parent
|
|
108
|
+
tmp = parent / f".{link_path.name}.skf-symlink.tmp"
|
|
109
|
+
# Clean up a stale temp from a prior crash, if any.
|
|
110
|
+
if tmp.is_symlink() or tmp.exists():
|
|
111
|
+
os.unlink(tmp)
|
|
112
|
+
try:
|
|
113
|
+
os.symlink(target_name, tmp)
|
|
114
|
+
os.replace(tmp, link_path)
|
|
115
|
+
except OSError:
|
|
116
|
+
# Best-effort cleanup; raise the original error.
|
|
117
|
+
try:
|
|
118
|
+
if tmp.is_symlink() or tmp.exists():
|
|
119
|
+
os.unlink(tmp)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
# Core operations
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _envelope_missing_target(skill_group: Path, version: str) -> dict:
|
|
131
|
+
return {
|
|
132
|
+
"status": "missing-target",
|
|
133
|
+
"skill_group": str(skill_group),
|
|
134
|
+
"active_link": str(skill_group / ACTIVE_NAME),
|
|
135
|
+
"expected_target": version,
|
|
136
|
+
"current_target": None,
|
|
137
|
+
"action_taken": "halt",
|
|
138
|
+
"log_message": (
|
|
139
|
+
f"active_symlink_update: missing-target "
|
|
140
|
+
f"({skill_group / version} does not exist)"
|
|
141
|
+
),
|
|
142
|
+
"halt_message": (
|
|
143
|
+
f"Cannot point {skill_group / ACTIVE_NAME} at `{version}` — "
|
|
144
|
+
f"target directory `{skill_group / version}` does not exist on "
|
|
145
|
+
"disk. Verify the version directory was written before §5b runs."
|
|
146
|
+
),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _envelope_ok(skill_group: Path, version: str, current: str | None) -> dict:
|
|
151
|
+
return {
|
|
152
|
+
"status": "ok",
|
|
153
|
+
"skill_group": str(skill_group),
|
|
154
|
+
"active_link": str(skill_group / ACTIVE_NAME),
|
|
155
|
+
"expected_target": version,
|
|
156
|
+
"current_target": current,
|
|
157
|
+
"action_taken": "no-op",
|
|
158
|
+
"log_message": f"active_symlink_update: ok ({version})",
|
|
159
|
+
"halt_message": None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _envelope_flipped(skill_group: Path, version: str, previous: str | None) -> dict:
|
|
164
|
+
prev = previous if previous is not None else "(none)"
|
|
165
|
+
return {
|
|
166
|
+
"status": "flipped",
|
|
167
|
+
"skill_group": str(skill_group),
|
|
168
|
+
"active_link": str(skill_group / ACTIVE_NAME),
|
|
169
|
+
"expected_target": version,
|
|
170
|
+
"current_target": version,
|
|
171
|
+
"action_taken": "flipped",
|
|
172
|
+
"log_message": f"active_symlink_update: flipped ({prev} -> {version})",
|
|
173
|
+
"halt_message": None,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _envelope_mismatch(skill_group: Path, version: str, current: str | None) -> dict:
|
|
178
|
+
cur = current if current is not None else "(none)"
|
|
179
|
+
return {
|
|
180
|
+
"status": "mismatch",
|
|
181
|
+
"skill_group": str(skill_group),
|
|
182
|
+
"active_link": str(skill_group / ACTIVE_NAME),
|
|
183
|
+
"expected_target": version,
|
|
184
|
+
"current_target": current,
|
|
185
|
+
"action_taken": "halt",
|
|
186
|
+
"log_message": (
|
|
187
|
+
f"active_symlink_update: mismatch "
|
|
188
|
+
f"(expected={version}, current={cur})"
|
|
189
|
+
),
|
|
190
|
+
"halt_message": (
|
|
191
|
+
f"Active symlink divergence. `{skill_group / ACTIVE_NAME}` "
|
|
192
|
+
f"resolves to `{cur}` but metadata.json reports `version: "
|
|
193
|
+
f"{version}`. §5b did not apply. Re-point the symlink manually "
|
|
194
|
+
f"(`ln -sfn {version} {skill_group / ACTIVE_NAME}`) or re-run "
|
|
195
|
+
"update-skill, then re-verify."
|
|
196
|
+
),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def update(skill_group: Path, version: str) -> dict:
|
|
201
|
+
"""Idempotently flip {skill_group}/active to point at {version}."""
|
|
202
|
+
target_dir = skill_group / version
|
|
203
|
+
if not target_dir.is_dir():
|
|
204
|
+
return _envelope_missing_target(skill_group, version)
|
|
205
|
+
|
|
206
|
+
link_path = skill_group / ACTIVE_NAME
|
|
207
|
+
current = read_link_target(link_path)
|
|
208
|
+
if current == version:
|
|
209
|
+
return _envelope_ok(skill_group, version, current)
|
|
210
|
+
|
|
211
|
+
atomic_flip_symlink(link_path, version)
|
|
212
|
+
|
|
213
|
+
# Re-read to confirm
|
|
214
|
+
post = read_link_target(link_path)
|
|
215
|
+
if post != version:
|
|
216
|
+
# Should be impossible — os.replace would have raised — but defend
|
|
217
|
+
return _envelope_mismatch(skill_group, version, post)
|
|
218
|
+
return _envelope_flipped(skill_group, version, current)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def verify(skill_group: Path, version: str) -> dict:
|
|
222
|
+
"""Read-only check: assert active points at version."""
|
|
223
|
+
link_path = skill_group / ACTIVE_NAME
|
|
224
|
+
current = read_link_target(link_path)
|
|
225
|
+
if current == version:
|
|
226
|
+
return _envelope_ok(skill_group, version, current)
|
|
227
|
+
return _envelope_mismatch(skill_group, version, current)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------
|
|
231
|
+
# CLI
|
|
232
|
+
# --------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _exit_code_for_status(status: str) -> int:
|
|
236
|
+
if status in ("ok", "flipped"):
|
|
237
|
+
return 0
|
|
238
|
+
if status in ("mismatch", "missing-target"):
|
|
239
|
+
return 2
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cmd_update(args: argparse.Namespace) -> int:
|
|
244
|
+
skill_group = Path(args.skill_group)
|
|
245
|
+
if not skill_group.is_dir():
|
|
246
|
+
print(
|
|
247
|
+
f"error: skill-group not a directory: {skill_group}", file=sys.stderr
|
|
248
|
+
)
|
|
249
|
+
return 1
|
|
250
|
+
try:
|
|
251
|
+
result = update(skill_group, args.version)
|
|
252
|
+
except (ValueError, OSError) as exc:
|
|
253
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
254
|
+
return 1
|
|
255
|
+
json.dump(result, sys.stdout, indent=2)
|
|
256
|
+
sys.stdout.write("\n")
|
|
257
|
+
return _exit_code_for_status(result["status"])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
261
|
+
skill_group = Path(args.skill_group)
|
|
262
|
+
if not skill_group.is_dir():
|
|
263
|
+
print(
|
|
264
|
+
f"error: skill-group not a directory: {skill_group}", file=sys.stderr
|
|
265
|
+
)
|
|
266
|
+
return 1
|
|
267
|
+
try:
|
|
268
|
+
result = verify(skill_group, args.version)
|
|
269
|
+
except (ValueError, OSError) as exc:
|
|
270
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
json.dump(result, sys.stdout, indent=2)
|
|
273
|
+
sys.stdout.write("\n")
|
|
274
|
+
return _exit_code_for_status(result["status"])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
278
|
+
parser = argparse.ArgumentParser(
|
|
279
|
+
prog="skf-update-active-symlink",
|
|
280
|
+
description=(
|
|
281
|
+
"Atomically + idempotently update {skill-group}/active to point "
|
|
282
|
+
"at a target version directory."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
286
|
+
|
|
287
|
+
p_upd = sub.add_parser("update", help="flip the symlink if needed")
|
|
288
|
+
p_upd.add_argument("--skill-group", required=True, help="path to the skill_group dir")
|
|
289
|
+
p_upd.add_argument("--version", required=True, help="version directory name")
|
|
290
|
+
p_upd.set_defaults(func=_cmd_update)
|
|
291
|
+
|
|
292
|
+
p_ver = sub.add_parser("verify", help="read-only: assert symlink matches version")
|
|
293
|
+
p_ver.add_argument("--skill-group", required=True)
|
|
294
|
+
p_ver.add_argument("--version", required=True)
|
|
295
|
+
p_ver.set_defaults(func=_cmd_verify)
|
|
296
|
+
|
|
297
|
+
return parser
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main(argv: list[str] | None = None) -> int:
|
|
301
|
+
if sys.platform.startswith("win"):
|
|
302
|
+
print(
|
|
303
|
+
"error: native Windows symlinks require admin/developer mode; "
|
|
304
|
+
"use WSL2 to run update-skill (same constraint as "
|
|
305
|
+
"skf-atomic-write.py flip-link)",
|
|
306
|
+
file=sys.stderr,
|
|
307
|
+
)
|
|
308
|
+
return 1
|
|
309
|
+
parser = _build_parser()
|
|
310
|
+
args = parser.parse_args(argv)
|
|
311
|
+
return args.func(args)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
raise SystemExit(main())
|