bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) 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 +143 -18
  18. package/docs/verifying-a-skill.md +87 -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 +3 -3
  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 +31 -59
  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 +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  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 +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  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 +21 -3
  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 +197 -55
  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 +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -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 +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -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 +143 -20
  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 +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  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 +155 -14
  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 +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  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 +23 -2
  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 +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  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 +28 -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/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,38 +1,210 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
- """SKF Emit Result Envelope — Schema-locked headless output for skf-setup.
6
-
7
- Replaces the prose-driven envelope assembly in `src/skf-setup/references/
8
- report.md` §4 with one Python invocation. The envelope contract
9
- (SKF_SETUP_RESULT_JSON) was added to step 4 in PR #247, extended in
10
- PR #248 (header / outputs / failure modes), and extended again in this
11
- PR (previous_tier, tier_changed, tools_added, tools_removed, error).
12
-
13
- LLM-rendered envelopes risk silent schema drift on every invocation —
14
- a pipeline that grep's `SKF_SETUP_RESULT_JSON: {…}` out of the workflow
15
- log can break if the model decides to rename a key or rearrange a
16
- nested structure. This script is the single source of truth: it takes
17
- a context payload as JSON on stdin, computes derived fields
18
- deterministically, and emits the envelope in a fixed shape that
19
- matches the JSON Schema at
20
- `src/shared/scripts/schemas/skf-setup-result-envelope.v1.json`.
5
+ """SKF Emit Result Envelope: one schema-checked emitter for every workflow.
21
6
 
22
- Subcommands:
7
+ A workflow with a headless contract ends each run, and each HARD HALT, with
8
+ one prefixed line that pipelines grep out of the workflow log:
9
+ `SKF_<NAME>_RESULT_JSON: {one-line JSON}`. The model never types that line.
10
+ It stages a payload file and runs this script, which fills the derived
11
+ fields, folds in the run's auto-decisions and warnings, checks the envelope
12
+ against the workflow's JSON Schema, writes the run's result files and
13
+ prints the line:
23
14
 
24
- emit Read context payload as JSON on stdin, derive missing
25
- fields (tools_added/removed, tier_changed), assemble the
26
- envelope, emit `SKF_SETUP_RESULT_JSON: {one-line JSON}`
27
- on stdout. Default subcommand.
15
+ uv run {helper} emit --workflow <name> --run-dir <run_dir> --result-dir <dir> < <run_dir>/result-context.json
16
+ uv run {helper} emit-halt --workflow <name> --run-dir <run_dir> < <run_dir>/halt.json
28
17
 
29
- validate Read an envelope (without the prefix) as JSON on stdin
30
- and verify it against the documented schema. No stdout
31
- on success; non-zero exit + stderr error on failure.
32
- Useful for paranoid pipelines that want to validate a
33
- received envelope before consuming it.
18
+ Subcommands:
34
19
 
35
- Context payload shape (consumed by `emit`):
20
+ emit Build the envelope that ends a run from the context payload
21
+ on stdin. Without --workflow it builds skf-setup's, from
22
+ setup's own payload (see "skf-setup" below). Default
23
+ subcommand.
24
+
25
+ emit-halt Build the envelope of a HARD HALT from the halt payload on
26
+ stdin: {"phase", "reason", "halt_reason"?, "exit_code"?,
27
+ "path"?, "status"?} plus any envelope field the halt already
28
+ knows (a skill name, a version). Needs --workflow.
29
+
30
+ emit-blocked
31
+ skf-setup's halt: `emit-halt --workflow skf-setup` under its
32
+ older name. Reads {"phase", "reason", "path"?} on stdin, or
33
+ takes them from --phase, --reason and --path, and emits a
34
+ status 'blocked' envelope, with placeholders for the fields a
35
+ halt does not know (tier 'Quick', no tools, config_path set to
36
+ the path, files_written []). With the options the emitter
37
+ writes the JSON itself, so a step types no payload. With
38
+ --stderr-from (a file, or `-` for stdin) the failed command's
39
+ stderr fills `<message>` in --reason: the `message` of its
40
+ JSON error object, else its first non-blank line, folded to
41
+ one line (`(no error message)` when it holds neither). A
42
+ `<message>` with no --stderr-from is refused. A non-empty
43
+ --customization-resolver-unavailable is the payload key of
44
+ that name: the envelope warns that the `_bmad/custom/`
45
+ overrides were not applied.
46
+
47
+ record Append one entry to the run's sink: --decision reads one
48
+ auto-decision (a JSON object) on stdin, --warning takes the
49
+ warning text. With --workflow a decision is checked against
50
+ that workflow's headless_decisions item schema first.
51
+
52
+ validate Read an envelope (without the prefix) as JSON on stdin and
53
+ verify it against the workflow's schema (skf-setup's without
54
+ --workflow). No stdout on success; non-zero exit + stderr
55
+ error on failure. Useful for paranoid pipelines that want to
56
+ validate a received envelope before consuming it.
57
+
58
+ render-report
59
+ skf-setup's interactive FORGE STATUS banner: reads the same
60
+ payload as `emit` on stdin (plus the banner keys below),
61
+ folds in the helper outputs --run-dir holds and the paths
62
+ --project-root, --sidecar-path and --forge-data-folder give,
63
+ and prints the banner's lines in English, one per line,
64
+ ending with the REQUIRED TIER NOT MET block when
65
+ --require-tier was not met. --tier-rules names skf-setup's
66
+ references/tier-rules.md, which holds the tier descriptions
67
+ and re-run messages (default: the copy beside this script's
68
+ folder, in the source tree and in an installed project
69
+ alike).
70
+
71
+ Workflow schemas. A workflow's envelope schema is
72
+ `schemas/skf-<name>-result-envelope.v<N>.json`, found beside this script in
73
+ both the source tree and an installed project. --workflow takes the
74
+ workflow's folder name (`skf-update-skill`) or the schema's stem
75
+ (`skf-update`); two versions of one schema resolve to the higher. The
76
+ schema tells the emitter what JSON Schema cannot in its `skf-envelope`
77
+ settings: a `$defs` entry that holds them as a `const` and that nothing
78
+ references. `$defs` and `const` are standard keywords, so a strict
79
+ validator (Ajv's default mode) still compiles the schema:
80
+
81
+ "$defs": {"skf-envelope": {"const": {
82
+ "workflow": "skf-update-skill",
83
+ "prefix": "SKF_UPDATE_RESULT_JSON",
84
+ "wrapper": "skf_update" | null,
85
+ "halt_status": "blocked",
86
+ "exit_codes": {"<halt_reason>": <exit code>, ...},
87
+ "success_exit_code": 0,
88
+ "result_file": "update-skill-result" | null,
89
+ "result_stamp": "run_id"
90
+ }}}
91
+
92
+ wrapper names the one property that wraps the envelope (null for a flat
93
+ envelope); halt_status is the status a halt envelope carries unless its
94
+ payload names one; exit_codes maps each halt_reason to its exit code, and
95
+ success_exit_code is the code a null halt_reason carries (both optional);
96
+ result_file is the stem of the run's result files (null: none);
97
+ result_stamp "run_id" (optional) names the per-run result file after the
98
+ run's own stamp instead of the clock (see "Result files").
99
+
100
+ Building an envelope (every workflow but skf-setup). Each payload key is an
101
+ envelope field of the same name, except `result_contract` and
102
+ `customization_resolver_unavailable` (below). Then:
103
+
104
+ - A halt payload's phase, reason, halt_reason, exit_code, path and
105
+ details become the fields of the same name the schema declares, and
106
+ fill its `error` object: phase, reason, path and details by name,
107
+ `code` from halt_reason, `message` from reason, and '<n/a>' for a
108
+ required path the halt has none for. A key the schema has no place for
109
+ is dropped. A payload that gives `error` itself keeps it whole.
110
+ - exit_code, when the payload has none, is exit_codes[halt_reason], or
111
+ for a finished run with a null halt_reason success_exit_code (else 0);
112
+ a halt without a mapped halt_reason must give its exit_code. A status
113
+ equal to halt_status needs a halt_reason and any other status a null
114
+ one, and exit_code must match the mapping, when the schema declares
115
+ them.
116
+ - Stamped from the run, never typed: `timestamp` (ISO-8601 UTC, from the
117
+ clock), `run_id` (the --run-dir folder name without its
118
+ `<workflow>-` prefix) and `result_path` (the per-run result file this
119
+ call wrote, or null), each when the schema declares it.
120
+ - headless_decisions and warnings: the payload's own entries, then the
121
+ sink's, duplicates dropped. A `customization_resolver_unavailable`
122
+ reason becomes the warning `customization_resolver_unavailable:
123
+ <reason>`. An optional list stays out of the envelope while empty.
124
+ - A required field the payload leaves out gets its schema `default`,
125
+ else null when the schema allows it; in a halt, else [], {}, false or
126
+ 0 by its type.
127
+
128
+ The envelope's keys follow the schema's property order, and the line is
129
+ ASCII JSON, non-ASCII text escaped, as skf-brief-skill's helper always
130
+ printed it: no character in a value can break the line for a reader.
131
+
132
+ Run sink. Every run owns a folder, `_bmad-output/.skf-run/<workflow>-<run_id>/`,
133
+ passed as --run-dir. Each gate records its auto-decision there the moment
134
+ it decides, and each warning as it is raised, with `record`:
135
+
136
+ {run_dir}/headless-decisions.jsonl one decision object per line
137
+ {run_dir}/warnings.jsonl one warning string per line
138
+
139
+ so the decision trail survives context compaction and a halt reports the
140
+ decisions taken before it. `record` writes each line as ASCII JSON, so no
141
+ U+2028, U+2029 or U+0085 inside a value can split it for a reader that
142
+ breaks lines there, and the emitter splits the files on newlines alone. A
143
+ line that is not JSON (a write cut short) or a decision the schema rejects
144
+ is left out and named in the warnings (`sink_line_unreadable:
145
+ <file>:<line>`, `headless_decision_invalid: <file>:<line>: <error>`), so a
146
+ halt still emits.
147
+
148
+ Result files. With --result-dir naming a folder that exists (the version
149
+ folder), the call writes `<result_file>-<YYYYMMDD-HHmmss>.json` (UTC; `-2`,
150
+ `-3`, ... appended when a run already took that second's name) and then
151
+ the copy `<result_file>-latest.json`, each atomically. The stamp is the
152
+ clock's, unless the settings give `result_stamp: "run_id"` and the run's
153
+ run_id is itself a `YYYYMMDD-HHmmss` stamp (a run folder named
154
+ `<workflow>-<YYYYMMDD-HHmmss>`): the file then carries the run's stamp, the
155
+ one its run_id and the report files the run wrote carry. The file holds the
156
+ payload's `result_contract` object (see
157
+ `shared/references/output-contract-schema.md`) with `timestamp`, `run_id`,
158
+ `headless_decisions` and `warnings` stamped in, and the payload's own
159
+ `status` and `summary` where the contract leaves them out, or the envelope
160
+ itself when the payload has none. When --result-dir names no folder, nothing is
161
+ written: a halt before the version folder exists reports on stdout only.
162
+ A write that fails adds the warning `result_file_write_failed: <path>:
163
+ <reason>`, and a failed per-run record leaves `result_path` null. A
164
+ workflow whose `result_file` is null writes none: it ignores --result-dir
165
+ with the warning `result_dir_ignored: <workflow> writes no result file`.
166
+
167
+ Clock. The timestamp is the system clock's time, read with time.time().
168
+ The helper imports only argparse, json, os, pathlib, sys and time, all in
169
+ the standard library of every Python the halt contract may run it under,
170
+ so reading the clock needs no folder: a halt emits before any run folder
171
+ exists, and the call creates none.
172
+
173
+ skf-setup. setup keeps its own payload, which the emitter turns into the
174
+ envelope below (`emit` and `emit-blocked` keep working without --workflow).
175
+ Its envelope follows `schemas/skf-setup-result-envelope.v1.json` and prints
176
+ with sorted keys and raw UTF-8, except that U+2028, U+2029 and U+0085 are
177
+ escaped so the line stays one line. --run-dir appends the sink's warnings
178
+ that setup's own list does not already hold.
179
+
180
+ A setup run also stages each helper's JSON output in its run folder, as
181
+ the step that runs the helper writes it, so no step types those values
182
+ back into a payload:
183
+
184
+ detect-tools.json step 1, skf-detect-tools.py
185
+ ccc-exclusions.json step 1b, skf-merge-ccc-exclusions.py --build-index
186
+ (its --result-to file: the result, or its error)
187
+ qmd-classify.json step 3, skf-qmd-classify-collections.py classify
188
+ qmd-remove.json step 3, skf-qmd-classify-collections.py remove-orphans
189
+ clean-stale.json step 3, skf-forge-tier-rw.py clean-stale
190
+
191
+ With --run-dir, `emit` and `render-report` fold them into the payload:
192
+ a staged output replaces the payload's own value of each field it is the
193
+ source of (fold_staged lists them). A file that holds no JSON object means
194
+ its helper failed (the classifier then reads as qmd unavailable); a file
195
+ that is absent means its step did not run the helper, except a missing
196
+ ccc-exclusions.json when ccc is available, which reads as a failed index.
197
+
198
+ The run's paths come from options, not the payload. With --project-root,
199
+ --sidecar-path and --forge-data-folder (the `{sidecar_path}` and
200
+ `{forge_data_folder}` setup's On Activation bound from skf-preflight.py's
201
+ sidecar_path_resolved and forge_data_folder_resolved), `emit` and
202
+ `render-report` derive `project_root`, `config_path` (the forge-tier.yaml
203
+ in the sidecar folder) and `forge_data_folder`, each written with `/`.
204
+ The emitter reads no config.yaml value itself, so preflight stays the one
205
+ place that resolves them. The derived values replace the payload's own.
206
+
207
+ Context payload shape (consumed by `emit` for skf-setup):
36
208
 
37
209
  {
38
210
  "tier": "Quick|Forge|Forge+|Deep",
@@ -51,31 +223,82 @@ Context payload shape (consumed by `emit`):
51
223
  "require_tier_satisfied": bool|null,
52
224
  "require_tier_failure_missing": ["ccc", ...],
53
225
  "qmd_status": "absent|daemon_stopped|healthy",
226
+ "tools_below_minimum": [{"tool": "ast_grep", "name": "ast-grep", "version": "0.42.2",
227
+ "minimum": "0.45.3", "upgrade": "string|null",
228
+ "tier": "Forge|Forge+|Deep|null"}, ...],
54
229
  "ccc_exclusion_warnings": ["string", ...],
55
230
  "ccc_registry_stale_removed": ["/path", ...],
56
231
  "ccc_indexing_failed_reason": "string|null",
57
- "orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|orphan-action-flag"},
232
+ "orphan_auto_resolution": null|{"action": "keep|remove", "count": int, "source": "headless-default|quiet-default|orphan-action-flag", "removed": ["name", ...], "failed": ["name", ...]},
233
+ "customization_resolver_unavailable": "string|null",
58
234
  "error": null|{"phase","path","reason"}
59
235
  }
