bmad-module-skill-forge 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +51 -49
- package/docs/404.md +10 -8
- package/docs/_data/pinned.yaml +22 -13
- package/docs/_internal/STABILITY.md +58 -35
- package/docs/agents.md +39 -53
- package/docs/architecture.md +65 -51
- package/docs/bmad-synergy.md +53 -51
- package/docs/campaign.md +127 -75
- package/docs/concepts.md +34 -28
- package/docs/examples.md +70 -68
- package/docs/forge-auto.md +34 -31
- package/docs/getting-started.md +118 -57
- package/docs/how-it-works.md +25 -21
- package/docs/index.md +15 -15
- package/docs/skill-model.md +70 -59
- package/docs/troubleshooting.md +135 -18
- package/docs/verifying-a-skill.md +83 -57
- package/docs/why-skf.md +24 -22
- package/docs/workflows.md +274 -110
- package/package.json +18 -10
- package/src/forger/preferences.yaml +8 -1
- package/src/knowledge/agentskills-spec.md +2 -0
- package/src/knowledge/ccc-bridge.md +40 -24
- package/src/knowledge/confidence-tiers.md +9 -5
- package/src/knowledge/overview.md +3 -3
- package/src/knowledge/progressive-capability.md +8 -8
- package/src/knowledge/provenance-tracking.md +9 -5
- package/src/knowledge/qmd-registry.md +2 -2
- package/src/knowledge/skf-knowledge-index.csv +2 -2
- package/src/knowledge/skill-lifecycle.md +18 -11
- package/src/knowledge/split-body-strategy.md +0 -3
- package/src/knowledge/tool-resolution.md +4 -2
- package/src/knowledge/version-paths.md +101 -15
- package/src/module-help.csv +3 -3
- package/src/module.yaml +10 -0
- package/src/shared/data/ast-grep-recipes.yaml +608 -0
- package/src/shared/data/ide-context-files.json +30 -0
- package/src/shared/health-check.md +32 -12
- package/src/shared/references/description-guard-protocol.md +14 -9
- package/src/shared/references/feasibility-report-schema.md +19 -13
- package/src/shared/references/headless-gate-convention.md +55 -3
- package/src/shared/references/output-contract-schema.md +19 -8
- package/src/shared/references/pipeline-contracts.md +94 -29
- package/src/shared/references/tessl-review.md +77 -0
- package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
- package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
- package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
- package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
- package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
- package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
- package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
- package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
- package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
- package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
- package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
- package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +30 -14
- package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
- package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
- package/src/shared/scripts/skf-check-unit-records.py +184 -0
- package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
- package/src/shared/scripts/skf-classify-changed-files.py +694 -0
- package/src/shared/scripts/skf-comention-pairs.py +1333 -123
- package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
- package/src/shared/scripts/skf-count-imports.py +1867 -0
- package/src/shared/scripts/skf-count-tokens.py +1 -1
- package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
- package/src/shared/scripts/skf-description-guard.py +117 -9
- package/src/shared/scripts/skf-detect-docs.py +892 -4
- package/src/shared/scripts/skf-detect-language.py +486 -107
- package/src/shared/scripts/skf-detect-registry.py +887 -0
- package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
- package/src/shared/scripts/skf-detect-tools.py +215 -27
- package/src/shared/scripts/skf-detect-workspaces.py +506 -30
- package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
- package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
- package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
- package/src/shared/scripts/skf-extract-public-api.py +4084 -85
- package/src/shared/scripts/skf-extraction-inventory.py +746 -0
- package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
- package/src/shared/scripts/skf-fetch-temporal.py +674 -0
- package/src/shared/scripts/skf-find-cycles.py +97 -29
- package/src/shared/scripts/skf-find-test-report.py +476 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
- package/src/shared/scripts/skf-github-fetch.py +365 -0
- package/src/shared/scripts/skf-github-probe.py +661 -0
- package/src/shared/scripts/skf-hash-content.py +166 -12
- package/src/shared/scripts/skf-language-corpora.py +1 -1
- package/src/shared/scripts/skf-load-provenance.py +159 -27
- package/src/shared/scripts/skf-manifest-ops.py +1 -1
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
- package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
- package/src/shared/scripts/skf-names-present.py +221 -0
- package/src/shared/scripts/skf-pair-intersect.py +119 -28
- package/src/shared/scripts/skf-parse-gaps.py +1384 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-preflight.py +161 -27
- package/src/shared/scripts/skf-promote-staged.py +430 -0
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
- package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
- package/src/shared/scripts/skf-quick-batch.py +774 -0
- package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
- package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
- package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
- package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
- package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
- package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
- package/src/shared/scripts/skf-resolve-package.py +523 -106
- package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
- package/src/shared/scripts/skf-run-lock.py +577 -0
- package/src/shared/scripts/skf-scan-manifests.py +998 -57
- package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
- package/src/shared/scripts/skf-severity-classify.py +712 -108
- package/src/shared/scripts/skf-shape-detect.py +298 -204
- package/src/shared/scripts/skf-shard-body.py +9 -8
- package/src/shared/scripts/skf-skill-inventory.py +2412 -89
- package/src/shared/scripts/skf-skills-module.py +472 -0
- package/src/shared/scripts/skf-source-tree.py +1796 -0
- package/src/shared/scripts/skf-structural-diff.py +670 -119
- package/src/shared/scripts/skf-tessl-review.py +706 -0
- package/src/shared/scripts/skf-update-active-symlink.py +5 -5
- package/src/shared/scripts/skf-update-run-state.py +624 -0
- package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
- package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
- package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
- package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
- package/src/shared/scripts/skf-validate-output.py +712 -64
- package/src/shared/scripts/skf-validate-pins.py +1 -1
- package/src/shared/scripts/skf-verify-no-trace.py +116 -10
- package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
- package/src/shared/scripts/skf-write-skill-brief.py +367 -19
- package/src/shared/tool-requirements.yaml +201 -0
- package/src/skf-analyze-source/SKILL.md +41 -36
- package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
- package/src/skf-analyze-source/customize.toml +36 -24
- package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
- package/src/skf-analyze-source/references/continue.md +2 -13
- package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
- package/src/skf-analyze-source/references/generate-briefs.md +121 -55
- package/src/skf-analyze-source/references/headless-contract.md +42 -15
- package/src/skf-analyze-source/references/identify-units.md +96 -110
- package/src/skf-analyze-source/references/init.md +73 -71
- package/src/skf-analyze-source/references/map-and-detect.md +123 -105
- package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
- package/src/skf-analyze-source/references/recommend.md +51 -70
- package/src/skf-analyze-source/references/scan-project.md +60 -84
- package/src/skf-analyze-source/references/scan-root.md +39 -0
- package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
- package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
- package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
- package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
- package/src/skf-audit-skill/SKILL.md +43 -51
- package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
- package/src/skf-audit-skill/customize.toml +41 -20
- package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
- package/src/skf-audit-skill/references/doc-drift.md +141 -0
- package/src/skf-audit-skill/references/headless-contract.md +29 -0
- package/src/skf-audit-skill/references/init.md +148 -210
- package/src/skf-audit-skill/references/re-index.md +96 -108
- package/src/skf-audit-skill/references/report.md +96 -33
- package/src/skf-audit-skill/references/semantic-diff.md +27 -18
- package/src/skf-audit-skill/references/severity-classify.md +79 -65
- package/src/skf-audit-skill/references/severity-rules.md +15 -6
- package/src/skf-audit-skill/references/structural-diff.md +75 -90
- package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
- package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
- package/src/skf-brief-skill/SKILL.md +36 -25
- package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +17 -24
- package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
- package/src/skf-brief-skill/customize.toml +48 -20
- package/src/skf-brief-skill/references/analyze-target.md +142 -125
- package/src/skf-brief-skill/references/confirm-brief.md +10 -8
- package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
- package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
- package/src/skf-brief-skill/references/gather-intent.md +87 -182
- package/src/skf-brief-skill/references/headless-args.md +84 -16
- package/src/skf-brief-skill/references/health-check.md +1 -1
- package/src/skf-brief-skill/references/invocation-contract.md +18 -13
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
- package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
- package/src/skf-brief-skill/references/scope-definition.md +123 -47
- package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
- package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
- package/src/skf-brief-skill/references/version-resolution.md +3 -2
- package/src/skf-brief-skill/references/write-brief.md +66 -57
- package/src/skf-campaign/SKILL.md +31 -34
- package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
- package/src/skf-campaign/customize.toml +47 -16
- package/src/skf-campaign/references/campaign-contracts.md +39 -29
- package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
- package/src/skf-campaign/references/health-check.md +7 -16
- package/src/skf-campaign/references/step-01-setup.md +46 -84
- package/src/skf-campaign/references/step-02-strategy.md +31 -30
- package/src/skf-campaign/references/step-03-pins.md +26 -18
- package/src/skf-campaign/references/step-04-provenance.md +24 -23
- package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
- package/src/skf-campaign/references/step-06-batch.md +41 -34
- package/src/skf-campaign/references/step-07-capstone.md +40 -25
- package/src/skf-campaign/references/step-08-verify.md +28 -29
- package/src/skf-campaign/references/step-09-refine.md +29 -30
- package/src/skf-campaign/references/step-10-export.md +48 -36
- package/src/skf-campaign/references/step-11-maintenance.md +31 -34
- package/src/skf-campaign/references/step-resume.md +37 -68
- package/src/skf-campaign/scripts/campaign-deps.py +57 -18
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
- package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
- package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
- package/src/skf-campaign/scripts/campaign-report.py +204 -37
- package/src/skf-campaign/scripts/campaign-state.py +1128 -0
- package/src/skf-campaign/scripts/campaign-status.py +10 -74
- package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
- package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
- package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
- package/src/skf-campaign/templates/campaign-report-template.md +5 -1
- package/src/skf-campaign/templates/kickoff-template.md +0 -14
- package/src/skf-create-skill/SKILL.md +27 -20
- package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
- package/src/skf-create-skill/assets/skill-sections.md +17 -6
- package/src/skf-create-skill/customize.toml +20 -8
- package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
- package/src/skf-create-skill/references/batch-mode.md +81 -0
- package/src/skf-create-skill/references/ccc-index-check.md +27 -0
- package/src/skf-create-skill/references/compile.md +107 -29
- package/src/skf-create-skill/references/component-extraction.md +113 -91
- package/src/skf-create-skill/references/enrich.md +41 -23
- package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
- package/src/skf-create-skill/references/extract.md +200 -161
- package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
- package/src/skf-create-skill/references/extraction-patterns.md +584 -196
- package/src/skf-create-skill/references/generate-artifacts.md +145 -93
- package/src/skf-create-skill/references/health-check.md +1 -1
- package/src/skf-create-skill/references/load-brief.md +53 -36
- package/src/skf-create-skill/references/relabel-rule.md +12 -0
- package/src/skf-create-skill/references/report.md +28 -59
- package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
- package/src/skf-create-skill/references/step-auto-shard.md +20 -93
- package/src/skf-create-skill/references/step-doc-rot.md +44 -60
- package/src/skf-create-skill/references/step-doc-sources.md +55 -18
- package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
- package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
- package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
- package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
- package/src/skf-create-skill/references/validate.md +173 -99
- package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
- package/src/skf-create-stack-skill/SKILL.md +29 -56
- package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
- package/src/skf-create-stack-skill/customize.toml +23 -12
- package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
- package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
- package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
- package/src/skf-create-stack-skill/references/generate-output.md +138 -128
- package/src/skf-create-stack-skill/references/init.md +90 -32
- package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
- package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
- package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
- package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
- package/src/skf-create-stack-skill/references/report.md +39 -33
- package/src/skf-create-stack-skill/references/validate.md +20 -39
- package/src/skf-drop-skill/SKILL.md +28 -51
- package/src/skf-drop-skill/customize.toml +44 -30
- package/src/skf-drop-skill/references/execute.md +79 -146
- package/src/skf-drop-skill/references/invocation-contract.md +64 -0
- package/src/skf-drop-skill/references/report.md +46 -30
- package/src/skf-drop-skill/references/select.md +118 -124
- package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
- package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
- package/src/skf-export-skill/SKILL.md +40 -62
- package/src/skf-export-skill/assets/managed-section-format.md +14 -50
- package/src/skf-export-skill/assets/snippet-format.md +3 -1
- package/src/skf-export-skill/customize.toml +42 -13
- package/src/skf-export-skill/references/generate-snippet.md +54 -46
- package/src/skf-export-skill/references/health-check.md +2 -0
- package/src/skf-export-skill/references/invocation-contract.md +15 -0
- package/src/skf-export-skill/references/load-skill.md +88 -62
- package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
- package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
- package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
- package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
- package/src/skf-export-skill/references/package.md +15 -73
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
- package/src/skf-export-skill/references/result-envelope.md +31 -16
- package/src/skf-export-skill/references/summary.md +41 -17
- package/src/skf-export-skill/references/token-report.md +4 -12
- package/src/skf-export-skill/references/update-context.md +118 -237
- package/src/skf-forger/SKILL.md +61 -37
- package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
- package/src/skf-forger/customize.toml +43 -0
- package/src/skf-forger/references/pipeline-mode.md +73 -18
- package/src/skf-forger/scripts/forge-status.py +299 -0
- package/src/skf-forger/scripts/parse-pipeline.py +322 -54
- package/src/skf-forger/scripts/pipeline-gate.py +428 -0
- package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
- package/src/skf-quick-skill/SKILL.md +54 -53
- package/src/skf-quick-skill/assets/skill-template.md +4 -2
- package/src/skf-quick-skill/customize.toml +51 -20
- package/src/skf-quick-skill/references/batch-mode.md +74 -21
- package/src/skf-quick-skill/references/compile.md +30 -31
- package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
- package/src/skf-quick-skill/references/finalize.md +69 -18
- package/src/skf-quick-skill/references/halt-contract.md +41 -29
- package/src/skf-quick-skill/references/health-check.md +5 -3
- package/src/skf-quick-skill/references/quick-extract.md +114 -45
- package/src/skf-quick-skill/references/registry-resolution.md +9 -64
- package/src/skf-quick-skill/references/resolve-target.md +116 -66
- package/src/skf-quick-skill/references/write-and-validate.md +79 -40
- package/src/skf-refine-architecture/SKILL.md +43 -53
- package/src/skf-refine-architecture/customize.toml +49 -20
- package/src/skf-refine-architecture/references/compile.md +97 -54
- package/src/skf-refine-architecture/references/exit-codes.md +30 -0
- package/src/skf-refine-architecture/references/finding-storage.md +21 -0
- package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
- package/src/skf-refine-architecture/references/improvements.md +14 -20
- package/src/skf-refine-architecture/references/init.md +98 -57
- package/src/skf-refine-architecture/references/issue-detection.md +32 -39
- package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
- package/src/skf-refine-architecture/references/report.md +65 -54
- package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
- package/src/skf-rename-skill/SKILL.md +19 -54
- package/src/skf-rename-skill/customize.toml +32 -26
- package/src/skf-rename-skill/references/execute.md +230 -184
- package/src/skf-rename-skill/references/exit-codes.md +7 -7
- package/src/skf-rename-skill/references/health-check.md +1 -7
- package/src/skf-rename-skill/references/invocation-contract.md +25 -0
- package/src/skf-rename-skill/references/report.md +63 -52
- package/src/skf-rename-skill/references/select.md +197 -115
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
- package/src/skf-setup/SKILL.md +26 -26
- package/src/skf-setup/customize.toml +14 -10
- package/src/skf-setup/references/auto-index.md +50 -63
- package/src/skf-setup/references/ccc-index.md +29 -89
- package/src/skf-setup/references/detect-and-tier.md +58 -66
- package/src/skf-setup/references/health-check.md +2 -0
- package/src/skf-setup/references/invocation-contract.md +14 -0
- package/src/skf-setup/references/report.md +43 -166
- package/src/skf-setup/references/tier-rules.md +5 -5
- package/src/skf-setup/references/write-config.md +34 -57
- package/src/skf-test-skill/SKILL.md +17 -57
- package/src/skf-test-skill/assets/output-section-formats.md +33 -34
- package/src/skf-test-skill/customize.toml +27 -13
- package/src/skf-test-skill/references/coherence-check.md +134 -62
- package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
- package/src/skf-test-skill/references/coverage-check.md +217 -254
- package/src/skf-test-skill/references/detect-mode.md +5 -5
- package/src/skf-test-skill/references/external-validators.md +94 -82
- package/src/skf-test-skill/references/health-check.md +6 -7
- package/src/skf-test-skill/references/init.md +163 -88
- package/src/skf-test-skill/references/invocation-contract.md +66 -0
- package/src/skf-test-skill/references/migration-section-rules.md +5 -2
- package/src/skf-test-skill/references/report.md +162 -142
- package/src/skf-test-skill/references/score.md +80 -107
- package/src/skf-test-skill/references/scoring-rules.md +41 -155
- package/src/skf-test-skill/references/source-access-protocol.md +19 -92
- package/src/skf-test-skill/references/step-hard-gate.md +51 -33
- package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
- package/src/skf-test-skill/scripts/build-result-context.py +287 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
- package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
- package/src/skf-test-skill/scripts/compute-score.py +223 -45
- package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
- package/src/skf-test-skill/scripts/hard-gate.py +203 -0
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
- package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
- package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
- package/src/skf-test-skill/scripts/score-signatures.py +459 -0
- package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
- package/src/skf-test-skill/templates/test-report-template.md +21 -15
- package/src/skf-update-skill/SKILL.md +38 -25
- package/src/skf-update-skill/customize.toml +27 -12
- package/src/skf-update-skill/references/detect-changes.md +233 -222
- package/src/skf-update-skill/references/gap-driven.md +336 -0
- package/src/skf-update-skill/references/health-check.md +31 -5
- package/src/skf-update-skill/references/init.md +250 -84
- package/src/skf-update-skill/references/invocation-contract.md +12 -0
- package/src/skf-update-skill/references/manual-section-rules.md +6 -23
- package/src/skf-update-skill/references/merge.md +180 -47
- package/src/skf-update-skill/references/re-extract.md +53 -262
- package/src/skf-update-skill/references/report.md +100 -35
- package/src/skf-update-skill/references/write.md +331 -149
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
- package/src/skf-verify-stack/SKILL.md +23 -42
- package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
- package/src/skf-verify-stack/customize.toml +55 -22
- package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
- package/src/skf-verify-stack/references/coverage.md +67 -64
- package/src/skf-verify-stack/references/exit-codes.md +21 -8
- package/src/skf-verify-stack/references/init.md +107 -62
- package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
- package/src/skf-verify-stack/references/integrations.md +106 -65
- package/src/skf-verify-stack/references/report.md +59 -42
- package/src/skf-verify-stack/references/requirements.md +32 -9
- package/src/skf-verify-stack/references/synthesize.md +53 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
- package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
- package/tools/cli/commands/install.js +11 -4
- package/tools/cli/commands/status.js +15 -3
- package/tools/cli/commands/update.js +11 -4
- package/tools/cli/lib/tool-check.js +320 -0
- package/tools/cli/lib/version-check.js +22 -9
- package/tools/cli/skf-cli.js +4 -1
- package/.gitattributes +0 -16
- package/.nvmrc +0 -1
- package/docs/_internal/RELEASING.md +0 -645
- package/release-audits/v1.0.0-launch-audit.md +0 -728
- package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
- package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
- package/src/skf-campaign/manifest.yaml +0 -15
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
- package/src/skf-create-skill/references/ecosystem-check.md +0 -74
- package/src/skf-drop-skill/references/headless-contract.md +0 -18
- package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
- package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
- package/src/skf-update-skill/references/validate.md +0 -124
- package/tools/validate-docs-drift.js +0 -233
- package/tools/validate-docs-links.js +0 -665
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Tessl Review — the optional Tessl Review for create-skill and test-skill.
|
|
6
|
+
|
|
7
|
+
Tessl Review (`tessl review run`) scores a skill on Tessl's servers: validation
|
|
8
|
+
checks plus two AI judges, one for the frontmatter description and one for
|
|
9
|
+
the content. It needs a Tessl account, uploads the skill's files, keeps each
|
|
10
|
+
review in a Tessl workspace's history and spends Tessl credits, so SKF runs it
|
|
11
|
+
only when the user names that workspace as `tessl_review_workspace` in the
|
|
12
|
+
forger-sidecar preferences.yaml. This helper owns every decision around the
|
|
13
|
+
review, so the step files hold no tessl commands:
|
|
14
|
+
|
|
15
|
+
submit read the preference, find tessl, check the sign-in, copy the
|
|
16
|
+
skill's own files into a temporary folder, send that copy to
|
|
17
|
+
Tessl Review without waiting for the review (`--no-wait`), remove
|
|
18
|
+
the copy and print one JSON result: `pending` with Tessl's run id,
|
|
19
|
+
or why no review was started.
|
|
20
|
+
collect check a submitted review (`tessl review view <run-id> --json`)
|
|
21
|
+
every POLL_INTERVAL_SEC seconds for up to --max-seconds (at most
|
|
22
|
+
COLLECT_MAX_SEC) and print `pending` again or the finished
|
|
23
|
+
result. A check that fails without an answer SKF can read, or
|
|
24
|
+
does not answer in time, is tried again: Tessl may still finish
|
|
25
|
+
the review. With --final, a review SKF did not see finish is
|
|
26
|
+
`timeout`. collect runs no other tessl command; it repeats the
|
|
27
|
+
submit result's workspace and tessl version (--workspace,
|
|
28
|
+
--tessl-version) in its result.
|
|
29
|
+
parse normalize a saved `tessl review run --json` or `tessl review view
|
|
30
|
+
--json` output (for tests and for re-reading a review by hand).
|
|
31
|
+
|
|
32
|
+
A fresh review takes Tessl about two minutes, as long as some shell tools
|
|
33
|
+
allow one command, so no call waits for a whole review: every call ends
|
|
34
|
+
within two minutes, the tessl calls' own time limits included (on a limit
|
|
35
|
+
the helper kills tessl and everything it started). The calling step runs
|
|
36
|
+
collect again while the result is `pending`, at most MAX_COLLECTS times,
|
|
37
|
+
the last one with --final.
|
|
38
|
+
|
|
39
|
+
Only SKILL.md and the references/, scripts/ and assets/ folders are uploaded,
|
|
40
|
+
without links. metadata.json, context-snippet.md and the workspace artifacts
|
|
41
|
+
create-skill stages beside them (provenance-map.json, evidence-report.md)
|
|
42
|
+
stay on the machine. Every tessl call runs with a temporary folder as its
|
|
43
|
+
working directory, so tessl never reads or writes the user's project.
|
|
44
|
+
|
|
45
|
+
Output (stdout, one JSON object with every key in RESULT_KEYS, every
|
|
46
|
+
subcommand):
|
|
47
|
+
status one of STATUSES
|
|
48
|
+
summary one line; the reports record `Tessl Review: <summary>`
|
|
49
|
+
detail diagnostic text, or null
|
|
50
|
+
workspace the workspace the review was submitted to, or null
|
|
51
|
+
tessl_version x.y.z (from submit's `tessl --version`), or null
|
|
52
|
+
exit_code exit code of the last `tessl review run` or
|
|
53
|
+
`tessl review view`, or null
|
|
54
|
+
run_id Tessl's reviewRunId, or null before a submit
|
|
55
|
+
review_score 0-100 (review.reviewScore), or null
|
|
56
|
+
description_score 0-100 (description judge), or null
|
|
57
|
+
content_score 0-100 (content judge), or null
|
|
58
|
+
validation null, or {passed, errors, warnings,
|
|
59
|
+
findings: [{name, status, message}]}
|
|
60
|
+
description_suggestions, content_suggestions lists of strings
|
|
61
|
+
warnings lines for the reports' warnings: a score below
|
|
62
|
+
SCORE_FLOOR, validation errors, or, for every status
|
|
63
|
+
but `reviewed` and `off`, the summary itself
|
|
64
|
+
|
|
65
|
+
Exit codes:
|
|
66
|
+
0 a result was printed (every status, including off and failed)
|
|
67
|
+
2 usage error (argparse)
|
|
68
|
+
|
|
69
|
+
It never passes --force, --threshold or --label to tessl, never waits for a
|
|
70
|
+
review in one call, never installs tessl and never follows a link inside the
|
|
71
|
+
skill folder.
|
|
72
|
+
|
|
73
|
+
CLI (uv run, for the PEP 723 pyyaml dependency):
|
|
74
|
+
uv run skf-tessl-review.py submit <skill-dir> --preferences <preferences.yaml>
|
|
75
|
+
uv run skf-tessl-review.py collect <run-id> [--workspace <name>] [--tessl-version <x.y.z>]
|
|
76
|
+
[--max-seconds N] [--final]
|
|
77
|
+
uv run skf-tessl-review.py parse <saved-review.json>
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
import argparse
|
|
83
|
+
import json
|
|
84
|
+
import math
|
|
85
|
+
import os
|
|
86
|
+
import re
|
|
87
|
+
import shutil
|
|
88
|
+
import signal
|
|
89
|
+
import stat
|
|
90
|
+
import subprocess
|
|
91
|
+
import sys
|
|
92
|
+
import tempfile
|
|
93
|
+
import time
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
|
|
96
|
+
import yaml
|
|
97
|
+
|
|
98
|
+
PREF_KEY = "tessl_review_workspace"
|
|
99
|
+
PROBE_TIMEOUT_SEC = 20 # tessl --version, tessl whoami (submit only)
|
|
100
|
+
SUBMIT_TIMEOUT_SEC = 45 # tessl review run --no-wait (about 7 seconds observed)
|
|
101
|
+
VIEW_TIMEOUT_SEC = 20 # one tessl review view
|
|
102
|
+
POLL_INTERVAL_SEC = 10 # pause between two views in one collect
|
|
103
|
+
COLLECT_MAX_SEC = 90 # default and upper bound of collect --max-seconds
|
|
104
|
+
KILL_WAIT_SEC = 5 # each wait after a time limit: taskkill, then the child
|
|
105
|
+
MAX_COLLECTS = 6 # collect calls a step makes before it gives up (the last with --final)
|
|
106
|
+
SCORE_FLOOR = 60
|
|
107
|
+
STATUSES = (
|
|
108
|
+
"reviewed", "pending", "off", "invalid-config", "not-installed", "signed-out",
|
|
109
|
+
"command-unavailable", "unknown-run", "timeout", "failed", "parse-failure",
|
|
110
|
+
)
|
|
111
|
+
RESULT_KEYS = (
|
|
112
|
+
"status", "summary", "detail", "workspace", "tessl_version", "exit_code",
|
|
113
|
+
"run_id", "review_score", "description_score", "content_score", "validation",
|
|
114
|
+
"description_suggestions", "content_suggestions", "warnings",
|
|
115
|
+
)
|
|
116
|
+
# Run statuses tessl reports before a review finishes: `review run --no-wait`
|
|
117
|
+
# answers pending and `review view` incomplete; running and queued are
|
|
118
|
+
# accepted too, so a renamed in-progress status is not read as a failure.
|
|
119
|
+
PENDING_RUN_STATUSES = ("pending", "incomplete", "running", "queued")
|
|
120
|
+
BUNDLE_DIRS = ("references", "scripts", "assets")
|
|
121
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
122
|
+
STATUS_GLYPHS = "✘✔⚠✖✓✗!•› \t"
|
|
123
|
+
# tessl's stub for a removed command, and its unknown-command error.
|
|
124
|
+
REMOVED_RE = re.compile(r"has been replaced|has been removed|no command registered", re.IGNORECASE)
|
|
125
|
+
SIGNED_OUT_RE = re.compile(r"not logged in|please authenticate|tessl login", re.IGNORECASE)
|
|
126
|
+
UNKNOWN_RUN_RE = re.compile(r"could not find review run", re.IGNORECASE)
|
|
127
|
+
RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}") # used with fullmatch
|
|
128
|
+
# Used with fullmatch. On Windows tessl is often an npm .cmd shim that cmd.exe
|
|
129
|
+
# parses, so a workspace name never carries &, |, ^, % or quotes.
|
|
130
|
+
WORKSPACE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
|
131
|
+
|
|
132
|
+
SUMMARIES = {
|
|
133
|
+
"off": f"off — set {PREF_KEY} in preferences.yaml to enable",
|
|
134
|
+
"not-installed": "not run — tessl is not installed",
|
|
135
|
+
"signed-out": "not run — not signed in to Tessl (run tessl login, or set TESSL_TOKEN)",
|
|
136
|
+
"command-unavailable": "not run — the installed tessl has no working review run command (update tessl)",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Keep identical to _is_link_or_junction in skf-skill-inventory.py
|
|
141
|
+
# (test/test-skf-skill-inventory.py pins the copies).
|
|
142
|
+
def _is_link_or_junction(p: Path) -> bool:
|
|
143
|
+
"""True for POSIX symlinks AND Windows junctions/symlinks.
|
|
144
|
+
|
|
145
|
+
`Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
|
|
146
|
+
for both symlinks and junctions (since CPython 3.8 on Windows). A regular
|
|
147
|
+
directory raises OSError on readlink, which is the signal we want to
|
|
148
|
+
refuse replacement. On Windows, any other reparse point (a cloud-sync
|
|
149
|
+
placeholder, a deduplicated file, an app execution alias) raises
|
|
150
|
+
ValueError: it does not redirect to another path, so it is not a link.
|
|
151
|
+
"""
|
|
152
|
+
if p.is_symlink():
|
|
153
|
+
return True
|
|
154
|
+
if not p.exists() and not p.is_symlink():
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
os.readlink(p)
|
|
158
|
+
return True
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_outside_cwd(command: str) -> str | None:
|
|
164
|
+
"""shutil.which with a CWD-shim guard. Returns the resolved path or None.
|
|
165
|
+
|
|
166
|
+
shutil.which on Windows searches the current directory ahead of PATH,
|
|
167
|
+
and CWD here is the user's project — a bare-name lookup resolving into
|
|
168
|
+
CWD would execute a planted shim (e.g. tessl.cmd). Such a resolution is
|
|
169
|
+
treated as not-found. Explicit paths supplied by callers (containing a
|
|
170
|
+
separator) are honored as-is. Keep the code identical to the sibling
|
|
171
|
+
guards in skf-merge-ccc-exclusions.py, skf-detect-tools.py,
|
|
172
|
+
skf-qmd-classify-collections.py, skf-ccc-git-hygiene.py,
|
|
173
|
+
skf-source-tree.py and skf-verify-provenance-completeness.py
|
|
174
|
+
(test/test-skf-ccc-git-hygiene.py pins it against
|
|
175
|
+
skf-merge-ccc-exclusions.py).
|
|
176
|
+
"""
|
|
177
|
+
resolved = shutil.which(command)
|
|
178
|
+
if resolved is None:
|
|
179
|
+
return None
|
|
180
|
+
if os.sep in command or (os.altsep and os.altsep in command):
|
|
181
|
+
return resolved
|
|
182
|
+
resolved_dir = os.path.dirname(resolved)
|
|
183
|
+
if resolved_dir:
|
|
184
|
+
cwd = os.path.normcase(os.path.abspath(os.getcwd()))
|
|
185
|
+
if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
|
|
186
|
+
return None
|
|
187
|
+
return resolved
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _result(status: str, summary: str, **fields) -> dict:
|
|
191
|
+
"""A result with every key in RESULT_KEYS; `fields` overrides the defaults."""
|
|
192
|
+
out = {key: None for key in RESULT_KEYS}
|
|
193
|
+
out.update({"description_suggestions": [], "content_suggestions": [], "warnings": []})
|
|
194
|
+
out.update(status=status, summary=summary)
|
|
195
|
+
out.update(fields)
|
|
196
|
+
if status not in ("reviewed", "off") and not out["warnings"]:
|
|
197
|
+
out["warnings"] = [f"Tessl Review: {summary}"]
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _in_workspace(workspace: str | None) -> str:
|
|
202
|
+
return f" in workspace {workspace}" if workspace else ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _pending(run_id: str, workspace: str | None = None, **fields) -> dict:
|
|
206
|
+
summary = (f"no result yet — Tessl Review run {run_id} has not finished{_in_workspace(workspace)} "
|
|
207
|
+
f"(tessl review view {run_id})")
|
|
208
|
+
return _result("pending", summary, run_id=run_id, workspace=workspace, **fields)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _timed_out(run_id: str, workspace: str | None = None, **fields) -> dict:
|
|
212
|
+
summary = (f"no result — Tessl Review run {run_id} did not finish while SKF waited; it may still "
|
|
213
|
+
f"finish{_in_workspace(workspace)} (tessl review view {run_id})")
|
|
214
|
+
return _result("timeout", summary, run_id=run_id, workspace=workspace, **fields)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _lines(text: str) -> list[str]:
|
|
218
|
+
"""Non-empty lines without ANSI codes or leading status glyphs."""
|
|
219
|
+
out = []
|
|
220
|
+
for line in ANSI_RE.sub("", text or "").splitlines():
|
|
221
|
+
stripped = line.strip().lstrip(STATUS_GLYPHS).strip()
|
|
222
|
+
if stripped:
|
|
223
|
+
out.append(stripped)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _first_line(text: str) -> str | None:
|
|
228
|
+
lines = _lines(text)
|
|
229
|
+
return lines[0] if lines else None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _now() -> float:
|
|
233
|
+
return time.monotonic()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _sleep(seconds: float) -> None:
|
|
237
|
+
time.sleep(seconds)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- preferences ------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_workspace(prefs_path: Path) -> tuple[str, str | None, str | None]:
|
|
244
|
+
"""Return (state, workspace, reason) from preferences.yaml.
|
|
245
|
+
|
|
246
|
+
state is "on", "off" or "invalid". Only a usable workspace name turns the
|
|
247
|
+
review on. A missing or unreadable file, invalid YAML, an absent key or
|
|
248
|
+
a null, false or empty value is off. Any other value is invalid: the
|
|
249
|
+
user tried to opt in, and the report says why it did not work.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
text = prefs_path.read_text(encoding="utf-8")
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return "off", None, None
|
|
255
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
256
|
+
return "off", None, f"cannot read {prefs_path.as_posix()}: {exc}"
|
|
257
|
+
try:
|
|
258
|
+
data = yaml.safe_load(text)
|
|
259
|
+
except yaml.YAMLError as exc:
|
|
260
|
+
return "off", None, f"preferences.yaml is not valid YAML: {exc}"
|
|
261
|
+
if not isinstance(data, dict):
|
|
262
|
+
return "off", None, None
|
|
263
|
+
value = data.get(PREF_KEY)
|
|
264
|
+
if value is None or value is False or value == "":
|
|
265
|
+
return "off", None, None
|
|
266
|
+
if not isinstance(value, str) or not WORKSPACE_RE.fullmatch(value):
|
|
267
|
+
return "invalid", None, (f"{PREF_KEY} must be a Tessl workspace name of letters, digits, '.', '_' "
|
|
268
|
+
f"and '-' (got {value!r})")
|
|
269
|
+
return "on", value, None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- parsing ----------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _extract_json(text: str):
|
|
276
|
+
stripped = (text or "").strip()
|
|
277
|
+
try:
|
|
278
|
+
return json.loads(stripped)
|
|
279
|
+
except ValueError:
|
|
280
|
+
pass
|
|
281
|
+
lines = stripped.splitlines()
|
|
282
|
+
for i, line in enumerate(lines):
|
|
283
|
+
if line.startswith("{"):
|
|
284
|
+
try:
|
|
285
|
+
return json.loads("\n".join(lines[i:]))
|
|
286
|
+
except ValueError:
|
|
287
|
+
return None
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _percent(value) -> int | None:
|
|
292
|
+
"""0-1 fraction to a whole percent, rounding half up as tessl displays it."""
|
|
293
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
294
|
+
return None
|
|
295
|
+
if value < 0 or value > 1:
|
|
296
|
+
return None
|
|
297
|
+
return int(math.floor(value * 100 + 0.5))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _count(value) -> int | None:
|
|
301
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
302
|
+
return None
|
|
303
|
+
return value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _judge(judges: dict, name: str) -> tuple[int | None, list[str]]:
|
|
307
|
+
judge = judges.get(name)
|
|
308
|
+
if not isinstance(judge, dict) or judge.get("success") is not True:
|
|
309
|
+
return None, []
|
|
310
|
+
evaluation = judge.get("evaluation") if isinstance(judge.get("evaluation"), dict) else {}
|
|
311
|
+
raw = evaluation.get("suggestions")
|
|
312
|
+
suggestions = [s.strip() for s in raw if isinstance(s, str) and s.strip()] if isinstance(raw, list) else []
|
|
313
|
+
return _percent(judge.get("normalizedScore")), suggestions
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def parse_review(data) -> dict:
|
|
317
|
+
"""Normalize a parsed, finished `tessl review run|view --json` object. Never raises."""
|
|
318
|
+
if not isinstance(data, dict):
|
|
319
|
+
why = "no JSON object"
|
|
320
|
+
return _result("parse-failure", f"failed — Tessl Review output could not be read ({why})", detail=why)
|
|
321
|
+
run_id = data.get("reviewRunId") if isinstance(data.get("reviewRunId"), str) else None
|
|
322
|
+
run_status = data.get("status")
|
|
323
|
+
if run_status is not None and run_status != "completed":
|
|
324
|
+
detail = f"Tessl Review run {run_id or '(no id)'} ended with status {run_status!r}"
|
|
325
|
+
return _result("failed", f"failed — {detail}", detail=detail, run_id=run_id)
|
|
326
|
+
review = data.get("review")
|
|
327
|
+
score = review.get("reviewScore") if isinstance(review, dict) else None
|
|
328
|
+
if isinstance(score, bool) or not isinstance(score, (int, float)) or not 0 <= score <= 100:
|
|
329
|
+
why = "no review.reviewScore from 0 to 100"
|
|
330
|
+
return _result("parse-failure", f"failed — Tessl Review output could not be read ({why})",
|
|
331
|
+
detail=why, run_id=run_id)
|
|
332
|
+
review_score = int(math.floor(score + 0.5))
|
|
333
|
+
raw_validation = data.get("validation") if isinstance(data.get("validation"), dict) else {}
|
|
334
|
+
checks = raw_validation.get("checks") if isinstance(raw_validation.get("checks"), list) else []
|
|
335
|
+
passed = raw_validation.get("overallPassed")
|
|
336
|
+
validation = {
|
|
337
|
+
"passed": passed if isinstance(passed, bool) else None,
|
|
338
|
+
"errors": _count(raw_validation.get("errorCount")),
|
|
339
|
+
"warnings": _count(raw_validation.get("warningCount")),
|
|
340
|
+
"findings": [
|
|
341
|
+
{"name": str(c.get("name", "")), "status": str(c.get("status", "")), "message": str(c.get("message", ""))}
|
|
342
|
+
for c in checks
|
|
343
|
+
if isinstance(c, dict) and c.get("status") != "passed"
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
judges = data.get("judges") if isinstance(data.get("judges"), dict) else {}
|
|
347
|
+
description_score, description_suggestions = _judge(judges, "description")
|
|
348
|
+
content_score, content_suggestions = _judge(judges, "content")
|
|
349
|
+
|
|
350
|
+
warnings = []
|
|
351
|
+
for label, value in (("review score", review_score), ("description score", description_score),
|
|
352
|
+
("content score", content_score)):
|
|
353
|
+
if value is not None and value < SCORE_FLOOR:
|
|
354
|
+
warnings.append(f"Tessl Review {label} {value}% is below {SCORE_FLOOR}%")
|
|
355
|
+
if validation["errors"]:
|
|
356
|
+
warnings.append(f"Tessl Review validation found {validation['errors']} error(s)")
|
|
357
|
+
|
|
358
|
+
def pct(v):
|
|
359
|
+
return "n/a" if v is None else f"{v}%"
|
|
360
|
+
|
|
361
|
+
def num(v):
|
|
362
|
+
return "n/a" if v is None else str(v)
|
|
363
|
+
|
|
364
|
+
verdict = {True: "passed", False: "failed", None: "n/a"}[validation["passed"]]
|
|
365
|
+
summary = (
|
|
366
|
+
f"reviewed — score {review_score}% (description {pct(description_score)}, "
|
|
367
|
+
f"content {pct(content_score)}, validation {verdict}: "
|
|
368
|
+
f"{num(validation['errors'])} errors, {num(validation['warnings'])} warnings)"
|
|
369
|
+
)
|
|
370
|
+
return _result(
|
|
371
|
+
"reviewed", summary,
|
|
372
|
+
run_id=run_id,
|
|
373
|
+
review_score=review_score,
|
|
374
|
+
description_score=description_score,
|
|
375
|
+
content_score=content_score,
|
|
376
|
+
validation=validation,
|
|
377
|
+
description_suggestions=description_suggestions,
|
|
378
|
+
content_suggestions=content_suggestions,
|
|
379
|
+
warnings=warnings,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def interpret(data, workspace: str | None = None) -> dict:
|
|
384
|
+
"""Normalize a parsed `tessl review run|view --json` object: `pending` while
|
|
385
|
+
the run is in progress, else parse_review. Never raises."""
|
|
386
|
+
if isinstance(data, dict) and data.get("status") in PENDING_RUN_STATUSES:
|
|
387
|
+
run_id = data.get("reviewRunId")
|
|
388
|
+
if not isinstance(run_id, str) or not RUN_ID_RE.fullmatch(run_id):
|
|
389
|
+
why = "a review in progress without a reviewRunId"
|
|
390
|
+
return _result("parse-failure", f"failed — Tessl Review output could not be read ({why})",
|
|
391
|
+
detail=why, workspace=workspace)
|
|
392
|
+
return _pending(run_id, workspace)
|
|
393
|
+
result = parse_review(data)
|
|
394
|
+
result["workspace"] = workspace
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def classify(stdout: str, stderr: str, exit_code: int | None, run_id: str | None = None,
|
|
399
|
+
workspace: str | None = None) -> dict:
|
|
400
|
+
"""Turn a finished `tessl review run --no-wait --json` or `tessl review view
|
|
401
|
+
--json` call into a result.
|
|
402
|
+
|
|
403
|
+
JSON on stdout wins whatever the exit code: tessl exits 1 with the full
|
|
404
|
+
JSON when a score misses --threshold or validation fails. Without JSON,
|
|
405
|
+
the removed-command text is checked first, then an unknown run id, then
|
|
406
|
+
the sign-in text; a clean exit with no JSON is a parse failure, anything
|
|
407
|
+
else a failure.
|
|
408
|
+
"""
|
|
409
|
+
data = _extract_json(stdout)
|
|
410
|
+
if data is not None:
|
|
411
|
+
result = interpret(data, workspace)
|
|
412
|
+
if result["run_id"] is None:
|
|
413
|
+
result["run_id"] = run_id
|
|
414
|
+
return result
|
|
415
|
+
common = {"run_id": run_id, "workspace": workspace}
|
|
416
|
+
text = f"{stderr}\n{stdout}"
|
|
417
|
+
message = _first_line(stderr) or _first_line(stdout) or f"tessl exited with code {exit_code}"
|
|
418
|
+
if REMOVED_RE.search(text):
|
|
419
|
+
return _result("command-unavailable", SUMMARIES["command-unavailable"], detail=message, **common)
|
|
420
|
+
if UNKNOWN_RUN_RE.search(text):
|
|
421
|
+
summary = f"no result — Tessl has no review run {run_id} (tessl review list shows the runs)"
|
|
422
|
+
return _result("unknown-run", summary, detail=message, **common)
|
|
423
|
+
if SIGNED_OUT_RE.search(text):
|
|
424
|
+
return _result("signed-out", SUMMARIES["signed-out"], detail=message, **common)
|
|
425
|
+
if exit_code == 0:
|
|
426
|
+
why = "tessl printed no JSON"
|
|
427
|
+
return _result("parse-failure", f"failed — Tessl Review output could not be read ({why})",
|
|
428
|
+
detail=message, **common)
|
|
429
|
+
summary = f"failed — {message}"
|
|
430
|
+
if "workspace" in message.lower():
|
|
431
|
+
summary += f" (check {PREF_KEY} in preferences.yaml; tessl workspace list shows yours)"
|
|
432
|
+
return _result("failed", summary, detail=message, **common)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# --- running tessl ----------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def resolve_tessl() -> list[str] | None:
|
|
439
|
+
"""tessl on PATH first, else the npm launcher already in the npx cache (never installed)."""
|
|
440
|
+
exe = _resolve_outside_cwd("tessl")
|
|
441
|
+
if exe:
|
|
442
|
+
return [exe]
|
|
443
|
+
npx = _resolve_outside_cwd("npx")
|
|
444
|
+
if npx:
|
|
445
|
+
return [npx, "--no-install", "tessl"]
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _kill_tree(proc: subprocess.Popen, windows: bool = os.name == "nt") -> None:
|
|
450
|
+
"""Kill the child and everything it started (the npm launcher starts the tessl binary)."""
|
|
451
|
+
try:
|
|
452
|
+
if windows:
|
|
453
|
+
taskkill = _resolve_outside_cwd("taskkill")
|
|
454
|
+
if taskkill:
|
|
455
|
+
subprocess.run([taskkill, "/F", "/T", "/PID", str(proc.pid)], stdin=subprocess.DEVNULL,
|
|
456
|
+
capture_output=True, timeout=KILL_WAIT_SEC, check=False)
|
|
457
|
+
proc.kill()
|
|
458
|
+
else:
|
|
459
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
460
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _run(cmd: list[str], timeout: float, cwd: str) -> tuple[int | None, str, str, bool]:
|
|
465
|
+
"""Run cmd in cwd; return (returncode, stdout, stderr, timed_out).
|
|
466
|
+
|
|
467
|
+
returncode is None when the command timed out or could not start. Output
|
|
468
|
+
goes to temporary files, not pipes, so a grandchild that keeps a pipe
|
|
469
|
+
open cannot block the wait after a timeout; the child gets its own
|
|
470
|
+
process group (POSIX session) so the kill reaches the whole tree.
|
|
471
|
+
"""
|
|
472
|
+
env = dict(os.environ)
|
|
473
|
+
env["NO_COLOR"] = "1"
|
|
474
|
+
kwargs = {}
|
|
475
|
+
if os.name == "nt":
|
|
476
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
477
|
+
else:
|
|
478
|
+
kwargs["start_new_session"] = True
|
|
479
|
+
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
|
|
480
|
+
try:
|
|
481
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=out_f, stderr=err_f,
|
|
482
|
+
cwd=cwd, env=env, **kwargs)
|
|
483
|
+
except (OSError, ValueError) as exc:
|
|
484
|
+
return None, "", f"cannot start {Path(cmd[0]).name}: {exc}", False
|
|
485
|
+
timed_out = False
|
|
486
|
+
try:
|
|
487
|
+
rc = proc.wait(timeout=timeout)
|
|
488
|
+
except subprocess.TimeoutExpired:
|
|
489
|
+
timed_out = True
|
|
490
|
+
_kill_tree(proc)
|
|
491
|
+
try:
|
|
492
|
+
proc.wait(timeout=KILL_WAIT_SEC)
|
|
493
|
+
except subprocess.TimeoutExpired:
|
|
494
|
+
pass
|
|
495
|
+
rc = None
|
|
496
|
+
out_f.seek(0)
|
|
497
|
+
err_f.seek(0)
|
|
498
|
+
stdout = out_f.read().decode("utf-8", errors="replace")
|
|
499
|
+
stderr = err_f.read().decode("utf-8", errors="replace")
|
|
500
|
+
return rc, stdout, stderr, timed_out
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _raise(exc: OSError) -> None:
|
|
504
|
+
raise exc
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def stage_bundle(skill_dir: Path, dest_root: Path) -> Path:
|
|
508
|
+
"""Copy SKILL.md, references/, scripts/ and assets/ (no links) to dest_root/<skill-dir name>/.
|
|
509
|
+
|
|
510
|
+
Probes with os.lstat and walks with onerror=_raise, so an unreadable
|
|
511
|
+
folder raises OSError instead of silently shrinking the upload (Python
|
|
512
|
+
3.14's pathlib reports such a folder as absent).
|
|
513
|
+
"""
|
|
514
|
+
skill_dir = Path(os.path.abspath(skill_dir))
|
|
515
|
+
target = dest_root / skill_dir.name
|
|
516
|
+
target.mkdir()
|
|
517
|
+
skill_md = skill_dir / "SKILL.md"
|
|
518
|
+
if _is_link_or_junction(skill_md):
|
|
519
|
+
raise OSError(f"{skill_md.as_posix()} is a link; SKF uploads no linked file")
|
|
520
|
+
shutil.copyfile(skill_md, target / "SKILL.md")
|
|
521
|
+
for entry in BUNDLE_DIRS:
|
|
522
|
+
src = skill_dir / entry
|
|
523
|
+
try:
|
|
524
|
+
st = os.lstat(src)
|
|
525
|
+
except FileNotFoundError:
|
|
526
|
+
continue
|
|
527
|
+
if _is_link_or_junction(src) or not stat.S_ISDIR(st.st_mode):
|
|
528
|
+
continue
|
|
529
|
+
for root, dirs, files in os.walk(src, onerror=_raise):
|
|
530
|
+
root_path = Path(root)
|
|
531
|
+
dirs[:] = sorted(d for d in dirs if not _is_link_or_junction(root_path / d))
|
|
532
|
+
rel = root_path.relative_to(skill_dir)
|
|
533
|
+
(target / rel).mkdir(parents=True, exist_ok=True)
|
|
534
|
+
for name in sorted(files):
|
|
535
|
+
file_path = root_path / name
|
|
536
|
+
if _is_link_or_junction(file_path):
|
|
537
|
+
continue
|
|
538
|
+
shutil.copyfile(file_path, target / rel / name)
|
|
539
|
+
return target
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _probe_version(base: list[str], cwd: str, **common) -> tuple[str | None, dict | None]:
|
|
543
|
+
"""(version, None) when `tessl --version` answers, else (None, the result to print)."""
|
|
544
|
+
rc, out, err, timed_out = _run([*base, "--version"], PROBE_TIMEOUT_SEC, cwd)
|
|
545
|
+
if timed_out:
|
|
546
|
+
detail = f"tessl --version did not answer within {PROBE_TIMEOUT_SEC} seconds"
|
|
547
|
+
return None, _result("failed", f"failed — {detail}", detail=detail, **common)
|
|
548
|
+
lines = _lines(out) if rc == 0 else []
|
|
549
|
+
version = lines[-1] if lines else None # the npm launcher may print a download notice first
|
|
550
|
+
if not version:
|
|
551
|
+
return None, _result("not-installed", SUMMARIES["not-installed"],
|
|
552
|
+
detail=_first_line(err) or _first_line(out), **common)
|
|
553
|
+
return version, None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def submit_review(skill_dir: Path, prefs_path: Path) -> dict:
|
|
557
|
+
"""Send a copy of the skill to Tessl Review without waiting; `pending` with the run id on success."""
|
|
558
|
+
state, workspace, reason = load_workspace(prefs_path)
|
|
559
|
+
if state == "off":
|
|
560
|
+
return _result("off", SUMMARIES["off"], detail=reason)
|
|
561
|
+
if state == "invalid":
|
|
562
|
+
return _result("invalid-config", f"not run — {reason}", detail=reason)
|
|
563
|
+
try:
|
|
564
|
+
os.stat(skill_dir / "SKILL.md")
|
|
565
|
+
except OSError as exc:
|
|
566
|
+
detail = f"cannot read {(skill_dir / 'SKILL.md').as_posix()}: {exc.strerror or exc}"
|
|
567
|
+
return _result("failed", f"failed — {detail}", detail=detail, workspace=workspace)
|
|
568
|
+
base = resolve_tessl()
|
|
569
|
+
if base is None:
|
|
570
|
+
return _result("not-installed", SUMMARIES["not-installed"], detail="neither tessl nor npx is on PATH",
|
|
571
|
+
workspace=workspace)
|
|
572
|
+
with tempfile.TemporaryDirectory(prefix="skf-tessl-", ignore_cleanup_errors=True) as tmp:
|
|
573
|
+
version, failure = _probe_version(base, tmp, workspace=workspace)
|
|
574
|
+
if failure is not None:
|
|
575
|
+
return failure
|
|
576
|
+
rc, out, err, timed_out = _run([*base, "whoami"], PROBE_TIMEOUT_SEC, tmp)
|
|
577
|
+
if timed_out:
|
|
578
|
+
detail = f"tessl whoami did not answer within {PROBE_TIMEOUT_SEC} seconds"
|
|
579
|
+
return _result("failed", f"failed — {detail}", detail=detail, workspace=workspace,
|
|
580
|
+
tessl_version=version)
|
|
581
|
+
if rc != 0:
|
|
582
|
+
message = _first_line(err) or _first_line(out) or f"tessl whoami exited with code {rc}"
|
|
583
|
+
if SIGNED_OUT_RE.search(f"{err}\n{out}"):
|
|
584
|
+
return _result("signed-out", SUMMARIES["signed-out"], detail=message, workspace=workspace,
|
|
585
|
+
tessl_version=version)
|
|
586
|
+
return _result("failed", f"failed — tessl whoami: {message}", detail=message, workspace=workspace,
|
|
587
|
+
tessl_version=version)
|
|
588
|
+
try:
|
|
589
|
+
bundle = stage_bundle(skill_dir, Path(tmp))
|
|
590
|
+
except OSError as exc:
|
|
591
|
+
detail = f"cannot copy the skill's files: {exc}"
|
|
592
|
+
return _result("failed", f"failed — {detail}", detail=detail, workspace=workspace,
|
|
593
|
+
tessl_version=version)
|
|
594
|
+
cmd = [*base, "review", "run", str(bundle), "--workspace", workspace, "--no-wait", "--json"]
|
|
595
|
+
rc, out, err, timed_out = _run(cmd, SUBMIT_TIMEOUT_SEC, tmp)
|
|
596
|
+
# Leaving the block removed the copy: Tessl has it once `review run --no-wait` answers.
|
|
597
|
+
if timed_out:
|
|
598
|
+
detail = f"tessl review run did not answer within {SUBMIT_TIMEOUT_SEC} seconds"
|
|
599
|
+
return _result("failed", f"failed — {detail}", detail=detail, workspace=workspace,
|
|
600
|
+
tessl_version=version)
|
|
601
|
+
result = classify(out, err, rc, workspace=workspace)
|
|
602
|
+
result.update(tessl_version=version, exit_code=rc)
|
|
603
|
+
return result
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def collect_review(run_id: str, workspace: str | None = None, max_seconds: float = COLLECT_MAX_SEC,
|
|
607
|
+
final: bool = False, tessl_version: str | None = None) -> dict:
|
|
608
|
+
"""Check a submitted review until it finishes or max_seconds (at most COLLECT_MAX_SEC) run out.
|
|
609
|
+
|
|
610
|
+
Runs only `tessl review view`; workspace and tessl_version are the submit
|
|
611
|
+
result's, repeated in this result. A view starts only when it and the
|
|
612
|
+
pause before it still end within the budget, which starts before
|
|
613
|
+
anything else, so the call's waiting stays within max(budget, one view)
|
|
614
|
+
plus the kill waits. A view that does not answer in time, or that tessl
|
|
615
|
+
answers with an error and no JSON (a network error, say), is retried like
|
|
616
|
+
one that reports the run in progress: Tessl may still finish the review.
|
|
617
|
+
Only JSON, an unknown run id, a sign-in error, a removed command or a
|
|
618
|
+
tessl that cannot start ends the call early.
|
|
619
|
+
"""
|
|
620
|
+
deadline = _now() + min(max(max_seconds, 0), COLLECT_MAX_SEC)
|
|
621
|
+
common = {"run_id": run_id, "workspace": workspace, "tessl_version": tessl_version}
|
|
622
|
+
if not isinstance(run_id, str) or not RUN_ID_RE.fullmatch(run_id):
|
|
623
|
+
detail = f"{run_id!r} is not a Tessl review run id"
|
|
624
|
+
return _result("unknown-run", f"no result — Tessl has no review run {run_id!r}: it is not a run id",
|
|
625
|
+
detail=detail, **common)
|
|
626
|
+
base = resolve_tessl()
|
|
627
|
+
if base is None:
|
|
628
|
+
return _result("not-installed", SUMMARIES["not-installed"], detail="neither tessl nor npx is on PATH",
|
|
629
|
+
**common)
|
|
630
|
+
rc, detail = None, None
|
|
631
|
+
with tempfile.TemporaryDirectory(prefix="skf-tessl-", ignore_cleanup_errors=True) as tmp:
|
|
632
|
+
while True:
|
|
633
|
+
rc, out, err, timed_out = _run([*base, "review", "view", run_id, "--json"], VIEW_TIMEOUT_SEC, tmp)
|
|
634
|
+
if timed_out:
|
|
635
|
+
detail = f"tessl review view did not answer within {VIEW_TIMEOUT_SEC} seconds"
|
|
636
|
+
else:
|
|
637
|
+
result = classify(out, err, rc, run_id=run_id, workspace=workspace)
|
|
638
|
+
result.update(tessl_version=tessl_version, exit_code=rc)
|
|
639
|
+
retry = (rc is not None and _extract_json(out) is None
|
|
640
|
+
and result["status"] in ("failed", "parse-failure"))
|
|
641
|
+
if result["status"] != "pending" and not retry:
|
|
642
|
+
return result
|
|
643
|
+
detail = f"tessl review view: {result['detail']}" if retry else None
|
|
644
|
+
if _now() + POLL_INTERVAL_SEC + VIEW_TIMEOUT_SEC > deadline:
|
|
645
|
+
break
|
|
646
|
+
_sleep(POLL_INTERVAL_SEC)
|
|
647
|
+
if final:
|
|
648
|
+
return _timed_out(run_id, workspace, detail=detail, tessl_version=tessl_version, exit_code=rc)
|
|
649
|
+
return _pending(run_id, workspace, detail=detail, tessl_version=tessl_version, exit_code=rc)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# --- CLI --------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _force_utf8(*streams) -> None:
|
|
656
|
+
for stream in streams:
|
|
657
|
+
if hasattr(stream, "reconfigure"):
|
|
658
|
+
stream.reconfigure(encoding="utf-8", errors=getattr(stream, "errors", None) or "strict")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
662
|
+
parser = argparse.ArgumentParser(prog="skf-tessl-review", description="Optional Tessl Review for SKF skills.")
|
|
663
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
664
|
+
p_submit = sub.add_parser("submit", help=f"send a skill folder to Tessl Review when preferences.yaml sets {PREF_KEY}")
|
|
665
|
+
p_submit.add_argument("skill_dir")
|
|
666
|
+
p_submit.add_argument("--preferences", required=True)
|
|
667
|
+
p_collect = sub.add_parser("collect", help="check a submitted review and print its result or pending")
|
|
668
|
+
p_collect.add_argument("run_id")
|
|
669
|
+
p_collect.add_argument("--workspace", default=None, help="the workspace named in the result (the submit result's)")
|
|
670
|
+
p_collect.add_argument("--tessl-version", default=None,
|
|
671
|
+
help="the tessl version named in the result (the submit result's)")
|
|
672
|
+
p_collect.add_argument("--max-seconds", type=int, default=COLLECT_MAX_SEC,
|
|
673
|
+
help=f"how long to keep checking, at most {COLLECT_MAX_SEC} (larger values count as {COLLECT_MAX_SEC})")
|
|
674
|
+
p_collect.add_argument("--final", action="store_true", help="report a review still running as timeout")
|
|
675
|
+
p_parse = sub.add_parser("parse", help="normalize a saved `tessl review run|view --json` output")
|
|
676
|
+
p_parse.add_argument("file")
|
|
677
|
+
return parser
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _given(value: str | None) -> str | None:
|
|
681
|
+
"""A value repeated from the submit result; empty or a rendered null means none."""
|
|
682
|
+
value = (value or "").strip()
|
|
683
|
+
return None if value.lower() in ("", "null", "none", "~") else value
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def main(argv=None) -> int:
|
|
687
|
+
_force_utf8(sys.stdout)
|
|
688
|
+
args = _build_parser().parse_args(argv)
|
|
689
|
+
if args.cmd == "submit":
|
|
690
|
+
result = submit_review(Path(args.skill_dir), Path(args.preferences))
|
|
691
|
+
elif args.cmd == "collect":
|
|
692
|
+
result = collect_review(args.run_id, _given(args.workspace), args.max_seconds, args.final,
|
|
693
|
+
_given(args.tessl_version))
|
|
694
|
+
else:
|
|
695
|
+
try:
|
|
696
|
+
text = Path(args.file).read_text(encoding="utf-8")
|
|
697
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
698
|
+
result = _result("parse-failure", f"failed — cannot read {args.file}: {exc}", detail=str(exc))
|
|
699
|
+
else:
|
|
700
|
+
result = interpret(_extract_json(text))
|
|
701
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
if __name__ == "__main__":
|
|
706
|
+
sys.exit(main())
|