@event4u/agent-config 1.13.0 → 1.15.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 (291) hide show
  1. package/.agent-src/commands/agent-handoff.md +4 -1
  2. package/.agent-src/commands/agent-status.md +3 -0
  3. package/.agent-src/commands/agents-audit.md +4 -0
  4. package/.agent-src/commands/agents-cleanup.md +6 -1
  5. package/.agent-src/commands/agents-prepare.md +3 -0
  6. package/.agent-src/commands/analyze-reference-repo.md +4 -0
  7. package/.agent-src/commands/bug-fix.md +7 -3
  8. package/.agent-src/commands/bug-investigate.md +4 -0
  9. package/.agent-src/commands/chat-history-checkpoint.md +126 -0
  10. package/.agent-src/commands/chat-history-clear.md +6 -1
  11. package/.agent-src/commands/chat-history-resume.md +7 -2
  12. package/.agent-src/commands/chat-history.md +7 -2
  13. package/.agent-src/commands/check-current-md.md +137 -0
  14. package/.agent-src/commands/commit-in-chunks.md +118 -0
  15. package/.agent-src/commands/commit.md +4 -0
  16. package/.agent-src/commands/compress.md +37 -2
  17. package/.agent-src/commands/context-create.md +4 -0
  18. package/.agent-src/commands/context-refactor.md +4 -0
  19. package/.agent-src/commands/copilot-agents-init.md +3 -0
  20. package/.agent-src/commands/copilot-agents-optimize.md +3 -0
  21. package/.agent-src/commands/create-pr-description.md +4 -0
  22. package/.agent-src/commands/create-pr.md +4 -0
  23. package/.agent-src/commands/do-and-judge.md +4 -1
  24. package/.agent-src/commands/do-in-steps.md +3 -0
  25. package/.agent-src/commands/e2e-heal.md +4 -0
  26. package/.agent-src/commands/e2e-plan.md +4 -0
  27. package/.agent-src/commands/estimate-ticket.md +4 -1
  28. package/.agent-src/commands/feature-dev.md +4 -0
  29. package/.agent-src/commands/feature-explore.md +4 -0
  30. package/.agent-src/commands/feature-plan.md +4 -0
  31. package/.agent-src/commands/feature-refactor.md +4 -0
  32. package/.agent-src/commands/feature-roadmap.md +6 -0
  33. package/.agent-src/commands/fix-ci.md +4 -0
  34. package/.agent-src/commands/fix-portability.md +5 -2
  35. package/.agent-src/commands/fix-pr-bot-comments.md +4 -0
  36. package/.agent-src/commands/fix-pr-comments.md +4 -0
  37. package/.agent-src/commands/fix-pr-developer-comments.md +4 -0
  38. package/.agent-src/commands/fix-references.md +3 -0
  39. package/.agent-src/commands/fix-seeder.md +4 -0
  40. package/.agent-src/commands/implement-ticket.md +39 -13
  41. package/.agent-src/commands/jira-ticket.md +4 -0
  42. package/.agent-src/commands/judge.md +3 -0
  43. package/.agent-src/commands/memory-add.md +5 -3
  44. package/.agent-src/commands/memory-full.md +5 -2
  45. package/.agent-src/commands/memory-promote.md +7 -6
  46. package/.agent-src/commands/mode.md +3 -0
  47. package/.agent-src/commands/module-create.md +4 -0
  48. package/.agent-src/commands/module-explore.md +4 -0
  49. package/.agent-src/commands/onboard.md +33 -0
  50. package/.agent-src/commands/optimize-agents.md +4 -0
  51. package/.agent-src/commands/optimize-augmentignore.md +12 -0
  52. package/.agent-src/commands/optimize-rtk-filters.md +3 -0
  53. package/.agent-src/commands/optimize-skills.md +4 -0
  54. package/.agent-src/commands/override-create.md +4 -0
  55. package/.agent-src/commands/override-manage.md +4 -0
  56. package/.agent-src/commands/package-reset.md +3 -0
  57. package/.agent-src/commands/package-test.md +3 -0
  58. package/.agent-src/commands/prepare-for-review.md +4 -0
  59. package/.agent-src/commands/project-analyze.md +4 -0
  60. package/.agent-src/commands/project-health.md +4 -0
  61. package/.agent-src/commands/propose-memory.md +6 -8
  62. package/.agent-src/commands/quality-fix.md +4 -0
  63. package/.agent-src/commands/refine-ticket.md +12 -7
  64. package/.agent-src/commands/review-changes.md +39 -8
  65. package/.agent-src/commands/review-routing.md +4 -0
  66. package/.agent-src/commands/roadmap-create.md +18 -0
  67. package/.agent-src/commands/roadmap-execute.md +14 -1
  68. package/.agent-src/commands/rule-compliance-audit.md +4 -0
  69. package/.agent-src/commands/set-cost-profile.md +11 -0
  70. package/.agent-src/commands/sync-agent-settings.md +12 -0
  71. package/.agent-src/commands/sync-gitignore.md +3 -0
  72. package/.agent-src/commands/tests-create.md +4 -0
  73. package/.agent-src/commands/tests-execute.md +6 -3
  74. package/.agent-src/commands/threat-model.md +4 -0
  75. package/.agent-src/commands/update-form-request-messages.md +4 -0
  76. package/.agent-src/commands/upstream-contribute.md +4 -0
  77. package/.agent-src/commands/work.md +161 -0
  78. package/.agent-src/guidelines/agent-infra/engineering-memory-data-format.md +2 -6
  79. package/.agent-src/guidelines/agent-infra/layered-settings.md +0 -1
  80. package/.agent-src/guidelines/agent-infra/memory-access.md +0 -7
  81. package/.agent-src/guidelines/agent-infra/role-contracts.md +2 -4
  82. package/.agent-src/guidelines/agent-infra/self-improvement-pipeline.md +0 -1
  83. package/.agent-src/guidelines/php/patterns/strategy.md +180 -2
  84. package/.agent-src/personas/README.md +0 -1
  85. package/.agent-src/rules/artifact-drafting-protocol.md +7 -2
  86. package/.agent-src/rules/artifact-engagement-recording.md +133 -0
  87. package/.agent-src/rules/ask-when-uncertain.md +18 -13
  88. package/.agent-src/rules/augment-portability.md +64 -37
  89. package/.agent-src/rules/autonomous-execution.md +158 -0
  90. package/.agent-src/rules/chat-history-cadence.md +109 -0
  91. package/.agent-src/rules/chat-history-ownership.md +123 -0
  92. package/.agent-src/rules/chat-history-visibility.md +96 -0
  93. package/.agent-src/rules/cli-output-handling.md +27 -4
  94. package/.agent-src/rules/command-suggestion.md +134 -0
  95. package/.agent-src/rules/commit-policy.md +109 -0
  96. package/.agent-src/rules/direct-answers.md +114 -0
  97. package/.agent-src/rules/docs-sync.md +36 -0
  98. package/.agent-src/rules/downstream-changes.md +10 -9
  99. package/.agent-src/rules/improve-before-implement.md +9 -6
  100. package/.agent-src/rules/language-and-tone.md +85 -6
  101. package/.agent-src/rules/non-destructive-by-default.md +117 -0
  102. package/.agent-src/rules/package-ci-checks.md +4 -0
  103. package/.agent-src/rules/preservation-guard.md +20 -0
  104. package/.agent-src/rules/roadmap-progress-sync.md +159 -27
  105. package/.agent-src/rules/role-mode-adherence.md +1 -1
  106. package/.agent-src/rules/scope-control.md +42 -1
  107. package/.agent-src/rules/size-enforcement.md +2 -3
  108. package/.agent-src/rules/skill-quality.md +3 -8
  109. package/.agent-src/rules/ui-audit-before-build.md +106 -0
  110. package/.agent-src/rules/user-interaction.md +107 -51
  111. package/.agent-src/scripts/update_roadmap_progress.py +73 -9
  112. package/.agent-src/skills/blade-ui/SKILL.md +47 -3
  113. package/.agent-src/skills/command-routing/SKILL.md +32 -0
  114. package/.agent-src/skills/command-writing/SKILL.md +52 -2
  115. package/.agent-src/skills/description-assist/SKILL.md +21 -0
  116. package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
  117. package/.agent-src/skills/existing-ui-audit/SKILL.md +202 -0
  118. package/.agent-src/skills/fe-design/SKILL.md +78 -61
  119. package/.agent-src/skills/file-editor/SKILL.md +9 -0
  120. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
  121. package/.agent-src/skills/flux/SKILL.md +31 -4
  122. package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
  123. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
  124. package/.agent-src/skills/livewire/SKILL.md +49 -4
  125. package/.agent-src/skills/md-language-check/SKILL.md +103 -0
  126. package/.agent-src/skills/php-coder/SKILL.md +24 -0
  127. package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
  128. package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
  129. package/.agent-src/skills/refine-ticket/SKILL.md +32 -28
  130. package/.agent-src/skills/roadmap-management/SKILL.md +24 -11
  131. package/.agent-src/skills/rule-writing/SKILL.md +23 -1
  132. package/.agent-src/skills/skill-writing/SKILL.md +3 -5
  133. package/.agent-src/skills/upstream-contribute/SKILL.md +3 -3
  134. package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
  135. package/.agent-src/templates/AGENTS.md +24 -6
  136. package/.agent-src/templates/agent-settings.md +149 -0
  137. package/.agent-src/templates/roadmaps.md +11 -4
  138. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  139. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  140. package/.agent-src/templates/scripts/memory_lookup.py +1 -1
  141. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  142. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  143. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  144. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  145. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  146. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  147. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  148. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  149. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  150. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  151. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  152. package/.agent-src/templates/scripts/work_engine/cli.py +195 -0
  153. package/.agent-src/templates/scripts/work_engine/cli_args.py +116 -0
  154. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +10 -3
  155. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  156. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +3 -3
  159. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +2 -2
  160. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +2 -2
  161. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  162. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +37 -5
  163. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  164. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  165. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  176. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  177. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  178. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  179. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  180. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  181. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  182. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  183. package/.agent-src/templates/scripts/work_engine/emitters.py +43 -0
  184. package/.agent-src/templates/scripts/work_engine/errors.py +19 -0
  185. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +76 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  194. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  195. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  196. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  197. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  198. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  199. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  200. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  201. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  202. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  203. package/.agent-src/templates/scripts/work_engine/input_builders.py +163 -0
  204. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  205. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  206. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  207. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +231 -0
  208. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +1 -1
  209. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  210. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  211. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  212. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  213. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  214. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  215. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  216. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  217. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  218. package/.agent-src/templates/scripts/work_engine/state_io.py +202 -0
  219. package/.claude-plugin/marketplace.json +105 -2
  220. package/AGENTS.md +38 -8
  221. package/CHANGELOG.md +609 -0
  222. package/README.md +136 -14
  223. package/config/agent-settings.template.yml +45 -0
  224. package/config/gitignore-block.txt +4 -0
  225. package/docs/MIGRATION.md +122 -0
  226. package/docs/architecture.md +111 -35
  227. package/docs/contracts/STABILITY.md +95 -0
  228. package/docs/contracts/adr-chat-history-split.md +132 -0
  229. package/docs/contracts/adr-command-suggestion.md +146 -0
  230. package/docs/contracts/adr-implement-ticket-runtime.md +122 -0
  231. package/docs/contracts/adr-product-ui-track.md +384 -0
  232. package/docs/contracts/adr-prompt-driven-execution.md +187 -0
  233. package/docs/contracts/agent-memory-contract.md +149 -0
  234. package/docs/contracts/artifact-engagement-flow.md +262 -0
  235. package/docs/contracts/command-clusters.md +126 -0
  236. package/docs/contracts/command-suggestion-flow.md +148 -0
  237. package/docs/contracts/implement-ticket-flow.md +628 -0
  238. package/docs/contracts/linear-ai-rules-inclusion.md +143 -0
  239. package/docs/contracts/linear-ai-three-layers.md +131 -0
  240. package/docs/contracts/rule-interactions.md +107 -0
  241. package/docs/contracts/rule-interactions.yml +142 -0
  242. package/docs/contracts/ui-stack-extension.md +236 -0
  243. package/docs/contracts/ui-track-flow.md +338 -0
  244. package/docs/development.md +1 -1
  245. package/docs/getting-started.md +3 -3
  246. package/docs/installation.md +124 -2
  247. package/docs/migrations/commands-1.15.0.md +112 -0
  248. package/docs/showcase.md +204 -0
  249. package/docs/ui-track-mental-model.md +121 -0
  250. package/package.json +1 -1
  251. package/scripts/agent-config +199 -0
  252. package/scripts/audit_cloud_compatibility.py +288 -0
  253. package/scripts/build_cloud_bundle.py +458 -0
  254. package/scripts/build_linear_digest.py +263 -0
  255. package/scripts/chat_history.py +796 -7
  256. package/scripts/check_compression.py +139 -0
  257. package/scripts/check_iron_law_prominence.py +143 -0
  258. package/scripts/check_md_language.py +159 -0
  259. package/scripts/check_portability.py +38 -0
  260. package/scripts/check_public_links.py +185 -0
  261. package/scripts/check_references.py +1 -0
  262. package/scripts/check_reply_consistency.py +140 -0
  263. package/scripts/command_suggester/__init__.py +51 -0
  264. package/scripts/command_suggester/cooldown.py +132 -0
  265. package/scripts/command_suggester/loader.py +70 -0
  266. package/scripts/command_suggester/match.py +180 -0
  267. package/scripts/command_suggester/rank.py +120 -0
  268. package/scripts/command_suggester/render.py +86 -0
  269. package/scripts/command_suggester/sanitize.py +113 -0
  270. package/scripts/command_suggester/settings.py +125 -0
  271. package/scripts/command_suggester/types.py +78 -0
  272. package/scripts/hooks/augment-chat-history.sh +56 -0
  273. package/scripts/install-hooks.sh +67 -0
  274. package/scripts/install.py +150 -33
  275. package/scripts/lint_marketplace.py +27 -0
  276. package/scripts/lint_no_new_atomic_commands.py +179 -0
  277. package/scripts/lint_rule_interactions.py +149 -0
  278. package/scripts/memory_lookup.py +1 -1
  279. package/scripts/migrate_command_suggestions.py +151 -0
  280. package/scripts/release.py +297 -64
  281. package/scripts/schemas/command.schema.json +41 -0
  282. package/scripts/skill_linter.py +81 -0
  283. package/scripts/sync_agent_settings.py +42 -12
  284. package/scripts/update_counts.py +10 -0
  285. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  286. package/templates/consumer-settings/claude-settings.json +55 -1
  287. package/.agent-src/rules/chat-history.md +0 -171
  288. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  289. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  290. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  291. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