60
236
 
237
+ The `tools` values may also be skf-detect-tools.py's own objects
238
+ ({"available": bool, "version": ..., ...}), as detect-tools.json holds
239
+ them. Without `files_written`, the emitter derives it: forge-tier.yaml,
240
+ then settings.yml and ccc_index when `settings_yml_written` and a
241
+ ccc_index status of "created" say so (none when `error` is set).
242
+
61
243
  When the step-3 orphan-removal gate is resolved non-interactively
62
- (headless default Keep, or an explicit --orphan-action), pass
63
- `orphan_auto_resolution` so the audit trail lands in `warnings` —
64
- most importantly when the destructive `remove` ran headlessly, which a
244
+ (headless or quiet default Keep, or an explicit --orphan-action), pass
245
+ `orphan_auto_resolution` so the audit trail lands in `warnings`: most
246
+ importantly when the destructive `remove` ran headlessly, which a
65
247
  pipeline otherwise could not distinguish from a no-op by reading the
66
- envelope alone.
248
+ envelope alone. Its `removed` and `failed` lists name the collections
249
+ the removal deleted and the ones it could not, and each becomes its own
250
+ warning (`orphan_removed: <name>`, `orphan_remove_failed: <name>`).
251
+ With --run-dir, the staged classification supplies `count` and the
252
+ staged removal supplies `removed` and `failed`, so setup's payload
253
+ carries only `action` and `source`.
254
+
255
+ `tools_below_minimum` is skf-detect-tools.py's list of the tools below
256
+ their minimum version, which detect-tools.json supplies. Each becomes the
257
+ warning `tool_below_minimum: <name> <version> (minimum <minimum>)`, and
258
+ the banner's Tool upgrades line; a tier tool among them already reads
259
+ false under `tools`, so no envelope field changes.
260
+
261
+ When the On Activation customization resolver was missing or failed,
262
+ pass its one-line reason as `customization_resolver_unavailable`: the
263
+ run then used only the skill's own customize.toml, and the warning tells
264
+ a pipeline that the `_bmad/custom/` overrides were not applied. The
265
+ emit-blocked payload takes the same key.
266
+
267
+ The banner keys, which `render-report` reads (`emit` reads only
268
+ "settings_yml_written", from which it derives files_written):
269
+ "project_root" and "forge_data_folder" (resolved paths),
270
+ "settings_yml_written" and "gitignore_updated" (bool),
271
+ "settings_yml_patterns_added" and "settings_yml_patterns_removed"
272
+ (int), "hygiene_result" ("completed|qmd_unavailable|skipped"),
273
+ "hygiene_healthy", "hygiene_orphaned_removed", "hygiene_orphaned_kept",
274
+ "hygiene_stale_cleaned" and "ccc_registry_stale_cleaned" (int),
275
+ "ccc_index_deferred" (bool: --defer-index skipped a due index build),
276
+ and "require_tier" (the tier --require-tier named, or null). The staged
277
+ helper outputs supply every one of them but the two resolved paths,
278
+ which the path options give with `config_path`: setup's
279
+ step 4 stages only the orphan decision, the resolver reason and a null
280
+ `error`.
67
281
 
68
282
  Caller does NOT need to compute warnings, tools_added/removed, or
69
- tier_changed — the script derives them from the inputs above.
283
+ tier_changed: the script derives them from the inputs above.
70
284
 
71
- CLI — invoke via `uv run` for invocation consistency with sibling
72
- scripts (PEP 723 inline metadata is honored automatically; this script
73
- declares dependencies = [] so technically `python3` works too, but
74
- prefer `uv run` so all 5 cutover scripts share one canonical invocation
75
- pattern documented in docs/getting-started.md):
285
+ Setup's step 4 always passes `"error": null`: every halt that names a
286
+ phase emits through `emit-blocked` instead. A non-null error still
287
+ yields status 'blocked'.
288
+
289
+ CLI: the step files invoke it via `uv run`, like every sibling helper.
290
+ It imports only the standard library (dependencies = []) and must stay
291
+ that way: the setup halt contract runs `emit-blocked` for the On
292
+ Activation halts, which can fire before `uv` is proven present, under
293
+ the first of `uv run`, `python3`, `python` and `py -3` that works.
76
294
 
77
295
  echo '{...context payload...}' | uv run skf-emit-result-envelope.py emit
296
+ uv run skf-emit-result-envelope.py emit --run-dir "{run_dir}" --project-root "{project-root}" --sidecar-path "{sidecar_path}" --forge-data-folder "{forge_data_folder}" < "{run_dir}/report-context.json"
297
+ uv run skf-emit-result-envelope.py render-report --run-dir "{run_dir}" --project-root "{project-root}" --sidecar-path "{sidecar_path}" --forge-data-folder "{forge_data_folder}" --tier-rules "<skill>/references/tier-rules.md" < "{run_dir}/report-context.json"
298
+ echo '{"phase":"...","reason":"...","path":"..."}' | uv run skf-emit-result-envelope.py emit-blocked
299
+ uv run skf-emit-result-envelope.py emit-blocked --phase "step 2:write-tools" --path "<path>" --reason "<message>" --stderr-from "{run_dir}/write-tools.err"
78
300
  echo '{"skf_setup":{...}}' | uv run skf-emit-result-envelope.py validate
301
+ uv run skf-emit-result-envelope.py record --run-dir "{run_dir}" --warning "customization_resolver_unavailable: <reason>"
79
302
 
80
303
  Exit codes:
81
304
  0 success
@@ -89,6 +312,7 @@ import argparse
89
312
  import json
90
313
  import os
91
314
  import sys
315
+ import time
92
316
  from pathlib import Path
93
317
 
94
318
 
@@ -96,8 +320,52 @@ ENVELOPE_PREFIX = "SKF_SETUP_RESULT_JSON: "
96
320
  SCHEMA_FILE = Path(__file__).parent / "schemas" / "skf-setup-result-envelope.v1.json"
97
321
  TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
98
322
  VALID_TIERS = ("Quick", "Forge", "Forge+", "Deep")
99
- VALID_FILES = ("forge-tier.yaml", "preferences.yaml", "settings.yml", "ccc_index")
323
+ VALID_FILES = ("forge-tier.yaml", "settings.yml", "ccc_index")
100
324
  VALID_CCC_STATUS = ("fresh", "created", "failed", "none", "skipped")
325
+ # The helper outputs a skf-setup run stages in its run folder, each by the
326
+ # step that runs the helper; `emit` and `render-report` read them from
327
+ # --run-dir (see "skf-setup" in the module docstring).
328
+ STAGED_DETECT = "detect-tools.json"
329
+ STAGED_CCC = "ccc-exclusions.json"
330
+ STAGED_CLASSIFY = "qmd-classify.json"
331
+ STAGED_REMOVE = "qmd-remove.json"
332
+ STAGED_CLEAN_STALE = "clean-stale.json"
333
+ STAGED_FILES = (STAGED_DETECT, STAGED_CCC, STAGED_CLASSIFY, STAGED_REMOVE, STAGED_CLEAN_STALE)
334
+ # The index failure a run reports when ccc is available and step 1b staged no result.
335
+ CCC_NO_RESULT = "the ccc settings helper wrote no result"
336
+
337
+ SCHEMA_DIR = SCHEMA_FILE.parent
338
+ SETUP_WORKFLOW = "skf-setup"
339
+ # The `$defs` entry whose `const` holds a schema's emitter settings.
340
+ META_KEY = "skf-envelope"
341
+ META_FIELDS = ("workflow", "prefix", "wrapper", "halt_status", "exit_codes",
342
+ "success_exit_code", "result_file", "result_stamp")
343
+ SINK_DECISIONS = "headless-decisions.jsonl"
344
+ SINK_WARNINGS = "warnings.jsonl"
345
+ # The halt payload's own keys; each lands only where the schema has a place for it.
346
+ HALT_KEYS = ("phase", "reason", "halt_reason", "exit_code", "path", "details")
347
+ # Payload keys the emitter reads and never copies into the envelope.
348
+ PAYLOAD_ONLY_KEYS = ("result_contract", "customization_resolver_unavailable")
349
+ # The fields of a schema's `error` object a halt fills, and the halt key each reads.
350
+ ERROR_FIELDS = {"phase": "phase", "reason": "reason", "path": "path", "code": "halt_reason",
351
+ "message": "reason", "halt_reason": "halt_reason", "exit_code": "exit_code",
352
+ "details": "details"}
353
+ # The record fields a result_contract may leave out, taken from the payload's own.
354
+ CONTRACT_DEFAULTS = ("status", "summary")
355
+ # Every JSON Schema keyword _validate_against_schema enforces, and the ones
356
+ # it may skip: annotations, and `$defs`, which holds only the emitter's
357
+ # settings (no envelope schema has a `$ref`). An envelope schema uses no
358
+ # other keyword, so the built-in validator never passes what the schema
359
+ # forbids.
360
+ VALIDATOR_KEYWORDS = frozenset({"type", "enum", "const", "oneOf", "anyOf", "properties",
361
+ "additionalProperties", "required", "items", "uniqueItems",
362
+ "minLength", "minimum"})
363
+ ANNOTATION_KEYWORDS = frozenset({"$schema", "$id", "$defs", "title", "description", "default",
364
+ "examples"})
365
+ # What str.splitlines() and some log readers break a line at, and json.dumps
366
+ # leaves raw when it keeps non-ASCII text (it escapes every control character).
367
+ RAW_LINE_BREAKS = ("\x85", "\u2028", "\u2029")
368
+ _MISSING = object()
101
369
 
102
370
 
103
371
  def _die(code: int, message: str) -> None:
@@ -152,13 +420,29 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
152
420
  return added, removed
153
421
 
154
422
 
423
+ def _resolver_warning(payload: dict) -> list[str]:
424
+ """The customization_resolver_unavailable warning, when the payload reports one.
425
+
426
+ Every workflow's On Activation runs resolve_customization.py. When that
427
+ script is missing or fails, the run falls back to the skill's own
428
+ customize.toml, so the team and user overrides under `_bmad/custom/`
429
+ silently do nothing; the payload key carries the resolver's reason so
430
+ the envelope says so.
431
+ """
432
+ reason = payload.get("customization_resolver_unavailable")
433
+ if not reason:
434
+ return []
435
+ text = reason.strip() if isinstance(reason, str) else ""
436
+ return [f"customization_resolver_unavailable: {text or '<unknown>'}"]
437
+
438
+
155
439
  def _assemble_warnings(payload: dict) -> list[str]:
156
440
  """Fold every documented warning source into the envelope's warnings array.
157
441
 
158
442
  Matches the field-rules block in step 4 §4 — pipelines should only need
159
443
  to consult `warnings` to surface non-fatal issues.
160
444
  """
161
- warnings: list[str] = []
445
+ warnings: list[str] = _resolver_warning(payload)
162
446
  if payload.get("tier_override_invalid"):
163
447
  bad = payload.get("tier_override_invalid_value")
164
448
  suggestion = payload.get("tier_override_invalid_suggestion")
@@ -176,6 +460,9 @@ def _assemble_warnings(payload: dict) -> list[str]:
176
460
  warnings.append(f"ccc_registry_stale_removed: {p}")
177
461
  if payload.get("qmd_status") == "daemon_stopped":
178
462
  warnings.append("qmd_daemon_stopped")
