bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. 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())