bmad-module-skill-forge 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +15 -5
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -679
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,1128 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["pyyaml", "jsonschema>=4.0"]
4
+ # ///
5
+ """Campaign State: every write to _campaign-state.yaml, and the resume point.
6
+
7
+ The campaign's progress lives in one YAML file that must survive a session
8
+ dying at any moment. The step files used to re-type the whole file on every
9
+ change: copy it to .bak, edit it in memory, type a timestamp with no clock to
10
+ read, write it back, and check it only when the next step loaded it. A bad
11
+ write for one skill was then copied over the last good .bak by the next
12
+ write, and resume found both files invalid. This script owns every write, so
13
+ the step files name an operation and its inputs and never touch the YAML.
14
+
15
+ Each write operation, in this order:
16
+
17
+ 1. reads the state and validates it (campaign-validate-state.py's schema
18
+ check, date-time fields included): an invalid state is never rotated
19
+ into .bak, and nothing is written (exit 3);
20
+ 2. applies the operation, sets `campaign.current_stage` when --stage is
21
+ given, and stamps `campaign.last_updated` (and any started_at or
22
+ completed_at the operation sets) from the system clock, in UTC;
23
+ 3. validates the result: a change that would make the state invalid is
24
+ refused and nothing is written (exit 3);
25
+ 4. copies the primary it read, which validated, to the .bak beside it,
26
+ then writes the new state; each file is written to a temporary file
27
+ and renamed over the old one, so a crash leaves the old file or the
28
+ new one, never half of one.
29
+
30
+ Operations:
31
+
32
+ init Create the state (step-01). Reads the parsed targets
33
+ (campaign-parse-manifest.py's JSON output, a file or `-`
34
+ for stdin) and settles the quality gate through
35
+ campaign-quality-gate.py's check (a brief's
36
+ quality_gate wins when --brief-file names one). Refuses
37
+ a state file or backup that already exists. With
38
+ --brief-out it also writes campaign-brief.yaml from
39
+ --brief-template (the template's mapping, its keys in
40
+ their order, after its leading comment block): the
41
+ campaign name, created_at (the state's started_at), the
42
+ same targets[] with every field each one carries
43
+ (repo_url, which the state has no place for, and any
44
+ hint), the settled quality_gate, architecture_doc_path
45
+ and notes (--notes, else the --brief-file's notes, else
46
+ empty). The template is read before anything is written;
47
+ the brief is written right after the state, and a resume
48
+ that finds the state without it halts (missing-brief).
49
+ set-stage Write current_stage alone: a stage's final write when it
50
+ records nothing else.
51
+ set-skill Set the status, quality_score, skill_path or brief_path
52
+ of one or more skills (--skill repeats). `active` stamps
53
+ started_at when it is not set yet (an interrupted run
54
+ keeps its start); `completed` stamps completed_at;
55
+ `pending` (a reset for a re-run) clears both and the
56
+ export outcome. --export records the Export stage's
57
+ outcome (step-10) as `export`: `exported`, or `failed`
58
+ with --export-halt-reason and --export-exit-code from
59
+ the skf-export-skill call.
60
+ apply-plan Write the execution order and the cycle flag that
61
+ campaign-deps.py --compute gives for the state (step-02);
62
+ refuses a plan the stages cannot follow (exit 4).
63
+ apply-pins Write the resolved pins from campaign-validate-pins.py's
64
+ output (step-03); refuses output with an invalid pin
65
+ (exit 5).
66
+ apply-provenance Write each commit SHA from campaign-provenance.py's
67
+ output (step-04); refuses output with an inaccessible
68
+ repository (exit 6).
69
+ apply-batch The Tier B batch (step-06), from the line-to-skill map
70
+ campaign-render-batch.py wrote: --start marks the map's
71
+ skipped_by_directive skills `skipped` and its batched
72
+ skills `active`; --results-file records the joined
73
+ results (campaign-render-batch.py --record output);
74
+ --no-results fails every batched skill.
75
+ append-workarounds
76
+ Append entries to one skill's workarounds_applied; an
77
+ entry it already holds is not added twice.
78
+ set-campaign Write campaign-level fields: the architecture document
79
+ path, and the capstone, verification or refinement
80
+ summary read from the sub-skill's result envelope line
81
+ (a file or `-`; the line's SKF_*_RESULT_JSON: prefix is
82
+ optional). A success envelope is recorded; an error
83
+ envelope, or --no-capstone and the like, records null.
84
+ A recorded verification also sets capstone.verified.
85
+ recover Copy a .bak that validates over the primary (step-resume).
86
+ archive Move the state, its .bak and the brief, each one that
87
+ exists, into archive/<campaign name>-<UTC stamp>/ beside
88
+ the state (SKILL.md's overwrite), so step-01's init can
89
+ start a new campaign. The name comes from the state, else
90
+ the .bak, else "campaign"; an existing folder is refused.
91
+ log Append one typed entry to the decision log: decision (an
92
+ operator's choice), auto (a default a headless run took)
93
+ or event (a failure, a recovery, a skip), stamped from
94
+ the clock. The log is append-only.
95
+ halt-payload Print the payload of a HARD HALT's error envelope for
96
+ skf-emit-result-envelope.py emit-halt (read-only): the
97
+ phase, the halt reason, the halt message read from stdin,
98
+ the completed and failed counts of the state (0 when it
99
+ cannot be read) and the decision log beside it (null
100
+ before the campaign workspace exists).
101
+ resume Compute the resume point (read-only): the stage and step
102
+ file to chain to, from the state and --from.
103
+
104
+ Resume rule. Without --from: an active skill resumes its own stage (Tier A
105
+ at stage 4, the skill loop; Tier B at stage 5, the batch, whose script
106
+ batches the active skills an interrupted batch left), with no +1; otherwise
107
+ the next stage is current_stage + 1, because current_stage is the highest
108
+ completed stage, capped at 10 (the report stage reruns, never a stage 11).
109
+ The campaign is complete when current_stage is 10 and no skill is pending or
110
+ active. With --from: an unknown skill is an error; a pending or active skill
111
+ resumes its tier's stage; a completed, failed or skipped one needs the
112
+ operator's choice (needs_choice), unless --next takes the next pending or
113
+ active skill after it in the execution order (complete when there is none).
114
+
115
+ CLI:
116
+ uv run campaign-state.py init --state-file <p> --targets-file <p|-> --name <name> \\
117
+ --hard <value> --soft-target <N> --soft-fallback <N> [--brief-file <p>] \\
118
+ [--directive-path <p>] [--architecture-doc-path <p>] \\
119
+ [--brief-out <p> [--brief-template <p>] [--notes <text>]]
120
+ uv run campaign-state.py set-stage --state-file <p> --stage <N>
121
+ uv run campaign-state.py set-skill --state-file <p> --skill <name> [--skill <name> ...] \\
122
+ [--status <status>] [--quality-score <N>] [--skill-path <p>] [--brief-path <p>] \\
123
+ [--export exported|failed [--export-halt-reason <r>] [--export-exit-code <N>]] [--stage <N>]
124
+ uv run campaign-state.py apply-plan --state-file <p> [--stage <N>]
125
+ uv run campaign-state.py apply-pins --state-file <p> --results-file <p|-> [--stage <N>]
126
+ uv run campaign-state.py apply-provenance --state-file <p> --results-file <p|-> [--stage <N>]
127
+ uv run campaign-state.py apply-batch --state-file <p> --map-file <p> \\
128
+ (--start | --results-file <p|-> | --no-results) [--stage <N>]
129
+ uv run campaign-state.py append-workarounds --state-file <p> --skill <name> \\
130
+ --entry <text> [--entry <text> ...] [--stage <N>]
131
+ uv run campaign-state.py set-campaign --state-file <p> [--architecture-doc-path <p>] \\
132
+ [--capstone <p|-> | --no-capstone] [--verification <p|-> | --no-verification] \\
133
+ [--refinement <p|-> | --no-refinement] [--stage <N>]
134
+ uv run campaign-state.py recover --state-file <p>
135
+ uv run campaign-state.py archive --state-file <p> --brief-file <p>
136
+ uv run campaign-state.py log --log-file <p> --type <decision|auto|event> --text <entry>
137
+ uv run campaign-state.py halt-payload --state-file <p> --phase <slug> --halt-reason <class> < <message>
138
+ uv run campaign-state.py resume --state-file <p> [--from <skill>] [--next]
139
+
140
+ The backup is always <state-file>.bak.
141
+
142
+ Output (one JSON object on stdout):
143
+ writes: {"op", "state_file", "backup_file", "backup_rotated",
144
+ "last_updated", "current_stage", "changed": [...]} plus the
145
+ operation's own keys (init: "quality_gate", "brief_file", null
146
+ without --brief-out; apply-batch: "activated", "skipped",
147
+ "completed", "failed"; set-campaign: "recorded", "not_recorded")
148
+ recover: {"op", "state_file", "backup_file", "recovered", "last_updated",
149
+ "current_stage"}
150
+ archive: {"op", "archive_dir", "moved": [...]}
151
+ log: {"op", "log_file", "entry"}
152
+ halt-payload:
153
+ {"phase", "reason", "halt_reason", "skills_completed",
154
+ "skills_failed", "decision_log"}
155
+ resume: {"complete", "stage", "step_file", "reason", "skill",
156
+ "needs_choice", "from_status", "active_other"}
157
+ Errors: {"error", "code", "errors": [...]} on stderr.
158
+
159
+ Exit codes (each is the campaign's HALT code for the same class):
160
+ 0 written, recovered, archived, logged or computed
161
+ 2 invalid input: a missing or unreadable input file, an unknown skill,
162
+ output from another script that holds an error, a gate the gate script
163
+ rejects, a state that init would overwrite, a brief template that is
164
+ no YAML mapping, export flags that do not go together, or an archive
165
+ folder that exists
166
+ 3 invalid state: the state is missing, malformed or fails the schema, or
167
+ the change would make it fail (nothing written)
168
+ 4 apply-plan: a plan the stages cannot follow (circular-deps)
169
+ 5 apply-pins: an invalid pin (invalid-pin)
170
+ 6 apply-provenance: an inaccessible repository (inaccessible-repo)
171
+ 9 recover: the backup is missing or fails validation too
172
+ """
173
+
174
+ from __future__ import annotations
175
+
176
+ import argparse
177
+ import contextlib
178
+ import copy
179
+ import importlib.util
180
+ import io
181
+ import json
182
+ import os
183
+ import re
184
+ import secrets
185
+ import sys
186
+ import time
187
+ from datetime import datetime, timezone
188
+ from pathlib import Path
189
+ from typing import Any, Callable
190
+
191
+ import yaml
192
+
193
+ HERE = Path(__file__).resolve().parent
194
+ VALIDATE_SCRIPT = HERE / "campaign-validate-state.py"
195
+ DEPS_SCRIPT = HERE / "campaign-deps.py"
196
+ GATE_SCRIPT = HERE / "campaign-quality-gate.py"
197
+ BRIEF_TEMPLATE = HERE.parent / "templates" / "campaign-brief-template.yaml"
198
+ STATUSES = ("pending", "active", "completed", "failed", "skipped")
199
+ SETTLED = ("completed", "failed", "skipped")
200
+ OPEN = ("pending", "active")
201
+ LOG_TYPES = ("decision", "auto", "event")
202
+ EXPORT_STATUSES = ("exported", "failed")
203
+ FINAL_STAGE = 10
204
+ # Stage N runs step-(N+1); SKILL.md's Stages table.
205
+ STEP_FILES = {
206
+ 0: "step-01-setup.md",
207
+ 1: "step-02-strategy.md",
208
+ 2: "step-03-pins.md",
209
+ 3: "step-04-provenance.md",
210
+ 4: "step-05-skill-loop.md",
211
+ 5: "step-06-batch.md",
212
+ 6: "step-07-capstone.md",
213
+ 7: "step-08-verify.md",
214
+ 8: "step-09-refine.md",
215
+ 9: "step-10-export.md",
216
+ 10: "step-11-maintenance.md",
217
+ }
218
+ TIER_STAGE = {"A": 4, "B": 5}
219
+ DECISION_LOG = "_campaign-decision-log.md"
220
+ EXIT_INPUT = 2
221
+ EXIT_STATE = 3
222
+ EXIT_CIRCULAR = 4
223
+ EXIT_PIN = 5
224
+ EXIT_REPO = 6
225
+ EXIT_NO_BACKUP = 9
226
+ _IS_WINDOWS = os.name == "nt"
227
+ _REPLACE_WAIT_SECONDS = 5.0
228
+ _REPLACE_POLL_SECONDS = 0.05
229
+ _modules: dict[str, Any] = {}
230
+
231
+
232
+ class StateError(Exception):
233
+ """A failure reported as {"error", "code", "errors"} on stderr with its exit code."""
234
+
235
+ def __init__(self, exit_code: int, code: str, message: str, errors: list | None = None) -> None:
236
+ super().__init__(message)
237
+ self.exit_code = exit_code
238
+ self.code = code
239
+ self.errors = errors or []
240
+
241
+
242
+ def _module(path: Path) -> Any:
243
+ """A sibling script, loaded once."""
244
+ key = path.name
245
+ if key not in _modules:
246
+ spec = importlib.util.spec_from_file_location(path.stem.replace("-", "_"), path)
247
+ module = importlib.util.module_from_spec(spec)
248
+ spec.loader.exec_module(module)
249
+ _modules[key] = module
250
+ return _modules[key]
251
+
252
+
253
+ def now() -> str:
254
+ """The clock's time in UTC, as ISO-8601 with its offset."""
255
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
256
+
257
+
258
+ # --------------------------------------------------------------------------
259
+ # Reading, validating and writing the state
260
+ # --------------------------------------------------------------------------
261
+
262
+
263
+ def _schema() -> dict:
264
+ validator = _module(VALIDATE_SCRIPT)
265
+ return json.loads(validator.DEFAULT_SCHEMA_PATH.read_text(encoding="utf-8"))
266
+
267
+
268
+ def validation_errors(state: Any) -> list[dict]:
269
+ """campaign-validate-state.py's errors for a state already loaded."""
270
+ if not isinstance(state, dict):
271
+ return [{"field": "(root)", "message": "State root must be a mapping"}]
272
+ return _module(VALIDATE_SCRIPT).validate_state(state, _schema())["errors"]
273
+
274
+
275
+ def read_state(path: Path) -> tuple[dict, bytes]:
276
+ """The state and its bytes; StateError (exit 3) unless it validates."""
277
+ if not path.is_file():
278
+ raise StateError(EXIT_STATE, "state-missing", f"State not found at `{path.as_posix()}`.")
279
+ try:
280
+ raw = path.read_bytes()
281
+ text = raw.decode("utf-8")
282
+ except (OSError, UnicodeDecodeError) as exc:
283
+ raise StateError(EXIT_STATE, "state-malformed", f"State unreadable: {exc}") from exc
284
+ state, problem = _module(VALIDATE_SCRIPT)._load_yaml_text(text)
285
+ if problem is not None:
286
+ raise StateError(EXIT_STATE, "state-malformed", problem)
287
+ errors = validation_errors(state)
288
+ if errors:
289
+ raise StateError(EXIT_STATE, "state-invalid", "The state fails validation: nothing was written.", errors)
290
+ return state, raw
291
+
292
+
293
+ def dump_state(state: dict) -> bytes:
294
+ text = yaml.safe_dump(state, sort_keys=False, default_flow_style=False, allow_unicode=True)
295
+ return text.encode("utf-8")
296
+
297
+
298
+ def _replace(source: Path, target: Path) -> None:
299
+ """os.replace, retried on Windows while a reader holds either file open."""
300
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
301
+ while True:
302
+ try:
303
+ os.replace(source, target)
304
+ return
305
+ except PermissionError:
306
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
307
+ raise
308
+ time.sleep(_REPLACE_POLL_SECONDS)
309
+
310
+
311
+ def write_atomic(path: Path, data: bytes) -> None:
312
+ """Write a temporary file beside path, then rename it over path.
313
+
314
+ A reader holding the file open on Windows makes the rename fail with
315
+ PermissionError for a moment, so the rename is retried there.
316
+ """
317
+ path.parent.mkdir(parents=True, exist_ok=True)
318
+ tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
319
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
320
+ fd = os.open(tmp, flags, 0o644)
321
+ try:
322
+ with open(fd, "wb") as fh:
323
+ fh.write(data)
324
+ fh.flush()
325
+ os.fsync(fh.fileno())
326
+ _replace(tmp, path)
327
+ except BaseException:
328
+ with contextlib.suppress(OSError):
329
+ tmp.unlink()
330
+ raise
331
+
332
+
333
+ def backup_path(state_file: Path) -> Path:
334
+ return state_file.with_name(state_file.name + ".bak")
335
+
336
+
337
+ def _set_stage(state: dict, stage: int | None, changed: list[str]) -> None:
338
+ if stage is None:
339
+ return
340
+ state["campaign"]["current_stage"] = stage
341
+ changed.append(f"campaign.current_stage={stage}")
342
+
343
+
344
+ def mutate(
345
+ state_file: str,
346
+ op: str,
347
+ change: Callable[[dict, str, list[str]], dict | None],
348
+ stage: int | None = None,
349
+ ) -> dict:
350
+ """Read, validate, change, stamp, validate again, rotate .bak, write.
351
+
352
+ `change(state, stamp, changed)` edits the state in place, appends a short
353
+ description of each change to `changed`, and may return extra output
354
+ keys. It raises StateError to refuse the operation before anything is
355
+ written.
356
+ """
357
+ path = Path(state_file)
358
+ state, raw = read_state(path)
359
+ new_state = copy.deepcopy(state)
360
+ stamp = now()
361
+ changed: list[str] = []
362
+ extra = change(new_state, stamp, changed) or {}
363
+ _set_stage(new_state, stage, changed)
364
+ new_state["campaign"]["last_updated"] = stamp
365
+ errors = validation_errors(new_state)
366
+ if errors:
367
+ raise StateError(EXIT_STATE, "state-change-invalid",
368
+ "The change would make the state invalid: nothing was written.", errors)
369
+ bak = backup_path(path)
370
+ write_atomic(bak, raw)
371
+ write_atomic(path, dump_state(new_state))
372
+ return {
373
+ "op": op,
374
+ "state_file": path.as_posix(),
375
+ "backup_file": bak.as_posix(),
376
+ "backup_rotated": True,
377
+ "last_updated": stamp,
378
+ "current_stage": new_state["campaign"]["current_stage"],
379
+ "changed": changed,
380
+ **extra,
381
+ }
382
+
383
+
384
+ # --------------------------------------------------------------------------
385
+ # Input files
386
+ # --------------------------------------------------------------------------
387
+
388
+
389
+ def read_text(source: str, label: str) -> str:
390
+ """A file's text, or stdin's for `-`, in the encoding it was written in.
391
+
392
+ A shell redirect on Windows PowerShell writes UTF-16 with a byte-order
393
+ mark, so one is honoured.
394
+ """
395
+ try:
396
+ raw = sys.stdin.buffer.read() if source == "-" else Path(source).read_bytes()
397
+ except OSError as exc:
398
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} unreadable: {exc}") from exc
399
+ try:
400
+ if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
401
+ return raw.decode("utf-16")
402
+ return raw.decode("utf-8-sig")
403
+ except UnicodeDecodeError as exc:
404
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not UTF-8 text: {exc}") from exc
405
+
406
+
407
+ def read_json(source: str, label: str) -> dict:
408
+ text = read_text(source, label)
409
+ if not text.strip():
410
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is empty")
411
+ try:
412
+ data = json.loads(text)
413
+ except json.JSONDecodeError as exc:
414
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not JSON: {exc}") from exc
415
+ if not isinstance(data, dict):
416
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a JSON object")
417
+ return data
418
+
419
+
420
+ def read_envelope(source: str, label: str) -> dict:
421
+ """A sub-skill's result envelope: the last SKF_*_RESULT_JSON line, or bare JSON."""
422
+ text = read_text(source, label)
423
+ lines = [line for line in text.splitlines() if "_RESULT_JSON:" in line and "SKF_" in line]
424
+ payload = lines[-1].split("_RESULT_JSON:", 1)[1].strip() if lines else text.strip()
425
+ if not payload:
426
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} holds no result envelope")
427
+ try:
428
+ data = json.loads(payload)
429
+ except json.JSONDecodeError as exc:
430
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a result envelope: {exc}") from exc
431
+ if not isinstance(data, dict):
432
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} is not a JSON object")
433
+ return data
434
+
435
+
436
+ def _skills_by_name(state: dict) -> dict[str, dict]:
437
+ return {s["name"]: s for s in state.get("skills", [])}
438
+
439
+
440
+ def _known(state: dict, names: list[str]) -> dict[str, dict]:
441
+ skills = _skills_by_name(state)
442
+ unknown = [n for n in names if n not in skills]
443
+ if unknown:
444
+ raise StateError(EXIT_INPUT, "unknown-skill",
445
+ f"Unknown skill(s): {', '.join(unknown)}. Known skills: {', '.join(skills)}.")
446
+ return skills
447
+
448
+
449
+ # --------------------------------------------------------------------------
450
+ # Operations
451
+ # --------------------------------------------------------------------------
452
+
453
+
454
+ def _brief_template(source: str) -> tuple[str, dict]:
455
+ """The template's leading comment block and its mapping (empty for an empty template)."""
456
+ text = read_text(source, "Brief template")
457
+ try:
458
+ mapping = yaml.safe_load(text)
459
+ except yaml.YAMLError as exc:
460
+ raise StateError(EXIT_INPUT, "brief-template-invalid", f"Brief template is not YAML: {exc}") from exc
461
+ if mapping is None:
462
+ mapping = {}
463
+ if not isinstance(mapping, dict):
464
+ raise StateError(EXIT_INPUT, "brief-template-invalid", "Brief template is not a YAML mapping")
465
+ header = []
466
+ for line in text.splitlines():
467
+ if not line.startswith("#"):
468
+ break
469
+ header.append(line)
470
+ return "".join(f"{line}\n" for line in header), mapping
471
+
472
+
473
+ def render_brief(template: tuple[str, dict], fields: dict) -> bytes:
474
+ """campaign-brief.yaml: the template's header, then its mapping with `fields` laid over it."""
475
+ header, mapping = template
476
+ brief = {**copy.deepcopy(mapping), **fields}
477
+ body = yaml.safe_dump(brief, sort_keys=False, default_flow_style=False, allow_unicode=True)
478
+ return ((header + "\n" if header else "") + body).encode("utf-8")
479
+
480
+
481
+ def op_init(args: argparse.Namespace) -> dict:
482
+ path = Path(args.state_file)
483
+ bak = backup_path(path)
484
+ for existing in (path, bak):
485
+ if existing.exists():
486
+ raise StateError(EXIT_INPUT, "state-exists",
487
+ f"`{existing.as_posix()}` already exists: archive the campaign before starting a new one.")
488
+ if not args.brief_out and (args.brief_template or args.notes is not None):
489
+ raise StateError(EXIT_INPUT, "input-invalid", "--brief-template and --notes go with --brief-out")
490
+ parsed = read_json(args.targets_file, "Targets")
491
+ if parsed.get("errors"):
492
+ raise StateError(EXIT_INPUT, "targets-invalid", "The targets hold errors: fix them and parse again.",
493
+ list(parsed["errors"]))
494
+ targets = parsed.get("targets")
495
+ if not isinstance(targets, list) or not targets:
496
+ raise StateError(EXIT_INPUT, "no-targets", "A campaign needs at least one target.")
497
+
498
+ gate_module = _module(GATE_SCRIPT)
499
+ try:
500
+ brief = gate_module._load_brief(args.brief_file) if args.brief_file else None
501
+ gate = gate_module.check(args.hard, args.soft_target, args.soft_fallback, brief)
502
+ except gate_module.GateError as exc:
503
+ raise StateError(EXIT_INPUT, exc.code, str(exc), list(exc.extra.get("errors", []))) from exc
504
+
505
+ template = _brief_template(args.brief_template or str(BRIEF_TEMPLATE)) if args.brief_out else None
506
+ stamp = now()
507
+ campaign: dict[str, Any] = {
508
+ "name": args.name,
509
+ "started_at": stamp,
510
+ "last_updated": stamp,
511
+ "current_stage": 0,
512
+ }
513
+ if args.directive_path:
514
+ campaign["directive_path"] = args.directive_path
515
+ if args.architecture_doc_path:
516
+ campaign["architecture_doc_path"] = args.architecture_doc_path
517
+ campaign["quality_gate"] = {key: gate[key] for key in ("hard", "soft_target", "soft_fallback")}
518
+
519
+ skills = []
520
+ for target in targets:
521
+ if not isinstance(target, dict) or not target.get("name"):
522
+ raise StateError(EXIT_INPUT, "targets-invalid", "A target has no name: parse the targets again.")
523
+ skills.append({
524
+ "name": target["name"],
525
+ "status": "pending",
526
+ "depends_on": list(target.get("depends_on") or []),
527
+ "tier": target.get("tier") or "A",
528
+ "pin": target.get("pin"),
529
+ "brief_path": None,
530
+ "skill_path": None,
531
+ "quality_score": None,
532
+ "workarounds_applied": [],
533
+ "started_at": None,
534
+ "completed_at": None,
535
+ "commit_sha": None,
536
+ })
537
+ state = {
538
+ "campaign": campaign,
539
+ "skills": skills,
540
+ "dependency_graph": {"execution_order": [], "circular_deps_detected": False},
541
+ }
542
+ errors = validation_errors(state)
543
+ if errors:
544
+ raise StateError(EXIT_STATE, "state-change-invalid",
545
+ "The new state would be invalid: nothing was written.", errors)
546
+ brief_data = None
547
+ if template is not None:
548
+ notes = args.notes if args.notes is not None else (brief or {}).get("notes")
549
+ brief_data = render_brief(template, {
550
+ "campaign_name": args.name,
551
+ "created_at": stamp,
552
+ "targets": copy.deepcopy(targets),
553
+ "quality_gate": dict(campaign["quality_gate"]),
554
+ "architecture_doc_path": args.architecture_doc_path or "",
555
+ "notes": notes if isinstance(notes, str) else "",
556
+ })
557
+ write_atomic(path, dump_state(state))
558
+ changed = [f"skills={len(skills)}", "campaign.quality_gate"]
559
+ brief_file = Path(args.brief_out) if brief_data is not None else None
560
+ if brief_file is not None:
561
+ write_atomic(brief_file, brief_data)
562
+ changed.append("brief")
563
+ return {
564
+ "op": "init",
565
+ "state_file": path.as_posix(),
566
+ "backup_file": bak.as_posix(),
567
+ "backup_rotated": False,
568
+ "last_updated": stamp,
569
+ "current_stage": 0,
570
+ "changed": changed,
571
+ "quality_gate": campaign["quality_gate"],
572
+ "brief_file": brief_file.as_posix() if brief_file else None,
573
+ }
574
+
575
+
576
+ def op_set_stage(args: argparse.Namespace) -> dict:
577
+ return mutate(args.state_file, "set-stage", lambda state, stamp, changed: None, args.stage)
578
+
579
+
580
+ def _export_outcome(args: argparse.Namespace) -> dict | None:
581
+ """The `export` record --export gives, or None without it."""
582
+ detail = args.export_halt_reason is not None or args.export_exit_code is not None
583
+ if args.export is None:
584
+ if detail:
585
+ raise StateError(EXIT_INPUT, "input-invalid", "--export-halt-reason and --export-exit-code go with --export")
586
+ return None
587
+ if args.export == "exported":
588
+ if detail:
589
+ raise StateError(EXIT_INPUT, "input-invalid",
590
+ "--export-halt-reason and --export-exit-code go with --export failed")
591
+ return {"status": "exported", "halt_reason": None, "exit_code": 0}
592
+ return {"status": "failed", "halt_reason": args.export_halt_reason or None, "exit_code": args.export_exit_code}
593
+
594
+
595
+ def op_set_skill(args: argparse.Namespace) -> dict:
596
+ outcome = _export_outcome(args)
597
+
598
+ def change(state: dict, stamp: str, changed: list[str]) -> None:
599
+ skills = _known(state, args.skill)
600
+ for name in args.skill:
601
+ skill = skills[name]
602
+ if args.status is not None:
603
+ skill["status"] = args.status
604
+ changed.append(f"{name}.status={args.status}")
605
+ if args.status == "active" and not skill.get("started_at"):
606
+ skill["started_at"] = stamp
607
+ changed.append(f"{name}.started_at")
608
+ elif args.status == "completed":
609
+ skill["completed_at"] = stamp
610
+ changed.append(f"{name}.completed_at")
611
+ elif args.status == "pending":
612
+ skill["started_at"] = None
613
+ skill["completed_at"] = None
614
+ skill.pop("export", None)
615
+ for field in ("quality_score", "skill_path", "brief_path"):
616
+ value = getattr(args, field)
617
+ if value is not None:
618
+ skill[field] = value
619
+ changed.append(f"{name}.{field}")
620
+ if outcome is not None:
621
+ skill["export"] = dict(outcome)
622
+ changed.append(f"{name}.export={outcome['status']}")
623
+
624
+ return mutate(args.state_file, "set-skill", change, args.stage)
625
+
626
+
627
+ def computed_plan(state_file: str) -> list[str]:
628
+ """campaign-deps.py --compute's execution order for the state file.
629
+
630
+ StateError (exit 4) when the graph cannot be followed: a cycle, a
631
+ dangling depends_on name or a Tier A skill on a Tier B one.
632
+ """
633
+ out, err = io.StringIO(), io.StringIO()
634
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
635
+ rc = _module(DEPS_SCRIPT).compute(state_file)
636
+ if rc == 0:
637
+ return json.loads(out.getvalue())["execution_order"]
638
+ details = [line for line in (out.getvalue() + err.getvalue()).splitlines() if line.strip()]
639
+ raise StateError(EXIT_CIRCULAR, "plan-unorderable",
640
+ "The dependency graph cannot be followed: campaign-deps.py --compute says why.", details)
641
+
642
+
643
+ def op_apply_plan(args: argparse.Namespace) -> dict:
644
+ def change(state: dict, stamp: str, changed: list[str]) -> dict:
645
+ order = computed_plan(args.state_file)
646
+ state["dependency_graph"] = {"execution_order": order, "circular_deps_detected": False}
647
+ changed.append("dependency_graph.execution_order")
648
+ return {"execution_order": order}
649
+
650
+ return mutate(args.state_file, "apply-plan", change, args.stage)
651
+
652
+
653
+ def _results(source: str, label: str) -> list[dict]:
654
+ data = read_json(source, label)
655
+ results = data.get("results")
656
+ if not isinstance(results, list) or not all(isinstance(r, dict) for r in results):
657
+ raise StateError(EXIT_INPUT, "input-unreadable", f"{label} holds no results[] list")
658
+ return results
659
+
660
+
661
+ def op_apply_pins(args: argparse.Namespace) -> dict:
662
+ results = _results(args.results_file, "Pin results")
663
+ invalid = [r.get("name") for r in results if r.get("status") not in ("valid", "resolved")]
664
+ if invalid:
665
+ raise StateError(EXIT_PIN, "invalid-pin", f"Invalid pin(s): {', '.join(map(str, invalid))}: nothing was written.")
666
+
667
+ def change(state: dict, stamp: str, changed: list[str]) -> None:
668
+ skills = _known(state, [r.get("name") for r in results])
669
+ for result in results:
670
+ ref = result.get("resolved_ref")
671
+ skill = skills[result["name"]]
672
+ if ref and ref != skill.get("pin"):
673
+ skill["pin"] = ref
674
+ changed.append(f"{result['name']}.pin={ref}")
675
+
676
+ return mutate(args.state_file, "apply-pins", change, args.stage)
677
+
678
+
679
+ def op_apply_provenance(args: argparse.Namespace) -> dict:
680
+ results = _results(args.results_file, "Provenance results")
681
+ inaccessible = [r.get("name") for r in results if r.get("status") != "accessible" or not r.get("commit_sha")]
682
+ if inaccessible:
683
+ raise StateError(EXIT_REPO, "inaccessible-repo",
684
+ f"Inaccessible repositories: {', '.join(map(str, inaccessible))}: nothing was written.")
685
+
686
+ def change(state: dict, stamp: str, changed: list[str]) -> None:
687
+ skills = _known(state, [r.get("name") for r in results])
688
+ for result in results:
689
+ skills[result["name"]]["commit_sha"] = result["commit_sha"]
690
+ changed.append(f"{result['name']}.commit_sha")
691
+
692
+ return mutate(args.state_file, "apply-provenance", change, args.stage)
693
+
694
+
695
+ def op_apply_batch(args: argparse.Namespace) -> dict:
696
+ batch_map = read_json(args.map_file, "Batch map")
697
+ lines = batch_map.get("lines")
698
+ if not isinstance(lines, list):
699
+ raise StateError(EXIT_INPUT, "input-unreadable", "Batch map holds no lines[] list")
700
+ batched = [entry.get("skill") for entry in lines if isinstance(entry, dict)]
701
+ skipped = [e for e in batch_map.get("skipped_by_directive") or [] if isinstance(e, dict)]
702
+ rows: list[dict] = []
703
+ if args.results_file:
704
+ rows = _results(args.results_file, "Batch results")
705
+ unmapped = [r.get("skill") for r in rows if r.get("skill") not in batched]
706
+ if unmapped:
707
+ raise StateError(EXIT_INPUT, "input-unreadable",
708
+ f"Batch results name skills the map does not: {', '.join(map(str, unmapped))}")
709
+
710
+ def change(state: dict, stamp: str, changed: list[str]) -> dict:
711
+ skills = _known(state, batched + [e.get("name") for e in skipped])
712
+ out: dict[str, list] = {"activated": [], "skipped": [], "completed": [], "failed": []}
713
+ if args.start:
714
+ for entry in skipped:
715
+ skills[entry["name"]]["status"] = "skipped"
716
+ out["skipped"].append({"name": entry["name"], "reason": entry.get("reason")})
717
+ for name in batched:
718
+ skill = skills[name]
719
+ skill["status"] = "active"
720
+ if not skill.get("started_at"):
721
+ skill["started_at"] = stamp
722
+ out["activated"].append(name)
723
+ elif args.no_results:
724
+ for name in batched:
725
+ skills[name]["status"] = "failed"
726
+ out["failed"].append({"skill": name, "error_code": "no-batch-result"})
727
+ else:
728
+ for row in rows:
729
+ skill = skills[row["skill"]]
730
+ if row.get("status") == "completed":
731
+ skill["status"] = "completed"
732
+ skill["completed_at"] = stamp
733
+ skill["quality_score"] = row.get("quality_score")
734
+ skill["skill_path"] = row.get("skill_path")
735
+ out["completed"].append(row["skill"])
736
+ else:
737
+ skill["status"] = "failed"
738
+ out["failed"].append({"skill": row["skill"], "error_code": row.get("error_code")})
739
+ for key, values in out.items():
740
+ if values:
741
+ changed.append(f"{key}={len(values)}")
742
+ return out
743
+
744
+ return mutate(args.state_file, "apply-batch", change, args.stage)
745
+
746
+
747
+ def op_append_workarounds(args: argparse.Namespace) -> dict:
748
+ def change(state: dict, stamp: str, changed: list[str]) -> dict:
749
+ skill = _known(state, [args.skill])[args.skill]
750
+ held = skill.setdefault("workarounds_applied", [])
751
+ added = []
752
+ for entry in args.entry:
753
+ if entry.strip() and entry not in held:
754
+ held.append(entry)
755
+ added.append(entry)
756
+ changed.append(f"{args.skill}.workarounds_applied+{len(added)}")
757
+ return {"added": added}
758
+
759
+ return mutate(args.state_file, "append-workarounds", change, args.stage)
760
+
761
+
762
+ def _capstone(envelope: dict, stamp: str) -> dict | None:
763
+ if envelope.get("status") != "success":
764
+ return None
765
+ return {
766
+ "skill_path": envelope.get("skill_package"),
767
+ "quality_score": envelope.get("quality_score"),
768
+ "verified": None,
769
+ "completed_at": stamp,
770
+ }
771
+
772
+
773
+ def _verification(envelope: dict, stamp: str) -> dict | None:
774
+ if envelope.get("status") != "success" or envelope.get("exit_code", 0) != 0:
775
+ return None
776
+ return {key: envelope.get(key) for key in
777
+ ("report_path", "overall_verdict", "coverage_percentage", "recommendation_count")}
778
+
779
+
780
+ def _refinement(envelope: dict, stamp: str) -> dict | None:
781
+ if envelope.get("status") != "success" or envelope.get("exit_code", 0) != 0:
782
+ return None
783
+ return {key: envelope.get(key) for key in ("refined_path", "gap_count", "issue_count", "improvement_count")}
784
+
785
+
786
+ SUMMARIES = (("capstone", _capstone), ("verification", _verification), ("refinement", _refinement))
787
+
788
+
789
+ def op_set_campaign(args: argparse.Namespace) -> dict:
790
+ envelopes = {}
791
+ for field, _build in SUMMARIES:
792
+ source = getattr(args, field)
793
+ if source is not None:
794
+ envelopes[field] = read_envelope(source, f"The {field} envelope")
795
+
796
+ def change(state: dict, stamp: str, changed: list[str]) -> dict:
797
+ campaign = state["campaign"]
798
+ recorded, not_recorded = [], []
799
+ if args.architecture_doc_path:
800
+ campaign["architecture_doc_path"] = args.architecture_doc_path
801
+ changed.append("campaign.architecture_doc_path")
802
+ for field, build in SUMMARIES:
803
+ if field in envelopes:
804
+ envelope = envelopes[field]
805
+ value = build(envelope, stamp)
806
+ if value is None:
807
+ not_recorded.append({"field": field, "status": envelope.get("status"),
808
+ "halt_reason": envelope.get("halt_reason"),
809
+ "exit_code": envelope.get("exit_code")})
810
+ else:
811
+ recorded.append(field)
812
+ elif getattr(args, f"no_{field}"):
813
+ value = None
814
+ not_recorded.append({"field": field, "status": None, "halt_reason": None, "exit_code": None})
815
+ else:
816
+ continue
817
+ campaign[field] = value
818
+ changed.append(f"campaign.{field}")
819
+ if field == "verification" and isinstance(campaign.get("capstone"), dict):
820
+ verdict = (value or {}).get("overall_verdict")
821
+ campaign["capstone"]["verified"] = None if value is None else verdict == "FEASIBLE"
822
+ changed.append("campaign.capstone.verified")
823
+ return {"recorded": recorded, "not_recorded": not_recorded}
824
+
825
+ return mutate(args.state_file, "set-campaign", change, args.stage)
826
+
827
+
828
+ def op_recover(args: argparse.Namespace) -> dict:
829
+ path = Path(args.state_file)
830
+ bak = backup_path(path)
831
+ try:
832
+ state, raw = read_state(bak)
833
+ except StateError as exc:
834
+ raise StateError(EXIT_NO_BACKUP, "corrupt-state",
835
+ f"No usable backup at `{bak.as_posix()}`: {exc}", exc.errors) from exc
836
+ write_atomic(path, raw)
837
+ return {
838
+ "op": "recover",
839
+ "state_file": path.as_posix(),
840
+ "backup_file": bak.as_posix(),
841
+ "recovered": True,
842
+ "last_updated": state["campaign"]["last_updated"],
843
+ "current_stage": state["campaign"]["current_stage"],
844
+ }
845
+
846
+
847
+ def _loose_state(path: Path) -> dict | None:
848
+ """The state as YAML, unvalidated: what an archive or a halt can still read."""
849
+ if not path.is_file():
850
+ return None
851
+ try:
852
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
853
+ except (OSError, UnicodeDecodeError, yaml.YAMLError):
854
+ return None
855
+ return data if isinstance(data, dict) else None
856
+
857
+
858
+ def _campaign_name(path: Path) -> str | None:
859
+ campaign = (_loose_state(path) or {}).get("campaign")
860
+ name = campaign.get("name") if isinstance(campaign, dict) else None
861
+ return name if isinstance(name, str) and name.strip() else None
862
+
863
+
864
+ def op_archive(args: argparse.Namespace) -> dict:
865
+ path = Path(args.state_file)
866
+ bak = backup_path(path)
867
+ sources = [p for p in (path, bak, Path(args.brief_file)) if p.is_file()]
868
+ if not sources:
869
+ raise StateError(EXIT_INPUT, "nothing-to-archive", "No campaign state, backup or brief to archive.")
870
+ name = _campaign_name(path) or _campaign_name(bak) or "campaign"
871
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.") or "campaign"
872
+ # No colon in a folder name: Windows refuses one.
873
+ stamp = now().replace("+00:00", "Z").replace(":", "")
874
+ folder = path.parent / "archive" / f"{slug}-{stamp}"
875
+ if folder.exists():
876
+ raise StateError(EXIT_INPUT, "archive-exists", f"`{folder.as_posix()}` already exists: nothing was moved.")
877
+ folder.mkdir(parents=True)
878
+ moved = []
879
+ for source in sources:
880
+ target = folder / source.name
881
+ _replace(source, target)
882
+ moved.append(target.as_posix())
883
+ return {"op": "archive", "archive_dir": folder.as_posix(), "moved": moved}
884
+
885
+
886
+ def op_log(args: argparse.Namespace) -> dict:
887
+ text = " ".join(args.text.split())
888
+ if not text:
889
+ raise StateError(EXIT_INPUT, "input-unreadable", "The log entry is empty")
890
+ entry = f"- {now()} ({args.type}) {text}"
891
+ path = Path(args.log_file)
892
+ path.parent.mkdir(parents=True, exist_ok=True)
893
+ with open(path, "a", encoding="utf-8", newline="\n") as fh:
894
+ fh.write(entry + "\n")
895
+ fh.flush()
896
+ os.fsync(fh.fileno())
897
+ return {"op": "log", "log_file": path.as_posix(), "entry": entry}
898
+
899
+
900
+ def op_halt_payload(args: argparse.Namespace) -> dict:
901
+ path = Path(args.state_file)
902
+ reason = " ".join(read_text("-", "The halt message").split())
903
+ if not reason:
904
+ raise StateError(EXIT_INPUT, "input-unreadable", "The halt message is empty")
905
+ skills = (_loose_state(path) or {}).get("skills")
906
+ skills = [s for s in skills if isinstance(s, dict)] if isinstance(skills, list) else []
907
+ return {
908
+ "phase": args.phase,
909
+ "reason": reason,
910
+ "halt_reason": args.halt_reason,
911
+ "skills_completed": sum(1 for s in skills if s.get("status") == "completed"),
912
+ "skills_failed": sum(1 for s in skills if s.get("status") == "failed"),
913
+ "decision_log": (path.parent / DECISION_LOG).as_posix() if path.parent.is_dir() else None,
914
+ }
915
+
916
+
917
+ def resume_point(state: dict, from_skill: str | None = None, take_next: bool = False) -> dict:
918
+ """Where a resumed campaign continues (see the module docstring)."""
919
+ campaign = state["campaign"]
920
+ skills = state.get("skills", [])
921
+ by_name = _skills_by_name(state)
922
+ result: dict[str, Any] = {
923
+ "complete": False, "stage": None, "step_file": None, "reason": None, "skill": None,
924
+ "needs_choice": False, "from_status": None, "active_other": None,
925
+ }
926
+
927
+ def route(stage: int, reason: str, skill: str | None = None) -> dict:
928
+ result.update(stage=stage, step_file=STEP_FILES[stage], reason=reason, skill=skill)
929
+ return result
930
+
931
+ if from_skill is not None:
932
+ if from_skill not in by_name:
933
+ raise StateError(EXIT_INPUT, "unknown-skill",
934
+ f"Unknown skill '{from_skill}'. Known skills: {', '.join(by_name)}.")
935
+ target = by_name[from_skill]
936
+ result["from_status"] = target["status"]
937
+ active = [s["name"] for s in skills if s["status"] == "active" and s["name"] != from_skill]
938
+ result["active_other"] = active[0] if active else None
939
+ if target["status"] in OPEN:
940
+ return route(TIER_STAGE[target["tier"]], "from-skill", from_skill)
941
+ if not take_next:
942
+ result.update(needs_choice=True, skill=from_skill)
943
+ return result
944
+ order = state["dependency_graph"].get("execution_order") or [s["name"] for s in skills]
945
+ after = order[order.index(from_skill) + 1:] if from_skill in order else []
946
+ for name in after:
947
+ skill = by_name.get(name)
948
+ if skill is not None and skill["status"] in OPEN:
949
+ return route(TIER_STAGE[skill["tier"]], "from-next", name)
950
+ result.update(complete=True, reason="nothing-after-from")
951
+ return result
952
+
953
+ active = [s for s in skills if s["status"] == "active"]
954
+ if active:
955
+ first = min(active, key=lambda s: TIER_STAGE[s["tier"]])
956
+ return route(TIER_STAGE[first["tier"]], "active-skill", first["name"])
957
+ current = campaign["current_stage"]
958
+ if current >= FINAL_STAGE and not any(s["status"] in OPEN for s in skills):
959
+ result.update(complete=True, reason="complete")
960
+ return result
961
+ if current >= FINAL_STAGE:
962
+ return route(FINAL_STAGE, "terminal-cap")
963
+ return route(current + 1, "next-stage")
964
+
965
+
966
+ def op_resume(args: argparse.Namespace) -> dict:
967
+ state, _raw = read_state(Path(args.state_file))
968
+ if args.next and args.from_skill is None:
969
+ raise StateError(EXIT_INPUT, "input-invalid", "--next needs --from")
970
+ return resume_point(state, args.from_skill, args.next)
971
+
972
+
973
+ # --------------------------------------------------------------------------
974
+ # CLI
975
+ # --------------------------------------------------------------------------
976
+
977
+
978
+ def _stage(value: str) -> int:
979
+ stage = int(value)
980
+ if not 0 <= stage <= FINAL_STAGE:
981
+ raise argparse.ArgumentTypeError(f"a stage is 0 to {FINAL_STAGE}")
982
+ return stage
983
+
984
+
985
+ def _score(value: str) -> int | float:
986
+ number = float(value)
987
+ return int(number) if number.is_integer() else number
988
+
989
+
990
+ def build_parser() -> argparse.ArgumentParser:
991
+ parser = argparse.ArgumentParser(
992
+ prog="campaign-state",
993
+ description="Write _campaign-state.yaml (validated before and after, .bak rotated from a valid "
994
+ "primary, timestamps from the clock, atomic) and compute the resume point.",
995
+ )
996
+ sub = parser.add_subparsers(dest="op", required=True)
997
+
998
+ def command(name: str, help_text: str, stage: str | None = "optional") -> argparse.ArgumentParser:
999
+ p = sub.add_parser(name, help=help_text)
1000
+ if name != "log":
1001
+ p.add_argument("--state-file", required=True, help="path to _campaign-state.yaml")
1002
+ if stage is not None:
1003
+ p.add_argument("--stage", type=_stage, required=stage == "required",
1004
+ help="write campaign.current_stage (0 to 10) in the same write")
1005
+ return p
1006
+
1007
+ p = command("init", "create the state from parsed targets (step-01)", stage=None)
1008
+ p.add_argument("--targets-file", required=True, help="campaign-parse-manifest.py output, or - for stdin")
1009
+ p.add_argument("--name", required=True, help="campaign name")
1010
+ p.add_argument("--hard", required=True, help="quality_gate_hard")
1011
+ p.add_argument("--soft-target", required=True, help="quality_gate_soft_target")
1012
+ p.add_argument("--soft-fallback", required=True, help="quality_gate_soft_fallback")
1013
+ p.add_argument("--brief-file", help="campaign brief whose quality_gate wins over the values above")
1014
+ p.add_argument("--directive-path", help="campaign directive file")
1015
+ p.add_argument("--architecture-doc-path", help="architecture document for verify and refine")
1016
+ p.add_argument("--brief-out", help="also write campaign-brief.yaml here, from the same targets")
1017
+ p.add_argument("--brief-template", help="the brief template (default: templates/campaign-brief-template.yaml)")
1018
+ p.add_argument("--notes", help="the brief's notes (default: the --brief-file's notes, else empty)")
1019
+
1020
+ command("set-stage", "write campaign.current_stage", stage="required")
1021
+
1022
+ p = command("set-skill", "set fields of one or more skills")
1023
+ p.add_argument("--skill", action="append", required=True, help="skill name (repeat for several)")
1024
+ p.add_argument("--status", choices=STATUSES)
1025
+ p.add_argument("--quality-score", type=_score)
1026
+ p.add_argument("--skill-path")
1027
+ p.add_argument("--brief-path")
1028
+ p.add_argument("--export", choices=EXPORT_STATUSES, help="the skill's skf-export-skill outcome (step-10)")
1029
+ p.add_argument("--export-halt-reason", help="with --export failed: the export envelope's halt_reason")
1030
+ p.add_argument("--export-exit-code", type=int, help="with --export failed: skf-export-skill's exit code")
1031
+
1032
+ command("apply-plan", "write the execution order campaign-deps.py computes (step-02)")
1033
+
1034
+ p = command("apply-pins", "write resolved pins (step-03)")
1035
+ p.add_argument("--results-file", required=True, help="campaign-validate-pins.py output, or -")
1036
+
1037
+ p = command("apply-provenance", "write commit SHAs (step-04)")
1038
+ p.add_argument("--results-file", required=True, help="campaign-provenance.py output, or -")
1039
+
1040
+ p = command("apply-batch", "record the Tier B batch (step-06)")
1041
+ p.add_argument("--map-file", required=True, help="the line-to-skill map campaign-render-batch.py wrote")
1042
+ mode = p.add_mutually_exclusive_group(required=True)
1043
+ mode.add_argument("--start", action="store_true", help="mark skipped and batched skills before the batch")
1044
+ mode.add_argument("--results-file", help="campaign-render-batch.py --record output, or -")
1045
+ mode.add_argument("--no-results", action="store_true", help="fail every batched skill")
1046
+
1047
+ p = command("append-workarounds", "append entries to a skill's workarounds_applied")
1048
+ p.add_argument("--skill", required=True)
1049
+ p.add_argument("--entry", action="append", required=True, help="one entry (repeat for several)")
1050
+
1051
+ p = command("set-campaign", "write campaign-level fields")
1052
+ p.add_argument("--architecture-doc-path")
1053
+ for field, _build in SUMMARIES:
1054
+ group = p.add_mutually_exclusive_group()
1055
+ group.add_argument(f"--{field}", help=f"the {field} sub-skill's result envelope line, a file or -")
1056
+ group.add_argument(f"--no-{field}", action="store_true", help=f"record campaign.{field} as null")
1057
+
1058
+ command("recover", "copy a valid .bak over the primary", stage=None)
1059
+
1060
+ p = command("archive", "move the state, its .bak and the brief into archive/ (overwrite)", stage=None)
1061
+ p.add_argument("--brief-file", required=True, help="path to campaign-brief.yaml")
1062
+
1063
+ p = command("log", "append a typed entry to the decision log", stage=None)
1064
+ p.add_argument("--log-file", required=True, help="path to _campaign-decision-log.md")
1065
+ p.add_argument("--type", required=True, choices=LOG_TYPES)
1066
+ p.add_argument("--text", required=True)
1067
+
1068
+ p = command("halt-payload", "print a HARD HALT's emit-halt payload; the message on stdin", stage=None)
1069
+ p.add_argument("--phase", required=True, help="the kebab slug of the step that halts")
1070
+ p.add_argument("--halt-reason", required=True, help="the Exit Codes meaning, such as circular-deps")
1071
+
1072
+ p = command("resume", "compute the resume point (read-only)", stage=None)
1073
+ p.add_argument("--from", dest="from_skill", help="resume at this skill")
1074
+ p.add_argument("--next", action="store_true", help="with --from at a finished skill: take the next open one")
1075
+ return parser
1076
+
1077
+
1078
+ OPERATIONS = {
1079
+ "init": op_init,
1080
+ "set-stage": op_set_stage,
1081
+ "set-skill": op_set_skill,
1082
+ "apply-plan": op_apply_plan,
1083
+ "apply-pins": op_apply_pins,
1084
+ "apply-provenance": op_apply_provenance,
1085
+ "apply-batch": op_apply_batch,
1086
+ "append-workarounds": op_append_workarounds,
1087
+ "set-campaign": op_set_campaign,
1088
+ "recover": op_recover,
1089
+ "archive": op_archive,
1090
+ "log": op_log,
1091
+ "halt-payload": op_halt_payload,
1092
+ "resume": op_resume,
1093
+ }
1094
+
1095
+
1096
+ def main(argv: list[str] | None = None) -> int:
1097
+ parser = build_parser()
1098
+ args = parser.parse_args(argv)
1099
+ try:
1100
+ result = OPERATIONS[args.op](args)
1101
+ except StateError as exc:
1102
+ sys.stderr.write(json.dumps({"error": str(exc), "code": exc.code, "errors": exc.errors}) + "\n")
1103
+ return exc.exit_code
1104
+ except OSError as exc:
1105
+ sys.stderr.write(json.dumps({"error": f"Write failed: {exc}", "code": "write-failed", "errors": []}) + "\n")
1106
+ return EXIT_INPUT
1107
+ sys.stdout.write(json.dumps(result, separators=(",", ":")) + "\n")
1108
+ return 0
1109
+
1110
+
1111
+ def _force_utf8(*streams) -> None:
1112
+ """Reconfigure the streams to UTF-8, keeping each one's error handler.
1113
+
1114
+ A Windows console pipes them as cp1252, which cannot print every
1115
+ character a path or a log entry may hold.
1116
+ """
1117
+ for stream in streams:
1118
+ if hasattr(stream, "reconfigure"):
1119
+ errors = getattr(stream, "errors", None)
1120
+ if errors is None:
1121
+ stream.reconfigure(encoding="utf-8")
1122
+ else:
1123
+ stream.reconfigure(encoding="utf-8", errors=errors)
1124
+
1125
+
1126
+ if __name__ == "__main__":
1127
+ _force_utf8(sys.stdout, sys.stderr)
1128
+ sys.exit(main())