463
+ for tool in _objects(payload.get("tools_below_minimum")):
464
+ warnings.append(f"tool_below_minimum: {_tool_name(tool)} {tool.get('version')} "
465
+ f"(minimum {tool.get('minimum')})")
179
466
  failure_reason = payload.get("ccc_indexing_failed_reason")
180
467
  if failure_reason:
181
468
  warnings.append(f"ccc_indexing_failed: {failure_reason}")
@@ -193,9 +480,30 @@ def _assemble_warnings(payload: dict) -> list[str]:
193
480
  f"orphan_auto_resolution: {action} {count} orphaned collection(s) "
194
481
  f"(non-interactive, {source})"
195
482
  )
483
+ # A removal nobody confirmed names each collection it deleted, and
484
+ # each it could not, so the audit trail says what to rebuild.
485
+ warnings += [f"orphan_removed: {name}" for name in _strings(orphan.get("removed"))]
486
+ warnings += [f"orphan_remove_failed: {name}" for name in _strings(orphan.get("failed"))]
196
487
  return warnings
197
488
 
198
489
 
490
+ def _dict(value) -> dict:
491
+ return value if isinstance(value, dict) else {}
492
+
493
+
494
+ def _strings(value) -> list[str]:
495
+ return [str(item) for item in value] if isinstance(value, list) else []
496
+
497
+
498
+ def _objects(value) -> list[dict]:
499
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
500
+
501
+
502
+ def _tool_name(tool: dict) -> str:
503
+ """A tools_below_minimum entry's name, as the docs write it."""
504
+ return str(tool.get("name") or tool.get("tool"))
505
+
506
+
199
507
  def _normalize_files_written(maybe_files) -> list[str]:
200
508
  """Accept either a list of names or a dict {name: bool}; emit sorted-canonical list."""
201
509
  if maybe_files is None:
@@ -212,6 +520,24 @@ def _normalize_files_written(maybe_files) -> list[str]:
212
520
  return [name for name in VALID_FILES if name in names]
213
521
 
214
522
 
523
+ def _files_written(payload: dict, error) -> list[str]:
524
+ """files_written as the payload gives it, else derived from the run's flags.
525
+
526
+ A finished run always wrote forge-tier.yaml; settings.yml and the ccc
527
+ index count when settings_yml_written and a ccc_index status of
528
+ "created" say so. An envelope with an error reports no file.
529
+ """
530
+ if "files_written" in payload:
531
+ return _normalize_files_written(payload["files_written"])
532
+ if error is not None:
533
+ return []
534
+ return _normalize_files_written({
535
+ "forge-tier.yaml": True,
536
+ "settings.yml": payload.get("settings_yml_written") is True,
537
+ "ccc_index": _dict(payload.get("ccc_index")).get("status") == "created",
538
+ })
539
+
540
+
215
541
  def _normalize_ccc_index(maybe_idx) -> dict:
216
542
  """Coerce caller's ccc_index dict to the envelope shape (drops extras)."""
217
543
  if maybe_idx is None:
@@ -284,7 +610,7 @@ def assemble_envelope(payload: dict) -> dict:
284
610
  "tools_removed": tools_removed,
285
611
  "config_path": config_path,
286
612
  "ccc_index": ccc_index,
287
- "files_written": _normalize_files_written(payload.get("files_written")),
613
+ "files_written": _files_written(payload, error),
288
614
  "tier_override_active": bool(payload.get("tier_override_active", False)),
289
615
  "tier_override_invalid": bool(payload.get("tier_override_invalid", False)),
290
616
  "require_tier_satisfied": require_tier_satisfied,
@@ -297,15 +623,16 @@ def assemble_envelope(payload: dict) -> dict:
297
623
  def _compute_status(error: dict | None, require_tier_satisfied) -> str:
298
624
  """Derive the single-field status from error + require_tier_satisfied.
299
625
 
300
- - 'write_failure' when error.phase signals a file-write failure
301
- - 'blocked' when error is non-null but not a write failure
302
- - 'tier_failure' when require_tier_satisfied is False
303
- - 'success' otherwise
626
+ - 'blocked' when error is non-null: a halt produced the envelope,
627
+ and error.phase names it (a write failure is
628
+ 'step 2:write-tools' or 'step 2:forge-data-dir')
629
+ - 'tier_failure' when require_tier_satisfied is False
630
+ - 'success' otherwise
631
+
632
+ Both envelope builders derive status here, so every value it returns
633
+ is one the schema's status enum lists.
304
634
  """
305
635
  if error is not None:
306
- phase = (error.get("phase") or "").lower()
307
- if "write" in phase or phase.endswith("forge-tier.yaml") or phase.endswith("preferences.yaml"):
308
- return "write_failure"
309
636
  return "blocked"
310
637
  if require_tier_satisfied is False:
311
638
  return "tier_failure"
@@ -313,13 +640,460 @@ def _compute_status(error: dict | None, require_tier_satisfied) -> str:
313
640
 
314
641
 
315
642
  def emit_envelope_line(envelope: dict) -> str:
316
- """Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism."""
643
+ """Serialize the envelope as one prefixed line. No embedded newlines, sort_keys=True for determinism.
644
+
645
+ The body keeps non-ASCII text raw, except the RAW_LINE_BREAKS, which only
646
+ a string can hold and which are escaped there so the line stays one line.
647
+ """
317
648
  body = json.dumps(envelope, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
649
+ for char in RAW_LINE_BREAKS:
650
+ body = body.replace(char, f"\\u{ord(char):04x}")
318
651
  if "\n" in body:
319
652
  _die(2, "envelope serialization produced embedded newline (should be impossible)")
320
653
  return ENVELOPE_PREFIX + body
321
654
 
322
655
 
656
+ # ─── skf-setup: staged helper outputs ───────────────────────────────────────
657
+
658
+
659
+ def read_staged(run_dir: Path | None) -> dict:
660
+ """The helper outputs a setup run staged in its run folder, by file name.
661
+
662
+ Each value is the helper's JSON object, or None when the file holds
663
+ none: the helper exited non-zero, so its redirected stdout stayed
664
+ empty. A file that is absent is left out: its step never ran the
665
+ helper (step 1b runs none without ccc, and step 3 none below Deep tier
666
+ without ccc), or, for ccc-exclusions.json, the helper wrote nothing
667
+ (fold_staged).
668
+ """
669
+ staged: dict = {}
670
+ if run_dir is None:
671
+ return staged
672
+ for name in STAGED_FILES:
673
+ try:
674
+ # utf-8-sig: a shell that writes a byte-order mark still stages JSON.
675
+ text = (run_dir / name).read_text(encoding="utf-8-sig")
676
+ except FileNotFoundError:
677
+ continue
678
+ except (OSError, UnicodeDecodeError):
679
+ text = ""
680
+ try:
681
+ value = json.loads(text) if text.strip() else None
682
+ except json.JSONDecodeError:
683
+ value = None
684
+ staged[name] = value if isinstance(value, dict) else None
685
+ return staged
686
+
687
+
688
+ def fold_staged(payload: dict, staged: dict) -> dict:
689
+ """The setup payload with the fields each staged helper output is the source of.
690
+
691
+ A staged output replaces the payload's own value of every field it
692
+ holds: the step files no longer type those values, so the helper's are
693
+ the ones that count.
694
+
695
+ detect-tools.json tier, previous_tier, tools, previous_tools, the
696
+ tier_override_* and require_tier* fields,
697
+ qmd_status and tools_below_minimum
698
+ ccc-exclusions.json settings_yml_written, settings_yml_patterns_added
699
+ and _removed, gitignore_updated,
700
+ ccc_exclusion_warnings (each entry verbatim),
701
+ ccc_index, ccc_indexing_failed_reason and
702
+ ccc_index_deferred (index_action "defer"); a file
703
+ that holds the helper's error, or no JSON
704
+ object, reads as nothing written and the index
705
+ failed, with the error as its reason, and so
706
+ does no file at all when detect-tools.json
707
+ reports ccc available (step 1b ran the helper)
708
+ qmd-classify.json hygiene_result ("completed", or "qmd_unavailable"
709
+ when the classifier failed), hygiene_healthy,
710
+ hygiene_orphaned_kept (every orphan, while no
711
+ removal ran) and orphan_auto_resolution's count
712
+ qmd-remove.json hygiene_orphaned_removed, hygiene_orphaned_kept
713
+ (the orphans the removal could not delete) and
714
+ orphan_auto_resolution's removed and failed
715
+ clean-stale.json hygiene_stale_cleaned, ccc_registry_stale_cleaned
716
+ and ccc_registry_stale_removed (none when it failed)
717
+ """
718
+ out = dict(payload)
719
+ detect = staged.get(STAGED_DETECT)
720
+ if detect is not None:
721
+ tier, prior = _dict(detect.get("tier")), _dict(detect.get("prior"))
722
+ required, tools = _dict(detect.get("require_tier")), _dict(detect.get("tools"))
723
+ out.update({
724
+ "tier": tier.get("calculated"),
725
+ "previous_tier": prior.get("previous_tier"),
726
+ "tools": tools,
727
+ "previous_tools": prior.get("previous_tools") or None,
728
+ "tier_override_active": tier.get("override_applied") is True,
729
+ "tier_override_invalid": tier.get("override_invalid") is True,
730
+ "tier_override_invalid_value": tier.get("override_invalid_value"),
731
+ "tier_override_invalid_suggestion": tier.get("override_invalid_suggestion"),
732
+ "tier_override_unsafe": tier.get("override_unsafe") is True,
733
+ "tier_override_unsafe_missing": _strings(tier.get("override_unsafe_missing")),
734
+ "require_tier": required.get("requested"),
735
+ "require_tier_satisfied": required.get("satisfied"),
736
+ "require_tier_failure_missing": _strings(required.get("missing_tools")),
737
+ "qmd_status": _dict(tools.get("qmd")).get("status"),
738
+ "tools_below_minimum": _objects(detect.get("tools_below_minimum")),
739
+ })
740
+ if STAGED_CCC in staged:
741
+ result = _dict(staged[STAGED_CCC])
742
+ ok = result.get("status") == "ok"
743
+ out.update({
744
+ "settings_yml_written": ok and result.get("written") is True,
745
+ "settings_yml_patterns_added": _count(result.get("patterns_added")) if ok else 0,
746
+ "settings_yml_patterns_removed": _count(result.get("patterns_removed")) if ok else 0,
747
+ "gitignore_updated": ok and result.get("gitignore_updated") is True,
748
+ "ccc_exclusion_warnings": _strings(result.get("warnings")) if ok else [],
749
+ "ccc_index_deferred": ok and result.get("index_action") == "defer",
750
+ })
751
+ index = _dict(result.get("index"))
752
+ if not ok:
753
+ out["ccc_index"] = {"status": "failed", "indexed_path": None, "file_count": None}
754
+ out["ccc_indexing_failed_reason"] = (result.get("message")
755
+ or "the ccc settings helper returned no result")
756
+ elif index:
757
+ out["ccc_index"] = {key: index.get(key) for key in ("status", "indexed_path", "file_count")}
758
+ out["ccc_indexing_failed_reason"] = index.get("failed_reason")
759
+ elif detect is not None and _dict(_dict(detect.get("tools")).get("ccc")).get("available") is True:
760
+ # Step 1b ran the helper, which wrote nothing: a usage error, or a
761
+ # call its host stopped before the first write.
762
+ out["ccc_index"] = {"status": "failed", "indexed_path": None, "file_count": None}
763
+ out["ccc_indexing_failed_reason"] = CCC_NO_RESULT
764
+ orphaned = _strings(_dict(staged.get(STAGED_CLASSIFY)).get("orphaned"))
765
+ resolution = out.get("orphan_auto_resolution")
766
+ if STAGED_CLASSIFY in staged:
767
+ healthy = _dict(staged[STAGED_CLASSIFY]).get("healthy")
768
+ completed = isinstance(healthy, list)
769
+ out["hygiene_result"] = "completed" if completed else "qmd_unavailable"
770
+ out["hygiene_healthy"] = len(healthy) if completed else 0
771
+ out["hygiene_orphaned_removed"], out["hygiene_orphaned_kept"] = 0, len(orphaned)
772
+ if isinstance(resolution, dict) and "count" not in resolution:
773
+ resolution = {**resolution, "count": len(orphaned)}
774
+ if STAGED_REMOVE in staged:
775
+ removal = staged[STAGED_REMOVE]
776
+ removed = _strings(_dict(removal).get("removed"))
777
+ # A removal that printed nothing deleted nothing anyone can name.
778
+ failed = _strings(removal.get("failed")) if removal is not None else orphaned
779
+ out["hygiene_orphaned_removed"], out["hygiene_orphaned_kept"] = len(removed), len(failed)
780
+ if isinstance(resolution, dict):
781
+ resolution = {**resolution, "removed": removed, "failed": failed}
782
+ if isinstance(resolution, dict):
783
+ out["orphan_auto_resolution"] = resolution
784
+ if STAGED_CLEAN_STALE in staged:
785
+ cleaned = _dict(staged[STAGED_CLEAN_STALE])
786
+ pruned = _strings(cleaned.get("ccc_removed"))
787
+ out["hygiene_stale_cleaned"] = len(_strings(cleaned.get("qmd_removed")))
788
+ out["ccc_registry_stale_cleaned"] = len(pruned)
789
+ out["ccc_registry_stale_removed"] = pruned
790
+ return out
791
+
792
+
793
+ # ─── skf-setup: the run's paths ─────────────────────────────────────────────
794
+
795
+
796
+ def setup_paths(project_root: str | None, sidecar_path: str | None, forge_data_folder: str | None) -> dict:
797
+ """project_root, config_path and forge_data_folder from the path options.
798
+
799
+ --sidecar-path and --forge-data-folder take the folders setup's On
800
+ Activation bound from skf-preflight.py (its sidecar_path_resolved and
801
+ forge_data_folder_resolved), so the emitter resolves no config.yaml
802
+ value itself and cannot drift from preflight's rules. Each path is
803
+ written with `/`: the project root resolved, the forge-tier.yaml in the
804
+ sidecar folder, and the forge data folder (left out when empty). A
805
+ relative folder is joined to the project root. {} without
806
+ --project-root.
807
+ """
808
+ if project_root is None:
809
+ if sidecar_path is not None or forge_data_folder is not None:
810
+ _die(1, "--sidecar-path and --forge-data-folder need --project-root")
811
+ return {}
812
+ root = Path(project_root).resolve()
813
+ paths = {"project_root": root.as_posix()}
814
+
815
+ def folder(value: str) -> Path:
816
+ path = Path(value)
817
+ return path if path.is_absolute() else root / path
818
+
819
+ if sidecar_path is not None:
820
+ if not sidecar_path.strip():
821
+ _die(1, "--sidecar-path must be non-empty")
822
+ paths["config_path"] = (folder(sidecar_path) / "forge-tier.yaml").as_posix()
823
+ if forge_data_folder and forge_data_folder.strip():
824
+ paths["forge_data_folder"] = folder(forge_data_folder).as_posix()
825
+ return paths
826
+
827
+
828
+ # ─── skf-setup: FORGE STATUS banner ─────────────────────────────────────────
829
+
830
+
831
+ BANNER_RULE = "═" * 39
832
+ # The name the banner gives each tool key.
833
+ TOOL_NAMES = {"ast_grep": "ast-grep", "gh_cli": "gh", "qmd": "qmd", "ccc": "ccc"}
834
+ # tier-rules.md sits beside this script's folder in the source tree
835
+ # (src/skf-setup/) and in an installed project (_bmad/skf/skf-setup/).
836
+ TIER_RULES_FILE = Path(__file__).resolve().parent.parent.parent / "skf-setup" / "references" / "tier-rules.md"
837
+ # Each tier-rules.md heading the banner reads, and the key its copy goes under.
838
+ TIER_RULES_HEADINGS = {"Quick Tier": "Quick", "Forge Tier": "Forge", "Forge+ Tier": "Forge+",
839
+ "Deep Tier": "Deep", "Upgrade": "upgrade", "Downgrade": "downgrade",
840
+ "Same": "same"}
841
+ NEXT_STEPS = (
842
+ " Next: the fastest start is `@Ferris forge-auto <repo-or-doc-url>`: one command auto-scopes, "
843
+ "briefs, compiles, tests at a 90% quality gate, and exports a verified skill with zero "
844
+ "configuration. Prefer to scope by hand? `/skf-brief-skill` scopes your first compilation "
845
+ "target, or `/skf-quick-skill` is a fast template-driven path. Already have a skill? "
846
+ "`/skf-audit-skill` drift-checks an existing skill against current sources."
847
+ )
848
+
849
+
850
+ def load_tier_rules(path: Path) -> dict:
851
+ """The tier descriptions and re-run messages of tier-rules.md, by key.
852
+
853
+ Each is the first non-blank line under its `### <heading>`, without its
854
+ surrounding double quotes, so the banner's tier wording lives in that
855
+ one file.
856
+ """
857
+ try:
858
+ text = path.read_text(encoding="utf-8")
859
+ except (OSError, UnicodeDecodeError) as e:
860
+ _die(1, f"render-report: cannot read the tier copy {path.as_posix()}: {e}")
861
+ copy: dict[str, str] = {}
862
+ key = None
863
+ for line in text.splitlines():
864
+ stripped = line.strip()
865
+ if stripped.startswith("#"):
866
+ key = TIER_RULES_HEADINGS.get(stripped[4:].strip()) if stripped.startswith("### ") else None
867
+ elif key and stripped:
868
+ if len(stripped) > 1 and stripped[0] == stripped[-1] == '"':
869
+ stripped = stripped[1:-1]
870
+ copy.setdefault(key, stripped)
871
+ key = None
872
+ missing = [heading for heading, name in TIER_RULES_HEADINGS.items() if name not in copy]
873
+ if missing:
874
+ _die(1, f"render-report: {path.as_posix()} has no copy under: {', '.join(missing)}")
875
+ return copy
876
+
877
+
878
+ def _count(value) -> int:
879
+ return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 0
880
+
881
+
882
+ def _names(keys) -> str:
883
+ return ", ".join(TOOL_NAMES.get(key, key) for key in keys)
884
+
885
+
886
+ def _tool_line(key: str, probe, ccc_daemon) -> str:
887
+ """One Tools Detected line: the tool and its version, the probe's version
888
+ line without the tool's name or a leading "version" (gh prints
889
+ `gh version 2.91.0 (...)`). ccc also shows its daemon; its version is the
890
+ one `uv tool list` gave, when uv installed it."""
891
+ name = TOOL_NAMES[key]
892
+ version = str(_dict(probe).get("version") or "").strip()
893
+ for word in (name, "version"):
894
+ if version.lower().startswith(word + " "):
895
+ version = version[len(word) + 1:].lstrip()
896
+ line = f" - {name} {version}" if version else f" - {name}"
897
+ return f"{line} (daemon {ccc_daemon})" if key == "ccc" and ccc_daemon else line
898
+
899
+
900
+ def _upgrade_line(tool: dict) -> str:
901
+ """One Tool upgrades line: the version found, the minimum, how to upgrade
902
+ and, for a tier tool, the tier it counts toward once upgraded."""
903
+ line = f"- Upgrade {_tool_name(tool)} {tool.get('version')} to {tool.get('minimum')} or newer"
904
+ if tool.get("upgrade"):
905
+ line += f" ({tool['upgrade']})"
906
+ return line + (f" to use the {tool['tier']} tier" if tool.get("tier") else "")
907
+
908
+
909
+ def _tier_change_message(copy: dict, previous: str, current: str, added, removed) -> str:
910
+ """tier-rules.md's upgrade or downgrade message, filled in.
911
+
912
+ With no tool to name (a tier_override moved the tier), the message
913
+ stops after its first sentence.
914
+ """
915
+ upgrade = VALID_TIERS.index(current) > VALID_TIERS.index(previous)
916
+ message = copy["upgrade" if upgrade else "downgrade"]
917
+ if not (added if upgrade else removed):
918
+ head, sep, _ = message.partition("{current}.")
919
+ message = head + sep
920
+ return (message.replace("{previous}", previous).replace("{current}", current)
921
+ .replace("{newly available tool(s)}", _names(added)).replace("{tool}", _names(removed)))
922
+
923
+
924
+ def render_report(payload: dict, copy: dict) -> list[str]:
925
+ """The FORGE STATUS banner of a finished setup run, one string per line.
926
+
927
+ Each `{if ...}` condition of the banner template is one test below, on
928
+ the payload (with the staged helper outputs folded in) and the envelope
929
+ fields derived from it; the tests keep the template. The lines are
930
+ English; the step translates them.
931
+ """
932
+ inner = assemble_envelope(payload)["skf_setup"]
933
+ tier, previous, tools = inner["tier"], inner["previous_tier"], inner["tools"]
934
+ added, removed, changed = inner["tools_added"], inner["tools_removed"], inner["tier_changed"]
935
+ index = inner["ccc_index"]["status"]
936
+ probes = _dict(payload.get("tools"))
937
+ ccc_daemon = _dict(probes.get("ccc")).get("daemon")
938
+ root = str(payload.get("project_root") or "{project-root}").rstrip("/\\")
939
+ hygiene = payload.get("hygiene_result")
940
+ settings_written = payload.get("settings_yml_written")
941
+ # A run that missed --require-tier skipped a due index build: not --ccc-skip-index.
942
+ deferred = payload.get("ccc_index_deferred") is True
943
+ # A tool below its minimum is installed: its Tool upgrades line replaces
944
+ # an install hint, and it is never called "no longer detected".
945
+ below = _objects(payload.get("tools_below_minimum"))
946
+ below_keys = {str(tool.get("tool")) for tool in below}
947
+ removed_shown = [key for key in removed if key not in below_keys]
948
+
949
+ blocks = [[BANNER_RULE, " FORGE STATUS", BANNER_RULE], [f" Tier: {tier}", f" {copy[tier]}"]]
950
+
951
+ detected = [" Tools Detected:"]
952
+ detected += [_tool_line(key, probes.get(key), ccc_daemon) for key in TOOL_KEYS if tools[key]]
953
+ if not any(tools.values()):
954
+ # With no tool counted, the only climb hint is ast-grep's install line.
955
+ section = "Tool upgrades" if "ast_grep" in below_keys else "Climb to next tier"
956
+ detected.append(f' (none yet, see "{section}" below)')
957
+ blocks.append(detected)
958
+
959
+ if below:
960
+ blocks.append([" Tool upgrades:"] + [f" {_upgrade_line(tool)}" for tool in below])
961
+
962
+ if tier != "Deep":
963
+ ast_grep, qmd_status = tools["ast_grep"], payload.get("qmd_status")
964
+ hints = [hint for holds, hint in (
965
+ (not ast_grep and "ast_grep" not in below_keys,
966
+ "- Install ast-grep (https://ast-grep.github.io): unlocks AST-backed code analysis (Forge tier)"),
967
+ (ast_grep and not tools["ccc"] and "ccc" not in below_keys,
968
+ "- Install cocoindex-code (https://github.com/cocoindex-io/cocoindex-code): adds "
969
+ "semantic-guided precision compilation (Forge+ tier)"),
970
+ (ast_grep and not tools["gh_cli"] and "gh_cli" not in below_keys,
971
+ "- Install GitHub CLI (https://cli.github.com): required for Deep tier "
972
+ "(cross-repository synthesis)"),
973
+ (ast_grep and not tools["qmd"] and qmd_status == "absent",
974
+ "- Install qmd (https://github.com/tobi/qmd): required for Deep tier (knowledge search)"),
975
+ (ast_grep and not tools["qmd"] and qmd_status == "daemon_stopped" and "qmd" not in below_keys,
976
+ "- Start the qmd daemon (already installed): run `qmd start` (or your distribution's "
977
+ "qmd service command) to unlock Deep tier (knowledge search)"),
978
+ (tools["ccc"] and ccc_daemon == "error",
979
+ "- The ccc daemon is reporting errors: run `ccc doctor` to diagnose. CCC index will "
980
+ "fail until resolved"),
981
+ ) if holds]
982
+ if hints:
983
+ blocks.append([" Climb to next tier:"] + [f" {hint}" for hint in hints])
984
+
985
+ if hygiene == "completed":
986
+ qmd = [" QMD Registry:", f" {_count(payload.get('hygiene_healthy'))} collection(s) healthy"]
987
+ for key, what in (("hygiene_orphaned_removed", "orphaned collection(s) removed"),
988
+ ("hygiene_orphaned_kept", "orphaned collection(s) kept"),
989
+ ("hygiene_stale_cleaned", "stale QMD registry entry/entries cleaned")):
990
+ if _count(payload.get(key)) > 0:
991
+ qmd.append(f" {_count(payload.get(key))} {what}")
992
+ blocks.append(qmd)
993
+ if _count(payload.get("ccc_registry_stale_cleaned")) > 0:
994
+ blocks.append([f" CCC Registry: {_count(payload.get('ccc_registry_stale_cleaned'))} stale "
995
+ "entry/entries cleaned"])
996
+ if hygiene == "completed" and _count(payload.get("hygiene_healthy")) == 0:
997
+ blocks.append([" QMD Registry: empty. Collections are created automatically when you run "
998
+ "/skf-create-skill."])
999
+ if hygiene == "qmd_unavailable":
1000
+ blocks.append([" QMD Registry: skipped (qmd unavailable; if the daemon is stopped, "
1001
+ "`qmd start` restores it)."])
1002
+
1003
+ if tools["ccc"]:
1004
+ ccc = [" CCC Index:"]
1005
+ # A failed `ccc index` can report on several lines; its line stays one line.
1006
+ reason = " ".join(str(payload.get("ccc_indexing_failed_reason") or "").split())
1007
+ ccc += [f" {line}" for status, line in (
1008
+ ("fresh", "up to date, semantic discovery ready"),
1009
+ ("created", "indexed this run, semantic discovery ready"),
1010
+ ("skipped", "skipped (--require-tier not met). The next `/skf-setup` run that meets the "
1011
+ "required tier builds or refreshes the index" if deferred else
1012
+ "skipped (--ccc-skip-index). Run `/skf-setup` without --ccc-skip-index to "
1013
+ "build or refresh the index when you're ready"),
1014
+ ("failed", "indexing failed, semantic discovery unavailable this session"
1015
+ + (f" ({reason})" if reason else "")),
1016
+ ) if index == status]
1017
+ notes = _strings(payload.get("ccc_exclusion_warnings"))
1018
+ if notes:
1019
+ # A note that names the project root in SKF's words writes the
1020
+ # placeholder, shown here as the resolved root; the envelope keeps it.
1021
+ ccc.append(" CCC exclusion notes:")
1022
+ ccc += [f" - {note.replace('{project-root}', root)}" for note in notes]
1023
+ blocks.append(ccc)
1024
+
1025
+ config_path = inner["config_path"]
1026
+ forge_data = str(payload.get("forge_data_folder") or "{forge_data_folder}").rstrip("/\\")
1027
+ files = [" Files written this run:", f" - forge-tier.yaml: {config_path}",
1028
+ f" - {forge_data}/ (directory ensured)"]
1029
+ if settings_written is True:
1030
+ pruned = _count(payload.get("settings_yml_patterns_removed"))
1031
+ files.append(f" - .cocoindex_code/settings.yml: {root}/.cocoindex_code/settings.yml "
1032
+ f"({_count(payload.get('settings_yml_patterns_added'))} SKF exclusion pattern(s) merged"
1033
+ + (f", {pruned} stale SKF pattern(s) removed" if pruned > 0 else "") + ")")
1034
+ if payload.get("gitignore_updated") is True:
1035
+ files.append(f" - .gitignore: {root}/.gitignore (`/.cocoindex_code/` added by `ccc init`)")
1036
+ if index == "created":
1037
+ count = inner["ccc_index"]["file_count"]
1038
+ files.append(" - .cocoindex_code/ ccc index" + (f": {count} files indexed" if count is not None else ""))
1039
+ blocks.append(files)
1040
+
1041
+ if inner["tier_override_active"]:
1042
+ blocks.append([" Note: Tier override active (set in preferences.yaml)"])
1043
+ if inner["tier_override_invalid"]:
1044
+ suggestion = payload.get("tier_override_invalid_suggestion")
1045
+ blocks.append([
1046
+ f' Note: tier_override value "{payload.get("tier_override_invalid_value")}" in '
1047
+ "preferences.yaml is not valid.",
1048
+ *([f' Did you mean "{suggestion}"?'] if suggestion is not None else []),
1049
+ " Valid values are case-sensitive: Quick, Forge, Forge+, Deep. "
1050
+ f"Using detected tier {tier}.",
1051
+ ])
1052
+ if payload.get("tier_override_unsafe") is True:
1053
+ missing = ", ".join(_strings(payload.get("tier_override_unsafe_missing")))
1054
+ blocks.append([
1055
+ f" Warning: tier_override is forcing {tier} but the underlying tool prerequisites are "
1056
+ "not satisfied.",
1057
+ f" Missing: {missing}. The override is honored, but downstream skills that",
1058
+ " rely on the missing tool(s) will fail at runtime. Install the missing tool(s) "
1059
+ "or remove",
1060
+ " the override from preferences.yaml.",
1061
+ ])
1062
+
1063
+ if previous is None:
1064
+ blocks.append([f" Initial detection: {tier} tier established."])
1065
+ if changed:
1066
+ blocks.append([f" {_tier_change_message(copy, previous, tier, added, removed_shown)}"])
1067
+ if not changed and not added and not removed and previous is not None:
1068
+ same = [f" {copy['same'].replace('{current}', tier)}"]
1069
+ if settings_written is False and index == "fresh":
1070
+ same.append(" Your ccc settings were left untouched, and the ccc index was already current.")
1071
+ if settings_written is False and index == "skipped" and not deferred:
1072
+ same.append(" Your ccc settings were left untouched; the ccc index was not checked "
1073
+ "(--ccc-skip-index).")
1074
+ blocks.append(same)
1075
+ if not changed and (added or removed) and previous is not None:
1076
+ delta = [f" Tier unchanged: {tier}."]
1077
+ if added:
1078
+ deep_ccc = " ccc enhances Deep tier transparently." if "ccc" in added and tier == "Deep" else ""
1079
+ delta.append(f" Newly detected: {_names(added)}.{deep_ccc}")
1080
+ if removed_shown:
1081
+ delta.append(f" No longer detected: {_names(removed_shown)}. Re-install to restore those capabilities.")
1082
+ blocks.append(delta)
1083
+
1084
+ blocks += [[BANNER_RULE, f" Forge ready. {tier} tier active.", BANNER_RULE], [NEXT_STEPS]]
1085
+ if inner["require_tier_satisfied"] is False:
1086
+ missing = ", ".join(_strings(payload.get("require_tier_failure_missing"))) or "<none>"
1087
+ blocks += [[BANNER_RULE, " REQUIRED TIER NOT MET", BANNER_RULE],
1088
+ [f" Required: {payload.get('require_tier') or '<unknown>'}", f" Detected: {tier}",
1089
+ f" Missing: {missing}"],
1090
+ [" Install or upgrade the missing tool(s) and re-run, or relax `--require-tier`.", BANNER_RULE]]
1091
+ lines: list[str] = []
1092
+ for block in blocks:
1093
+ lines += ([""] if lines else []) + block
1094
+ return lines
1095
+
1096
+
323
1097
  # ─── minimal stdlib JSON Schema validator ───────────────────────────────────
324
1098
 
325
1099
 
@@ -332,10 +1106,11 @@ def _load_schema() -> dict:
332
1106
  def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
333
1107
  """Return a list of error strings (empty = valid).
334
1108
 
335
- Implements only the subset of Draft 2020-12 features used by our envelope
336
- schema: type, enum, oneOf, properties, additionalProperties, required,
337
- items, uniqueItems, minLength, minimum. Sufficient to catch every
338
- violation our schema can express; not a general-purpose validator.
1109
+ Implements only the subset of Draft 2020-12 features the envelope
1110
+ schemas use, the VALIDATOR_KEYWORDS: type, enum, const, oneOf, anyOf,
1111
+ properties, additionalProperties, required, items, uniqueItems,
1112
+ minLength, minimum. Sufficient to catch every violation those schemas
1113
+ can express; not a general-purpose validator.
339
1114
  """
340
1115
  errors: list[str] = []
341
1116
 
@@ -345,6 +1120,11 @@ def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
345
1120
  errors.append(f"{path}: matched {matches} of {len(schema['oneOf'])} oneOf branches (expected exactly 1)")
346
1121
  return errors
347
1122
 
1123
+ if "anyOf" in schema:
1124
+ if all(_validate_against_schema(value, sub, path) for sub in schema["anyOf"]):
1125
+ errors.append(f"{path}: matched none of {len(schema['anyOf'])} anyOf branches")
1126
+ return errors
1127
+
348
1128
  expected_type = schema.get("type")
349
1129
  if expected_type is not None:
350
1130
  if not _matches_type(value, expected_type):
@@ -355,6 +1135,9 @@ def _validate_against_schema(value, schema: dict, path: str = "$") -> list[str]:
355
1135
  if value not in schema["enum"]:
356
1136
  errors.append(f"{path}: value {value!r} not in enum {schema['enum']}")
357
1137
 
1138
+ if "const" in schema and value != schema["const"]:
1139
+ errors.append(f"{path}: value {value!r} is not {schema['const']!r}")
1140
+
358
1141
  if isinstance(value, dict):
359
1142
  props = schema.get("properties", {})
360
1143
  required = set(schema.get("required", []))
@@ -402,6 +1185,8 @@ def _matches_type(value, expected) -> bool:
402
1185
  return isinstance(value, bool)
403
1186
  if expected == "integer":
404
1187
  return isinstance(value, int) and not isinstance(value, bool)
1188
+ if expected == "number":
1189
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
405
1190
  if expected == "null":
406
1191
  return value is None
407
1192
  return False
@@ -416,39 +1201,661 @@ def _freeze(v):
416
1201
  return v
417
1202
 
418
1203
 
419
- # ─── subcommands ────────────────────────────────────────────────────────────
1204
+ # ─── workflow schemas ───────────────────────────────────────────────────────
420
1205
 
421
1206
 
422
- def cmd_emit() -> None:
423
- payload = _read_stdin_json("emit")
424
- envelope = assemble_envelope(payload)
425
- schema = _load_schema()
1207
+ def _schema_version(path: Path, suffix: str = "-result-envelope.v") -> tuple[str, int] | None:
1208
+ """(stem, version) of an envelope schema file name, or None for another file."""
1209
+ stem, sep, version = path.name[: -len(".json")].rpartition(suffix)
1210
+ if not sep or not version.isdigit():
1211
+ return None
1212
+ return stem, int(version)
1213
+
1214
+
1215
+ def _read_schema(path: Path) -> dict | None:
1216
+ try:
1217
+ schema = json.loads(path.read_text(encoding="utf-8"))
1218
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1219
+ return None
1220
+ return schema if isinstance(schema, dict) else None
1221
+
1222
+
1223
+ def load_workflow_schema(workflow: str) -> tuple[dict, Path]:
1224
+ """Return (schema, path) of the newest envelope schema of `workflow`.
1225
+
1226
+ A schema belongs to the workflow its settings name (`skf-update-skill`),
1227
+ and answers to its file stem too (`skf-update` for
1228
+ skf-update-result-envelope.v1.json). The stem is tried first, so an
1229
+ unreadable sibling schema never stops another workflow's halt.
1230
+ """
1231
+ candidates = sorted(SCHEMA_DIR.glob(f"{workflow}-result-envelope.v*.json"))
1232
+ found = []
1233
+ for path in candidates:
1234
+ parsed = _schema_version(path)
1235
+ if parsed is None or parsed[0] != workflow:
1236
+ continue
1237
+ schema = _read_schema(path)
1238
+ if schema is None:
1239
+ _die(2, f"schema file unreadable: {path.name}")
1240
+ found.append((parsed[1], schema, path))
1241
+ if not found:
1242
+ for path in sorted(SCHEMA_DIR.glob("*-result-envelope.v*.json")):
1243
+ parsed = _schema_version(path)
1244
+ schema = _read_schema(path) if parsed else None
1245
+ meta = _meta_of(schema)
1246
+ if meta is not None and meta.get("workflow") == workflow:
1247
+ found.append((parsed[1], schema, path))
1248
+ if not found:
1249
+ _die(1, f"no result-envelope schema for workflow {workflow!r} in {SCHEMA_DIR.as_posix()}")
1250
+ _, schema, path = max(found, key=lambda item: item[0])
1251
+ _meta(schema, path)
1252
+ return schema, path
1253
+
1254
+
1255
+ def _meta_of(schema) -> dict | None:
1256
+ """A schema's emitter settings, the `const` of its `$defs` entry META_KEY, or None."""
1257
+ defs = schema.get("$defs") if isinstance(schema, dict) else None
1258
+ entry = defs.get(META_KEY) if isinstance(defs, dict) else None
1259
+ meta = entry.get("const") if isinstance(entry, dict) else None
1260
+ return meta if isinstance(meta, dict) else None
1261
+
1262
+
1263
+ def _meta(schema: dict, path: Path | None = None) -> dict:
1264
+ """The schema's emitter settings, checked for the fields the emitter needs."""
1265
+ meta = _meta_of(schema)
1266
+ name = path.name if path else "schema"
1267
+ if meta is None:
1268
+ _die(2, f"{name}: no $defs.{META_KEY}.const object")
1269
+ for key in ("workflow", "prefix"):
1270
+ if not isinstance(meta.get(key), str) or not meta[key]:
1271
+ _die(2, f"{name}: {META_KEY}.{key} must be a non-empty string")
1272
+ wrapper = meta.get("wrapper")
1273
+ if wrapper is not None and not isinstance(schema.get("properties", {}).get(wrapper), dict):
1274
+ _die(2, f"{name}: {META_KEY}.wrapper {wrapper!r} is not a property of the schema")
1275
+ return meta
1276
+
1277
+
1278
+ def _inner_schema(schema: dict) -> dict:
1279
+ """The schema of the envelope's fields: under the wrapper, or the whole schema."""
1280
+ wrapper = (_meta_of(schema) or {}).get("wrapper")
1281
+ return schema["properties"][wrapper] if wrapper else schema
1282
+
1283
+
1284
+ def _allows(prop: dict, type_name: str) -> bool:
1285
+ """True when the property schema accepts a value of JSON type `type_name`."""
1286
+ branches = prop.get("oneOf") or prop.get("anyOf")
1287
+ if branches:
1288
+ return any(_allows(branch, type_name) for branch in branches)
1289
+ declared = prop.get("type")
1290
+ if isinstance(declared, list):
1291
+ return type_name in declared
1292
+ return declared == type_name
1293
+
1294
+
1295
+ def _object_branch(prop: dict) -> dict | None:
1296
+ """The object schema of a property that may also be null (`error`)."""
1297
+ if prop.get("type") == "object" or "properties" in prop:
1298
+ return prop
1299
+ for branch in prop.get("oneOf") or prop.get("anyOf") or []:
1300
+ if isinstance(branch, dict) and branch.get("type") == "object":
1301
+ return branch
1302
+ return None
1303
+
1304
+
1305
+ def _placeholder(prop: dict, halt: bool):
1306
+ """The value a required field the payload left out takes, or _MISSING.
1307
+
1308
+ The schema's `default` first, then null where the schema allows it. A
1309
+ halt knows little about its run, so it also takes an empty list, false
1310
+ or 0 by type; a finished run must supply those itself.
1311
+ """
1312
+ if "default" in prop:
1313
+ return json.loads(json.dumps(prop["default"]))
1314
+ if _allows(prop, "null"):
1315
+ return None
1316
+ if halt:
1317
+ if _allows(prop, "array"):
1318
+ return []
1319
+ if _allows(prop, "boolean"):
1320
+ return False
1321
+ if _allows(prop, "integer") or _allows(prop, "number"):
1322
+ return 0
1323
+ if _allows(prop, "object") and not prop.get("required"):
1324
+ return {}
1325
+ return _MISSING
1326
+
1327
+
1328
+ def _ordered(value, schema):
1329
+ """Order an object's keys as its schema lists them (unknown keys last)."""
1330
+ if isinstance(value, dict) and isinstance(schema, dict):
1331
+ branch = _object_branch(schema) or schema
1332
+ props = branch.get("properties") or {}
1333
+ keys = [k for k in props if k in value] + [k for k in value if k not in props]
1334
+ return {k: _ordered(value[k], props.get(k, {})) for k in keys}
1335
+ if isinstance(value, list) and isinstance(schema, dict) and isinstance(schema.get("items"), dict):
1336
+ return [_ordered(item, schema["items"]) for item in value]
1337
+ return value
1338
+
1339
+
1340
+ # ─── run sink ───────────────────────────────────────────────────────────────
1341
+
1342
+
1343
+ def read_sink(run_dir: Path | None) -> tuple[list[tuple[str, dict]], list[str], list[str]]:
1344
+ """Return (decisions, warnings, problems) the run recorded in its sink.
1345
+
1346
+ decisions pairs each object with its `<file>:<line>` location. A line
1347
+ that is not a JSON object (decisions) or a non-empty string (warnings),
1348
+ such as a write cut short, is left out and named in problems. Lines end
1349
+ at a newline only: str.splitlines() would also cut a line at a raw
1350
+ RAW_LINE_BREAKS character inside a string, which a sink line written by
1351
+ hand may hold.
1352
+ """
1353
+ decisions: list[tuple[str, dict]] = []
1354
+ warnings: list[str] = []
1355
+ problems: list[str] = []
1356
+ if run_dir is None:
1357
+ return decisions, warnings, problems
1358
+ for name, kind in ((SINK_DECISIONS, dict), (SINK_WARNINGS, str)):
1359
+ try:
1360
+ text = (run_dir / name).read_text(encoding="utf-8")
1361
+ except FileNotFoundError:
1362
+ continue
1363
+ except (OSError, UnicodeDecodeError) as e:
1364
+ problems.append(f"sink_line_unreadable: {name}: {e}")
1365
+ continue
1366
+ for number, line in enumerate(text.split("\n"), 1):
1367
+ if not line.strip():
1368
+ continue
1369
+ try:
1370
+ value = json.loads(line)
1371
+ except json.JSONDecodeError:
1372
+ value = None
1373
+ if not isinstance(value, kind) or not value:
1374
+ problems.append(f"sink_line_unreadable: {name}:{number}")
1375
+ elif kind is dict:
1376
+ decisions.append((f"{name}:{number}", value))
1377
+ else:
1378
+ warnings.append(value.strip())
1379
+ return decisions, warnings, problems
1380
+
1381
+
1382
+ def _decision_schema(schema: dict) -> dict | None:
1383
+ """The item schema of the envelope's headless_decisions, when it has one."""
1384
+ prop = _inner_schema(schema).get("properties", {}).get("headless_decisions")
1385
+ if not isinstance(prop, dict):
1386
+ return None
1387
+ items = prop.get("items")
1388
+ return items if isinstance(items, dict) else {}
1389
+
1390
+
1391
+ def _merged(*lists) -> list:
1392
+ """Concatenate the lists, keeping the first of any duplicate entries."""
1393
+ out, seen = [], set()
1394
+ for items in lists:
1395
+ for item in items:
1396
+ key = json.dumps(item, sort_keys=True, ensure_ascii=False)
1397
+ if key not in seen:
1398
+ seen.add(key)
1399
+ out.append(item)
1400
+ return out
1401
+
1402
+
1403
+ def _payload_list(payload: dict, key: str, kind: type) -> list:
1404
+ value = payload.get(key)
1405
+ if value is None:
1406
+ return []
1407
+ if not isinstance(value, list) or not all(isinstance(item, kind) for item in value):
1408
+ _die(1, f"{key} must be a list of {'objects' if kind is dict else 'strings'}")
1409
+ return value
1410
+
1411
+
1412
+ # ─── clock ──────────────────────────────────────────────────────────────────
1413
+
1414
+
1415
+ def _utc_parts(seconds: int) -> tuple[int, int, int, int, int, int]:
1416
+ """(year, month, day, hour, minute, second) of a Unix time, in UTC."""
1417
+ days, rest = divmod(seconds, 86400)
1418
+ # Civil date from a day count (Howard Hinnant's days_from_civil inverse).
1419
+ days += 719468
1420
+ era = days // 146097
1421
+ doe = days - era * 146097
1422
+ yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
1423
+ doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
1424
+ mp = (5 * doy + 2) // 153
1425
+ day = doy - (153 * mp + 2) // 5 + 1
1426
+ month = mp + 3 if mp < 10 else mp - 9
1427
+ year = yoe + era * 400 + (1 if month <= 2 else 0)
1428
+ return year, month, day, rest // 3600, rest % 3600 // 60, rest % 60
1429
+
1430
+
1431
+ def _is_file_stamp(value) -> bool:
1432
+ """True for a `YYYYMMDD-HHmmss` stamp, the form a run folder's name may end with."""
1433
+ if not isinstance(value, str) or len(value) != 15 or value[8] != "-":
1434
+ return False
1435
+ digits = value[:8] + value[9:]
1436
+ return digits.isascii() and digits.isdigit()
1437
+
1438
+
1439
+ def _stamps(seconds: int) -> tuple[str, str]:
1440
+ """(ISO-8601 UTC timestamp, YYYYMMDD-HHmmss file stamp) of a Unix time."""
1441
+ y, mo, d, h, mi, s = _utc_parts(seconds)
1442
+ return f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{s:02d}Z", f"{y:04d}{mo:02d}{d:02d}-{h:02d}{mi:02d}{s:02d}"
1443
+
1444
+
1445
+ # ─── generic envelope assembly ──────────────────────────────────────────────
1446
+
1447
+
1448
+ def _derived_exit_code(meta: dict, halt_reason):
1449
+ """The exit code the settings map `halt_reason` to, or None when they map none."""
1450
+ if halt_reason is None:
1451
+ return meta.get("success_exit_code", 0)
1452
+ if not isinstance(halt_reason, str):
1453
+ # Not a key the map can hold; the schema check names the bad value.
1454
+ return None
1455
+ return (meta.get("exit_codes") or {}).get(halt_reason)
1456
+
1457
+
1458
+ def tolerant_payload(schema: dict, payload: dict) -> tuple[dict, list[str]]:
1459
+ """The payload without what its envelope cannot take, and a warning for each drop.
1460
+
1461
+ For a caller that keeps an older helper's tolerance: a key the envelope
1462
+ has no field for is dropped (`payload_key_ignored: <key>`), and so is an
1463
+ exit_code the schema's exit_codes map decides, which the emitter then
1464
+ derives from halt_reason (`exit_code_overridden: <given> (halt_reason
1465
+ <reason> maps to <code>)` when the two differ). Such a payload still
1466
+ yields an envelope instead of a refusal.
1467
+ """
1468
+ meta = _meta_of(schema) or {}
1469
+ props = _inner_schema(schema).get("properties", {})
1470
+ kept = {k: v for k, v in payload.items() if k in props or k in PAYLOAD_ONLY_KEYS}
1471
+ notes = [f"payload_key_ignored: {k}" for k in payload if k not in kept]
1472
+ if meta.get("exit_codes") and "exit_code" in kept:
1473
+ reason = kept.get("halt_reason")
1474
+ code = _derived_exit_code(meta, reason)
1475
+ if code is not None:
1476
+ given = kept.pop("exit_code")
1477
+ if json.dumps(given) != json.dumps(code):
1478
+ notes.append(f"exit_code_overridden: {json.dumps(given)} "
1479
+ f"(halt_reason {reason if reason is not None else 'null'} maps to {code})")
1480
+ return kept, notes
1481
+
1482
+
1483
+ def build_envelope(schema: dict, payload: dict, *, halt: bool, stamps: dict | None = None,
1484
+ decisions: list | None = None, warnings: list | None = None) -> dict:
1485
+ """Assemble a workflow's envelope from its payload (see the module docstring).
1486
+
1487
+ `stamps` holds the timestamp, run_id and result_path the run supplies,
1488
+ None where it has none; `decisions` and `warnings` are the run's full
1489
+ lists, the payload's own entries included. Pure apart from _die.
1490
+ """
1491
+ meta = _meta_of(schema)
1492
+ stamps = stamps or {}
1493
+ decisions = decisions if decisions is not None else _payload_list(payload, "headless_decisions", dict)
1494
+ warnings = warnings if warnings is not None else _merged(
1495
+ _payload_list(payload, "warnings", str), _resolver_warning(payload))
1496
+ if meta["workflow"] == SETUP_WORKFLOW:
1497
+ return _setup_envelope(payload, halt, warnings)
1498
+
1499
+ inner_schema = _inner_schema(schema)
1500
+ props = inner_schema.get("properties", {})
1501
+ required = inner_schema.get("required", [])
1502
+ skip = PAYLOAD_ONLY_KEYS + (HALT_KEYS if halt else ())
1503
+ body = {k: v for k, v in payload.items() if k not in skip}
1504
+ if halt:
1505
+ for key in HALT_KEYS:
1506
+ if key in props and key in payload:
1507
+ body[key] = payload[key]
1508
+ if "error" in props and "error" not in body:
1509
+ branch = _object_branch(props["error"])
1510
+ if branch is not None:
1511
+ error = {name: payload[ERROR_FIELDS[name]] for name in branch.get("properties", {})
1512
+ if name in ERROR_FIELDS and payload.get(ERROR_FIELDS[name]) is not None}
1513
+ if "path" in branch.get("required", []) and "path" not in error:
1514
+ error["path"] = "<n/a>"
1515
+ body["error"] = error
1516
+ if "status" in props and "status" not in body and meta.get("halt_status"):
1517
+ body["status"] = meta["halt_status"]
1518
+
1519
+ for key in ("timestamp", "run_id", "result_path"):
1520
+ if key not in props:
1521
+ continue
1522
+ value = stamps.get(key)
1523
+ if value is not None:
1524
+ body[key] = value
1525
+ elif _allows(props[key], "null"):
1526
+ body[key] = None
1527
+ else:
1528
+ body.pop(key, None)
1529
+ for key, values in (("headless_decisions", decisions), ("warnings", warnings)):
1530
+ body.pop(key, None)
1531
+ if key in props and (values or key in required):
1532
+ body[key] = list(values)
1533
+ if "exit_code" in props and "exit_code" not in body:
1534
+ # A halt reads its halt_reason even where the schema has no field for
1535
+ # it, and never falls back to the success code.
1536
+ reason = body["halt_reason"] if "halt_reason" in body else payload.get("halt_reason")
1537
+ code = _derived_exit_code(meta, reason) if (reason is not None or not halt) else None
1538
+ if code is not None:
1539
+ body["exit_code"] = code
1540
+ for key in required:
1541
+ # exit_code is given or derived above, never a placeholder.
1542
+ if key not in body and key != "exit_code":
1543
+ value = _placeholder(props.get(key, {}), halt)
1544
+ if value is not _MISSING:
1545
+ body[key] = value
1546
+ body = _ordered(body, inner_schema)
1547
+ return {meta["wrapper"]: body} if meta.get("wrapper") else body
1548
+
1549
+
1550
+ def _setup_envelope(payload: dict, halt: bool, warnings: list) -> dict:
1551
+ """skf-setup's envelope through its own builders, with the run's warnings added.
1552
+
1553
+ Setup's own warnings stay as its builders made them, repeats included;
1554
+ the run's are appended when setup's list does not already hold them.
1555
+ """
1556
+ if halt:
1557
+ _check_halt_payload(payload, "emit-halt")
1558
+ envelope = assemble_blocked_envelope(payload["phase"], payload["reason"], payload.get("path"))
1559
+ else:
1560
+ envelope = assemble_envelope(payload)
1561
+ inner = envelope["skf_setup"]
1562
+ inner["warnings"] += [w for w in warnings if w not in inner["warnings"]]
1563
+ return envelope
1564
+
1565
+
1566
+ def _check_halt_payload(payload: dict, label: str) -> None:
1567
+ for key in ("phase", "reason"):
1568
+ value = payload.get(key)
1569
+ if not isinstance(value, str) or not value:
1570
+ _die(1, f"{label}: {key!r} must be a non-empty string")
1571
+ path = payload.get("path")
1572
+ if path is not None and not isinstance(path, str):
1573
+ _die(1, f"{label}: 'path' must be a string or omitted")
1574
+
1575
+
1576
+ def contract_errors(schema: dict, envelope) -> list[str]:
1577
+ """Schema violations, then the rules of the emitter settings JSON Schema cannot state."""
426
1578
  errors = _validate_against_schema(envelope, schema)
427
1579
  if errors:
428
- _die(2, f"assembled envelope failed schema validation (this is a bug): {errors}")
429
- print(emit_envelope_line(envelope))
1580
+ return errors
1581
+ meta = _meta_of(schema) or {}
1582
+ wrapper = meta.get("wrapper")
1583
+ inner = envelope.get(wrapper) if wrapper else envelope
1584
+ props = _inner_schema(schema).get("properties", {}) if meta else {}
1585
+ if not isinstance(inner, dict):
1586
+ return errors
1587
+ halt_status = meta.get("halt_status")
1588
+ if halt_status and "status" in props and "halt_reason" in props:
1589
+ status, reason = inner.get("status"), inner.get("halt_reason")
1590
+ if status == halt_status and reason is None:
1591
+ errors.append(f"halt_reason must be set when status is {halt_status!r}")
1592
+ if status != halt_status and reason is not None:
1593
+ errors.append(f"halt_reason must be null when status is {status!r}; got {reason!r}")
1594
+ if meta.get("exit_codes") and "exit_code" in inner:
1595
+ reason = inner.get("halt_reason")
1596
+ expected = _derived_exit_code(meta, reason) if (reason is not None or "success_exit_code" in meta) else None
1597
+ if expected is not None and inner["exit_code"] != expected:
1598
+ errors.append(f"exit_code {inner['exit_code']!r} does not match canonical mapping "
1599
+ f"for halt_reason {reason!r} (expected {expected})")
1600
+ return errors
1601
+
430
1602
 
1603
+ def envelope_line(schema: dict, envelope: dict) -> str:
1604
+ """The prefixed one-line form: ASCII JSON, as skf-brief-skill's helper printed it.
431
1605
 
432
- def cmd_validate() -> None:
1606
+ skf-setup's keeps its sorted keys and raw UTF-8 (emit_envelope_line).
1607
+ """
1608
+ meta = _meta_of(schema)
1609
+ if meta["workflow"] == SETUP_WORKFLOW:
1610
+ return emit_envelope_line(envelope)
1611
+ return f"{meta['prefix']}: " + json.dumps(envelope, separators=(",", ":"), ensure_ascii=True)
1612
+
1613
+
1614
+ # ─── result files ───────────────────────────────────────────────────────────
1615
+
1616
+
1617
+ def _is_dir(path: Path | None) -> bool:
1618
+ """Path.is_dir() that reads a folder it may not stat as no folder."""
1619
+ try:
1620
+ return path is not None and path.is_dir()
1621
+ except OSError:
1622
+ return False
1623
+
1624
+
1625
+ def _claim_result_path(result_dir: Path, stem: str, stamp: str) -> Path:
1626
+ """Create the run's per-run record, empty, under a name no run holds yet.
1627
+
1628
+ `<stem>-<stamp>.json` when it is free, else `-2`, `-3`, ... before
1629
+ `.json`: two runs that end in the same second never overwrite each
1630
+ other's record. The exclusive create makes each claim atomic.
1631
+ """
1632
+ for n in range(1, 1000):
1633
+ path = result_dir / (f"{stem}-{stamp}.json" if n == 1 else f"{stem}-{stamp}-{n}.json")
1634
+ try:
1635
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
1636
+ except FileExistsError:
1637
+ continue
1638
+ return path
1639
+ raise OSError(f"no free file name for {stem}-{stamp}.json")
1640
+
1641
+
1642
+ def _write_json_atomic(path: Path, value) -> None:
1643
+ """Write `value` as indented JSON through a temporary file and one rename."""
1644
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
1645
+ try:
1646
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
1647
+ json.dump(value, fh, indent=2, ensure_ascii=False)
1648
+ fh.write("\n")
1649
+ os.replace(tmp, path)
1650
+ except OSError:
1651
+ try:
1652
+ tmp.unlink()
1653
+ except OSError:
1654
+ pass
1655
+ raise
1656
+
1657
+
1658
+ def _result_record(payload: dict, envelope: dict, timestamp, run_id, decisions: list, warnings: list):
1659
+ """The result file's content: the stamped result contract, or the envelope."""
1660
+ contract = payload.get("result_contract")
1661
+ if contract is None:
1662
+ return envelope
1663
+ record = dict(contract)
1664
+ for key in CONTRACT_DEFAULTS:
1665
+ if key not in record and key in payload:
1666
+ record[key] = payload[key]
1667
+ record["timestamp"] = timestamp
1668
+ if run_id is not None:
1669
+ record["run_id"] = run_id
1670
+ record["headless_decisions"] = list(decisions)
1671
+ record["warnings"] = list(warnings)
1672
+ return record
1673
+
1674
+
1675
+ # ─── subcommands ────────────────────────────────────────────────────────────
1676
+
1677
+
1678
+ def run_emit(workflow: str | None, *, halt: bool, label: str, run_dir: str | None = None,
1679
+ result_dir: str | None = None, target: str = "stdout", tolerant: bool = False,
1680
+ payload: dict | None = None, project_root: str | None = None,
1681
+ sidecar_path: str | None = None, forge_data_folder: str | None = None) -> None:
1682
+ """Read the payload on stdin, build, check and print the envelope, write the result files.
1683
+
1684
+ `tolerant` passes the payload through tolerant_payload() first, for a
1685
+ caller that keeps an older helper's tolerance. A `payload` given here
1686
+ is used instead of stdin (emit-blocked's options). `project_root`,
1687
+ `sidecar_path` and `forge_data_folder` give skf-setup's paths
1688
+ (setup_paths()).
1689
+ """
1690
+ schema, schema_path = load_workflow_schema(workflow or SETUP_WORKFLOW)
1691
+ meta = _meta_of(schema)
1692
+ workflow = meta["workflow"]
1693
+ if (project_root, sidecar_path, forge_data_folder) != (None, None, None) and (
1694
+ workflow != SETUP_WORKFLOW or halt):
1695
+ _die(1, f"{label}: --project-root, --sidecar-path and --forge-data-folder give the paths of a "
1696
+ "finished skf-setup run")
1697
+ if payload is None:
1698
+ payload = _read_stdin_json(label)
1699
+ if not isinstance(payload, dict):
1700
+ _die(1, f"{label}: the payload must be a JSON object")
1701
+ if halt:
1702
+ _check_halt_payload(payload, label)
1703
+ contract = payload.get("result_contract")
1704
+ if contract is not None and not isinstance(contract, dict):
1705
+ _die(1, f"{label}: result_contract must be an object")
1706
+ tolerated: list[str] = []
1707
+ if tolerant:
1708
+ payload, tolerated = tolerant_payload(schema, payload)
1709
+
1710
+ extra: list[str] = []
1711
+ stem = meta.get("result_file")
1712
+ run_path = Path(run_dir) if run_dir else None
1713
+ if workflow == SETUP_WORKFLOW and not halt:
1714
+ payload = {**fold_staged(payload, read_staged(run_path)),
1715
+ **setup_paths(project_root, sidecar_path, forge_data_folder)}
1716
+ result_path_dir = Path(result_dir) if result_dir else None
1717
+ if result_path_dir is not None and not stem:
1718
+ # Refusing would leave a halt that passed the flag by mistake with no line.
1719
+ extra.append(f"result_dir_ignored: {workflow} writes no result file")
1720
+ result_path_dir = None
1721
+ writes = _is_dir(result_path_dir)
1722
+ run_id = None
1723
+ if run_path is not None:
1724
+ name, prefix = run_path.name, f"{workflow}-"
1725
+ run_id = name[len(prefix):] if name.startswith(prefix) and len(name) > len(prefix) else name
1726
+
1727
+ timestamp = file_stamp = None
1728
+ if writes or "timestamp" in _inner_schema(schema).get("properties", {}):
1729
+ timestamp, file_stamp = _stamps(int(time.time()))
1730
+ if meta.get("result_stamp") == "run_id" and _is_file_stamp(run_id):
1731
+ file_stamp = run_id
1732
+
1733
+ sink_decisions, sink_warnings, problems = read_sink(run_path)
1734
+ item_schema = _decision_schema(schema)
1735
+ valid = []
1736
+ for where, decision in sink_decisions:
1737
+ errors = _validate_against_schema(decision, item_schema) if item_schema else []
1738
+ if errors:
1739
+ problems.append(f"headless_decision_invalid: {where}: {errors[0]}")
1740
+ else:
1741
+ valid.append(decision)
1742
+ decisions = _merged(_payload_list(payload, "headless_decisions", dict), valid)
1743
+
1744
+ per_run = None
1745
+ if writes:
1746
+ try:
1747
+ per_run = _claim_result_path(result_path_dir, stem, file_stamp)
1748
+ except OSError as e:
1749
+ extra.append(f"result_file_write_failed: {result_path_dir.as_posix()}: {e.strerror or e}")
1750
+
1751
+ def assemble() -> tuple[dict, list]:
1752
+ warnings = _merged(_payload_list(payload, "warnings", str), _resolver_warning(payload),
1753
+ sink_warnings, problems, tolerated, extra)
1754
+ stamps = {"timestamp": timestamp, "run_id": run_id,
1755
+ "result_path": per_run.as_posix() if per_run else None}
1756
+ envelope = build_envelope(schema, payload, halt=halt, stamps=stamps,
1757
+ decisions=decisions, warnings=warnings)
1758
+ errors = contract_errors(schema, envelope)
1759
+ if errors:
1760
+ if per_run is not None:
1761
+ try:
1762
+ per_run.unlink()
1763
+ except OSError:
1764
+ pass
1765
+ code = 2 if workflow == SETUP_WORKFLOW else 1
1766
+ _die(code, f"{label}: the {workflow} envelope fails {schema_path.name}: {'; '.join(errors)}")
1767
+ return envelope, warnings
1768
+
1769
+ envelope, warnings = assemble()
1770
+ if per_run is not None:
1771
+ record = _result_record(payload, envelope, timestamp, run_id, decisions, warnings)
1772
+ latest = result_path_dir / f"{stem}-latest.json"
1773
+ for path in (per_run, latest):
1774
+ try:
1775
+ _write_json_atomic(path, record)
1776
+ except OSError as e:
1777
+ extra.append(f"result_file_write_failed: {path.as_posix()}: {e.strerror or e}")
1778
+ if path == per_run:
1779
+ try:
1780
+ per_run.unlink()
1781
+ except OSError:
1782
+ pass
1783
+ per_run = None
1784
+ break
1785
+ if extra:
1786
+ envelope, _ = assemble()
1787
+ print(envelope_line(schema, envelope), file=sys.stderr if target == "stderr" else sys.stdout)
1788
+
1789
+
1790
+ def cmd_record(args) -> None:
1791
+ """Append one decision or one warning to the run's sink, one JSON value per line."""
1792
+ run_dir = Path(args.run_dir)
1793
+ if args.warning is not None:
1794
+ value = args.warning.strip()
1795
+ if not value:
1796
+ _die(1, "record: --warning must be a non-empty string")
1797
+ name = SINK_WARNINGS
1798
+ else:
1799
+ value = _read_stdin_json("record")
1800
+ if not isinstance(value, dict) or not value:
1801
+ _die(1, "record: --decision reads one non-empty JSON object on stdin")
1802
+ if args.workflow:
1803
+ schema, schema_path = load_workflow_schema(args.workflow)
1804
+ item_schema = _decision_schema(schema)
1805
+ if item_schema is None:
1806
+ _die(1, f"record: {schema_path.name} has no headless_decisions")
1807
+ errors = _validate_against_schema(value, item_schema)
1808
+ if errors:
1809
+ _die(1, f"record: the decision fails {schema_path.name}: {'; '.join(errors)}")
1810
+ name = SINK_DECISIONS
1811
+ # ASCII only: a raw RAW_LINE_BREAKS character would split the line for a
1812
+ # reader that breaks lines there.
1813
+ line = json.dumps(value, separators=(",", ":"), ensure_ascii=True) + "\n"
1814
+ try:
1815
+ run_dir.mkdir(parents=True, exist_ok=True)
1816
+ with open(run_dir / name, "a", encoding="utf-8", newline="\n") as fh:
1817
+ fh.write(line)
1818
+ except OSError as e:
1819
+ _die(1, f"record: cannot append to {(run_dir / name).as_posix()}: {e.strerror or e}")
1820
+
1821
+
1822
+ def cmd_validate(workflow: str | None) -> None:
433
1823
  envelope = _read_stdin_json("validate")
434
- schema = _load_schema()
435
- errors = _validate_against_schema(envelope, schema)
1824
+ schema, _ = load_workflow_schema(workflow or SETUP_WORKFLOW)
1825
+ errors = contract_errors(schema, envelope)
436
1826
  if errors:
437
1827
  _die(1, "; ".join(errors))
438
1828
 
439
1829
 
1830
+ def cmd_render_report(run_dir: str | None, tier_rules: str | None, project_root: str | None = None,
1831
+ sidecar_path: str | None = None, forge_data_folder: str | None = None) -> None:
1832
+ """Print skf-setup's FORGE STATUS banner for the payload on stdin."""
1833
+ payload = _read_stdin_json("render-report")
1834
+ if not isinstance(payload, dict):
1835
+ _die(1, "render-report: the payload must be a JSON object")
1836
+ payload = {**fold_staged(payload, read_staged(Path(run_dir) if run_dir else None)),
1837
+ **setup_paths(project_root, sidecar_path, forge_data_folder)}
1838
+ copy = load_tier_rules(Path(tier_rules) if tier_rules else TIER_RULES_FILE)
1839
+ print("\n".join(render_report(payload, copy)))
1840
+
1841
+
440
1842
  def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None) -> dict:
441
- """Assemble a minimal status='blocked' envelope for early-halt paths.
1843
+ """Assemble a minimal status='blocked' envelope for a halt.
442
1844
 
443
- Used when On Activation or step 2 halts before the regular envelope
444
- construction has the inputs it needs (no tier, no detected tools,
445
- no config_path). Every other required field gets a documented sentinel
446
- so the envelope still passes schema validation and pipelines can
447
- branch on `status: "blocked"` and inspect `error` for context.
1845
+ Used by every halt that names a phase (On Activation and steps 1, 1b,
1846
+ 2 and 3), which has no complete run to report. Every other required
1847
+ field gets a placeholder, as the schema describes, so the envelope
1848
+ still passes schema validation and pipelines can branch on
1849
+ `status: "blocked"` and inspect `error` for context.
448
1850
  """
1851
+ error = {
1852
+ "phase": phase,
1853
+ "path": path or "<n/a>",
1854
+ "reason": reason,
1855
+ }
449
1856
  return {
450
1857
  "skf_setup": {
451
- "status": "blocked",
1858
+ "status": _compute_status(error, None),
452
1859
  "tier": "Quick",
453
1860
  "previous_tier": None,
454
1861
  "tier_changed": False,
@@ -462,42 +1869,84 @@ def assemble_blocked_envelope(phase: str, reason: str, path: str | None = None)
462
1869
  "tier_override_invalid": False,
463
1870
  "require_tier_satisfied": None,
464
1871
  "warnings": [],
465
- "error": {
466
- "phase": phase,
467
- "path": path or "<n/a>",
468
- "reason": reason,
469
- },
1872
+ "error": error,
470
1873
  }
471
1874
  }
472
1875
 
473
1876
 
474
- def cmd_emit_blocked() -> None:
475
- """Emit a blocked envelope. Reads `phase`, `reason`, optional `path` from stdin JSON.
1877
+ # The --reason placeholder --stderr-from fills, and its text when stderr holds no diagnostic.
1878
+ STDERR_MESSAGE = "<message>"
1879
+ NO_STDERR_MESSAGE = "(no error message)"
1880
+
476
1881
 
