@event4u/agent-config 1.14.0 → 1.16.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 (293) hide show
  1. package/.agent-src/commands/agent-handoff.md +1 -1
  2. package/.agent-src/commands/bug-fix.md +3 -3
  3. package/.agent-src/commands/bug-investigate.md +2 -2
  4. package/.agent-src/commands/chat-history-checkpoint.md +3 -3
  5. package/.agent-src/commands/chat-history-clear.md +2 -2
  6. package/.agent-src/commands/chat-history-resume.md +2 -2
  7. package/.agent-src/commands/chat-history.md +3 -3
  8. package/.agent-src/commands/check-current-md.md +44 -33
  9. package/.agent-src/commands/commit-in-chunks.md +43 -23
  10. package/.agent-src/commands/compress.md +34 -2
  11. package/.agent-src/commands/council-design.md +96 -0
  12. package/.agent-src/commands/council-optimize.md +115 -0
  13. package/.agent-src/commands/council-pr.md +123 -0
  14. package/.agent-src/commands/council.md +219 -0
  15. package/.agent-src/commands/create-pr.md +23 -0
  16. package/.agent-src/commands/do-and-judge.md +3 -3
  17. package/.agent-src/commands/do-in-steps.md +4 -4
  18. package/.agent-src/commands/e2e-heal.md +1 -1
  19. package/.agent-src/commands/e2e-plan.md +1 -1
  20. package/.agent-src/commands/feature-dev.md +8 -0
  21. package/.agent-src/commands/feature-explore.md +6 -1
  22. package/.agent-src/commands/feature-plan.md +33 -2
  23. package/.agent-src/commands/feature-refactor.md +5 -0
  24. package/.agent-src/commands/feature-roadmap.md +8 -3
  25. package/.agent-src/commands/feature.md +58 -0
  26. package/.agent-src/commands/fix-ci.md +5 -0
  27. package/.agent-src/commands/fix-portability.md +7 -2
  28. package/.agent-src/commands/fix-pr-bot-comments.md +5 -0
  29. package/.agent-src/commands/fix-pr-comments.md +5 -0
  30. package/.agent-src/commands/fix-pr-developer-comments.md +5 -0
  31. package/.agent-src/commands/fix-references.md +5 -0
  32. package/.agent-src/commands/fix-seeder.md +5 -0
  33. package/.agent-src/commands/fix.md +60 -0
  34. package/.agent-src/commands/jira-ticket.md +1 -1
  35. package/.agent-src/commands/judge.md +1 -1
  36. package/.agent-src/commands/memory-add.md +3 -3
  37. package/.agent-src/commands/memory-full.md +2 -2
  38. package/.agent-src/commands/memory-promote.md +2 -2
  39. package/.agent-src/commands/mode.md +5 -5
  40. package/.agent-src/commands/onboard.md +17 -8
  41. package/.agent-src/commands/optimize-agents.md +6 -1
  42. package/.agent-src/commands/optimize-augmentignore.md +14 -0
  43. package/.agent-src/commands/optimize-rtk-filters.md +5 -0
  44. package/.agent-src/commands/optimize-skills.md +6 -1
  45. package/.agent-src/commands/optimize.md +54 -0
  46. package/.agent-src/commands/propose-memory.md +2 -2
  47. package/.agent-src/commands/refine-ticket.md +9 -7
  48. package/.agent-src/commands/review-changes.md +61 -9
  49. package/.agent-src/commands/review-routing.md +1 -1
  50. package/.agent-src/commands/roadmap-create.md +42 -4
  51. package/.agent-src/commands/roadmap-execute.md +9 -7
  52. package/.agent-src/commands/set-cost-profile.md +11 -3
  53. package/.agent-src/commands/sync-agent-settings.md +11 -2
  54. package/.agent-src/commands/tests-create.md +1 -1
  55. package/.agent-src/commands/tests-execute.md +2 -3
  56. package/.agent-src/commands/upstream-contribute.md +1 -1
  57. package/.agent-src/contexts/authority/commit-mechanics.md +57 -0
  58. package/.agent-src/contexts/authority/destructive-mechanics.md +66 -0
  59. package/.agent-src/contexts/authority/scope-mechanics.md +87 -0
  60. package/.agent-src/contexts/execution/autonomy-detection.md +54 -0
  61. package/.agent-src/contexts/execution/autonomy-examples.md +90 -0
  62. package/.agent-src/contexts/execution/autonomy-mechanics.md +29 -0
  63. package/.agent-src/contexts/execution/verification-mechanics.md +80 -0
  64. package/.agent-src/personas/README.md +1 -1
  65. package/.agent-src/rules/agent-authority.md +24 -0
  66. package/.agent-src/rules/architecture.md +1 -1
  67. package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
  68. package/.agent-src/rules/artifact-engagement-recording.md +2 -2
  69. package/.agent-src/rules/ask-when-uncertain.md +1 -1
  70. package/.agent-src/rules/augment-portability.md +56 -37
  71. package/.agent-src/rules/autonomous-execution.md +78 -114
  72. package/.agent-src/rules/capture-learnings.md +1 -1
  73. package/.agent-src/rules/chat-history-cadence.md +109 -0
  74. package/.agent-src/rules/chat-history-ownership.md +123 -0
  75. package/.agent-src/rules/chat-history-visibility.md +96 -0
  76. package/.agent-src/rules/cli-output-handling.md +1 -1
  77. package/.agent-src/rules/{command-suggestion.md → command-suggestion-policy.md} +10 -9
  78. package/.agent-src/rules/commit-conventions.md +1 -1
  79. package/.agent-src/rules/commit-policy.md +43 -61
  80. package/.agent-src/rules/context-hygiene.md +3 -3
  81. package/.agent-src/rules/direct-answers.md +2 -2
  82. package/.agent-src/rules/docs-sync.md +1 -1
  83. package/.agent-src/rules/e2e-testing.md +1 -1
  84. package/.agent-src/rules/guidelines.md +4 -4
  85. package/.agent-src/rules/improve-before-implement.md +2 -2
  86. package/.agent-src/rules/language-and-tone.md +41 -96
  87. package/.agent-src/rules/minimal-safe-diff.md +3 -3
  88. package/.agent-src/rules/model-recommendation.md +4 -4
  89. package/.agent-src/rules/no-cheap-questions.md +89 -0
  90. package/.agent-src/rules/non-destructive-by-default.md +25 -59
  91. package/.agent-src/rules/onboarding-gate.md +5 -5
  92. package/.agent-src/rules/review-routing-awareness.md +9 -9
  93. package/.agent-src/rules/roadmap-progress-sync.md +132 -80
  94. package/.agent-src/rules/role-mode-adherence.md +3 -3
  95. package/.agent-src/rules/scope-control.md +65 -46
  96. package/.agent-src/rules/security-sensitive-stop.md +2 -2
  97. package/.agent-src/rules/size-enforcement.md +3 -2
  98. package/.agent-src/rules/think-before-action.md +5 -5
  99. package/.agent-src/rules/token-efficiency.md +4 -4
  100. package/.agent-src/rules/{ui-audit-before-build.md → ui-audit-gate.md} +3 -3
  101. package/.agent-src/rules/user-interaction.md +31 -7
  102. package/.agent-src/rules/verify-before-complete.md +12 -67
  103. package/.agent-src/scripts/update_roadmap_progress.py +65 -8
  104. package/.agent-src/skills/ai-council/SKILL.md +333 -0
  105. package/.agent-src/skills/api-endpoint/SKILL.md +2 -2
  106. package/.agent-src/skills/blade-ui/SKILL.md +30 -11
  107. package/.agent-src/skills/blast-radius-analyzer/SKILL.md +1 -1
  108. package/.agent-src/skills/bug-analyzer/SKILL.md +1 -1
  109. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  110. package/.agent-src/skills/command-writing/SKILL.md +16 -5
  111. package/.agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  112. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +2 -2
  113. package/.agent-src/skills/developer-like-execution/SKILL.md +2 -2
  114. package/.agent-src/skills/existing-ui-audit/SKILL.md +24 -9
  115. package/.agent-src/skills/fe-design/SKILL.md +20 -15
  116. package/.agent-src/skills/file-editor/SKILL.md +9 -0
  117. package/.agent-src/skills/flux/SKILL.md +1 -1
  118. package/.agent-src/skills/git-workflow/SKILL.md +1 -1
  119. package/.agent-src/skills/guideline-writing/SKILL.md +11 -11
  120. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +4 -4
  121. package/.agent-src/skills/livewire/SKILL.md +27 -8
  122. package/.agent-src/skills/override-management/SKILL.md +2 -2
  123. package/.agent-src/skills/php-coder/SKILL.md +1 -1
  124. package/.agent-src/skills/playwright-testing/SKILL.md +2 -2
  125. package/.agent-src/skills/readme-reviewer/SKILL.md +1 -1
  126. package/.agent-src/skills/readme-writing/SKILL.md +1 -1
  127. package/.agent-src/skills/readme-writing-package/SKILL.md +1 -1
  128. package/.agent-src/skills/receiving-code-review/SKILL.md +1 -1
  129. package/.agent-src/skills/refine-ticket/SKILL.md +30 -24
  130. package/.agent-src/skills/review-routing/SKILL.md +2 -2
  131. package/.agent-src/skills/roadmap-management/SKILL.md +22 -16
  132. package/.agent-src/skills/rule-writing/SKILL.md +1 -1
  133. package/.agent-src/skills/skill-reviewer/SKILL.md +1 -1
  134. package/.agent-src/skills/skill-writing/SKILL.md +6 -6
  135. package/.agent-src/skills/subagent-orchestration/SKILL.md +1 -0
  136. package/.agent-src/skills/systematic-debugging/SKILL.md +1 -1
  137. package/.agent-src/skills/upstream-contribute/SKILL.md +3 -3
  138. package/.agent-src/skills/validate-feature-fit/SKILL.md +2 -2
  139. package/.agent-src/skills/{verify-before-complete → verify-completion-evidence}/SKILL.md +2 -2
  140. package/.agent-src/templates/agent-settings.md +9 -9
  141. package/.agent-src/templates/contexts/auth-model.md +1 -1
  142. package/.agent-src/templates/roadmaps.md +9 -8
  143. package/.agent-src/templates/scripts/README.md +2 -2
  144. package/.agent-src/templates/scripts/memory_lookup.py +1 -1
  145. package/.agent-src/templates/scripts/telemetry/aggregator.py +16 -1
  146. package/.agent-src/templates/scripts/telemetry/engagement.py +59 -0
  147. package/.agent-src/templates/scripts/telemetry/report_renderer.py +28 -1
  148. package/.agent-src/templates/scripts/telemetry_record.py +14 -1
  149. package/.agent-src/templates/scripts/work_engine/__init__.py +2 -2
  150. package/.agent-src/templates/scripts/work_engine/cli.py +64 -461
  151. package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
  152. package/.agent-src/templates/scripts/work_engine/delivery_state.py +3 -3
  153. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +1 -1
  154. package/.agent-src/templates/scripts/work_engine/directives/backend/implement.py +1 -1
  155. package/.agent-src/templates/scripts/work_engine/directives/backend/memory.py +1 -1
  156. package/.agent-src/templates/scripts/work_engine/directives/backend/plan.py +1 -1
  157. package/.agent-src/templates/scripts/work_engine/directives/backend/report.py +1 -1
  158. package/.agent-src/templates/scripts/work_engine/dispatcher.py +1 -1
  159. package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
  160. package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
  161. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
  162. package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
  163. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +34 -2
  164. package/.agent-src/templates/scripts/work_engine/persona_policy.py +1 -1
  165. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +1 -1
  166. package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
  167. package/.claude-plugin/marketplace.json +10 -2
  168. package/AGENTS.md +16 -12
  169. package/CHANGELOG.md +206 -9
  170. package/README.md +51 -52
  171. package/config/agent-settings.template.yml +58 -1
  172. package/config/gitignore-block.txt +3 -0
  173. package/docs/MIGRATION.md +122 -0
  174. package/docs/architecture.md +83 -34
  175. package/docs/catalog.md +331 -0
  176. package/docs/contracts/STABILITY.md +134 -0
  177. package/docs/contracts/adr-chat-history-split.md +132 -0
  178. package/docs/contracts/adr-command-suggestion.md +146 -0
  179. package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
  180. package/docs/contracts/adr-product-ui-track.md +384 -0
  181. package/docs/contracts/adr-prompt-driven-execution.md +187 -0
  182. package/docs/contracts/agent-memory-contract.md +149 -0
  183. package/docs/contracts/artifact-engagement-flow.md +262 -0
  184. package/docs/contracts/command-clusters.md +126 -0
  185. package/docs/contracts/command-suggestion-flow.md +148 -0
  186. package/docs/contracts/implement-ticket-flow.md +628 -0
  187. package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
  188. package/docs/contracts/linear-ai-three-layers.md +131 -0
  189. package/docs/contracts/load-context-schema.md +186 -0
  190. package/docs/contracts/rule-interactions.md +107 -0
  191. package/docs/contracts/rule-interactions.yml +238 -0
  192. package/docs/contracts/rule-priority-hierarchy.md +87 -0
  193. package/docs/contracts/ui-stack-extension.md +236 -0
  194. package/docs/contracts/ui-track-flow.md +338 -0
  195. package/docs/customization.md +14 -0
  196. package/docs/end-to-end-walkthroughs.md +165 -0
  197. package/docs/getting-started.md +27 -9
  198. package/docs/github-topics.md +12 -3
  199. package/docs/guidelines/agent-infra/language-and-tone-examples.md +79 -0
  200. package/{.agent-src → docs}/guidelines/docs/readme-size-and-splitting.md +26 -25
  201. package/docs/guidelines/php/git.md +164 -0
  202. package/docs/installation.md +42 -6
  203. package/docs/migrations/commands-1.15.0.md +112 -0
  204. package/docs/showcase.md +9 -4
  205. package/docs/skills-catalog.md +14 -8
  206. package/docs/ui-track-mental-model.md +121 -0
  207. package/llms.txt +13 -7
  208. package/package.json +1 -1
  209. package/scripts/agent-config +23 -0
  210. package/scripts/ai_council/__init__.py +39 -0
  211. package/scripts/ai_council/_default_prices.py +41 -0
  212. package/scripts/ai_council/_one_off_rebalancing_audit.py +149 -0
  213. package/scripts/ai_council/_one_off_roundtrip.py +106 -0
  214. package/scripts/ai_council/budget_guard.py +172 -0
  215. package/scripts/ai_council/bundler.py +261 -0
  216. package/scripts/ai_council/clients.py +381 -0
  217. package/scripts/ai_council/modes.py +127 -0
  218. package/scripts/ai_council/orchestrator.py +350 -0
  219. package/scripts/ai_council/pricing.py +213 -0
  220. package/scripts/ai_council/project_context.py +159 -0
  221. package/scripts/ai_council/prompts.py +232 -0
  222. package/scripts/ai_council/session.py +144 -0
  223. package/scripts/build_linear_digest.py +4 -4
  224. package/scripts/check_always_budget.py +126 -0
  225. package/scripts/check_augmentignore.py +69 -0
  226. package/scripts/check_command_count_messaging.py +120 -0
  227. package/scripts/check_portability.py +57 -0
  228. package/scripts/check_public_catalog_links.py +122 -0
  229. package/scripts/check_public_links.py +185 -0
  230. package/scripts/check_references.py +5 -1
  231. package/scripts/check_roadmap_trackable.py +111 -0
  232. package/scripts/command_suggester/cooldown.py +1 -1
  233. package/scripts/generate_index.py +266 -0
  234. package/scripts/install_anthropic_key.sh +5 -0
  235. package/scripts/install_openai_key.sh +106 -0
  236. package/scripts/lint_load_context.py +163 -0
  237. package/scripts/lint_no_new_atomic_commands.py +179 -0
  238. package/scripts/lint_rule_interactions.py +149 -0
  239. package/scripts/memory_lookup.py +1 -1
  240. package/scripts/release.py +297 -64
  241. package/scripts/schemas/command.schema.json +20 -0
  242. package/scripts/schemas/rule.schema.json +10 -0
  243. package/scripts/skill_linter.py +26 -4
  244. package/scripts/sync_agent_settings.py +1 -1
  245. package/scripts/update_counts.py +19 -4
  246. package/scripts/update_prices.py +124 -0
  247. package/.agent-src/guidelines/php/git.md +0 -96
  248. package/.agent-src/rules/chat-history.md +0 -200
  249. /package/.agent-src/rules/{slash-commands.md → slash-command-routing-policy.md} +0 -0
  250. /package/{.agent-src → docs}/guidelines/agent-infra/agent-interaction-and-decision-quality.md +0 -0
  251. /package/{.agent-src → docs}/guidelines/agent-infra/break-glass-usage.md +0 -0
  252. /package/{.agent-src → docs}/guidelines/agent-infra/developer-judgment.md +0 -0
  253. /package/{.agent-src → docs}/guidelines/agent-infra/engineering-memory-data-format.md +0 -0
  254. /package/{.agent-src → docs}/guidelines/agent-infra/layered-settings.md +0 -0
  255. /package/{.agent-src → docs}/guidelines/agent-infra/memory-access.md +0 -0
  256. /package/{.agent-src → docs}/guidelines/agent-infra/naming.md +0 -0
  257. /package/{.agent-src → docs}/guidelines/agent-infra/output-patterns.md +0 -0
  258. /package/{.agent-src → docs}/guidelines/agent-infra/review-routing-data-format.md +0 -0
  259. /package/{.agent-src → docs}/guidelines/agent-infra/role-contracts.md +0 -0
  260. /package/{.agent-src → docs}/guidelines/agent-infra/role-mode-router.md +0 -0
  261. /package/{.agent-src → docs}/guidelines/agent-infra/runtime-layer.md +0 -0
  262. /package/{.agent-src → docs}/guidelines/agent-infra/self-improvement-pipeline.md +0 -0
  263. /package/{.agent-src → docs}/guidelines/agent-infra/size-and-scope.md +0 -0
  264. /package/{.agent-src → docs}/guidelines/agent-infra/tool-integration.md +0 -0
  265. /package/{.agent-src → docs}/guidelines/e2e/playwright.md +0 -0
  266. /package/{.agent-src → docs}/guidelines/php/api-design.md +0 -0
  267. /package/{.agent-src → docs}/guidelines/php/artisan-commands.md +0 -0
  268. /package/{.agent-src → docs}/guidelines/php/blade-ui.md +0 -0
  269. /package/{.agent-src → docs}/guidelines/php/controllers.md +0 -0
  270. /package/{.agent-src → docs}/guidelines/php/database.md +0 -0
  271. /package/{.agent-src → docs}/guidelines/php/eloquent.md +0 -0
  272. /package/{.agent-src → docs}/guidelines/php/flux.md +0 -0
  273. /package/{.agent-src → docs}/guidelines/php/general.md +0 -0
  274. /package/{.agent-src → docs}/guidelines/php/jobs.md +0 -0
  275. /package/{.agent-src → docs}/guidelines/php/livewire.md +0 -0
  276. /package/{.agent-src → docs}/guidelines/php/logging.md +0 -0
  277. /package/{.agent-src → docs}/guidelines/php/naming.md +0 -0
  278. /package/{.agent-src → docs}/guidelines/php/patterns/dependency-injection.md +0 -0
  279. /package/{.agent-src → docs}/guidelines/php/patterns/dtos.md +0 -0
  280. /package/{.agent-src → docs}/guidelines/php/patterns/events.md +0 -0
  281. /package/{.agent-src → docs}/guidelines/php/patterns/factory.md +0 -0
  282. /package/{.agent-src → docs}/guidelines/php/patterns/pipelines.md +0 -0
  283. /package/{.agent-src → docs}/guidelines/php/patterns/policies.md +0 -0
  284. /package/{.agent-src → docs}/guidelines/php/patterns/repositories.md +0 -0
  285. /package/{.agent-src → docs}/guidelines/php/patterns/service-layer.md +0 -0
  286. /package/{.agent-src → docs}/guidelines/php/patterns/strategy.md +0 -0
  287. /package/{.agent-src → docs}/guidelines/php/patterns.md +0 -0
  288. /package/{.agent-src → docs}/guidelines/php/performance.md +0 -0
  289. /package/{.agent-src → docs}/guidelines/php/resources.md +0 -0
  290. /package/{.agent-src → docs}/guidelines/php/security.md +0 -0
  291. /package/{.agent-src → docs}/guidelines/php/sql.md +0 -0
  292. /package/{.agent-src → docs}/guidelines/php/validations.md +0 -0
  293. /package/{.agent-src → docs}/guidelines/php/websocket.md +0 -0
