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,1052 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = []
5
+ # ///
6
+ """The forger's pipeline journal: a chain's state on disk, and its resume.
7
+
8
+ Pipeline Mode (references/pipeline-mode.md) keeps a chain's state in a
9
+ journal rather than only in the conversation, so a closed session, a killed
10
+ terminal or a compacted context loses nothing, and a later activation picks
11
+ the chain up at the step it stopped on, with the plan the first parse
12
+ produced: its alias, bracket values and arguments. Re-parsing only the codes
13
+ still pending dropped the alias (forge-auto's TS[min:90] fell back to the
14
+ default threshold) and warned about prerequisites the chain had already run.
15
+ Which step a chain stopped on, what a gate's decision records, what a halt
16
+ offers, and the pipeline result's name and shape each have one correct
17
+ answer, so they run here, not in the prompt.
18
+
19
+ The journal is `pipeline-journal.json` in the run's own folder,
20
+ `<run-root>/skf-forger-<run_id>/`, under the run root every SKF workflow
21
+ keeps its run state in (`{project-root}/_bmad-output/.skf-run`). `run_id` is
22
+ the UTC time the chain started and a random suffix, so the folder names sort
23
+ by start time. `src/shared/references/pipeline-contracts.md` (Pipeline State)
24
+ describes its fields. Each step entry keeps one shape, in the journal and in
25
+ the pipeline result alike:
26
+
27
+ {"code": "TS", "min": 90, "mode": null, "target": null, "flags": [],
28
+ "status": "pending" | "completed" | "skipped" | "halted", "reason": null}
29
+
30
+ Subcommands:
31
+
32
+ start Read the whole invocation on stdin, parse it with
33
+ parse-pipeline.py (beside this script), and create the run folder
34
+ and its journal: every step pending, the alias, the first
35
+ workflow's args.
36
+ step Record the step the chain just finished: the first pending step,
37
+ whose code must be --code. After AN, TS, AS or VS, --gate reads
38
+ the JSON pipeline-gate.py printed on stdin: `continue` records
39
+ the step `completed`, `skip` also records the next step, the one
40
+ the gate passes over, `skipped`, and `halt` records the step
41
+ `halted` with the gate's reason (the journal is then `halted`).
42
+ The gate's `handoff` is recorded too, on a halt as well: AS's
43
+ `target_ref` when upstream moved, which a value given with --set
44
+ does not replace. Any other workflow, or a gate that printed no
45
+ JSON, takes --status `completed`, or `halted` with --reason.
46
+ --set records a value the step hands the next, by its Data Flow
47
+ name (a name given twice in one call holds a list): a completed
48
+ CS, QS or US must hand on `skill_name`, a completed AN or BS
49
+ `brief_path`. --output records a result or report path the
50
+ step's envelope names.
51
+ finish Write the pipeline result from the journal to --result-dir:
52
+ `pipeline-result-<YYYYMMDD-HHmmss>.json` (UTC; -2, -3 ... when a
53
+ run already took that second's name), then its copy
54
+ `pipeline-result-latest.json`, and print the next action the
55
+ stop leaves (below). The run folder is deleted when nothing is
56
+ left to resume (every step completed or skipped, or a halt no
57
+ resume fixes) and kept otherwise. A --halt-reason with no halted
58
+ step halts the first pending one. A journal that cannot be saved
59
+ or read still gets its result, with a warning, unless its run
60
+ already has one. Without --journal it records a chain that
61
+ stopped before its journal started (at its parse, or a `start`
62
+ that failed): --halt-reason and no step.
63
+ resume The offer a new activation makes, from the newest journal under
64
+ --run-root. No offer when no journal is left, when a later chain
65
+ recorded a result with a step in it (any record in --result-dir,
66
+ the per-run ones included), when the journal's skill was tested
67
+ (--forge-data-folder) or exported (--skills-output-folder) after
68
+ the journal's last write, or when its halt has nothing to resume.
69
+ reopen Apply the offer the user accepted: the halted step is pending
70
+ again (after AS CRITICAL the audit counts as done), a repair that
71
+ is a workflow of its own joins the plan right before the step it
72
+ repairs, and the first step to run takes the journal's skill as
73
+ its target when it takes a skill and names none. The journal runs
74
+ again. Prints the steps still to run.
75
+ discard Delete a stopped chain's run folder, so its offer is made no more.
76
+
77
+ Next action. A chain interrupted mid-step (its journal still `running`),
78
+ or halted by anything but a quality verdict, gets the offer `resume` at the
79
+ step it stopped on. A quality halt gets a `repair` (REPAIRS below), which
80
+ the user makes first unless it is a workflow of its own (USER_ACTIONS):
81
+
82
+ halted on reason repair picks up at
83
+ TS FAIL update-from-test-report TS, after
84
+ (US <skill> --from-test-report)
85
+ TS INCONCLUSIVE add-evidence TS
86
+ TS pass-with-drift,
87
+ workspace-drift retest-at-pinned-commit TS
88
+ AS CRITICAL review-drift-report the step
89
+ after AS
90
+ VS zero-coverage create-missing-skills VS
91
+ AN no-skillable-units,
92
+ units-below-min, skipped new-target no resume
93
+ AN redirect update-existing-skill no resume
94
+
95
+ `finish` and `resume` print it in one shape: `offer` (`resume`, `repair`,
96
+ or null when nothing is left to resume), `kind` (`interrupted`, `error` or
97
+ `quality`), `repair`, `user_action` (what the user does first, or null),
98
+ `route` (the next action in one line, such as `US hono --from-test-report,
99
+ then TS EX at the recorded threshold of 90`), `halted_on`, `resume_at` and
100
+ `threshold` (the TS `min` the rest of the plan carries). Every value is
101
+ null for a chain that nothing stopped.
102
+
103
+ The journal's skill is the handoff `skill_name`, else the alias argument of
104
+ that name (`forge`, `maintain`), else the bracket name of a TS, EX, US or AS
105
+ step. It counts as tested after the journal when one of its
106
+ `skf-test-skill-result-latest.json` records (flat, or in a version folder)
107
+ started later than the journal's `updated_at`: the UTC time that leads its
108
+ `runId`, which the run took from the clock, or its `timestamp` when it has
109
+ no run id. It counts as exported after it when its export manifest entry
110
+ records a later `last_exported` day, or that same day with the manifest's
111
+ `updated_at` later (the chain's own EX excepted). The manifest dates an
112
+ export by its day only, and any skill's export moves its `updated_at`, so
113
+ a same-day export of another skill after the journal counts too.
114
+
115
+ Shared formats. The run id is skf-run-lock.py's (`new_run_id`), and the
116
+ per-run record's name with its -2, -3 suffix is the shared emitter's
117
+ (skf-emit-result-envelope.py `_claim_result_path`). This script ships with
118
+ the forger and loads no shared script, so it keeps its own copy of both;
119
+ test/test-skf-pipeline-journal.py checks that they name the same files for
120
+ the same clock.
121
+
122
+ CLI usage (from the skf-forger skill root):
123
+ uv run scripts/pipeline-journal.py start --run-root <dir> <<'SKF_PIPELINE'
124
+ forge-auto https://github.com/honojs/hono --pin v4.6.0
125
+ SKF_PIPELINE
126
+ uv run scripts/pipeline-journal.py step --journal <file> --code CS \\
127
+ --status completed --set skill_name=hono
128
+ uv run scripts/pipeline-journal.py step --journal <file> --code TS --gate <<'SKF_GATE'
129
+ {"code": "TS", "decision": "halt", "reason": "FAIL", "skip": null, "message": "..."}
130
+ SKF_GATE
131
+ uv run scripts/pipeline-journal.py finish --journal <file> --result-dir <dir> \\
132
+ [--halt-reason <reason>]
133
+ uv run scripts/pipeline-journal.py finish --result-dir <dir> --halt-reason <reason>
134
+ uv run scripts/pipeline-journal.py resume --run-root <dir> --result-dir <dir> \\
135
+ [--forge-data-folder <dir>] [--skills-output-folder <dir>]
136
+ uv run scripts/pipeline-journal.py reopen --journal <file>
137
+ uv run scripts/pipeline-journal.py discard --journal <file>
138
+
139
+ Output (stdout): one JSON object. `status` is `ok` (start, step, finish,
140
+ reopen, discard), `offer` or `none` (resume), or `error`, with `error`
141
+ naming the problem, `message` saying it in one line, and `retry` true when
142
+ the call itself lacked what the message names (a handoff value, a reason,
143
+ the gate's decision): the same call with it goes through.
144
+
145
+ Exit codes:
146
+ 0 done: read `status`
147
+ 1 error: the journal or a folder cannot be read or written, the
148
+ invocation does not parse to a runnable plan, a step out of order or
149
+ without the value it hands on, a gate decision that does not fit the
150
+ step, a halt with no reason, nothing to resume
151
+ 2 usage error (argparse: usage on stderr, no JSON)
152
+ """
153
+
154
+ from __future__ import annotations
155
+
156
+ import argparse
157
+ import importlib.util
158
+ import json
159
+ import os
160
+ import re
161
+ import secrets
162
+ import shutil
163
+ import sys
164
+ from datetime import datetime, timezone
165
+ from pathlib import Path
166
+
167
+ PARSER = Path(__file__).resolve().parent / "parse-pipeline.py"
168
+
169
+ JOURNAL = "pipeline-journal.json"
170
+ RUN_PREFIX = "skf-forger-"
171
+ RESULT_STEM = "pipeline-result"
172
+ TEST_RESULT = "skf-test-skill-result-latest.json"
173
+ EXPORT_MANIFEST = ".export-manifest.json"
174
+ JOURNAL_VERSION = 1
175
+
176
+ STEP_STATUSES = ("pending", "completed", "skipped", "halted")
177
+ STATUS_CHOICES = ("completed", "halted") # what `step --status` records; a skip comes from a gate
178
+ DECISIONS = ("continue", "skip", "halt") # pipeline-gate.py's `decision`
179
+
180
+ RUN_ID_FORMAT = "%Y%m%dT%H%M%SZ"
181
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
182
+ FILE_STAMP_FORMAT = "%Y%m%d-%H%M%S"
183
+ _RUN_ID_RE = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{8}$")
184
+ _RUN_TIME_RE = re.compile(r"^(\d{8}T\d{6}Z)")
185
+ _FILE_STAMP_RE = re.compile(r"^\d{8}-\d{6}$")
186
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
187
+
188
+ # The codes pipeline-gate.py decides for: their step records the gate's
189
+ # decision (--gate), not a status given by hand.
190
+ GATED = frozenset({"AN", "TS", "AS", "VS"})
191
+ # The value a completed step hands the next workflow, by its Data Flow name
192
+ # in pipeline-contracts.md. A resumed chain passes it on, and the offer's
193
+ # later-test and later-export checks look the skill up by it.
194
+ HANDOFFS = {"AN": "brief_path", "BS": "brief_path", "CS": "skill_name", "QS": "skill_name", "US": "skill_name"}
195
+
196
+ # A quality halt, keyed by the halted code and its reason (lower case, `-`
197
+ # for `_`): the repair the offer names, and where the chain picks up again
198
+ # ("same" re-runs the halted step, "next" the one after it, None: no resume).
199
+ # Mirrors the Repair Routes table in pipeline-contracts.md. Any other halt is
200
+ # an error the user fixes, and the chain re-runs the step that halted.
201
+ REPAIRS = {
202
+ ("TS", "fail"): ("update-from-test-report", "same"),
203
+ ("TS", "inconclusive"): ("add-evidence", "same"),
204
+ ("TS", "pass-with-drift"): ("retest-at-pinned-commit", "same"),
205
+ ("TS", "workspace-drift"): ("retest-at-pinned-commit", "same"),
206
+ ("AS", "critical"): ("review-drift-report", "next"),
207
+ ("VS", "zero-coverage"): ("create-missing-skills", "same"),
208
+ ("AN", "no-skillable-units"): ("new-target", None),
209
+ ("AN", "units-below-min"): ("new-target", None),
210
+ ("AN", "skipped"): ("new-target", None),
211
+ ("AN", "redirect"): ("update-existing-skill", None),
212
+ }
213
+ # A repair that is a workflow of its own: it joins the plan right before the
214
+ # step it repairs, with the journal's skill as its target.
215
+ REPAIR_STEPS = {"update-from-test-report": ("US", ("--from-test-report",))}
216
+ # What the user does before a repair picks the chain up again, or instead of
217
+ # a resume when there is none (None: the chain runs the repair itself). The
218
+ # Repair Routes table in pipeline-contracts.md shows the same texts.
219
+ USER_ACTIONS = {
220
+ "update-from-test-report": None,
221
+ "add-evidence": "add evidence: install skill-check, or move up a tier (the tier's tools, then SF)",
222
+ "retest-at-pinned-commit": "put the source back at the commit the skill pins",
223
+ "review-drift-report": "review the drift report",
224
+ "create-missing-skills": "create skills for the architecture's technologies (for example forge-quick <package>)",
225
+ "new-target": "start a new chain with another target, or with a scope hint",
226
+ "update-existing-skill": "run US on the skill the target already has",
227
+ }
228
+ # The codes whose bracket target is a skill name (CS's names a brief, QS's a
229
+ # package or URL).
230
+ SKILL_TARGET_CODES = frozenset({"TS", "EX", "US", "AS"})
231
+ # The keys of the next action `finish` and `resume` print.
232
+ NEXT_ACTION_KEYS = ("offer", "kind", "repair", "user_action", "route", "halted_on", "resume_at", "threshold")
233
+ # The errors a call causes by lacking something its message names: the same
234
+ # call with it goes through, so a chain need not stop on them.
235
+ RETRYABLE = frozenset({"bad-value", "gate-unreadable", "gate-mismatch", "gate-required", "reason-missing",
236
+ "handoff-missing", "unfinished"})
237
+
238
+
239
+ class JournalError(Exception):
240
+ """A request the journal cannot serve: printed as a `status: error` object."""
241
+
242
+ def __init__(self, error: str, message: str):
243
+ super().__init__(message)
244
+ self.error = error
245
+ self.message = message
246
+
247
+
248
+ # --------------------------------------------------------------------------
249
+ # Time, run ids and files
250
+ # --------------------------------------------------------------------------
251
+
252
+
253
+ def _now() -> datetime:
254
+ return datetime.now(timezone.utc)
255
+
256
+
257
+ def _stamp(t: datetime) -> str:
258
+ return t.strftime(TIME_FORMAT)
259
+
260
+
261
+ def new_run_id(now: datetime | None = None) -> str:
262
+ """A run id: the UTC time and 8 random hex characters (skf-run-lock.py's format)."""
263
+ return f"{(now or _now()).strftime(RUN_ID_FORMAT)}-{secrets.token_hex(4)}"
264
+
265
+
266
+ def _file_stamp(run_id: str) -> str:
267
+ """A run id's start time as a result file stamp (YYYYMMDD-HHmmss), or "" for another shape."""
268
+ return f"{run_id[:8]}-{run_id[9:15]}" if _RUN_ID_RE.match(run_id) else ""
269
+
270
+
271
+ def parse_time(value) -> datetime | None:
272
+ """A UTC time from an ISO-8601 time or a run id's leading stamp, else None."""
273
+ if not isinstance(value, str) or not value.strip():
274
+ return None
275
+ text = value.strip()
276
+ m = _RUN_TIME_RE.match(text)
277
+ try:
278
+ if m:
279
+ return datetime.strptime(m.group(1), RUN_ID_FORMAT).replace(tzinfo=timezone.utc)
280
+ t = datetime.fromisoformat(text)
281
+ except ValueError:
282
+ return None
283
+ return t.replace(tzinfo=timezone.utc) if t.tzinfo is None else t.astimezone(timezone.utc)
284
+
285
+
286
+ def _read_json(path: Path):
287
+ """The JSON value in `path`, or None when it is missing or not JSON."""
288
+ try:
289
+ return json.loads(path.read_text(encoding="utf-8-sig"))
290
+ except (OSError, UnicodeDecodeError, ValueError):
291
+ return None
292
+
293
+
294
+ def _write_json(path: Path, value) -> None:
295
+ """Write `value` as indented JSON through a temporary file and one rename.
296
+
297
+ newline="\\n" keeps LF endings on Windows too.
298
+ """
299
+ tmp = path.with_name(f".{path.name}.{os.getpid()}-{secrets.token_hex(2)}.tmp")
300
+ try:
301
+ with open(tmp, "w", encoding="utf-8", newline="\n") as fh:
302
+ json.dump(value, fh, indent=2, ensure_ascii=False)
303
+ fh.write("\n")
304
+ fh.flush()
305
+ os.fsync(fh.fileno())
306
+ os.replace(tmp, path)
307
+ except OSError:
308
+ try:
309
+ tmp.unlink()
310
+ except OSError:
311
+ pass
312
+ raise
313
+
314
+
315
+ def _save(path: Path, journal: dict) -> None:
316
+ try:
317
+ _write_json(path, journal)
318
+ except OSError as e:
319
+ raise JournalError("write-failed", f"The journal {path} cannot be written: {e.strerror or e}") from None
320
+
321
+
322
+ def _is_journal(value) -> bool:
323
+ return (
324
+ isinstance(value, dict)
325
+ and isinstance(value.get("run_id"), str)
326
+ and isinstance(value.get("data"), dict)
327
+ and isinstance(value.get("steps"), list)
328
+ and all(
329
+ isinstance(s, dict) and isinstance(s.get("code"), str) and s.get("status") in STEP_STATUSES
330
+ for s in value["steps"]
331
+ )
332
+ )
333
+
334
+
335
+ def load_journal(path: Path) -> dict:
336
+ journal = _read_json(path)
337
+ if not _is_journal(journal):
338
+ raise JournalError("journal-unreadable", f"{path} is missing, not JSON, or not a pipeline journal.")
339
+ for key, empty in (("args", {}), ("outputs", []), ("history", [])):
340
+ if not isinstance(journal.get(key), type(empty)):
341
+ journal[key] = empty
342
+ return journal
343
+
344
+
345
+ def _folder_run_id(path: Path) -> str | None:
346
+ """The run id the name of the journal's folder carries, or None for any other file or folder."""
347
+ name = path.parent.name
348
+ run_id = name[len(RUN_PREFIX):] if name.startswith(RUN_PREFIX) else ""
349
+ return run_id if path.name == JOURNAL and _RUN_ID_RE.match(run_id) else None
350
+
351
+
352
+ def _delete_run_folder(path: Path, run_id: str) -> str | None:
353
+ """Delete the run folder of the journal at `path`; a warning when it is not deleted.
354
+
355
+ Only the journal's own folder, `skf-forger-<run_id>/`, is ever deleted:
356
+ a journal given from anywhere else leaves its folder in place.
357
+ """
358
+ folder = path.parent
359
+ if path.name != JOURNAL or folder.name != f"{RUN_PREFIX}{run_id}":
360
+ return f"run_dir_not_deleted: {folder.as_posix()}: not the journal's run folder"
361
+ try:
362
+ shutil.rmtree(folder)
363
+ except FileNotFoundError:
364
+ pass
365
+ except OSError as e:
366
+ return f"run_dir_not_deleted: {folder.as_posix()}: {e.strerror or e}"
367
+ return None
368
+
369
+
370
+ # --------------------------------------------------------------------------
371
+ # Steps and offers
372
+ # --------------------------------------------------------------------------
373
+
374
+
375
+ def _step_entry(code: str, min_n=None, mode=None, target=None, flags=()) -> dict:
376
+ return {"code": code, "min": min_n, "mode": mode, "target": target,
377
+ "flags": list(flags), "status": "pending", "reason": None}
378
+
379
+
380
+ def _first(steps: list, status: str) -> int | None:
381
+ return next((i for i, s in enumerate(steps) if s["status"] == status), None)
382
+
383
+
384
+ def _indexed(steps: list, index: int) -> dict:
385
+ return {"index": index, **steps[index]}
386
+
387
+
388
+ def _reason_key(reason) -> str:
389
+ return str(reason or "").strip().lower().replace("_", "-")
390
+
391
+
392
+ def _repair_rule(step: dict) -> tuple[str, str | None] | None:
393
+ return REPAIRS.get((step["code"].upper(), _reason_key(step.get("reason"))))
394
+
395
+
396
+ def offer_for(journal: dict) -> dict | None:
397
+ """What a stopped chain offers, from its journal alone; None: nothing to resume.
398
+
399
+ {"offer": "resume" | "repair", "kind": "interrupted" | "error" | "quality",
400
+ "repair": <REPAIRS key> | None, "halted_on": <index>, "resume_at": <index>}
401
+ """
402
+ steps = journal["steps"]
403
+ halted = _first(steps, "halted")
404
+ if halted is None:
405
+ pending = _first(steps, "pending")
406
+ if pending is None:
407
+ return None
408
+ return {"offer": "resume", "kind": "interrupted", "repair": None, "halted_on": pending, "resume_at": pending}
409
+ rule = _repair_rule(steps[halted])
410
+ if rule is None:
411
+ return {"offer": "resume", "kind": "error", "repair": None, "halted_on": halted, "resume_at": halted}
412
+ repair, pickup = rule
413
+ if pickup is None:
414
+ return None
415
+ resume_at = halted + 1 if pickup == "next" else halted
416
+ if resume_at >= len(steps):
417
+ return None
418
+ return {"offer": "repair", "kind": "quality", "repair": repair, "halted_on": halted, "resume_at": resume_at}
419
+
420
+
421
+ def pipeline_status(steps: list) -> str:
422
+ """success: nothing halted; partial: a halt after a completed step; failed: before any."""
423
+ if _first(steps, "halted") is None:
424
+ return "success"
425
+ return "partial" if _first(steps, "completed") is not None else "failed"
426
+
427
+
428
+ def _skill_name(journal: dict) -> str | None:
429
+ """The skill the chain works on, or None while no step has named it."""
430
+ candidates = [journal["data"].get("skill_name"), journal["args"].get("skill_name")]
431
+ candidates += [s.get("target") for s in journal["steps"] if s["code"] in SKILL_TARGET_CODES]
432
+ for value in candidates:
433
+ if isinstance(value, list):
434
+ value = next((v for v in value if isinstance(v, str) and v.strip()), None)
435
+ if isinstance(value, str) and value.strip():
436
+ return value.strip()
437
+ return None
438
+
439
+
440
+ def _threshold(steps: list, start: int) -> int | None:
441
+ """The TS `min` the rest of the plan carries: the recorded threshold."""
442
+ return next((s["min"] for s in steps[start:] if s["code"] == "TS" and s["min"] is not None), None)
443
+
444
+
445
+ def _halted_on(steps: list, index: int) -> dict:
446
+ return {"index": index, "code": steps[index]["code"], "reason": steps[index]["reason"]}
447
+
448
+
449
+ def next_action(journal: dict) -> dict:
450
+ """The next action a stopped chain leaves, in NEXT_ACTION_KEYS; every value None when nothing stopped it."""
451
+ steps = journal["steps"]
452
+ action = dict.fromkeys(NEXT_ACTION_KEYS)
453
+ offer = offer_for(journal)
454
+ if offer is None:
455
+ halted = _first(steps, "halted")
456
+ rule = None if halted is None else _repair_rule(steps[halted])
457
+ if rule is None:
458
+ return action
459
+ user = USER_ACTIONS.get(rule[0])
460
+ action.update(kind="quality", repair=rule[0], user_action=user,
461
+ route=f"no resume: {user}" if user else "no resume", halted_on=_halted_on(steps, halted))
462
+ return action
463
+ at, repair = offer["resume_at"], offer["repair"]
464
+ user = USER_ACTIONS.get(repair) if repair else None
465
+ run = " ".join(s["code"] for s in steps[at:])
466
+ if repair in REPAIR_STEPS:
467
+ code, flags = REPAIR_STEPS[repair]
468
+ route = f"{' '.join([code, _skill_name(journal) or '<skill>', *flags])}, then {run}"
469
+ elif user:
470
+ route = f"{user}, then {run}"
471
+ else:
472
+ route = f"resume at {steps[at]['code']}: {run}"
473
+ threshold = _threshold(steps, at)
474
+ if threshold is not None:
475
+ route += f" at the recorded threshold of {threshold}"
476
+ action.update(offer=offer["offer"], kind=offer["kind"], repair=repair, user_action=user, route=route,
477
+ halted_on=_halted_on(steps, offer["halted_on"]),
478
+ resume_at={"index": at, "code": steps[at]["code"]}, threshold=threshold)
479
+ return action
480
+
481
+
482
+ # --------------------------------------------------------------------------
483
+ # Did the user move on? (resume)
484
+ # --------------------------------------------------------------------------
485
+
486
+
487
+ def _record_time(path: Path) -> datetime | None:
488
+ """When a test run started: its clock-stamped run id, else its `timestamp`."""
489
+ record = _read_json(path)
490
+ if not isinstance(record, dict):
491
+ return None
492
+ return parse_time(record.get("runId")) or parse_time(record.get("run_id")) or parse_time(record.get("timestamp"))
493
+
494
+
495
+ def tested_after(forge_data_folder: Path, skill: str, since: datetime) -> bool:
496
+ """A test result record of `skill`, flat or in a version folder, later than `since`."""
497
+ base = forge_data_folder / skill
498
+ try:
499
+ records = [base / TEST_RESULT, *sorted(base.glob(f"*/{TEST_RESULT}"))]
500
+ except OSError:
501
+ return False
502
+ return any(path.is_file() and (when := _record_time(path)) is not None and when > since for path in records)
503
+
504
+
505
+ def exported_after(skills_output_folder: Path, skill: str, since: datetime) -> bool:
506
+ """An export of `skill` later than `since`: a later day, or that day with a later manifest write.
507
+
508
+ `last_exported` holds a day only; the manifest's `updated_at` tells the
509
+ time of its last write, whichever skill it was for, so a same-day export
510
+ of another skill after `since` counts too.
511
+ """
512
+ manifest = _read_json(skills_output_folder / EXPORT_MANIFEST)
513
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("exports"), dict):
514
+ return False
515
+ entry = manifest["exports"].get(skill)
516
+ if not isinstance(entry, dict):
517
+ return False
518
+ versions = entry.get("versions")
519
+ if isinstance(versions, dict):
520
+ days = [r.get("last_exported") for r in versions.values() if isinstance(r, dict)]
521
+ else: # a v1 entry lists its versions; the manifest's own time dates them
522
+ days = [manifest.get("updated_at")] if isinstance(versions, list) else []
523
+ written = parse_time(manifest.get("updated_at"))
524
+ since_day = since.date().isoformat()
525
+ for day in days:
526
+ if not isinstance(day, str) or len(day) < 10:
527
+ continue
528
+ day = day[:10]
529
+ if day > since_day or (day == since_day and written is not None and written > since):
530
+ return True
531
+ return False
532
+
533
+
534
+ def _records(result_dir: Path, since: str = ""):
535
+ """Each pipeline result record in `result_dir` as (path, record), newest first.
536
+
537
+ The -latest copy comes first. A per-run record whose file stamp is
538
+ older than `since` (a file stamp) is passed over without being read.
539
+ """
540
+ try:
541
+ paths = sorted(result_dir.glob(f"{RESULT_STEM}-*.json"), key=lambda p: p.name, reverse=True)
542
+ except OSError:
543
+ return
544
+ for path in paths:
545
+ stamp = path.name[len(RESULT_STEM) + 1:][:15]
546
+ if since and _FILE_STAMP_RE.match(stamp) and stamp < since:
547
+ continue
548
+ record = _read_json(path)
549
+ if isinstance(record, dict):
550
+ yield path, record
551
+
552
+
553
+ def _later_result(result_dir: Path, run_id: str) -> str | None:
554
+ """The run id of a later chain's pipeline result that recorded a step, else None.
555
+
556
+ A record with no step, such as a parse halt's, supersedes no journal,
557
+ but its copy replaces `pipeline-result-latest.json`: the per-run records
558
+ are read too. One named before the journal's chain started is older.
559
+ """
560
+ for _, record in _records(result_dir, _file_stamp(run_id)):
561
+ later = record.get("run_id")
562
+ summary = record.get("summary") if isinstance(record.get("summary"), dict) else {}
563
+ steps = summary.get("steps")
564
+ if isinstance(later, str) and _RUN_ID_RE.match(later) and later > run_id and isinstance(steps, list) and steps:
565
+ return later
566
+ return None
567
+
568
+
569
+ def _recorded(result_dir: Path, run_id: str) -> dict | None:
570
+ """The pipeline result already written for `run_id`, else None."""
571
+ return next((record for _, record in _records(result_dir, _file_stamp(run_id))
572
+ if record.get("run_id") == run_id), None)
573
+
574
+
575
+ def _newest_journal(run_root: Path, warnings: list) -> tuple[Path, dict] | None:
576
+ """The newest readable journal under `run_root`; an unreadable one is named in `warnings`."""
577
+ try:
578
+ folders = sorted((p for p in run_root.glob(f"{RUN_PREFIX}*") if p.is_dir()), key=lambda p: p.name, reverse=True)
579
+ except OSError:
580
+ return None
581
+ for folder in folders:
582
+ path = folder / JOURNAL
583
+ try:
584
+ return path, load_journal(path)
585
+ except JournalError:
586
+ warnings.append(f"journal_unreadable: {path.as_posix()}")
587
+ return None
588
+
589
+
590
+ # --------------------------------------------------------------------------
591
+ # Subcommands
592
+ # --------------------------------------------------------------------------
593
+
594
+
595
+ def _load_parser():
596
+ """Load parse-pipeline.py, which ships in this script's folder."""
597
+ try:
598
+ spec = importlib.util.spec_from_file_location("skf_parse_pipeline", PARSER)
599
+ if spec is None or spec.loader is None:
600
+ raise ImportError("no import spec")
601
+ module = importlib.util.module_from_spec(spec)
602
+ spec.loader.exec_module(module)
603
+ except (OSError, ImportError) as e:
604
+ raise JournalError("parser-missing", f"parse-pipeline.py cannot be loaded from {PARSER.parent}: {e}") from None
605
+ return module
606
+
607
+
608
+ def _read_stdin() -> str:
609
+ # A Windows console reads cp1252: the arrows, paths and gate messages need UTF-8.
610
+ if hasattr(sys.stdin, "reconfigure"):
611
+ sys.stdin.reconfigure(encoding="utf-8")
612
+ return sys.stdin.read()
613
+
614
+
615
+ def cmd_start(args) -> dict:
616
+ raw = _read_stdin().strip()
617
+ if not raw:
618
+ raise JournalError("no-invocation", "No invocation on stdin: pass the whole pipeline invocation.")
619
+ parsed = _load_parser().parse_pipeline(raw)
620
+ if parsed["removed_alias"] or not parsed["valid"]:
621
+ raise JournalError("not-runnable", "The invocation does not parse to a runnable plan: "
622
+ "step 1 halts on it before any journal starts.")
623
+ now = _now()
624
+ run_id = new_run_id(now)
625
+ run_dir = Path(args.run_root) / f"{RUN_PREFIX}{run_id}"
626
+ try:
627
+ run_dir.mkdir(parents=True)
628
+ except OSError as e:
629
+ raise JournalError("write-failed", f"The run folder {run_dir} cannot be created: {e.strerror or e}") from None
630
+ journal = {
631
+ "journal_version": JOURNAL_VERSION,
632
+ "run_id": run_id,
633
+ "status": "running",
634
+ "started_at": _stamp(now),
635
+ "updated_at": _stamp(now),
636
+ "invocation": raw,
637
+ "alias": parsed["alias"],
638
+ "args": parsed["args"],
639
+ "steps": [_step_entry(p["code"], p["min"], p["mode"], p["target"]) for p in parsed["plan"]],
640
+ "data": {},
641
+ "outputs": [],
642
+ "history": [],
643
+ }
644
+ path = run_dir / JOURNAL
645
+ _save(path, journal)
646
+ return {"status": "ok", "run_id": run_id, "run_dir": str(run_dir), "journal": str(path),
647
+ "alias": journal["alias"], "args": journal["args"], "steps": journal["steps"]}
648
+
649
+
650
+ def _set_values(pairs: list[str]) -> dict:
651
+ """`name=value` pairs; a name given twice holds a list, in order."""
652
+ values: dict = {}
653
+ for pair in pairs:
654
+ name, eq, value = pair.partition("=")
655
+ name, value = name.strip(), value.strip()
656
+ if not eq or not _NAME_RE.match(name) or not value:
657
+ raise JournalError("bad-value", f"--set takes <name>=<value> with a lower-case name: {pair!r}")
658
+ if name in values:
659
+ held = values[name]
660
+ values[name] = [*held, value] if isinstance(held, list) else [held, value]
661
+ else:
662
+ values[name] = value
663
+ return values
664
+
665
+
666
+ def _gate_decision(text: str, code: str) -> dict:
667
+ """The decision pipeline-gate.py printed for `code`, read from `text`."""
668
+ text = text.strip()
669
+ try:
670
+ value = json.loads(text)
671
+ except ValueError:
672
+ start, end = text.find("{"), text.rfind("}")
673
+ try:
674
+ value = json.loads(text[start:end + 1]) if 0 <= start < end else None
675
+ except ValueError:
676
+ value = None
677
+ if not isinstance(value, dict) or value.get("decision") not in DECISIONS:
678
+ raise JournalError("gate-unreadable", "--gate reads the JSON object pipeline-gate.py printed, with its "
679
+ "`decision`, on stdin.")
680
+ if str(value.get("code") or "").strip().upper() != code:
681
+ raise JournalError("gate-mismatch", f"The gate decided for {value.get('code')!r}, not {code}.")
682
+ named = {"continue": (), "skip": ("reason", "skip"), "halt": ("reason",)}[value["decision"]]
683
+ for key in named:
684
+ if not (isinstance(value.get(key), str) and value[key].strip()):
685
+ raise JournalError("gate-unreadable", f"The gate's {value['decision']} decision names no `{key}`.")
686
+ return value
687
+
688
+
689
+ def _gate_handoff(decision: dict) -> dict:
690
+ """The values the gate's `handoff` names (AS's `target_ref`), each a lower-case name and a non-empty string."""
691
+ handoff = decision.get("handoff")
692
+ if not isinstance(handoff, dict):
693
+ return {}
694
+ return {name: value.strip() for name, value in handoff.items()
695
+ if isinstance(name, str) and _NAME_RE.match(name) and isinstance(value, str) and value.strip()}
696
+
697
+
698
+ def cmd_step(args) -> dict:
699
+ path = Path(args.journal)
700
+ journal = load_journal(path)
701
+ if journal.get("status") != "running":
702
+ raise JournalError("not-running", f"The journal is {journal.get('status')}, not running: reopen it before "
703
+ "recording a step.")
704
+ steps = journal["steps"]
705
+ index = _first(steps, "pending")
706
+ if index is None:
707
+ raise JournalError("no-step-pending", "Every step of the journal is already recorded.")
708
+ if steps[index]["code"] != args.code:
709
+ raise JournalError("out-of-order", f"The next step in the journal is {steps[index]['code']} "
710
+ f"(step {index + 1}), not {args.code}.")
711
+ skipped = skip_reason = None
712
+ gate_handoff: dict = {}
713
+ if args.gate:
714
+ decision = _gate_decision(_read_stdin(), args.code)
715
+ gate_handoff = _gate_handoff(decision)
716
+ status = "halted" if decision["decision"] == "halt" else "completed"
717
+ reason = decision["reason"].strip() if status == "halted" else None
718
+ if decision["decision"] == "skip":
719
+ skipped, skip_reason = index + 1, decision["reason"].strip()
720
+ following = steps[skipped]["code"] if skipped < len(steps) else None
721
+ if following != decision["skip"].strip().upper():
722
+ raise JournalError("skip-mismatch", f"The gate passes over {decision['skip']}, but the step after "
723
+ f"{args.code} is {following or 'none'}.")
724
+ else:
725
+ status = args.status
726
+ reason = ((args.reason or "").strip() or None) if status == "halted" else None
727
+ if status == "completed" and args.code in GATED:
728
+ raise JournalError("gate-required", f"{args.code} has a gate: record its decision with --gate and the "
729
+ "JSON pipeline-gate.py printed on stdin.")
730
+ if status == "halted" and reason is None:
731
+ raise JournalError("reason-missing", "A halted step needs --reason: the workflow's halt reason.")
732
+ # The gate read the envelope the step printed, so its value wins over one typed with --set.
733
+ values = {**_set_values(args.set or []), **gate_handoff}
734
+ need = HANDOFFS.get(args.code)
735
+ if status == "completed" and need and need not in values:
736
+ raise JournalError("handoff-missing", f"A completed {args.code} hands `{need}` to the next workflow: "
737
+ f"pass --set \"{need}=<value>\".")
738
+ steps[index].update(status=status, reason=reason)
739
+ if skipped is not None:
740
+ steps[skipped].update(status="skipped", reason=skip_reason)
741
+ journal["data"].update(values)
742
+ journal["outputs"].extend({"code": args.code, "path": p} for p in args.output or [])
743
+ if status == "halted":
744
+ journal["status"] = "halted"
745
+ journal["updated_at"] = _stamp(_now())
746
+ _save(path, journal)
747
+ following = None if status == "halted" else _first(steps, "pending")
748
+ return {"status": "ok", "journal": str(path), "recorded": _indexed(steps, index),
749
+ "skipped": None if skipped is None else _indexed(steps, skipped),
750
+ "next": None if following is None else _indexed(steps, following), "data": journal["data"]}
751
+
752
+
753
+ def _claim_result_path(result_dir: Path, stamp: str) -> Path:
754
+ """Create the per-run record, empty, under a name no run holds yet (-2, -3 ... on a clash)."""
755
+ for n in range(1, 1000):
756
+ path = result_dir / (f"{RESULT_STEM}-{stamp}.json" if n == 1 else f"{RESULT_STEM}-{stamp}-{n}.json")
757
+ try:
758
+ os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
759
+ except FileExistsError:
760
+ continue
761
+ return path
762
+ raise OSError(f"no free file name for {RESULT_STEM}-{stamp}.json")
763
+
764
+
765
+ def _write_result(result_dir: Path, record: dict, now: datetime) -> tuple[Path, Path]:
766
+ try:
767
+ result_dir.mkdir(parents=True, exist_ok=True)
768
+ per_run = _claim_result_path(result_dir, now.strftime(FILE_STAMP_FORMAT))
769
+ _write_json(per_run, record)
770
+ latest = result_dir / f"{RESULT_STEM}-latest.json"
771
+ _write_json(latest, record)
772
+ except OSError as e:
773
+ raise JournalError("write-failed", f"The pipeline result cannot be written to {result_dir}: "
774
+ f"{e.strerror or e}") from None
775
+ return per_run, latest
776
+
777
+
778
+ def result_record(*, run_id: str, now: datetime, alias, steps: list, outputs: list,
779
+ halt: str | None = None, warnings=()) -> dict:
780
+ """The pipeline result in output-contract-schema.md's shape, with the steps in `summary`.
781
+
782
+ `halt` is the reason of a chain recorded with no step: one that stopped
783
+ before its journal started, or whose journal is lost. Its status is
784
+ `failed`. `warnings` are what `finish` could not do.
785
+ """
786
+ halted = _first(steps, "halted")
787
+ if halt is not None:
788
+ status, halt_reason = "failed", halt
789
+ else:
790
+ status, halt_reason = pipeline_status(steps), None if halted is None else steps[halted]["reason"]
791
+ return {
792
+ "skill": "skf-forger",
793
+ "status": status,
794
+ "timestamp": _stamp(now),
795
+ "run_id": run_id,
796
+ "outputs": [{"type": "report", "path": o["path"]} for o in outputs if isinstance(o, dict) and o.get("path")],
797
+ "summary": {
798
+ "status": status,
799
+ "halt_reason": halt_reason,
800
+ "alias": alias,
801
+ "steps": [dict(s) for s in steps],
802
+ },
803
+ "headless_decisions": [],
804
+ "warnings": list(warnings),
805
+ }
806
+
807
+
808
+ def _finished(record: dict, per_run: Path, latest: Path, kept: bool, action: dict, warnings: list) -> dict:
809
+ result = {"status": "ok", "pipeline_status": record["status"], "halt_reason": record["summary"]["halt_reason"],
810
+ "result_path": str(per_run), "latest_path": str(latest), "run_dir_kept": kept, **action}
811
+ if warnings:
812
+ result["warnings"] = warnings
813
+ return result
814
+
815
+
816
+ def _finish_with_no_step(result_dir: Path, run_id: str, now: datetime, reason: str, warnings: list,
817
+ kept: bool = False) -> dict:
818
+ record = result_record(run_id=run_id, now=now, alias=None, steps=[], outputs=[], halt=reason, warnings=warnings)
819
+ per_run, latest = _write_result(result_dir, record, now)
820
+ return _finished(record, per_run, latest, kept, dict.fromkeys(NEXT_ACTION_KEYS), warnings)
821
+
822
+
823
+ def cmd_finish(args) -> dict:
824
+ now = _now()
825
+ result_dir = Path(args.result_dir)
826
+ halt_reason = (args.halt_reason or "").strip() or None
827
+ if args.journal is None:
828
+ if halt_reason is None:
829
+ raise JournalError("reason-missing", "Without --journal, finish records a chain that stopped before its "
830
+ "journal started: pass --halt-reason.")
831
+ return _finish_with_no_step(result_dir, new_run_id(now), now, halt_reason, [])
832
+ path = Path(args.journal)
833
+ try:
834
+ journal = load_journal(path)
835
+ except JournalError:
836
+ # The chain's state is lost, but its stop is still recorded: the
837
+ # latest result must never stay the previous run's.
838
+ run_id = _folder_run_id(path)
839
+ if run_id is not None and _recorded(result_dir, run_id) is not None:
840
+ raise JournalError("already-finished", f"The pipeline result of run {run_id} is already written.") from None
841
+ lost = [f"journal_unreadable: {path.as_posix()}"]
842
+ kept = run_id is not None and path.parent.is_dir()
843
+ return _finish_with_no_step(result_dir, run_id or new_run_id(now), now, halt_reason or "journal-unreadable",
844
+ lost, kept)
845
+ warnings: list = []
846
+ steps = journal["steps"]
847
+ if _first(steps, "halted") is None and (pending := _first(steps, "pending")) is not None:
848
+ if halt_reason is None:
849
+ raise JournalError("unfinished", f"Step {pending + 1} ({steps[pending]['code']}) is still pending and no "
850
+ "step halted: record the step that stopped, or pass --halt-reason.")
851
+ steps[pending].update(status="halted", reason=halt_reason)
852
+ journal["status"] = "halted"
853
+ journal["updated_at"] = _stamp(now)
854
+ try:
855
+ _save(path, journal)
856
+ except JournalError as e:
857
+ warnings.append(f"journal_not_saved: {e.message}")
858
+ action = next_action(journal)
859
+ record = result_record(run_id=journal["run_id"], now=now, alias=journal.get("alias"),
860
+ steps=steps, outputs=journal["outputs"], warnings=warnings)
861
+ per_run, latest = _write_result(result_dir, record, now)
862
+ kept = action["offer"] is not None
863
+ if not kept:
864
+ problem = _delete_run_folder(path, journal["run_id"])
865
+ if problem:
866
+ kept = True
867
+ warnings.append(problem)
868
+ record["warnings"] = list(warnings)
869
+ try:
870
+ _write_json(per_run, record)
871
+ _write_json(latest, record)
872
+ except OSError:
873
+ pass # the record stands without it; the output below still names the warning
874
+ return _finished(record, per_run, latest, kept, action, warnings)
875
+
876
+
877
+ def _no_offer(why: str, message: str, warnings: list, journal: Path | None = None) -> dict:
878
+ result = {"status": "none", "why": why, "message": message, "journal": None if journal is None else str(journal)}
879
+ if warnings:
880
+ result["warnings"] = warnings
881
+ return result
882
+
883
+
884
+ def cmd_resume(args) -> dict:
885
+ warnings: list = []
886
+ found = _newest_journal(Path(args.run_root), warnings)
887
+ if found is None:
888
+ return _no_offer("no-journal", "No stopped pipeline left a journal.", warnings)
889
+ path, journal = found
890
+ later = _later_result(Path(args.result_dir), journal["run_id"])
891
+ if later:
892
+ return _no_offer("later-pipeline", f"A later pipeline ({later}) recorded its result.", warnings, path)
893
+ action = next_action(journal)
894
+ if action["offer"] is None:
895
+ return _no_offer("nothing-to-resume", "The journal's chain has nothing left to resume.", warnings, path)
896
+ skill = _skill_name(journal)
897
+ since = parse_time(journal.get("updated_at"))
898
+ if skill and since:
899
+ ran_ex = any(s["code"] == "EX" and s["status"] == "completed" for s in journal["steps"])
900
+ if args.forge_data_folder and tested_after(Path(args.forge_data_folder), skill, since):
901
+ return _no_offer("tested-after", f"{skill} was tested after the journal's last write.", warnings, path)
902
+ if args.skills_output_folder and not ran_ex and exported_after(Path(args.skills_output_folder), skill, since):
903
+ return _no_offer("exported-after", f"{skill} was exported after the journal's last write.", warnings, path)
904
+ steps = journal["steps"]
905
+ at = action["resume_at"]["index"]
906
+ result = {
907
+ "status": "offer",
908
+ **action,
909
+ "run_id": journal["run_id"],
910
+ "journal": str(path),
911
+ "invocation": journal.get("invocation"),
912
+ "alias": journal.get("alias"),
913
+ "skill_name": skill,
914
+ "remaining": [_indexed(steps, i) for i in range(at, len(steps))],
915
+ "args": journal["args"],
916
+ "data": journal["data"],
917
+ }
918
+ if warnings:
919
+ result["warnings"] = warnings
920
+ return result
921
+
922
+
923
+ def cmd_reopen(args) -> dict:
924
+ path = Path(args.journal)
925
+ journal = load_journal(path)
926
+ offer = offer_for(journal)
927
+ if offer is None:
928
+ raise JournalError("nothing-to-resume", "The journal's chain has nothing left to resume.")
929
+ steps = journal["steps"]
930
+ now = _stamp(_now())
931
+ halted, at = offer["halted_on"], offer["resume_at"]
932
+ if offer["kind"] != "interrupted":
933
+ step = steps[halted]
934
+ journal["history"].append({"code": step["code"], "reason": step["reason"], "repair": offer["repair"], "at": now})
935
+ if at == halted:
936
+ step.update(status="pending", reason=None)
937
+ else:
938
+ step["status"] = "completed" # AS CRITICAL: the review stands in for the audit's go-ahead
939
+ skill = _skill_name(journal)
940
+ if offer["repair"] in REPAIR_STEPS:
941
+ code, flags = REPAIR_STEPS[offer["repair"]]
942
+ steps.insert(at, _step_entry(code, target=skill, flags=flags))
943
+ first = steps[at]
944
+ if first["code"] in SKILL_TARGET_CODES and first["target"] is None and skill:
945
+ first["target"] = skill # no step before it hands the skill on in this run
946
+ journal["status"] = "running"
947
+ journal["updated_at"] = now
948
+ _save(path, journal)
949
+ return {"status": "ok", "journal": str(path), "offer": offer["offer"], "repair": offer["repair"],
950
+ "alias": journal.get("alias"), "skill_name": skill,
951
+ "run": [_indexed(steps, i) for i in range(at, len(steps)) if steps[i]["status"] == "pending"],
952
+ "args": journal["args"], "data": journal["data"]}
953
+
954
+
955
+ def cmd_discard(args) -> dict:
956
+ path = Path(args.journal)
957
+ try:
958
+ run_id = load_journal(path)["run_id"]
959
+ except JournalError:
960
+ run_id = _folder_run_id(path) # a journal that no longer loads can still be discarded
961
+ if run_id is None:
962
+ raise
963
+ problem = _delete_run_folder(path, run_id)
964
+ if problem:
965
+ raise JournalError("not-deleted", problem)
966
+ return {"status": "ok", "journal": str(path), "run_dir": str(path.parent), "deleted": True}
967
+
968
+
969
+ # --------------------------------------------------------------------------
970
+ # CLI
971
+ # --------------------------------------------------------------------------
972
+
973
+
974
+ def _build_parser() -> argparse.ArgumentParser:
975
+ parser = argparse.ArgumentParser(
976
+ prog="pipeline-journal",
977
+ description=(
978
+ "The forger's pipeline journal (references/pipeline-mode.md): start it from the "
979
+ "invocation, record each step, write the pipeline result, and work out the resume "
980
+ "or repair offer a stopped chain leaves."
981
+ ),
982
+ formatter_class=argparse.RawDescriptionHelpFormatter,
983
+ )
984
+ sub = parser.add_subparsers(dest="command", required=True)
985
+
986
+ start = sub.add_parser("start", help="Parse the invocation on stdin and create the run folder and its journal.")
987
+ start.add_argument("--run-root", required=True, help="The folder that holds the runs' folders.")
988
+ start.set_defaults(func=cmd_start)
989
+
990
+ step = sub.add_parser("step", help="Record the step the chain just finished.")
991
+ step.add_argument("--journal", required=True, help="The journal `start` created.")
992
+ step.add_argument("--code", required=True, type=str.upper, help="The code of the step that finished.")
993
+ how = step.add_mutually_exclusive_group(required=True)
994
+ how.add_argument("--gate", action="store_true",
995
+ help="Read the JSON pipeline-gate.py printed for the step on stdin and record its decision.")
996
+ how.add_argument("--status", choices=STATUS_CHOICES, help="How a step with no gate decision ended.")
997
+ step.add_argument("--reason", help="The halt reason, with --status halted.")
998
+ step.add_argument("--set", action="append", metavar="NAME=VALUE",
999
+ help="A value the step hands the next, by its Data Flow name (skill_name, brief_path).")
1000
+ step.add_argument("--output", action="append", metavar="PATH",
1001
+ help="A result or report path the step's envelope names.")
1002
+ step.set_defaults(func=cmd_step)
1003
+
1004
+ finish = sub.add_parser("finish", help="Write the pipeline result; delete the run folder when nothing is left.")
1005
+ finish.add_argument("--result-dir", required=True, help="The folder of the pipeline result files (the sidecar).")
1006
+ finish.add_argument("--journal", help="The run's journal; omitted for a chain that stopped before it started.")
1007
+ finish.add_argument("--halt-reason", help="Why the chain stopped, when no step call recorded it.")
1008
+ finish.set_defaults(func=cmd_finish)
1009
+
1010
+ resume = sub.add_parser("resume", help="The resume or repair offer the newest journal leaves.")
1011
+ resume.add_argument("--run-root", required=True, help="The folder that holds the runs' folders.")
1012
+ resume.add_argument("--result-dir", required=True, help="The folder of the pipeline result files (the sidecar).")
1013
+ resume.add_argument("--forge-data-folder", help="Where test results live: a later test drops the offer.")
1014
+ resume.add_argument("--skills-output-folder", help="Where the export manifest lives: a later export drops the offer.")
1015
+ resume.set_defaults(func=cmd_resume)
1016
+
1017
+ reopen = sub.add_parser("reopen", help="Apply the accepted offer and print the steps still to run.")
1018
+ reopen.add_argument("--journal", required=True, help="The journal `resume` named.")
1019
+ reopen.set_defaults(func=cmd_reopen)
1020
+
1021
+ discard = sub.add_parser("discard", help="Delete a stopped chain's run folder, so its offer is made no more.")
1022
+ discard.add_argument("--journal", required=True, help="The journal the offer named.")
1023
+ discard.set_defaults(func=cmd_discard)
1024
+ return parser
1025
+
1026
+
1027
+ def _force_utf8(*streams) -> None:
1028
+ """Reconfigure the output streams to UTF-8 (a Windows console uses cp1252)."""
1029
+ for stream in streams:
1030
+ if hasattr(stream, "reconfigure"):
1031
+ errors = getattr(stream, "errors", None)
1032
+ if errors is None:
1033
+ stream.reconfigure(encoding="utf-8")
1034
+ else:
1035
+ stream.reconfigure(encoding="utf-8", errors=errors)
1036
+
1037
+
1038
+ def main(argv: list[str] | None = None) -> int:
1039
+ args = _build_parser().parse_args(argv)
1040
+ try:
1041
+ result = args.func(args)
1042
+ except JournalError as e:
1043
+ print(json.dumps({"status": "error", "error": e.error, "message": e.message, "retry": e.error in RETRYABLE},
1044
+ indent=2))
1045
+ return 1
1046
+ print(json.dumps(result, indent=2))
1047
+ return 0
1048
+
1049
+
1050
+ if __name__ == "__main__":
1051
+ _force_utf8(sys.stdout, sys.stderr)
1052
+ sys.exit(main())