477
- Designed for early-halt paths (uv missing, config.yaml missing, etc.) where
478
- the regular `emit` subcommand can't run because tier/tools/config_path are
479
- not yet known.
1882
+ def stderr_message(text: str) -> str:
1883
+ """The diagnostic a failed command's stderr holds, folded to one line.
1884
+
1885
+ The `message` of a JSON error object (the whole text, else the last line
1886
+ that holds one: a helper prints its error after any line `uv` printed),
1887
+ else the first non-blank line, else NO_STDERR_MESSAGE.
480
1888
  """
481
- payload = _read_stdin_json("emit-blocked")
482
- phase = payload.get("phase")
483
- reason = payload.get("reason")
484
- if not isinstance(phase, str) or not phase:
485
- _die(1, "emit-blocked: 'phase' must be a non-empty string")
486
- if not isinstance(reason, str) or not reason:
487
- _die(1, "emit-blocked: 'reason' must be a non-empty string")
488
- path = payload.get("path")
489
- if path is not None and not isinstance(path, str):
490
- _die(1, "emit-blocked: 'path' must be a string or omitted")
491
- envelope = assemble_blocked_envelope(phase, reason, path)
492
- schema = _load_schema()
493
- errors = _validate_against_schema(envelope, schema)
494
- if errors:
495
- _die(2, f"assembled blocked envelope failed schema validation: {errors}")
496
- print(emit_envelope_line(envelope))
1889
+ lines = [line for line in text.splitlines() if line.strip()]
1890
+ for candidate in [text, *reversed(lines)]:
1891
+ try:
1892
+ value = json.loads(candidate)
1893
+ except json.JSONDecodeError:
1894
+ continue
1895
+ message = value.get("message") if isinstance(value, dict) else None
1896
+ if isinstance(message, str) and message.strip():
1897
+ return " ".join(message.split())
1898
+ return " ".join(lines[0].split()) if lines else NO_STDERR_MESSAGE
1899
+
1900
+
1901
+ def _read_stderr(source: str) -> str:
1902
+ """The text of --stderr-from: stdin for `-`, else the file; bytes that are not UTF-8 replaced."""
1903
+ try:
1904
+ raw = sys.stdin.buffer.read() if source == "-" else Path(source).read_bytes()
1905
+ except OSError:
1906
+ return ""
1907
+ return raw.decode("utf-8", "replace").lstrip("\ufeff")
1908
+
1909
+
1910
+ def blocked_payload(phase: str | None, reason: str | None, path: str | None,
1911
+ stderr_from: str | None, resolver_reason: str | None = None) -> dict | None:
1912
+ """emit-blocked's halt payload from its options; None without --phase (stdin carries it)."""
1913
+ if phase is None:
1914
+ if (reason, path, stderr_from, resolver_reason) != (None, None, None, None):
1915
+ _die(1, "emit-blocked: --reason, --path, --stderr-from and --customization-resolver-unavailable "
1916
+ "need --phase")
1917
+ return None
1918
+ if not phase.strip() or not (reason or "").strip():
1919
+ _die(1, "emit-blocked: --phase and --reason must be non-empty")
1920
+ if stderr_from is None:
1921
+ if STDERR_MESSAGE in reason:
1922
+ _die(1, f"emit-blocked: {STDERR_MESSAGE} in --reason needs --stderr-from to fill it")
1923
+ else:
1924
+ if STDERR_MESSAGE not in reason:
1925
+ _die(1, f"emit-blocked: --stderr-from fills {STDERR_MESSAGE} in --reason, which has none")
1926
+ reason = reason.replace(STDERR_MESSAGE, stderr_message(_read_stderr(stderr_from)))
1927
+ payload = {"phase": phase, "reason": " ".join(reason.split())}
1928
+ if path:
1929
+ payload["path"] = path
1930
+ if resolver_reason and resolver_reason.strip():
1931
+ payload["customization_resolver_unavailable"] = " ".join(resolver_reason.split())
1932
+ return payload
1933
+
1934
+
1935
+ def cmd_emit_blocked(args) -> None:
1936
+ """Emit skf-setup's blocked envelope: `emit-halt --workflow skf-setup`.
1937
+
1938
+ Designed for the halts (uv missing, config.yaml missing, a failed write,
1939
+ etc.) where the regular `emit` subcommand can't run because the run has
1940
+ no complete tier/tools/config_path to report. The payload comes from
1941
+ the options when --phase is given, else from stdin.
1942
+ """
1943
+ payload = blocked_payload(args.phase, args.reason, args.path, args.stderr_from,
1944
+ args.customization_resolver_unavailable)
1945
+ run_emit(SETUP_WORKFLOW, halt=True, label="emit-blocked", payload=payload)
497
1946
 