@@ -1,6 +1,6 @@
1
1
  """``DeliveryState`` — the only object shared between orchestrator steps.
2
2
 
3
- The shape mirrors ``agents/contexts/implement-ticket-flow.md``. No step
3
+ The shape mirrors ``docs/contracts/implement-ticket-flow.md``. No step
4
4
  may invent fields not declared here; extensions require a roadmap
5
5
  amendment plus a flow-contract update.
6
6
 
@@ -59,7 +59,7 @@ class DeliveryState:
59
59
  """Canonical state passed between orchestrator steps.
60
60
 
61
61
  Field order matches the table in
62
- ``agents/contexts/implement-ticket-flow.md``. Mutable defaults use
62
+ ``docs/contracts/implement-ticket-flow.md``. Mutable defaults use
63
63
  ``field(default_factory=...)`` so every instance owns its own
64
64
  containers — a single shared list across runs would be a
65
65
  cross-run contamination hazard for the metrics pipeline.
@@ -103,7 +103,7 @@ skill; the user-facing numbered options follow on subsequent lines.
103
103
 
104
104
  The prefix is public contract: changing it breaks every agent that
105
105
  has learned to recognise it. See
106
- ``agents/contexts/implement-ticket-flow.md#agent-directives``.
106
+ ``docs/contracts/implement-ticket-flow.md#agent-directives``.
107
107
  """
108
108
 
109
109
 
@@ -22,7 +22,7 @@ validate upstream state; the delegation gates (``plan``,
22
22
  matching skill and resume. ``report`` renders the delivery Markdown
23
23
  once everything else has succeeded. See
24
24
  ``agents/roadmaps/road-to-implement-ticket.md`` for the shipping
25
- order and ``agents/contexts/implement-ticket-flow.md`` for the
25
+ order and ``docs/contracts/implement-ticket-flow.md`` for the
26
26
  slice contracts each handler writes to.
27
27
  """
