bmad-module-skill-forge 1.4.1 → 1.5.1
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 +147 -16
- 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-manifest-ops.py +22 -6
- 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} +48 -14
- package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +32 -9
- 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} +16 -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} +63 -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} +35 -17
- package/src/skf-brief-skill/SKILL.md +49 -19
- 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} +27 -15
- 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} +35 -29
- 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 +2 -2
- package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +27 -20
- 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} +29 -16
- 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 +10 -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} +48 -28
- 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} +37 -11
- package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +32 -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} +44 -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 +79 -0
- package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +23 -11
- 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} +37 -73
- 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} +13 -9
- 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 +170 -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} +18 -14
- package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +27 -42
- package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +13 -9
- package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +14 -10
- 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
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Emit Result Envelope — Schema-locked headless output for skf-setup.
|
|
6
6
|
|
|
7
|
-
Replaces the prose-driven envelope assembly in `src/skf-setup/
|
|
8
|
-
|
|
9
|
-
(SKF_SETUP_RESULT_JSON) was added to step
|
|
7
|
+
Replaces the prose-driven envelope assembly in `src/skf-setup/references/
|
|
8
|
+
report.md` §4 with one Python invocation. The envelope contract
|
|
9
|
+
(SKF_SETUP_RESULT_JSON) was added to step 4 in PR #247, extended in
|
|
10
10
|
PR #248 (header / outputs / failure modes), and extended again in this
|
|
11
11
|
PR (previous_tier, tier_changed, tools_added, tools_removed, error).
|
|
12
12
|
|
|
@@ -89,7 +89,7 @@ SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.
|
|
|
89
89
|
TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
|
|
90
90
|
VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
|
|
91
91
|
VALID_FILES = ("forge-tier.yaml", "preferences.yaml", "settings.yml", "ccc_index")
|
|
92
|
-
VALID_CCC_STATUS = ("fresh", "created", "failed", "none")
|
|
92
|
+
VALID_CCC_STATUS = ("fresh", "created", "failed", "none", "skipped")
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
def _die(code: int, message: str) -> None:
|
|
@@ -132,7 +132,7 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
|
|
|
132
132
|
"""Return (added, removed) tool-key lists, sorted for determinism.
|
|
133
133
|
|
|
134
134
|
First-run convention (previous is None or empty): added = currently
|
|
135
|
-
available tools; removed = []. Matches the documented step
|
|
135
|
+
available tools; removed = []. Matches the documented step 4 §4 rule.
|
|
136
136
|
"""
|
|
137
137
|
cur = _normalize_tools(current)
|
|
138
138
|
if not previous:
|
|
@@ -147,7 +147,7 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
|
|
|
147
147
|
def _assemble_warnings(payload: dict) -> list[str]:
|
|
148
148
|
"""Fold every documented warning source into the envelope's warnings array.
|
|
149
149
|
|
|
150
|
-
Matches the field-rules block in step
|
|
150
|
+
Matches the field-rules block in step 4 §4 — pipelines should only need
|
|
151
151
|
to consult `warnings` to surface non-fatal issues.
|
|
152
152
|
"""
|
|
153
153
|
warnings: list[str] = []
|
|
@@ -253,8 +253,12 @@ def assemble_envelope(payload: dict) -> dict:
|
|
|
253
253
|
if require_tier_satisfied is not None and not isinstance(require_tier_satisfied, bool):
|
|
254
254
|
_die(1, f"require_tier_satisfied must be bool or null, got {type(require_tier_satisfied).__name__}")
|
|
255
255
|
|
|
256
|
+
error = _normalize_error(payload.get("error"))
|
|
257
|
+
status = _compute_status(error, require_tier_satisfied)
|
|
258
|
+
|
|
256
259
|
return {
|
|
257
260
|
"skf_setup": {
|
|
261
|
+
"status": status,
|
|
258
262
|
"tier": tier,
|
|
259
263
|
"previous_tier": previous_tier,
|
|
260
264
|
"tier_changed": tier_changed,
|
|
@@ -268,11 +272,29 @@ def assemble_envelope(payload: dict) -> dict:
|
|
|
268
272
|
"tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
|
|
269
273
|
"require_tier_satisfied": require_tier_satisfied,
|
|
270
274
|
"warnings": _assemble_warnings(payload),
|
|
271
|
-
"error":
|
|
275
|
+
"error": error,
|
|
272
276
|
}
|
|
273
277
|
}
|
|
274
278
|
|
|
275
279
|
|
|
280
|
+
def _compute_status(error: dict | None, require_tier_satisfied) -> str:
|
|
281
|
+
"""Derive the single-field status from error + require_tier_satisfied.
|
|
282
|
+
|
|
283
|
+
- 'write_failure' when error.phase signals a file-write failure
|
|
284
|
+
- 'blocked' when error is non-null but not a write failure
|
|
285
|
+
- 'tier_failure' when require_tier_satisfied is False
|
|
286
|
+
- 'success' otherwise
|
|
287
|
+
"""
|
|
288
|
+
if error is not None:
|
|
289
|
+
phase = (error.get("phase") or "").lower()
|
|
290
|
+
if "write" in phase or phase.endswith("forge-tier.yaml") or phase.endswith("preferences.yaml"):
|
|
291
|
+
return "write_failure"
|
|
292
|
+
return "blocked"
|
|
293
|
+
if require_tier_satisfied is False:
|
|
294
|
+
return "tier_failure"
|
|
295
|
+
return "success"
|
|
296
|
+
|
|
297
|
+
|
|
276
298
|
def emit_envelope_line(envelope: dict) -> str:
|
|
277
299
|
"""Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism."""
|
|
278
300
|
body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
@@ -398,19 +420,81 @@ def cmd_validate() -> None:
|
|
|
398
420
|
_die(1, "; ".join(errors))
|
|
399
421
|
|
|
400
422
|
|
|
423
|
+
def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None) -> dict:
|
|
424
|
+
"""Assemble a minimal status='blocked' envelope for early-halt paths.
|
|
425
|
+
|
|
426
|
+
Used when On Activation or step 2 halts before the regular envelope
|
|
427
|
+
construction has the inputs it needs (no tier, no detected tools,
|
|
428
|
+
no config_path). Every other required field gets a documented sentinel
|
|
429
|
+
so the envelope still passes schema validation and pipelines can
|
|
430
|
+
branch on `status: "blocked"` and inspect `error` for context.
|
|
431
|
+
"""
|
|
432
|
+
return {
|
|
433
|
+
"skf_setup": {
|
|
434
|
+
"status": "blocked",
|
|
435
|
+
"tier": "Quick",
|
|
436
|
+
"previous_tier": None,
|
|
437
|
+
"tier_changed": False,
|
|
438
|
+
"tools": {"ast_grep": False, "gh_cli": False, "qmd": False, "ccc": False},
|
|
439
|
+
"tools_added": [],
|
|
440
|
+
"tools_removed": [],
|
|
441
|
+
"config_path": path or "<unknown — halt before config_path resolved>",
|
|
442
|
+
"ccc_index": {"status": "none", "indexed_path": None, "file_count": None},
|
|
443
|
+
"files_written": [],
|
|
444
|
+
"tier_override_active": False,
|
|
445
|
+
"tier_override_invalid": False,
|
|
446
|
+
"require_tier_satisfied": None,
|
|
447
|
+
"warnings": [],
|
|
448
|
+
"error": {
|
|
449
|
+
"phase": phase,
|
|
450
|
+
"path": path or "<n/a>",
|
|
451
|
+
"reason": reason,
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def cmd_emit_blocked() -> None:
|
|
458
|
+
"""Emit a blocked envelope. Reads `phase`, `reason`, optional `path` from stdin JSON.
|
|
459
|
+
|
|
460
|
+
Designed for early-halt paths (uv missing, config.yaml missing, etc.) where
|
|
461
|
+
the regular `emit` subcommand can't run because tier/tools/config_path are
|
|
462
|
+
not yet known.
|
|
463
|
+
"""
|
|
464
|
+
payload = _read_stdin_json("emit-blocked")
|
|
465
|
+
phase = payload.get("phase")
|
|
466
|
+
reason = payload.get("reason")
|
|
467
|
+
if not isinstance(phase, str) or not phase:
|
|
468
|
+
_die(1, "emit-blocked: 'phase' must be a non-empty string")
|
|
469
|
+
if not isinstance(reason, str) or not reason:
|
|
470
|
+
_die(1, "emit-blocked: 'reason' must be a non-empty string")
|
|
471
|
+
path = payload.get("path")
|
|
472
|
+
if path is not None and not isinstance(path, str):
|
|
473
|
+
_die(1, "emit-blocked: 'path' must be a string or omitted")
|
|
474
|
+
envelope = assemble_blocked_envelope(phase, reason, path)
|
|
475
|
+
schema = _load_schema()
|
|
476
|
+
errors = _validate_against_schema(envelope, schema)
|
|
477
|
+
if errors:
|
|
478
|
+
_die(2, f"assembled blocked envelope failed schema validation: {errors}")
|
|
479
|
+
print(emit_envelope_line(envelope))
|
|
480
|
+
|
|
481
|
+
|
|
401
482
|
def main() -> None:
|
|
402
483
|
parser = argparse.ArgumentParser(
|
|
403
484
|
description=__doc__,
|
|
404
485
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
405
486
|
)
|
|
406
487
|
sub = parser.add_subparsers(dest="cmd")
|
|
407
|
-
sub.add_parser("emit",
|
|
408
|
-
sub.add_parser("
|
|
488
|
+
sub.add_parser("emit", help="Build envelope from context payload (default).")
|
|
489
|
+
sub.add_parser("emit-blocked", help="Emit minimal status='blocked' envelope for early-halt paths.")
|
|
490
|
+
sub.add_parser("validate", help="Validate an envelope payload against the schema.")
|
|
409
491
|
args = parser.parse_args()
|
|
410
492
|
|
|
411
493
|
cmd = args.cmd or "emit"
|
|
412
494
|
if cmd == "emit":
|
|
413
495
|
cmd_emit()
|
|
496
|
+
elif cmd == "emit-blocked":
|
|
497
|
+
cmd_emit_blocked()
|
|
414
498
|
elif cmd == "validate":
|
|
415
499
|
cmd_validate()
|
|
416
500
|
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Enumerate Stack Skills — inventory skill packages + resolve exports.
|
|
6
|
+
|
|
7
|
+
Replaces the LLM-orchestrated "Exports resolution order" cascade in
|
|
8
|
+
`src/skf-create-stack-skill/references/parallel-extract.md` §0. The stage
|
|
9
|
+
prose previously asked the model to walk three fallback sources per
|
|
10
|
+
constituent skill (metadata.json → references/ → SKILL.md prose) and emit
|
|
11
|
+
the cascade decisions inline. That's an N-way implicit-read trap: for a
|
|
12
|
+
compose-mode stack with K skills the model must read up to K × 3 files
|
|
13
|
+
just to settle the inventory, before any actual per-skill usage analysis
|
|
14
|
+
runs.
|
|
15
|
+
|
|
16
|
+
This helper does the deterministic part once: scan the skills root,
|
|
17
|
+
resolve each skill's `exports[]` via the cascade, hash metadata.json
|
|
18
|
+
where present, and emit a structured JSON inventory. The per-skill
|
|
19
|
+
subagent fan-out at §1+ of parallel-extract.md remains unchanged — the
|
|
20
|
+
subprocesses read this inventory's cached result instead of re-reading
|
|
21
|
+
SKILL.md, then extract usage patterns per skill (the part that does
|
|
22
|
+
benefit from LLM judgment).
|
|
23
|
+
|
|
24
|
+
Subcommand:
|
|
25
|
+
enumerate <skills-root>
|
|
26
|
+
Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
|
|
27
|
+
describing every subdirectory of <skills-root> that contains a
|
|
28
|
+
SKILL.md. Each entry carries:
|
|
29
|
+
|
|
30
|
+
name — skill name (subdirectory name)
|
|
31
|
+
path — relative path under skills-root, forward-slash
|
|
32
|
+
exports — exports list resolved via cascade
|
|
33
|
+
exports_source — "metadata|references|skill-md|unknown"
|
|
34
|
+
confidence — "T1|T2|T1-low" (mapped from exports_source)
|
|
35
|
+
metadata_hash — sha256: prefix on metadata.json digest, or null
|
|
36
|
+
|
|
37
|
+
Exports resolution cascade (must match parallel-extract.md §0):
|
|
38
|
+
|
|
39
|
+
1. metadata.json — if present, valid JSON, and contains an `exports[]`
|
|
40
|
+
array: use the list, mark exports_source="metadata", compute
|
|
41
|
+
metadata_hash = sha256: + sha256(metadata.json content).
|
|
42
|
+
The metadata.json hash is computed even if exports[] is empty —
|
|
43
|
+
callers may want to detect "exports intentionally empty" vs
|
|
44
|
+
"no metadata at all".
|
|
45
|
+
|
|
46
|
+
2. references/*.md — scan top-level `*.md` files under references/
|
|
47
|
+
for an `## API` or `## Exports` section. Bulleted list items in
|
|
48
|
+
that section become exports. Set exports_source="references",
|
|
49
|
+
metadata_hash=null.
|
|
50
|
+
|
|
51
|
+
3. SKILL.md prose — look for an `## Exports` or `## API Surface`
|
|
52
|
+
section and parse its bulleted list. Set exports_source="skill-md",
|
|
53
|
+
metadata_hash=null.
|
|
54
|
+
|
|
55
|
+
4. None of the above — exports=[], exports_source="unknown",
|
|
56
|
+
confidence="T1-low", append a warning
|
|
57
|
+
`"<skill-name>: no exports found via any resolution path"`.
|
|
58
|
+
|
|
59
|
+
Confidence mapping:
|
|
60
|
+
metadata → T1 (structured manifest, trustworthy)
|
|
61
|
+
references → T2 (heuristic reconstruction, lower trust)
|
|
62
|
+
skill-md → T2 (prose parsing, comparable trust to references)
|
|
63
|
+
unknown → T1-low (degraded extraction; nothing found)
|
|
64
|
+
|
|
65
|
+
Cycle detection:
|
|
66
|
+
|
|
67
|
+
metadata.json may declare `composes: ["<skill-name>", ...]` referencing
|
|
68
|
+
other skill packages in this skills-root (compose mode). If A composes
|
|
69
|
+
B and B composes A — directly or transitively — append the cycle's
|
|
70
|
+
starting node to `cycles[]` and emit a warning. Cycles do not abort
|
|
71
|
+
enumeration; the inventory still lists every skill.
|
|
72
|
+
|
|
73
|
+
Symlink handling:
|
|
74
|
+
|
|
75
|
+
A skill directory may itself be a symlink (e.g. an "active" pointer).
|
|
76
|
+
If the target is unreadable (broken symlink, permission denied, etc.),
|
|
77
|
+
emit a warning and skip the entry — do not crash the whole scan.
|
|
78
|
+
|
|
79
|
+
Per-skill errors (malformed metadata.json, OSError, etc.) are captured
|
|
80
|
+
as warnings on the top-level result; they do not exit the process.
|
|
81
|
+
|
|
82
|
+
Exit codes:
|
|
83
|
+
0 enumeration succeeded (including zero skills found)
|
|
84
|
+
1 user error (bad skills-root path)
|
|
85
|
+
|
|
86
|
+
CLI:
|
|
87
|
+
uv run skf-enumerate-stack-skills.py enumerate <skills-root>
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
from __future__ import annotations
|
|
91
|
+
|
|
92
|
+
import argparse
|
|
93
|
+
import hashlib
|
|
94
|
+
import json
|
|
95
|
+
import re
|
|
96
|
+
import sys
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
# Constants
|
|
102
|
+
# --------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
SOURCE_METADATA = "metadata"
|
|
106
|
+
SOURCE_REFERENCES = "references"
|
|
107
|
+
SOURCE_SKILL_MD = "skill-md"
|
|
108
|
+
SOURCE_UNKNOWN = "unknown"
|
|
109
|
+
|
|
110
|
+
_CONFIDENCE_BY_SOURCE = {
|
|
111
|
+
SOURCE_METADATA: "T1",
|
|
112
|
+
SOURCE_REFERENCES: "T2",
|
|
113
|
+
SOURCE_SKILL_MD: "T2",
|
|
114
|
+
SOURCE_UNKNOWN: "T1-low",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Section headings recognised in the references/ cascade and the SKILL.md
|
|
118
|
+
# cascade. Case-sensitive — these are the canonical SKF heading forms
|
|
119
|
+
# documented in parallel-extract.md §0.
|
|
120
|
+
_REFERENCES_HEADINGS = ("## API", "## Exports")
|
|
121
|
+
_SKILL_MD_HEADINGS = ("## Exports", "## API Surface")
|
|
122
|
+
|
|
123
|
+
# Bulleted list-item pattern. Permissive: `- foo`, `* foo`, `+ foo`, with
|
|
124
|
+
# optional surrounding backticks on the name. We capture the first
|
|
125
|
+
# token-like identifier as the export name and stop at whitespace,
|
|
126
|
+
# punctuation, or the end-of-backtick.
|
|
127
|
+
_LIST_ITEM_RE = re.compile(
|
|
128
|
+
r"^\s*[-*+]\s+`?([A-Za-z_][A-Za-z0-9_.]*)`?",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# H2 boundary — any `## ...` line ends the current section.
|
|
132
|
+
_H2_BOUNDARY_RE = re.compile(r"^## ")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------
|
|
136
|
+
# Hashing helper
|
|
137
|
+
# --------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sha256_of_bytes(data: bytes) -> str:
|
|
141
|
+
"""SHA-256 hex digest with `sha256:` prefix (SKF convention)."""
|
|
142
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
# Resolution: metadata.json
|
|
147
|
+
# --------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_exports(raw) -> list[str]:
|
|
151
|
+
"""Coerce an exports[] payload (list of strings OR list of {name,...})
|
|
152
|
+
to a flat deduplicated list of names in declaration order.
|
|
153
|
+
|
|
154
|
+
Matches skf-render-quick-metadata.py's normalisation so the cascade
|
|
155
|
+
is consistent across the codebase.
|
|
156
|
+
"""
|
|
157
|
+
out: list[str] = []
|
|
158
|
+
seen: set[str] = set()
|
|
159
|
+
if not isinstance(raw, list):
|
|
160
|
+
return out
|
|
161
|
+
for item in raw:
|
|
162
|
+
name: str | None = None
|
|
163
|
+
if isinstance(item, str):
|
|
164
|
+
name = item
|
|
165
|
+
elif isinstance(item, dict):
|
|
166
|
+
v = item.get("name")
|
|
167
|
+
if isinstance(v, str):
|
|
168
|
+
name = v
|
|
169
|
+
if name and name not in seen:
|
|
170
|
+
seen.add(name)
|
|
171
|
+
out.append(name)
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str]]:
|
|
176
|
+
"""Try to resolve exports from metadata.json.
|
|
177
|
+
|
|
178
|
+
Returns (exports, metadata_hash, composes, warnings).
|
|
179
|
+
exports — list of names if metadata.json was readable AND
|
|
180
|
+
contained an `exports[]` array (may be empty);
|
|
181
|
+
None if metadata.json absent or malformed.
|
|
182
|
+
metadata_hash — sha256: digest of metadata.json content, or None
|
|
183
|
+
if file absent / unreadable.
|
|
184
|
+
composes — list of constituent skill names (for cycle
|
|
185
|
+
detection); empty list if absent.
|
|
186
|
+
warnings — per-skill warnings (malformed JSON, etc.)
|
|
187
|
+
"""
|
|
188
|
+
meta_path = skill_dir / "metadata.json"
|
|
189
|
+
if not meta_path.is_file():
|
|
190
|
+
return None, None, [], []
|
|
191
|
+
|
|
192
|
+
warnings: list[str] = []
|
|
193
|
+
try:
|
|
194
|
+
raw_bytes = meta_path.read_bytes()
|
|
195
|
+
except OSError as exc:
|
|
196
|
+
warnings.append(f"failed to read metadata.json: {exc}")
|
|
197
|
+
return None, None, [], warnings
|
|
198
|
+
|
|
199
|
+
metadata_hash = _sha256_of_bytes(raw_bytes)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
data = json.loads(raw_bytes.decode("utf-8"))
|
|
203
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
204
|
+
warnings.append(f"metadata.json is not valid JSON: {exc}")
|
|
205
|
+
# Hash is still useful — record it so callers can correlate
|
|
206
|
+
# malformed files across runs.
|
|
207
|
+
return None, metadata_hash, [], warnings
|
|
208
|
+
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
warnings.append("metadata.json root is not an object")
|
|
211
|
+
return None, metadata_hash, [], warnings
|
|
212
|
+
|
|
213
|
+
composes_raw = data.get("composes")
|
|
214
|
+
composes = [c for c in composes_raw if isinstance(c, str)] if isinstance(composes_raw, list) else []
|
|
215
|
+
|
|
216
|
+
if "exports" not in data:
|
|
217
|
+
# metadata.json exists and is valid JSON but lacks an exports
|
|
218
|
+
# field — fall through to references/ cascade. Carry the hash
|
|
219
|
+
# forward so callers see metadata existed.
|
|
220
|
+
return None, metadata_hash, composes, warnings
|
|
221
|
+
|
|
222
|
+
exports = _normalize_exports(data.get("exports"))
|
|
223
|
+
return exports, metadata_hash, composes, warnings
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
# Resolution: references/ and SKILL.md
|
|
228
|
+
# --------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_list_section(content: str, headings: tuple[str, ...]) -> list[str]:
|
|
232
|
+
"""Extract bulleted items from the first matching `## <heading>` section.
|
|
233
|
+
|
|
234
|
+
Returns names in document order, deduplicated. Returns [] if no
|
|
235
|
+
matching heading is present.
|
|
236
|
+
|
|
237
|
+
Section boundaries:
|
|
238
|
+
- starts on the first line equal to one of `headings` (case-sensitive)
|
|
239
|
+
- ends on the next `## ` h2 heading or end-of-file
|
|
240
|
+
"""
|
|
241
|
+
out: list[str] = []
|
|
242
|
+
seen: set[str] = set()
|
|
243
|
+
in_section = False
|
|
244
|
+
lines = content.splitlines()
|
|
245
|
+
for line in lines:
|
|
246
|
+
stripped = line.rstrip()
|
|
247
|
+
if not in_section:
|
|
248
|
+
if stripped in headings:
|
|
249
|
+
in_section = True
|
|
250
|
+
continue
|
|
251
|
+
# In-section. Boundary check first, then list-item parse.
|
|
252
|
+
if _H2_BOUNDARY_RE.match(line):
|
|
253
|
+
break
|
|
254
|
+
m = _LIST_ITEM_RE.match(line)
|
|
255
|
+
if m:
|
|
256
|
+
name = m.group(1)
|
|
257
|
+
if name not in seen:
|
|
258
|
+
seen.add(name)
|
|
259
|
+
out.append(name)
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _resolve_from_references(skill_dir: Path) -> list[str]:
|
|
264
|
+
"""Scan references/*.md for `## API` / `## Exports` sections.
|
|
265
|
+
|
|
266
|
+
Files are walked in sorted order (deterministic across runs). The
|
|
267
|
+
union of bulleted items from all matching sections is returned,
|
|
268
|
+
deduplicated, in scan order.
|
|
269
|
+
"""
|
|
270
|
+
refs_dir = skill_dir / "references"
|
|
271
|
+
if not refs_dir.is_dir():
|
|
272
|
+
return []
|
|
273
|
+
out: list[str] = []
|
|
274
|
+
seen: set[str] = set()
|
|
275
|
+
for md_path in sorted(refs_dir.glob("*.md")):
|
|
276
|
+
try:
|
|
277
|
+
content = md_path.read_text(encoding="utf-8")
|
|
278
|
+
except OSError:
|
|
279
|
+
continue
|
|
280
|
+
for name in _parse_list_section(content, _REFERENCES_HEADINGS):
|
|
281
|
+
if name not in seen:
|
|
282
|
+
seen.add(name)
|
|
283
|
+
out.append(name)
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _resolve_from_skill_md(skill_dir: Path) -> list[str]:
|
|
288
|
+
"""Parse SKILL.md for an `## Exports` / `## API Surface` section."""
|
|
289
|
+
skill_md = skill_dir / "SKILL.md"
|
|
290
|
+
if not skill_md.is_file():
|
|
291
|
+
return []
|
|
292
|
+
try:
|
|
293
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
294
|
+
except OSError:
|
|
295
|
+
return []
|
|
296
|
+
return _parse_list_section(content, _SKILL_MD_HEADINGS)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --------------------------------------------------------------------------
|
|
300
|
+
# Per-skill resolution (cascade orchestrator)
|
|
301
|
+
# --------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], list[str]]:
|
|
305
|
+
"""Build the result entry for a single skill directory.
|
|
306
|
+
|
|
307
|
+
Returns (entry, composes, warnings).
|
|
308
|
+
entry — the dict that will land in result["skills"][]
|
|
309
|
+
composes — list of skill names this one declares as composed (cycle input)
|
|
310
|
+
warnings — strings to append to result["warnings"][]
|
|
311
|
+
"""
|
|
312
|
+
warnings: list[str] = []
|
|
313
|
+
|
|
314
|
+
# 1. metadata.json
|
|
315
|
+
exports, metadata_hash, composes, meta_warnings = _resolve_from_metadata(skill_dir)
|
|
316
|
+
warnings.extend(f"{skill_name}: {w}" for w in meta_warnings)
|
|
317
|
+
|
|
318
|
+
if exports is not None:
|
|
319
|
+
source = SOURCE_METADATA
|
|
320
|
+
else:
|
|
321
|
+
# 2. references/
|
|
322
|
+
ref_exports = _resolve_from_references(skill_dir)
|
|
323
|
+
if ref_exports:
|
|
324
|
+
exports = ref_exports
|
|
325
|
+
source = SOURCE_REFERENCES
|
|
326
|
+
else:
|
|
327
|
+
# 3. SKILL.md prose
|
|
328
|
+
sk_exports = _resolve_from_skill_md(skill_dir)
|
|
329
|
+
if sk_exports:
|
|
330
|
+
exports = sk_exports
|
|
331
|
+
source = SOURCE_SKILL_MD
|
|
332
|
+
else:
|
|
333
|
+
# 4. None found
|
|
334
|
+
exports = []
|
|
335
|
+
source = SOURCE_UNKNOWN
|
|
336
|
+
warnings.append(f"{skill_name}: no exports found via any resolution path")
|
|
337
|
+
|
|
338
|
+
entry = {
|
|
339
|
+
"name": skill_name,
|
|
340
|
+
"path": skill_name, # forward-slash relative path under skills-root
|
|
341
|
+
"exports": exports,
|
|
342
|
+
"exports_source": source,
|
|
343
|
+
"confidence": _CONFIDENCE_BY_SOURCE[source],
|
|
344
|
+
"metadata_hash": metadata_hash,
|
|
345
|
+
}
|
|
346
|
+
return entry, composes, warnings
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --------------------------------------------------------------------------
|
|
350
|
+
# Cycle detection (composes graph)
|
|
351
|
+
# --------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def detect_cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
355
|
+
"""Find nodes that participate in a `composes` cycle.
|
|
356
|
+
|
|
357
|
+
Returns a sorted list of nodes from which a cycle is reachable.
|
|
358
|
+
Each cycle is represented once by the lexicographically-smallest
|
|
359
|
+
node on it — keeps output deterministic across runs without
|
|
360
|
+
requiring callers to interpret a path.
|
|
361
|
+
|
|
362
|
+
Algorithm: DFS with WHITE/GRAY/BLACK colouring. A back-edge to a
|
|
363
|
+
GRAY ancestor identifies a cycle; we record the ancestor.
|
|
364
|
+
"""
|
|
365
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
366
|
+
colour: dict[str, int] = {node: WHITE for node in graph}
|
|
367
|
+
on_path: list[str] = []
|
|
368
|
+
cycle_nodes: set[str] = set()
|
|
369
|
+
|
|
370
|
+
def dfs(node: str) -> None:
|
|
371
|
+
colour[node] = GRAY
|
|
372
|
+
on_path.append(node)
|
|
373
|
+
for nbr in graph.get(node, []):
|
|
374
|
+
if nbr not in graph:
|
|
375
|
+
# composes-target not in this skills-root; treat as
|
|
376
|
+
# external, no cycle contribution.
|
|
377
|
+
continue
|
|
378
|
+
c = colour[nbr]
|
|
379
|
+
if c == WHITE:
|
|
380
|
+
dfs(nbr)
|
|
381
|
+
elif c == GRAY:
|
|
382
|
+
# Back-edge → cycle. Record the smallest node on the
|
|
383
|
+
# cycle path from nbr onward.
|
|
384
|
+
idx = on_path.index(nbr)
|
|
385
|
+
cycle_nodes.add(min(on_path[idx:]))
|
|
386
|
+
on_path.pop()
|
|
387
|
+
colour[node] = BLACK
|
|
388
|
+
|
|
389
|
+
for node in sorted(graph):
|
|
390
|
+
if colour[node] == WHITE:
|
|
391
|
+
dfs(node)
|
|
392
|
+
|
|
393
|
+
return sorted(cycle_nodes)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --------------------------------------------------------------------------
|
|
397
|
+
# Enumeration entry point
|
|
398
|
+
# --------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def enumerate_stack_skills(skills_root: Path) -> dict:
|
|
402
|
+
"""Walk `skills_root`, build inventory, detect cycles, return result."""
|
|
403
|
+
result: dict = {"skills": [], "cycles": [], "warnings": []}
|
|
404
|
+
compose_graph: dict[str, list[str]] = {}
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
children = sorted(skills_root.iterdir())
|
|
408
|
+
except OSError as exc:
|
|
409
|
+
# Shouldn't reach here if CLI validated skills_root.is_dir(),
|
|
410
|
+
# but handle defensively for direct API callers.
|
|
411
|
+
result["warnings"].append(f"failed to read skills root: {exc}")
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
for child in children:
|
|
415
|
+
name = child.name
|
|
416
|
+
if name.startswith("."):
|
|
417
|
+
continue
|
|
418
|
+
|
|
419
|
+
# Symlink fallback — resolve and verify target is a directory we
|
|
420
|
+
# can read. If anything fails, warn and skip the entry.
|
|
421
|
+
if child.is_symlink():
|
|
422
|
+
try:
|
|
423
|
+
target = child.resolve(strict=True)
|
|
424
|
+
except (FileNotFoundError, RuntimeError, OSError) as exc:
|
|
425
|
+
result["warnings"].append(
|
|
426
|
+
f"{name}: symlink target unreadable ({exc})"
|
|
427
|
+
)
|
|
428
|
+
continue
|
|
429
|
+
if not target.is_dir():
|
|
430
|
+
result["warnings"].append(
|
|
431
|
+
f"{name}: symlink target is not a directory"
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
skill_dir = target
|
|
435
|
+
else:
|
|
436
|
+
if not child.is_dir():
|
|
437
|
+
continue
|
|
438
|
+
skill_dir = child
|
|
439
|
+
|
|
440
|
+
# Require SKILL.md to consider this a skill package. Subdirs
|
|
441
|
+
# without SKILL.md (e.g. `shared/`, `knowledge/`) are skipped
|
|
442
|
+
# silently — they're not skill packages by definition.
|
|
443
|
+
if not (skill_dir / "SKILL.md").is_file():
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
entry, composes, skill_warnings = resolve_skill(skill_dir, name)
|
|
448
|
+
except Exception as exc: # noqa: BLE001 — per-skill failures are warnings, not fatal
|
|
449
|
+
result["warnings"].append(f"{name}: enumeration failed: {exc}")
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
result["skills"].append(entry)
|
|
453
|
+
result["warnings"].extend(skill_warnings)
|
|
454
|
+
compose_graph[name] = composes
|
|
455
|
+
|
|
456
|
+
result["cycles"] = detect_cycles(compose_graph)
|
|
457
|
+
for cycle_node in result["cycles"]:
|
|
458
|
+
result["warnings"].append(
|
|
459
|
+
f"{cycle_node}: composes cycle detected"
|
|
460
|
+
)
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --------------------------------------------------------------------------
|
|
465
|
+
# CLI
|
|
466
|
+
# --------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _cmd_enumerate(args: argparse.Namespace) -> int:
|
|
470
|
+
skills_root = Path(args.skills_root)
|
|
471
|
+
if not skills_root.is_dir():
|
|
472
|
+
print(
|
|
473
|
+
f"error: skills root not a directory: {skills_root}",
|
|
474
|
+
file=sys.stderr,
|
|
475
|
+
)
|
|
476
|
+
return 1
|
|
477
|
+
result = enumerate_stack_skills(skills_root)
|
|
478
|
+
json.dump(result, sys.stdout, indent=2)
|
|
479
|
+
sys.stdout.write("\n")
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
484
|
+
parser = argparse.ArgumentParser(
|
|
485
|
+
prog="skf-enumerate-stack-skills",
|
|
486
|
+
description=(
|
|
487
|
+
"Enumerate skill packages under a skills root and resolve "
|
|
488
|
+
"each package's exports via the metadata.json → references/ "
|
|
489
|
+
"→ SKILL.md cascade."
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
493
|
+
|
|
494
|
+
p_enum = sub.add_parser(
|
|
495
|
+
"enumerate",
|
|
496
|
+
help="walk skills-root and emit JSON inventory",
|
|
497
|
+
)
|
|
498
|
+
p_enum.add_argument(
|
|
499
|
+
"skills_root",
|
|
500
|
+
help="directory containing skill packages (each subdir has SKILL.md)",
|
|
501
|
+
)
|
|
502
|
+
p_enum.set_defaults(func=_cmd_enumerate)
|
|
503
|
+
|
|
504
|
+
return parser
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def main(argv: list[str] | None = None) -> int:
|
|
508
|
+
parser = _build_parser()
|
|
509
|
+
args = parser.parse_args(argv)
|
|
510
|
+
return args.func(args)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
if __name__ == "__main__":
|
|
514
|
+
raise SystemExit(main())
|