498
1947
 
499
1948
  def _force_utf8(*streams) -> None:
500
- """Reconfigure JSON-carrying streams to UTF-8 (issue #465).
1949
+ """Reconfigure JSON-carrying streams to UTF-8.
501
1950
 
502
1951
  A default Windows console decodes stdio as cp1252, which cannot carry
503
1952
  non-ASCII JSON (ensure_ascii=False output, raw UTF-8 input). Preserves
@@ -516,24 +1965,73 @@ def _force_utf8(*streams) -> None:
516
1965
 
517
1966
 
518
1967
  def main() -> None:
519
- _force_utf8(sys.stdin, sys.stdout)
1968
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
520
1969
  parser = argparse.ArgumentParser(
521
1970
  description=__doc__,
522
1971
  formatter_class=argparse.RawDescriptionHelpFormatter,
523
1972
  )
524
1973
  sub = parser.add_subparsers(dest="cmd")
525
- sub.add_parser("emit", help="Build envelope from context payload (default).")
526
- sub.add_parser("emit-blocked", help="Emit minimal status='blocked' envelope for early-halt paths.")
527
- sub.add_parser("validate", help="Validate an envelope payload against the schema.")
1974
+ p_emit = sub.add_parser("emit", help="Build a run's envelope from its context payload (default).")
1975
+ p_halt = sub.add_parser("emit-halt", help="Build a HARD HALT's envelope from its halt payload.")
1976
+ p_blocked = sub.add_parser("emit-blocked", help="Emit skf-setup's status='blocked' envelope for early-halt paths.")
1977
+ p_record = sub.add_parser("record", help="Append an auto-decision or a warning to the run's sink.")
1978
+ p_validate = sub.add_parser("validate", help="Validate an envelope payload against the schema.")
1979
+ p_render = sub.add_parser("render-report", help="Print skf-setup's FORGE STATUS banner for its payload.")
1980
+ for p in (p_emit, p_halt):
1981
+ p.add_argument("--workflow", required=p is p_halt, default=None,
1982
+ help="Workflow folder name or schema stem (emit: skf-setup when omitted).")
1983
+ p.add_argument("--run-dir", default=None,
1984
+ help="The run folder that holds the sink (and skf-setup's staged helper outputs).")
1985
+ p.add_argument("--result-dir", default=None,
1986
+ help="Write the per-run and -latest result files here when the folder exists.")
1987
+ p.add_argument("--target", choices=["stdout", "stderr"], default="stdout",
1988
+ help="Stream for the envelope line (default stdout).")
1989
+ p_record.add_argument("--run-dir", required=True, help="The run folder that holds the sink.")
1990
+ p_record.add_argument("--workflow", default=None,
1991
+ help="Check a decision against this workflow's headless_decisions schema.")
1992
+ kind = p_record.add_mutually_exclusive_group(required=True)
1993
+ kind.add_argument("--decision", action="store_true", help="Read one decision object on stdin.")
1994
+ kind.add_argument("--warning", default=None, help="The warning text.")
1995
+ p_validate.add_argument("--workflow", default=None,
1996
+ help="Workflow folder name or schema stem (skf-setup when omitted).")
1997
+ p_render.add_argument("--run-dir", default=None, help="The setup run folder that holds the staged helper outputs.")
1998
+ p_render.add_argument("--tier-rules", default=None,
1999
+ help="skf-setup's references/tier-rules.md (default: beside this script's folder).")
2000
+ for p in (p_emit, p_render):
2001
+ p.add_argument("--project-root", default=None, help="skf-setup: the project root (project_root).")
2002
+ p.add_argument("--sidecar-path", default=None,
2003
+ help="skf-setup: the sidecar folder activation bound (needs --project-root); "
2004
+ "config_path is the forge-tier.yaml in it.")
2005
+ p.add_argument("--forge-data-folder", default=None,
2006
+ help="skf-setup: the forge data folder activation bound (needs --project-root).")
2007
+ p_blocked.add_argument("--phase", default=None,
2008
+ help="The halt's phase; with it the payload comes from these options, not stdin.")
2009
+ p_blocked.add_argument("--reason", default=None, help="The halt's reason (with --phase).")
2010
+ p_blocked.add_argument("--path", default=None, help="The file or folder the halt concerns (optional).")
2011
+ p_blocked.add_argument("--stderr-from", default=None, metavar="FILE|-",
2012
+ help="The failed command's stderr, a file or - for stdin: its diagnostic fills "
2013
+ "<message> in --reason.")
2014
+ p_blocked.add_argument("--customization-resolver-unavailable", default=None, metavar="REASON",
2015
+ help="The On Activation resolver's failure reason (with --phase): the envelope warns "
2016
+ "that the _bmad/custom/ overrides were not applied.")
528
2017
  args = parser.parse_args()
529
2018
 
530
2019
  cmd = args.cmd or "emit"
531
- if cmd == "emit":
532
- cmd_emit()
2020
+ if cmd in ("emit", "emit-halt"):
2021
+ run_emit(getattr(args, "workflow", None), halt=cmd == "emit-halt", label=cmd,
2022
+ run_dir=getattr(args, "run_dir", None), result_dir=getattr(args, "result_dir", None),
2023
+ target=getattr(args, "target", "stdout"), project_root=getattr(args, "project_root", None),
2024
+ sidecar_path=getattr(args, "sidecar_path", None),
2025
+ forge_data_folder=getattr(args, "forge_data_folder", None))
533
2026
  elif cmd == "emit-blocked":
534
- cmd_emit_blocked()
2027
+ cmd_emit_blocked(args)
2028
+ elif cmd == "record":
2029
+ cmd_record(args)
535
2030
  elif cmd == "validate":
536
- cmd_validate()
2031
+ cmd_validate(args.workflow)
2032
+ elif cmd == "render-report":
2033
+ cmd_render_report(args.run_dir, args.tier_rules, args.project_root, args.sidecar_path,
2034
+ args.forge_data_folder)
537
2035
 
538
2036
 
539
2037
  if __name__ == "__main__":