@@ -0,0 +1,331 @@
1
+ """Linear step dispatcher for ``/implement-ticket``.
2
+
3
+ The dispatcher holds no business logic. It walks the fixed eight-step
4
+ order declared in ``docs/contracts/implement-ticket-flow.md``, hands
5
+ each step a live ``DeliveryState``, and honours the three terminal
6
+ outcomes:
7
+
8
+ - ``SUCCESS`` — record and advance.
9
+ - ``BLOCKED`` — record, copy questions onto the state, halt.
10
+ - ``PARTIAL`` — record, copy questions onto the state, halt.
11
+
12
+ Resumption semantics (Option A, flow contract §agent-directives):
13
+ steps whose name is already marked ``success`` in
14
+ ``state.outcomes`` are **skipped**. This lets a caller re-invoke the
15
+ dispatcher after executing an agent-directive (the ``implement``,
16
+ ``test``, ``verify`` steps cannot run from pure Python), update the
17
+ relevant slice of ``DeliveryState``, record ``success`` on the
18
+ resumed step, and continue without replaying earlier work.
19
+
20
+ Step handlers are injected by the caller rather than discovered at
21
+ import time. Phase 1 shipped the dispatcher with mock handlers;
22
+ Phase 2 wires the real ones under ``steps/``. Keeping injection
23
+ explicit means the dispatcher is trivially testable and never
24
+ depends on handler import order.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Mapping
29
+ from importlib import import_module
30
+ from typing import Any
31
+
32
+ from .delivery_state import DeliveryState, Outcome, Step, StepResult
33
+ from .hooks import HookContext, HookEvent, HookHalt, HookRunner
34
+ from .state import KNOWN_DIRECTIVE_SETS
35
+
36
+ _NOOP_RUNNER: HookRunner = HookRunner()
37
+ """Shared empty-registry runner reused when ``dispatch`` is called
38
+ without an explicit ``hooks`` argument. ``HookRunner.emit`` short-circuits
39
+ when no callbacks are registered, so the hot path stays branch-light
40
+ while the call sites stay uniform."""
41
+
42
+ STEP_ORDER: tuple[str, ...] = (
43
+ "refine",
44
+ "memory",
45
+ "analyze",
46
+ "plan",
47
+ "implement",
48
+ "test",
49
+ "verify",
50
+ "report",
51
+ )
52
+ """Canonical execution order. Eight steps, fixed, no branching.
53
+
54
+ Changing this order is a roadmap-level decision — not a PR rider — per
55
+ the surface-growth guardrails in
56
+ ``agents/roadmaps/road-to-implement-ticket.md``.
57
+ """
58
+
59
+ DEFAULT_DIRECTIVE_SET: str = "backend"
60
+ """Directive set chosen when ``state`` does not carry one explicitly.
61
+
62
+ Backwards compatibility for v0 ``DeliveryState`` callers: the legacy
63
+ shape has no ``directive_set`` field, so ``select_directive_set``
64
+ falls back to ``"backend"`` and the engine behaves exactly as it did
65
+ before R1 Phase 4.
66
+ """
67
+
68
+ # Schema enum names use hyphens (``ui-trivial``) but Python packages
69
+ # cannot. The loader is the single place that bridges between the two
70
+ # forms; everywhere else uses the wire form.
71
+ _PACKAGE_NAME_OVERRIDES: Mapping[str, str] = {"ui-trivial": "ui_trivial"}
72
+
73
+
74
+ def dispatch(
75
+ state: DeliveryState,
76
+ steps: Mapping[str, Step],
77
+ hooks: HookRunner | None = None,
78
+ ) -> tuple[Outcome, str | None]:
79
+ """Run the eight steps linearly against ``state``.
80
+
81
+ Returns a ``(final_outcome, halting_step)`` tuple. ``halting_step``
82
+ is ``None`` when every step succeeded; otherwise it carries the
83
+ name of the step whose result halted the flow.
84
+
85
+ Parameters
86
+ ----------
87
+ state:
88
+ Live ``DeliveryState``. Mutated in place: each step's outcome
89
+ is recorded in ``state.outcomes`` under the step name, and
90
+ any surfaced questions land on ``state.questions``.
91
+ steps:
92
+ Mapping from step name to handler. Every entry in
93
+ :data:`STEP_ORDER` must be present; missing entries raise
94
+ ``KeyError`` at dispatch time rather than silently skipping,
95
+ so incomplete wiring surfaces as a hard failure.
96
+ hooks:
97
+ Optional :class:`HookRunner` carrying a registry of dispatcher-
98
+ layer hooks (``before_step``, ``after_step``, ``on_halt``,
99
+ ``on_error``). Default ``None`` preserves every existing call
100
+ site verbatim — internally ``dispatch`` falls back to a shared
101
+ empty-registry runner so hook bookkeeping stays uniform without
102
+ a per-emit ``if hooks is None`` branch.
103
+
104
+ Raises
105
+ ------
106
+ KeyError
107
+ If ``steps`` does not cover every entry in
108
+ :data:`STEP_ORDER`.
109
+ """
110
+ _assert_all_steps_present(steps)
111
+
112
+ # Clear stale questions from a previous halt before we resume so
113
+ # the caller never mistakes old options for fresh ones.
114
+ state.questions = []
115
+
116
+ runner = hooks if hooks is not None else _NOOP_RUNNER
117
+
118
+ for name in STEP_ORDER:
119
+ if state.outcomes.get(name) == Outcome.SUCCESS.value:
120
+ # Already completed on an earlier invocation — skip per the
121
+ # resume contract. The caller is responsible for keeping
122
+ # ``state.outcomes`` and the matching slice in sync.
123
+ continue
124
+
125
+ before_halt = runner.emit(
126
+ HookEvent.BEFORE_STEP,
127
+ HookContext(step_name=name, delivery=state),
128
+ )
129
+ if before_halt is not None:
130
+ return _hook_halt_blocked(state, runner, name, before_halt, result=None)
131
+
132
+ handler = steps[name]
133
+ try:
134
+ result = handler(state)
135
+ except Exception as exc:
136
+ # Let dispatcher-layer observers see the failure before the
137
+ # exception unwinds the engine. ``on_error`` is observe-only;
138
+ # the original exception is always re-raised.
139
+ runner.emit(
140
+ HookEvent.ON_ERROR,
141
+ HookContext(step_name=name, delivery=state, exception=exc),
142
+ )
143
+ raise
144
+ _validate_step_result(name, result)
145
+
146
+ state.outcomes[name] = result.outcome.value
147
+
148
+ after_halt = runner.emit(
149
+ HookEvent.AFTER_STEP,
150
+ HookContext(step_name=name, delivery=state, result=result),
151
+ )
152
+ if after_halt is not None:
153
+ return _hook_halt_blocked(state, runner, name, after_halt, result=result)
154
+
155
+ if result.outcome is Outcome.BLOCKED:
156
+ state.questions = list(result.questions)
157
+ _emit_on_halt(runner, name, state, result)
158
+ return Outcome.BLOCKED, name
159
+
160
+ if result.outcome is Outcome.PARTIAL:
161
+ state.questions = list(result.questions)
162
+ _emit_on_halt(runner, name, state, result)
163
+ return Outcome.PARTIAL, name
164
+
165
+ return Outcome.SUCCESS, None
166
+
167
+
168
+ def _hook_halt_blocked(
169
+ state: DeliveryState,
170
+ runner: HookRunner,
171
+ name: str,
172
+ halt: HookHalt,
173
+ result: StepResult | None,
174
+ ) -> tuple[Outcome, str | None]:
175
+ """Translate a hook-driven :class:`HookHalt` into a clean engine halt.
176
+
177
+ Hook-driven halts are treated as first-class engine halts per the
178
+ P2 contract: the dispatcher returns ``(BLOCKED, step_name)`` with
179
+ ``state.questions`` rendered verbatim from the halt's ``surface``.
180
+ The step's outcome marker is set to ``"blocked"`` only when the
181
+ halt fires before the handler ran (so resume re-enters the gate);
182
+ when it fires after the handler, the marker the handler produced
183
+ is preserved so resume reflects what actually happened.
184
+ """
185
+ if result is None:
186
+ state.outcomes[name] = Outcome.BLOCKED.value
187
+ state.questions = list(halt.surface)
188
+ _emit_on_halt(runner, name, state, result)
189
+ return Outcome.BLOCKED, name
190
+
191
+
192
+ def _emit_on_halt(
193
+ runner: HookRunner,
194
+ name: str,
195
+ state: DeliveryState,
196
+ result: StepResult | None,
197
+ ) -> None:
198
+ """Fire ``on_halt`` as an observe-only event.
199
+
200
+ A :class:`HookHalt` raised from inside ``on_halt`` would create a
201
+ halt-of-a-halt loop; the runner returns it but the dispatcher
202
+ deliberately ignores it — the halt surface is already populated.
203
+ """
204
+ runner.emit(
205
+ HookEvent.ON_HALT,
206
+ HookContext(step_name=name, delivery=state, result=result),
207
+ )
208
+
209
+
210
+ def _assert_all_steps_present(steps: Mapping[str, Step]) -> None:
211
+ """Reject an incomplete step mapping up front.
212
+
213
+ We deliberately fail loudly here: a missing step would otherwise
214
+ raise deep inside the dispatch loop after partial state mutation,
215
+ which makes debugging the wiring harder than it needs to be.
216
+ """
217
+ missing = [name for name in STEP_ORDER if name not in steps]
218
+ if missing:
219
+ raise KeyError(
220
+ "Step mapping is missing handlers for: " + ", ".join(missing),
221
+ )
222
+
223
+
224
+ def _validate_step_result(name: str, result: StepResult) -> None:
225
+ """Enforce the blocked/partial invariant: questions must be set.
226
+
227
+ A step that blocks without surfacing a question is a bug — there
228
+ is nothing for the user to answer. We raise ``ValueError`` instead
229
+ of silently recording the outcome so the defect is visible at the
230
+ earliest possible point.
231
+ """
232
+ if result.outcome in (Outcome.BLOCKED, Outcome.PARTIAL) and not result.questions:
233
+ raise ValueError(
234
+ f"Step {name!r} returned {result.outcome.value} with no questions; "
235
+ "blocked and partial outcomes must surface at least one numbered option.",
236
+ )
237
+
238
+
239
+ def select_directive_set(state: Any) -> str:
240
+ """Return the directive set name to dispatch ``state`` against.
241
+
242
+ Looks for ``state.directive_set`` (the v1 :class:`work_engine.state.WorkState`
243
+ field) and falls back to :data:`DEFAULT_DIRECTIVE_SET` when the
244
+ attribute is missing — the legacy v0 :class:`DeliveryState` has no
245
+ such field, and existing callers must keep working unchanged
246
+ until R1 Phase 4 Step 1 lands the runtime switch.
247
+
248
+ The returned name is validated against :data:`KNOWN_DIRECTIVE_SETS`;
249
+ an unknown value raises ``ValueError`` rather than silently
250
+ falling back, so a typo in a hand-written state file fails loudly
251
+ instead of producing surprising behavior.
252
+ """
253
+ name = getattr(state, "directive_set", DEFAULT_DIRECTIVE_SET)
254
+ if not isinstance(name, str) or not name:
255
+ raise ValueError(
256
+ f"directive_set must be a non-empty string; got {name!r}",
257
+ )
258
+ if name not in KNOWN_DIRECTIVE_SETS:
259
+ raise ValueError(
260
+ f"unknown directive_set {name!r}; "
261
+ f"known sets: {sorted(KNOWN_DIRECTIVE_SETS)}",
262
+ )
263
+ return name
264
+
265
+
266
+ def load_directive_set(name: str) -> Mapping[str, Step]:
267
+ """Import the ``directives.<name>`` package and return its step mapping.
268
+
269
+ The selected set's ``__init__`` exposes a ``get_steps()`` factory
270
+ (see :class:`work_engine.directives.backend`) that returns the
271
+ ``{step_name: handler}`` mapping the dispatcher walks. Unimplemented
272
+ sets (``ui``, ``ui-trivial``, ``mixed``) raise
273
+ ``NotImplementedError`` from their ``get_steps()`` so the failure
274
+ point is the loader, not a half-walked dispatch loop.
275
+
276
+ The schema enum carries hyphenated wire names (``ui-trivial``) but
277
+ Python packages must use underscores; :data:`_PACKAGE_NAME_OVERRIDES`
278
+ is the single translation point.
279
+ """
280
+ module = _import_directive_set(name)
281
+ get_steps = getattr(module, "get_steps", None)
282
+ if not callable(get_steps):
283
+ raise AttributeError(
284
+ f"work_engine.directives.{module.__name__.rsplit('.', 1)[-1]} "
285
+ "does not expose a callable get_steps()",
286
+ )
287
+ steps = get_steps()
288
+ if not isinstance(steps, Mapping):
289
+ raise TypeError(
290
+ f"work_engine.directives.{module.__name__.rsplit('.', 1)[-1]}"
291
+ f".get_steps() must return a Mapping; "
292
+ f"got {type(steps).__name__}",
293
+ )
294
+ return steps
295
+
296
+
297
+ def assert_kind_supported(kind: str, set_name: str) -> None:
298
+ """Raise ``NotImplementedError`` if ``set_name`` cannot handle ``kind``.
299
+
300
+ Reads the per-set ``SUPPORTED_KINDS`` tuple (see
301
+ :data:`work_engine.directives.backend.SUPPORTED_KINDS`) and checks
302
+ membership. Distinct from :func:`select_directive_set`, which only
303
+ validates the directive-set *name*: this gate validates the
304
+ name/kind *pair*, so a future schema widening that adds new
305
+ ``input.kind`` values (R2 ``prompt``) halts loudly at the boundary
306
+ instead of crashing inside the first deterministic step.
307
+
308
+ Sets that have no ``SUPPORTED_KINDS`` attribute are treated as
309
+ "supports nothing" — the unimplemented stubs (``ui``,
310
+ ``ui-trivial``, ``mixed``) already raise from ``get_steps()``, so
311
+ this branch only matters during the brief window between adding a
312
+ new directive set and wiring its capability tuple.
313
+ """
314
+ module = _import_directive_set(set_name)
315
+ supported = getattr(module, "SUPPORTED_KINDS", ())
316
+ if kind not in supported:
317
+ raise NotImplementedError(
318
+ f"directive_set {set_name!r} does not handle "
319
+ f"input.kind={kind!r}; supported kinds: {sorted(set(supported))}",
320
+ )
321
+
322
+
323
+ def _import_directive_set(name: str):
324
+ """Validate ``name`` and import the matching package module."""
325
+ if name not in KNOWN_DIRECTIVE_SETS:
326
+ raise ValueError(
327
+ f"unknown directive_set {name!r}; "
328
+ f"known sets: {sorted(KNOWN_DIRECTIVE_SETS)}",
329
+ )
330
+ package_name = _PACKAGE_NAME_OVERRIDES.get(name, name)
331
+ return import_module(f"work_engine.directives.{package_name}")
@@ -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,54 @@
1
+ """``work_engine.hooks`` — cross-cutting lifecycle hooks for the engine.
2
+
3
+ Phase 1 of ``agents/roadmaps/road-to-work-engine-hooks.md`` ships the
4
+ primitives only. The dispatcher and CLI are not yet instrumented;
5
+ golden tests must remain byte-identical until Phase 2 / Phase 3 land.
6
+
7
+ Public surface:
8
+
9
+ - :class:`HookEvent` — ten lifecycle events, two layers.
10
+ - :class:`HookContext` — per-event payload.
11
+ - :class:`HookError` / :class:`HookHalt` — three-tier error contract.
12
+ - :class:`HookRegistry` — insertion-ordered event \u2192 callbacks map.
13
+ - :class:`HookRunner` — single emit point, owns the error contract.
14
+
15
+ The principle is documented in
16
+ ``agents/roadmaps/road-to-work-engine-hooks.md`` § Underlying
17
+ principle: agent hooks are emulated by moving lifecycle ownership
18
+ from the agent into the work engine. The engine owns boundaries.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from .builtin import (
23
+ ChatHistoryAppendHook,
24
+ ChatHistoryHaltAppendHook,
25
+ ChatHistoryHeartbeatHook,
26
+ ChatHistoryTurnCheckHook,
27
+ DirectiveSetGuardHook,
28
+ HaltSurfaceAuditHook,
29
+ StateShapeValidationHook,
30
+ TraceHook,
31
+ )
32
+ from .context import HookContext
33
+ from .events import HookEvent
34
+ from .exceptions import HookError, HookHalt
35
+ from .registry import HookCallback, HookRegistry
36
+ from .runner import HookRunner
37
+
38
+ __all__ = [
39
+ "ChatHistoryAppendHook",
40
+ "ChatHistoryHaltAppendHook",
41
+ "ChatHistoryHeartbeatHook",
42
+ "ChatHistoryTurnCheckHook",
43
+ "DirectiveSetGuardHook",
44
+ "HaltSurfaceAuditHook",
45
+ "HookCallback",
46
+ "HookContext",
47
+ "HookError",
48
+ "HookEvent",
49
+ "HookHalt",
50
+ "HookRegistry",
51
+ "HookRunner",
52
+ "StateShapeValidationHook",
53
+ "TraceHook",
54
+ ]
@@ -0,0 +1,32 @@
1
+ """Concrete observability hooks shipped with the engine.
2
+
3
+ Phase 4 hooks: low-risk, default-off, observe-only. They are registered
4
+ by ``cli._build_hook_registry`` only when explicitly enabled in
5
+ ``.agent-settings.yml`` (Phase 6 wires the settings → registry path).
6
+
7
+ Each hook is a small class exposing a ``register(registry)`` method so
8
+ the registry stays the single source of truth for event → callback
9
+ wiring. None of these hooks mutate engine state; failures surface as
10
+ :class:`HookError` (non-fatal, the runner warns and continues).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from .chat_history_append import ChatHistoryAppendHook
15
+ from .chat_history_halt_append import ChatHistoryHaltAppendHook
16
+ from .chat_history_heartbeat import ChatHistoryHeartbeatHook
17
+ from .chat_history_turn_check import ChatHistoryTurnCheckHook
18
+ from .directive_set_guard import DirectiveSetGuardHook
19
+ from .halt_surface_audit import HaltSurfaceAuditHook
20
+ from .state_shape_validation import StateShapeValidationHook
21
+ from .trace import TraceHook
22
+
23
+ __all__ = [
24
+ "ChatHistoryAppendHook",
25
+ "ChatHistoryHaltAppendHook",
26
+ "ChatHistoryHeartbeatHook",
27
+ "ChatHistoryTurnCheckHook",
28
+ "DirectiveSetGuardHook",
29
+ "HaltSurfaceAuditHook",
30
+ "StateShapeValidationHook",
31
+ "TraceHook",
32
+ ]
@@ -0,0 +1,103 @@
1
+ """Shared plumbing for chat-history hooks.
2
+
3
+ Subprocess-driven so the work-engine package stays decoupled from
4
+ ``scripts/chat_history.py``'s internals. The ``runner`` injection
5
+ point is the test seam — production passes ``subprocess.run``,
6
+ tests pass a fake.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Callable, Sequence
14
+
15
+ from ..context import HookContext
16
+ from ..exceptions import HookError
17
+
18
+ ProcessRunner = Callable[[Sequence[str]], "subprocess.CompletedProcess[str]"]
19
+ """Callable that runs a subprocess. Production default: ``_default_runner``."""
20
+
21
+ EXIT_OK = 0
22
+ EXIT_MISSING = 10
23
+ EXIT_FOREIGN = 11
24
+ EXIT_RETURNING = 12
25
+
26
+
27
+ def _default_runner(cmd: Sequence[str]) -> "subprocess.CompletedProcess[str]":
28
+ return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
29
+
30
+
31
+ def _derive_first_user_msg(ctx: HookContext) -> str | None:
32
+ """Pull a stable first-user-msg out of the available context.
33
+
34
+ CLI-layer events carry ``ctx.work`` (the v1 envelope); dispatcher-layer
35
+ events (``before_step`` / ``after_step`` / ``on_halt``) carry only
36
+ ``ctx.delivery`` (the legacy :class:`DeliveryState`). Both shapes feed
37
+ the same ``id: title`` / ``raw`` derivation so chat-history entries
38
+ stay stable across the lifecycle. Returns ``None`` when the shape is
39
+ unknown — callers raise ``HookError`` so the runner converts it to
40
+ a warning.
41
+ """
42
+ work = ctx.work
43
+ if work is not None and getattr(work, "input", None) is not None:
44
+ inp = work.input
45
+ data = getattr(inp, "data", None) or {}
46
+ kind = getattr(inp, "kind", None)
47
+ if kind == "prompt":
48
+ raw = data.get("raw")
49
+ if raw:
50
+ return str(raw)
51
+ elif kind == "ticket":
52
+ joined = _ticket_msg(data)
53
+ if joined:
54
+ return joined
55
+
56
+ delivery = ctx.delivery
57
+ if delivery is not None:
58
+ ticket = getattr(delivery, "ticket", None) or {}
59
+ joined = _ticket_msg(ticket)
60
+ if joined:
61
+ return joined
62
+ return None
63
+
64
+
65
+ def _ticket_msg(ticket: dict) -> str:
66
+ ticket_id = ticket.get("id") or ""
67
+ title = ticket.get("title") or ""
68
+ return f"{ticket_id}: {title}".strip(": ").strip()
69
+
70
+
71
+ class _ChatHistoryHookBase:
72
+ """Shared plumbing — script path, runner, and first-msg derivation."""
73
+
74
+ def __init__(
75
+ self,
76
+ script_path: Path,
77
+ *,
78
+ runner: ProcessRunner | None = None,
79
+ first_user_msg: str | None = None,
80
+ ) -> None:
81
+ self.script_path = Path(script_path)
82
+ self._runner = runner or _default_runner
83
+ self._fixed_msg = first_user_msg
84
+
85
+ def _resolve_msg(self, ctx: HookContext) -> str:
86
+ msg = self._fixed_msg or _derive_first_user_msg(ctx)
87
+ if not msg:
88
+ raise HookError("chat-history hook: cannot derive first-user-msg")
89
+ return msg
90
+
91
+ def _invoke(self, *args: str) -> "subprocess.CompletedProcess[str]":
92
+ cmd = [sys.executable, str(self.script_path), *args]
93
+ return self._runner(cmd)
94
+
95
+
96
+ __all__ = [
97
+ "EXIT_FOREIGN",
98
+ "EXIT_MISSING",
99
+ "EXIT_OK",
100
+ "EXIT_RETURNING",
101
+ "ProcessRunner",
102
+ "_ChatHistoryHookBase",
103
+ ]