28
28
  from __future__ import annotations
@@ -17,7 +17,7 @@ Flow:
17
17
  - Otherwise → SUCCESS.
18
18
 
19
19
  ``changes`` entries use the loose shape described in
20
- ``agents/contexts/implement-ticket-flow.md#deliverystate-the-only-shared-object``
20
+ ``docs/contracts/implement-ticket-flow.md#deliverystate-the-only-shared-object``
21
21
  \u2014 each entry is a dict with at least a ``path``; optional
22
22
  ``lines`` / ``purpose`` feed the delivery report.
23
23
  """
@@ -1,7 +1,7 @@
1
1
  """``memory`` step — bounded retrieval over the four allowed types.
2
2
 
3
3
  Contract (see
4
- ``agents/contexts/implement-ticket-flow.md#memory-retrieval-contract``):
4
+ ``docs/contracts/implement-ticket-flow.md#memory-retrieval-contract``):
5
5
 
6
6
  - Four allowed types: ``domain-invariants``, ``architecture-decisions``,
7
7
  ``incident-learnings``, ``historical-patterns``.
@@ -3,7 +3,7 @@
3
3
  The dispatcher cannot synthesise a plan from pure Python: the real
4
4
  work needs code reading and judgement that only the agent has. The
5
5
  step therefore follows the Option-A delegation pattern described in
6
- ``agents/contexts/implement-ticket-flow.md#agent-directives``:
6
+ ``docs/contracts/implement-ticket-flow.md#agent-directives``:
7
7
 
8
8
  - ``state.plan`` empty → halt with ``BLOCKED`` and emit
9
9
  ``@agent-directive: create-plan``. The orchestrator runs the
@@ -1,7 +1,7 @@
1
1
  """``report`` step — delivery report renderer.
2
2
 
3
3
  Produces the markdown block described in
4
- ``agents/contexts/implement-ticket-flow.md#delivery-report-schema``.
4
+ ``docs/contracts/implement-ticket-flow.md#delivery-report-schema``.
5
5
  All nine headings are present on every run — the schema is stable
6
6
  for consumers — but section bodies are omitted when the matching
7
7
  slice of ``DeliveryState`` is empty. The single exception is the
@@ -1,7 +1,7 @@
1
1
  """Linear step dispatcher for ``/implement-ticket``.
2
2
 
3
3
  The dispatcher holds no business logic. It walks the fixed eight-step
4
- order declared in ``agents/contexts/implement-ticket-flow.md``, hands
4
+ order declared in ``docs/contracts/implement-ticket-flow.md``, hands
5
5
  each step a live ``DeliveryState``, and honours the three terminal
6
6
  outcomes:
7
7
 
@@ -0,0 +1,43 @@
1
+ """Stdout / stderr emitters for the CLI entry point.
2
+
3
+ Extracted from ``cli.py`` in P2.3 of
4
+ ``road-to-post-pr29-optimize.md``. Holds the two output helpers that
5
+ shape the wire surface of ``main()``: the SUCCESS/halt branch printed
6
+ on stdout, and the lifecycle-hook halt surface printed on stderr.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from .delivery_state import Outcome
13
+ from .hooks import HookHalt
14
+ from .state import WorkState
15
+
16
+
17
+ def _emit(work: WorkState, final: Outcome, halting: str | None) -> None:
18
+ if final is Outcome.SUCCESS:
19
+ print(work.report)
20
+ return
21
+ print(f"[halt] outcome={final.value} step={halting or '(none)'}")
22
+ for line in work.questions:
23
+ print(line)
24
+
25
+
26
+ def _emit_halt(halt: HookHalt) -> int:
27
+ """Render a :class:`HookHalt` surface to stderr and return exit 2.
28
+
29
+ Per the P3 halt branch table, every CLI-layer halt yields exit code
30
+ ``2`` regardless of which event fired it. State persistence is
31
+ governed by *where* in ``main`` the halt is detected: the call site
32
+ decides whether ``_save`` already ran. This helper is the single
33
+ place that formats the surface so the wire output stays consistent.
34
+ """
35
+ if halt.surface:
36
+ for line in halt.surface:
37
+ print(line, file=sys.stderr)
38
+ else:
39
+ print(f"halt: {halt.reason}", file=sys.stderr)
40
+ return 2
41
+
42
+
43
+ __all__ = ["_emit", "_emit_halt"]
@@ -0,0 +1,19 @@
1
+ """CLI-layer error type used by the dispatcher entry point.
2
+
3
+ Lives in its own module so the helper modules (``state_io``,
4
+ ``input_builders``, etc.) can raise it without depending on
5
+ ``cli.py``, which would create an import cycle.
6
+
7
+ Behaviour is identical to the original ``cli._CLIError`` it replaced
8
+ in P2.3 of ``road-to-post-pr29-optimize.md`` — same name (private,
9
+ underscore-prefixed) and same role: convert to exit code ``2`` at the
10
+ ``main()`` boundary.
11
+ """
12
+ from __future__ import annotations
13
+
14
+
15
+ class _CLIError(Exception):
16
+ """Raised on configuration or I/O problems. Converted to exit code 2."""
17
+
18
+
19
+ __all__ = ["_CLIError"]
@@ -0,0 +1,76 @@
1
+ """Lifecycle-hook registry assembly for the CLI entry point.
2
+
3
+ Extracted from ``cli.py`` in P2.3 of
4
+ ``road-to-post-pr29-optimize.md``. Owns nothing but
5
+ ``_build_hook_registry`` and its chat-history helper. The function
6
+ remains re-exported from ``work_engine.cli`` so the existing test
7
+ import (``from work_engine.cli import _build_hook_registry``) and
8
+ monkeypatch target (``work_engine.cli._build_hook_registry``) keep
9
+ working without a breaking change.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from .hooks import HookRegistry
17
+ from .hooks.builtin import (
18
+ ChatHistoryAppendHook,
19
+ ChatHistoryHaltAppendHook,
20
+ ChatHistoryHeartbeatHook,
21
+ ChatHistoryTurnCheckHook,
22
+ DirectiveSetGuardHook,
23
+ HaltSurfaceAuditHook,
24
+ StateShapeValidationHook,
25
+ TraceHook,
26
+ )
27
+ from .hooks.settings import HookSettings, load_hook_settings
28
+
29
+
30
+ def _build_hook_registry(args: argparse.Namespace) -> HookRegistry:
31
+ """Build the CLI-side :class:`HookRegistry` for one ``main()`` run.
32
+
33
+ Reads ``hooks.*`` from ``.agent-settings.yml`` and registers the
34
+ enabled hooks. The master switch ``hooks.enabled`` defaults to
35
+ ``False`` when the block (or the file) is missing — the registry
36
+ stays empty and golden replay flows are byte-stable.
37
+
38
+ ``--no-hooks`` on the CLI forces an empty registry regardless of
39
+ settings, which is the explicit escape hatch golden-replay test
40
+ harnesses can use.
41
+ """
42
+ registry = HookRegistry()
43
+ if getattr(args, "no_hooks", False):
44
+ return registry
45
+
46
+ settings_path = getattr(args, "hooks_config", None)
47
+ settings = load_hook_settings(settings_path)
48
+ if not settings.enabled:
49
+ return registry
50
+
51
+ if settings.trace:
52
+ TraceHook().register(registry)
53
+ if settings.halt_surface_audit:
54
+ HaltSurfaceAuditHook().register(registry)
55
+ if settings.state_shape_validation:
56
+ StateShapeValidationHook().register(registry)
57
+ if settings.directive_set_guard:
58
+ DirectiveSetGuardHook().register(registry)
59
+ if settings.chat_history_enabled:
60
+ _register_chat_history_hooks(registry, settings)
61
+
62
+ return registry
63
+
64
+
65
+ def _register_chat_history_hooks(
66
+ registry: HookRegistry, settings: HookSettings,
67
+ ) -> None:
68
+ """Register the four chat-history hooks bound to the configured script."""
69
+ script = Path(settings.chat_history_script)
70
+ ChatHistoryTurnCheckHook(script).register(registry)
71
+ ChatHistoryAppendHook(script).register(registry)
72
+ ChatHistoryHaltAppendHook(script).register(registry)
73
+ ChatHistoryHeartbeatHook(script).register(registry)
74
+
75
+
76
+ __all__ = ["_build_hook_registry", "_register_chat_history_hooks"]
@@ -0,0 +1,163 @@
1
+ """File-based input builders and the load-or-build dispatch helper.
2
+
3
+ Extracted from ``cli.py`` in P2.3 of
4
+ ``road-to-post-pr29-optimize.md``. Owns the CLI's "first run" path:
5
+ when no state file exists, build a fresh :class:`WorkState` from
6
+ ``--ticket-file``, ``--prompt-file``, ``--diff-file`` or
7
+ ``--file-file``. Every builder is byte-identical in behaviour to the
8
+ pre-split version — the resolvers it calls and the persona / routing
9
+ post-processing did not move.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ from .cli_args import _FMT_V0, _FMT_V1
17
+ from .errors import _CLIError
18
+ from .intent import populate_routing
19
+ from .resolvers.diff import DiffResolverError, build_envelope as _build_diff_envelope
20
+ from .resolvers.file import FileResolverError, build_envelope as _build_file_envelope
21
+ from .resolvers.prompt import PromptResolverError, build_envelope as _build_prompt_envelope
22
+ from .state import Input, WorkState
23
+ from .state_io import _load, _maybe_raise_legacy_hint, _read_json
24
+
25
+
26
+ def _load_or_build(
27
+ state_file: Path,
28
+ args: argparse.Namespace,
29
+ ) -> tuple[WorkState, str]:
30
+ """Return the WorkState to dispatch against plus its wire format.
31
+
32
+ Either loaded from ``state_file`` (format-preserving) or freshly
33
+ built from ``--ticket-file`` (R1), ``--prompt-file`` (R2),
34
+ ``--diff-file`` (R3) or ``--file-file`` (R3). Fresh ticket files
35
+ default to v0 wire format so that newly captured Goldens stay
36
+ byte-equal with the pre-Phase-4 baseline; the prompt / diff / file
37
+ paths emit v1 directly (v0 has no envelope concept for these
38
+ kinds). v1 round-trips for state files already on disk in v1 shape.
39
+ """
40
+ if state_file.exists():
41
+ return _load(state_file)
42
+ _maybe_raise_legacy_hint(state_file)
43
+ inputs = [
44
+ ("--ticket-file", args.ticket_file),
45
+ ("--prompt-file", args.prompt_file),
46
+ ("--diff-file", args.diff_file),
47
+ ("--file-file", args.file_file),
48
+ ]
49
+ supplied = [name for name, value in inputs if value is not None]
50
+ if len(supplied) > 1:
51
+ raise _CLIError(
52
+ f"{', '.join(supplied)} are mutually exclusive; pass exactly "
53
+ "one when building an initial state.",
54
+ )
55
+ if not supplied:
56
+ raise _CLIError(
57
+ f"No state file at {state_file} and no --ticket-file, "
58
+ "--prompt-file, --diff-file, or --file-file given; cannot "
59
+ "build an initial state.",
60
+ )
61
+ if args.prompt_file is not None:
62
+ return _build_from_prompt_file(args), _FMT_V1
63
+ if args.diff_file is not None:
64
+ return _build_from_diff_file(args), _FMT_V1
65
+ if args.file_file is not None:
66
+ return _build_from_file_file(args), _FMT_V1
67
+ ticket = _read_json(args.ticket_file)
68
+ if not isinstance(ticket, dict):
69
+ raise _CLIError(
70
+ f"--ticket-file must carry a JSON object; got {type(ticket).__name__}.",
71
+ )
72
+ work = WorkState(input=Input(kind="ticket", data=ticket))
73
+ if args.persona:
74
+ work.persona = args.persona
75
+ populate_routing(work)
76
+ return work, _FMT_V0
77
+
78
+
79
+ def _build_from_prompt_file(args: argparse.Namespace) -> WorkState:
80
+ """Read ``--prompt-file`` as raw text and wrap it in a prompt envelope.
81
+
82
+ The file is read verbatim (UTF-8) and handed to the prompt resolver,
83
+ which validates non-emptiness and returns the canonical
84
+ ``Input(kind="prompt", data={raw, reconstructed_ac, assumptions})``
85
+ envelope. Persona is honoured the same way as the ticket path.
86
+ """
87
+ try:
88
+ raw = args.prompt_file.read_text(encoding="utf-8")
89
+ except OSError as exc:
90
+ raise _CLIError(f"Cannot read {args.prompt_file}: {exc}") from exc
91
+ try:
92
+ envelope = _build_prompt_envelope(raw)
93
+ except PromptResolverError as exc:
94
+ raise _CLIError(f"--prompt-file is not a valid prompt: {exc}") from exc
95
+ work = WorkState(input=envelope)
96
+ if args.persona:
97
+ work.persona = args.persona
98
+ populate_routing(work)
99
+ return work
100
+
101
+
102
+ def _build_from_diff_file(args: argparse.Namespace) -> WorkState:
103
+ """Read ``--diff-file`` as raw text and wrap it in a diff envelope.
104
+
105
+ The file is read verbatim (UTF-8) and handed to the diff resolver,
106
+ which validates the unified-diff header heuristic and returns the
107
+ canonical
108
+ ``Input(kind="diff", data={raw, reconstructed_ac, assumptions})``
109
+ envelope. ``populate_routing`` then routes the envelope to the
110
+ UI-improve directive set without running the prose classifier — see
111
+ :mod:`work_engine.intent.classify` for the routing contract.
112
+ """
113
+ try:
114
+ raw = args.diff_file.read_text(encoding="utf-8")
115
+ except OSError as exc:
116
+ raise _CLIError(f"Cannot read {args.diff_file}: {exc}") from exc
117
+ try:
118
+ envelope = _build_diff_envelope(raw)
119
+ except DiffResolverError as exc:
120
+ raise _CLIError(f"--diff-file is not a valid diff: {exc}") from exc
121
+ work = WorkState(input=envelope)
122
+ if args.persona:
123
+ work.persona = args.persona
124
+ populate_routing(work)
125
+ return work
126
+
127
+
128
+ def _build_from_file_file(args: argparse.Namespace) -> WorkState:
129
+ """Read ``--file-file`` as a single-line path and wrap it in a file envelope.
130
+
131
+ The file is read verbatim (UTF-8); the first non-empty line is taken
132
+ as the path reference and handed to the file resolver, which
133
+ validates path shape (non-empty, NUL-free, not a URL) and returns
134
+ the canonical
135
+ ``Input(kind="file", data={path, reconstructed_ac, assumptions})``
136
+ envelope. Trailing whitespace and additional lines are ignored —
137
+ the resolver treats the file's content as the path itself, not as
138
+ structured payload.
139
+ """
140
+ try:
141
+ raw = args.file_file.read_text(encoding="utf-8")
142
+ except OSError as exc:
143
+ raise _CLIError(f"Cannot read {args.file_file}: {exc}") from exc
144
+ path = raw.strip().splitlines()[0] if raw.strip() else ""
145
+ try:
146
+ envelope = _build_file_envelope(path)
147
+ except FileResolverError as exc:
148
+ raise _CLIError(
149
+ f"--file-file does not carry a valid path: {exc}",
150
+ ) from exc
151
+ work = WorkState(input=envelope)
152
+ if args.persona:
153
+ work.persona = args.persona
154
+ populate_routing(work)
155
+ return work
156
+
157
+
158
+ __all__ = [
159
+ "_build_from_diff_file",
160
+ "_build_from_file_file",
161
+ "_build_from_prompt_file",
162
+ "_load_or_build",
163
+ ]
@@ -41,7 +41,39 @@ DEFAULT_V1_FILENAME = ".work-state.json"
41
41
  """Canonical filename for the v1 wire format."""
42
42
 
43
43
  BACKUP_SUFFIX = ".bak"
44
- """Appended to the v0 source path when the migration archives it."""
44
+ """Appended to the v0 source path when the migration archives it.
45
+
46
+ If the ``.bak`` slot is already taken (re-running the migration after
47
+ an aborted run, manual rollback, etc.) the rotator falls back to
48
+ ``.bak.1``, ``.bak.2``, ... — see :func:`_rotate_backup_path`. The
49
+ migration never silently overwrites an existing backup."""
50
+
51
+ _MAX_BACKUP_ROTATIONS = 999
52
+ """Hard ceiling on rotated backup filenames; surfaces an explicit
53
+ :class:`SchemaError` instead of looping forever if a checkout has
54
+ hundreds of stale backups."""
55
+
56
+
57
+ def _rotate_backup_path(source: Path) -> Path:
58
+ """Return the next free ``.bak`` slot for ``source``.
59
+
60
+ Tries ``source.bak`` first, then ``source.bak.1``,
61
+ ``source.bak.2``, ... up to :data:`_MAX_BACKUP_ROTATIONS`. The
62
+ rotator only inspects existence — collision-safe by construction —
63
+ and never deletes or overwrites prior backups.
64
+ """
65
+ primary = source.with_suffix(source.suffix + BACKUP_SUFFIX)
66
+ if not primary.exists():
67
+ return primary
68
+ for index in range(1, _MAX_BACKUP_ROTATIONS + 1):
69
+ candidate = primary.with_suffix(primary.suffix + f".{index}")
70
+ if not candidate.exists():
71
+ return candidate
72
+ raise SchemaError(
73
+ f"refusing to rotate backup for {source}: more than "
74
+ f"{_MAX_BACKUP_ROTATIONS} stale .bak files already exist; "
75
+ "clean them up before re-running the migration",
76
+ )
45
77
 
46
78
 
47
79
  def migrate_payload(payload: Any) -> dict[str, Any]:
@@ -143,7 +175,7 @@ def migrate_file(
143
175
  )
144
176
 
145
177
  if backup:
146
- backup_path = source.with_suffix(source.suffix + BACKUP_SUFFIX)
178
+ backup_path = _rotate_backup_path(source)
147
179
  shutil.move(str(source), str(backup_path))
148
180
 
149
181
  return target
@@ -2,7 +2,7 @@
2
2
 
3
3
  Three personas ship today, each keyed by the string already carried
4
4
  on ``DeliveryState.persona`` (see
5
- ``agents/contexts/implement-ticket-flow.md#personas``):
5
+ ``docs/contracts/implement-ticket-flow.md#personas``):
6
6
 
7
7
  ``senior-engineer``
8
8
  Default. Runs every step. No test widening.
@@ -15,7 +15,7 @@ Why split the resolver from the refiner:
15
15
  it without touching the LLM-facing skill harness.
16
16
  - The refiner runs inside the dispatcher loop and is allowed to halt
17
17
  (medium-confidence assumptions report, low-confidence one-question
18
- block) per :doc:`agents/contexts/implement-ticket-flow.md`. That
18
+ block) per :doc:`docs/contracts/implement-ticket-flow.md`. That
19
19
  control-flow surface does not belong in a resolver.
20
20
 
21
21
  Future R3 resolvers (``diff``, ``file``) follow the same pattern: thin
@@ -0,0 +1,202 @@
1
+ """State-file I/O helpers for the CLI entry point.
2
+
3
+ Extracted from ``cli.py`` in P2.3 of
4
+ ``road-to-post-pr29-optimize.md``. Holds the format-preserving
5
+ load/save pair, the v0 legacy serialiser, the JSON reader, the
6
+ ``DeliveryState`` projection helpers, and the legacy-file migration
7
+ hint. Behaviour is byte-identical to the pre-split version — Goldens
8
+ stay green.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import state as _state_module
17
+ from .cli_args import DEFAULT_STATE_FILE, LEGACY_STATE_FILE, _FMT_V0, _FMT_V1
18
+ from .delivery_state import DeliveryState
19
+ from .errors import _CLIError
20
+ from .migration.v0_to_v1 import migrate_payload
21
+ from .state import SchemaError, WorkState
22
+
23
+
24
+ def _maybe_raise_legacy_hint(state_file: Path) -> None:
25
+ """Surface a migration hint when only the pre-1.15.0 file is present.
26
+
27
+ The dispatcher renamed the default state file from
28
+ ``.implement-ticket-state.json`` to ``.work-state.json`` in 1.15.0
29
+ (alongside the ``implement_ticket → work_engine`` package move).
30
+ Existing checkouts that still carry the legacy file would otherwise
31
+ fail with a generic "no state file" message. This helper detects
32
+ the legacy file in the same directory and points the user at the
33
+ one-shot migration command instead.
34
+
35
+ Only fires when ``state_file`` has the canonical default name and
36
+ sits next to a legacy file — explicit ``--state-file`` overrides
37
+ bypass the hint so power users can carry their own naming scheme.
38
+ """
39
+ if state_file.name != DEFAULT_STATE_FILE.name:
40
+ return
41
+ legacy_candidate = state_file.with_name(LEGACY_STATE_FILE.name)
42
+ if not legacy_candidate.is_file():
43
+ return
44
+ raise _CLIError(
45
+ f"Found legacy state file {legacy_candidate} but no "
46
+ f"{state_file}. The default state file was renamed in 1.15.0. "
47
+ f"Run `python3 -m work_engine.migration.v0_to_v1 "
48
+ f"{legacy_candidate}` to migrate, or pass `--state-file "
49
+ f"{legacy_candidate}` to keep using the old name. See "
50
+ "docs/MIGRATION.md.",
51
+ )
52
+
53
+
54
+ def _load(state_file: Path) -> tuple[WorkState, str]:
55
+ """Load ``state_file`` and tag it with the wire format detected."""
56
+ data = _read_json(state_file)
57
+ if not isinstance(data, dict):
58
+ raise _CLIError(
59
+ f"State file {state_file} must carry a JSON object; "
60
+ f"got {type(data).__name__}.",
61
+ )
62
+
63
+ # v1 declares ``version``; v0 has none. Anything else is invalid.
64
+ if data.get("version") == _state_module.SCHEMA_VERSION:
65
+ try:
66
+ return _state_module.from_dict(data), _FMT_V1
67
+ except SchemaError as exc:
68
+ raise _CLIError(f"State file shape is invalid: {exc}") from exc
69
+ if "version" in data:
70
+ raise _CLIError(
71
+ f"State file shape is invalid: unsupported version "
72
+ f"{data.get('version')!r}; expected {_state_module.SCHEMA_VERSION}",
73
+ )
74
+ if "ticket" not in data:
75
+ raise _CLIError(
76
+ "State file shape is invalid: missing 'ticket' (v0) or "
77
+ "'version' (v1) — file is neither shape.",
78
+ )
79
+ try:
80
+ migrated = migrate_payload(data)
81
+ return _state_module.from_dict(migrated), _FMT_V0
82
+ except SchemaError as exc:
83
+ raise _CLIError(f"State file shape is invalid: {exc}") from exc
84
+
85
+
86
+ def _to_delivery(work: WorkState) -> DeliveryState:
87
+ """Project ``work`` into a ``DeliveryState`` for handler dispatch.
88
+
89
+ R1 P4 S1 (Option A2): handlers continue to consume ``DeliveryState``
90
+ with ``state.ticket``; the ``WorkState`` wrapper exists at the CLI
91
+ boundary so the dispatcher's directive-set selection has a v1
92
+ state object to read ``directive_set`` from. Mutable containers
93
+ (``memory``, ``changes``, ``outcomes``, ``questions``) are passed
94
+ by reference — in-place mutations land on both objects without an
95
+ explicit sync. Reassignments (``state.plan = …``, ``state.report
96
+ = …``) are mirrored back by :func:`_sync_back`.
97
+ """
98
+ return DeliveryState(
99
+ ticket=work.input.data,
100
+ persona=work.persona,
101
+ memory=work.memory,
102
+ plan=work.plan,
103
+ changes=work.changes,
104
+ tests=work.tests,
105
+ verify=work.verify,
106
+ outcomes=work.outcomes,
107
+ questions=work.questions,
108
+ report=work.report,
109
+ ui_audit=work.ui_audit,
110
+ ui_design=work.ui_design,
111
+ ui_review=work.ui_review,
112
+ ui_polish=work.ui_polish,
113
+ contract=work.contract,
114
+ stitch=work.stitch,
115
+ stack=work.stack,
116
+ )
117
+
118
+
119
+ def _sync_back(work: WorkState, delivery: DeliveryState) -> None:
120
+ """Mirror handler mutations from ``delivery`` back into ``work``.
121
+
122
+ Container fields are shared by reference (see :func:`_to_delivery`)
123
+ so the assignment is a no-op for those — we still mirror them
124
+ defensively to cover the case where a handler reassigned the
125
+ attribute (``state.memory = [new_list]``) instead of mutating in
126
+ place.
127
+ """
128
+ work.input.data = delivery.ticket
129
+ work.persona = delivery.persona
130
+ work.memory = delivery.memory
131
+ work.plan = delivery.plan
132
+ work.changes = delivery.changes
133
+ work.tests = delivery.tests
134
+ work.verify = delivery.verify
135
+ work.outcomes = delivery.outcomes
136
+ work.questions = delivery.questions
137
+ work.report = delivery.report
138
+ work.ui_audit = delivery.ui_audit
139
+ work.ui_design = delivery.ui_design
140
+ work.ui_review = delivery.ui_review
141
+ work.ui_polish = delivery.ui_polish
142
+ work.contract = delivery.contract
143
+ work.stitch = delivery.stitch
144
+ work.stack = delivery.stack
145
+
146
+
147
+ def _save(state_file: Path, work: WorkState, fmt: str) -> None:
148
+ """Persist ``work`` in the wire format it was loaded with.
149
+
150
+ v1 emits the canonical envelope via :func:`work_engine.state.to_dict`;
151
+ v0 emits the legacy flat shape that ``DeliveryState.asdict`` used
152
+ to produce, byte-identical to the pre-Phase-4 output so the
153
+ Golden Transcript replay stays green.
154
+ """
155
+ state_file.parent.mkdir(parents=True, exist_ok=True)
156
+ payload = _state_module.to_dict(work) if fmt == _FMT_V1 else _to_v0_dict(work)
157
+ state_file.write_text(
158
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
159
+ encoding="utf-8",
160
+ )
161
+
162
+
163
+ def _to_v0_dict(work: WorkState) -> dict[str, Any]:
164
+ """Serialise ``work`` in the legacy v0 wire format.
165
+
166
+ Field order matches ``DeliveryState`` declaration order so
167
+ pre-Phase-4 state files round-trip byte-equal.
168
+ """
169
+ return {
170
+ "ticket": work.input.data,
171
+ "persona": work.persona,
172
+ "memory": work.memory,
173
+ "plan": work.plan,
174
+ "changes": work.changes,
175
+ "tests": work.tests,
176
+ "verify": work.verify,
177
+ "outcomes": work.outcomes,
178
+ "questions": work.questions,
179
+ "report": work.report,
180
+ }
181
+
182
+
183
+ def _read_json(path: Path):
184
+ try:
185
+ raw = path.read_text(encoding="utf-8")
186
+ except OSError as exc:
187
+ raise _CLIError(f"Cannot read {path}: {exc}") from exc
188
+ try:
189
+ return json.loads(raw)
190
+ except json.JSONDecodeError as exc:
191
+ raise _CLIError(f"Invalid JSON in {path}: {exc}") from exc
192
+
193
+
194
+ __all__ = [
195
+ "_load",
196
+ "_maybe_raise_legacy_hint",
197
+ "_read_json",
198
+ "_save",
199
+ "_sync_back",
200
+ "_to_delivery",
201
+ "_to_v0_dict",
202
+ ]