@event4u/agent-config 1.12.0 → 1.14.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 (260) hide show
  1. package/.agent-src/commands/agent-handoff.md +3 -0
  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 +5 -1
  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 +5 -0
  11. package/.agent-src/commands/chat-history-resume.md +5 -0
  12. package/.agent-src/commands/chat-history.md +5 -0
  13. package/.agent-src/commands/check-current-md.md +126 -0
  14. package/.agent-src/commands/commit-in-chunks.md +98 -0
  15. package/.agent-src/commands/commit.md +4 -0
  16. package/.agent-src/commands/compress.md +3 -0
  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 +3 -0
  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 +24 -0
  50. package/.agent-src/commands/optimize-agents.md +4 -0
  51. package/.agent-src/commands/optimize-augmentignore.md +3 -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 +4 -1
  64. package/.agent-src/commands/review-changes.md +4 -0
  65. package/.agent-src/commands/review-routing.md +4 -0
  66. package/.agent-src/commands/roadmap-create.md +7 -0
  67. package/.agent-src/commands/roadmap-execute.md +12 -1
  68. package/.agent-src/commands/rule-compliance-audit.md +4 -0
  69. package/.agent-src/commands/set-cost-profile.md +3 -0
  70. package/.agent-src/commands/sync-agent-settings.md +3 -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 +4 -0
  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 +8 -0
  89. package/.agent-src/rules/autonomous-execution.md +158 -0
  90. package/.agent-src/rules/chat-history.md +147 -118
  91. package/.agent-src/rules/cli-output-handling.md +26 -3
  92. package/.agent-src/rules/command-suggestion.md +133 -0
  93. package/.agent-src/rules/commit-policy.md +99 -0
  94. package/.agent-src/rules/direct-answers.md +114 -0
  95. package/.agent-src/rules/docs-sync.md +36 -0
  96. package/.agent-src/rules/downstream-changes.md +10 -9
  97. package/.agent-src/rules/improve-before-implement.md +9 -6
  98. package/.agent-src/rules/language-and-tone.md +81 -6
  99. package/.agent-src/rules/non-destructive-by-default.md +117 -0
  100. package/.agent-src/rules/package-ci-checks.md +4 -0
  101. package/.agent-src/rules/preservation-guard.md +20 -0
  102. package/.agent-src/rules/roadmap-progress-sync.md +103 -30
  103. package/.agent-src/rules/scope-control.md +42 -1
  104. package/.agent-src/rules/size-enforcement.md +1 -3
  105. package/.agent-src/rules/skill-quality.md +3 -8
  106. package/.agent-src/rules/ui-audit-before-build.md +106 -0
  107. package/.agent-src/rules/user-interaction.md +81 -3
  108. package/.agent-src/scripts/update_roadmap_progress.py +48 -6
  109. package/.agent-src/skills/blade-ui/SKILL.md +30 -5
  110. package/.agent-src/skills/command-routing/SKILL.md +32 -0
  111. package/.agent-src/skills/command-writing/SKILL.md +41 -2
  112. package/.agent-src/skills/description-assist/SKILL.md +21 -0
  113. package/.agent-src/skills/estimate-ticket/SKILL.md +0 -1
  114. package/.agent-src/skills/existing-ui-audit/SKILL.md +187 -0
  115. package/.agent-src/skills/fe-design/SKILL.md +72 -60
  116. package/.agent-src/skills/finishing-a-development-branch/SKILL.md +4 -0
  117. package/.agent-src/skills/flux/SKILL.md +31 -4
  118. package/.agent-src/skills/guideline-writing/SKILL.md +24 -2
  119. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +51 -9
  120. package/.agent-src/skills/livewire/SKILL.md +30 -4
  121. package/.agent-src/skills/md-language-check/SKILL.md +103 -0
  122. package/.agent-src/skills/php-coder/SKILL.md +24 -0
  123. package/.agent-src/skills/react-shadcn-ui/SKILL.md +121 -0
  124. package/.agent-src/skills/refine-prompt/SKILL.md +220 -0
  125. package/.agent-src/skills/refine-ticket/SKILL.md +2 -4
  126. package/.agent-src/skills/roadmap-management/SKILL.md +10 -3
  127. package/.agent-src/skills/rule-writing/SKILL.md +23 -1
  128. package/.agent-src/skills/skill-writing/SKILL.md +1 -3
  129. package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
  130. package/.agent-src/skills/using-git-worktrees/SKILL.md +3 -1
  131. package/.agent-src/templates/AGENTS.md +24 -6
  132. package/.agent-src/templates/agent-settings.md +149 -0
  133. package/.agent-src/templates/github-workflows/roadmap-progress-check.yml +63 -0
  134. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +60 -0
  135. package/.agent-src/templates/roadmaps.md +8 -2
  136. package/.agent-src/templates/scripts/implement_ticket/__init__.py +63 -26
  137. package/.agent-src/templates/scripts/implement_ticket/__main__.py +8 -2
  138. package/.agent-src/templates/scripts/memory_lookup.py +382 -21
  139. package/.agent-src/templates/scripts/memory_status.py +110 -9
  140. package/.agent-src/templates/scripts/telemetry/__init__.py +42 -0
  141. package/.agent-src/templates/scripts/telemetry/aggregator.py +154 -0
  142. package/.agent-src/templates/scripts/telemetry/boundary.py +171 -0
  143. package/.agent-src/templates/scripts/telemetry/engagement.py +238 -0
  144. package/.agent-src/templates/scripts/telemetry/report_renderer.py +170 -0
  145. package/.agent-src/templates/scripts/telemetry/settings.py +112 -0
  146. package/.agent-src/templates/scripts/telemetry_record.py +166 -0
  147. package/.agent-src/templates/scripts/telemetry_report.py +161 -0
  148. package/.agent-src/templates/scripts/telemetry_status.py +142 -0
  149. package/.agent-src/templates/scripts/work_engine/__init__.py +58 -0
  150. package/.agent-src/templates/scripts/work_engine/__main__.py +9 -0
  151. package/.agent-src/templates/scripts/work_engine/cli.py +592 -0
  152. package/.agent-src/templates/scripts/{implement_ticket → work_engine}/delivery_state.py +7 -0
  153. package/.agent-src/templates/scripts/work_engine/directives/__init__.py +33 -0
  154. package/.agent-src/templates/scripts/work_engine/directives/backend/__init__.py +98 -0
  155. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/analyze.py +1 -1
  156. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/implement.py +2 -2
  157. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/memory.py +1 -1
  158. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/plan.py +1 -1
  159. package/.agent-src/templates/scripts/work_engine/directives/backend/refine.py +396 -0
  160. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/report.py +36 -4
  161. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/test.py +2 -2
  162. package/.agent-src/templates/scripts/{implement_ticket/steps → work_engine/directives/backend}/verify.py +2 -2
  163. package/.agent-src/templates/scripts/work_engine/directives/mixed/__init__.py +116 -0
  164. package/.agent-src/templates/scripts/work_engine/directives/mixed/contract.py +254 -0
  165. package/.agent-src/templates/scripts/work_engine/directives/mixed/stitch.py +229 -0
  166. package/.agent-src/templates/scripts/work_engine/directives/mixed/ui.py +231 -0
  167. package/.agent-src/templates/scripts/work_engine/directives/ui/__init__.py +113 -0
  168. package/.agent-src/templates/scripts/work_engine/directives/ui/_passthrough.py +44 -0
  169. package/.agent-src/templates/scripts/work_engine/directives/ui/apply.py +241 -0
  170. package/.agent-src/templates/scripts/work_engine/directives/ui/audit.py +414 -0
  171. package/.agent-src/templates/scripts/work_engine/directives/ui/design.py +335 -0
  172. package/.agent-src/templates/scripts/work_engine/directives/ui/polish.py +510 -0
  173. package/.agent-src/templates/scripts/work_engine/directives/ui/review.py +468 -0
  174. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/__init__.py +119 -0
  175. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.py +37 -0
  176. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.py +165 -0
  177. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.py +66 -0
  178. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/report.py +62 -0
  179. package/.agent-src/templates/scripts/work_engine/directives/ui_trivial/test.py +115 -0
  180. package/.agent-src/templates/scripts/work_engine/dispatcher.py +331 -0
  181. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +54 -0
  182. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +32 -0
  183. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +103 -0
  184. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +44 -0
  185. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +42 -0
  186. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +50 -0
  187. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +49 -0
  188. package/.agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.py +53 -0
  189. package/.agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.py +50 -0
  190. package/.agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.py +52 -0
  191. package/.agent-src/templates/scripts/work_engine/hooks/builtin/trace.py +84 -0
  192. package/.agent-src/templates/scripts/work_engine/hooks/context.py +66 -0
  193. package/.agent-src/templates/scripts/work_engine/hooks/events.py +44 -0
  194. package/.agent-src/templates/scripts/work_engine/hooks/exceptions.py +79 -0
  195. package/.agent-src/templates/scripts/work_engine/hooks/registry.py +60 -0
  196. package/.agent-src/templates/scripts/work_engine/hooks/runner.py +73 -0
  197. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +141 -0
  198. package/.agent-src/templates/scripts/work_engine/intent/__init__.py +47 -0
  199. package/.agent-src/templates/scripts/work_engine/intent/classify.py +280 -0
  200. package/.agent-src/templates/scripts/work_engine/migration/__init__.py +8 -0
  201. package/.agent-src/templates/scripts/work_engine/migration/v0_to_v1.py +199 -0
  202. package/.agent-src/templates/scripts/work_engine/resolvers/__init__.py +22 -0
  203. package/.agent-src/templates/scripts/work_engine/resolvers/diff.py +106 -0
  204. package/.agent-src/templates/scripts/work_engine/resolvers/file.py +113 -0
  205. package/.agent-src/templates/scripts/work_engine/resolvers/prompt.py +90 -0
  206. package/.agent-src/templates/scripts/work_engine/scoring/__init__.py +14 -0
  207. package/.agent-src/templates/scripts/work_engine/scoring/confidence.py +300 -0
  208. package/.agent-src/templates/scripts/work_engine/stack/__init__.py +31 -0
  209. package/.agent-src/templates/scripts/work_engine/stack/detect.py +187 -0
  210. package/.agent-src/templates/scripts/work_engine/state.py +641 -0
  211. package/.claude-plugin/marketplace.json +105 -2
  212. package/AGENTS.md +36 -8
  213. package/CHANGELOG.md +558 -0
  214. package/README.md +146 -4
  215. package/composer.json +3 -0
  216. package/config/agent-settings.template.yml +45 -0
  217. package/config/gitignore-block.txt +4 -0
  218. package/docs/architecture.md +28 -1
  219. package/docs/development.md +1 -1
  220. package/docs/getting-started.md +3 -2
  221. package/docs/installation.md +86 -0
  222. package/docs/showcase.md +204 -0
  223. package/package.json +9 -1
  224. package/scripts/agent-config +274 -0
  225. package/scripts/audit_cloud_compatibility.py +288 -0
  226. package/scripts/build_cloud_bundle.py +458 -0
  227. package/scripts/build_linear_digest.py +263 -0
  228. package/scripts/chat_history.py +796 -7
  229. package/scripts/check_compression.py +139 -0
  230. package/scripts/check_iron_law_prominence.py +143 -0
  231. package/scripts/check_md_language.py +159 -0
  232. package/scripts/check_portability.py +36 -0
  233. package/scripts/check_reply_consistency.py +140 -0
  234. package/scripts/command_suggester/__init__.py +51 -0
  235. package/scripts/command_suggester/cooldown.py +132 -0
  236. package/scripts/command_suggester/loader.py +70 -0
  237. package/scripts/command_suggester/match.py +180 -0
  238. package/scripts/command_suggester/rank.py +120 -0
  239. package/scripts/command_suggester/render.py +86 -0
  240. package/scripts/command_suggester/sanitize.py +113 -0
  241. package/scripts/command_suggester/settings.py +125 -0
  242. package/scripts/command_suggester/types.py +78 -0
  243. package/scripts/hooks/augment-chat-history.sh +56 -0
  244. package/scripts/install-hooks.sh +67 -0
  245. package/scripts/install.py +150 -33
  246. package/scripts/lint_marketplace.py +27 -0
  247. package/scripts/memory_lookup.py +143 -7
  248. package/scripts/memory_status.py +76 -14
  249. package/scripts/migrate_command_suggestions.py +151 -0
  250. package/scripts/postinstall.sh +16 -0
  251. package/scripts/schemas/command.schema.json +41 -0
  252. package/scripts/skill_linter.py +67 -0
  253. package/scripts/sync_agent_settings.py +42 -12
  254. package/templates/consumer-settings/augment-cli-hooks.json +54 -0
  255. package/templates/consumer-settings/claude-settings.json +55 -1
  256. package/.agent-src/templates/scripts/implement_ticket/cli.py +0 -171
  257. package/.agent-src/templates/scripts/implement_ticket/dispatcher.py +0 -134
  258. package/.agent-src/templates/scripts/implement_ticket/steps/__init__.py +0 -49
  259. package/.agent-src/templates/scripts/implement_ticket/steps/refine.py +0 -140
  260. /package/.agent-src/templates/scripts/{implement_ticket → work_engine}/persona_policy.py +0 -0
@@ -17,6 +17,7 @@ Usage:
17
17
  python3 scripts/chat_history.py init --first-user-msg "..." [--freq per_phase]
18
18
  python3 scripts/chat_history.py append --type phase --json '{...}'
19
19
  python3 scripts/chat_history.py status
20
+ python3 scripts/chat_history.py heartbeat --first-user-msg "..."
20
21
  python3 scripts/chat_history.py check --first-user-msg "..."
21
22
  python3 scripts/chat_history.py state --first-user-msg "..."
22
23
  python3 scripts/chat_history.py adopt --first-user-msg "..."
@@ -41,12 +42,34 @@ from pathlib import Path
41
42
  from typing import Any
42
43
 
43
44
  DEFAULT_FILE = ".agent-chat-history"
45
+ DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
44
46
  SCHEMA_VERSION = 2
45
47
  FORMER_FPS_CAP = 10
46
48
  VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
47
49
  VALID_OVERFLOW = {"rotate", "compress"}
48
50
  _WS_RE = re.compile(r"\s+")
49
51
 
52
+ # Exit codes for the CLI. Distinct codes let shell callers branch on state.
53
+ EXIT_OK = 0
54
+ EXIT_BAD_ARGS = 2
55
+ EXIT_OWNERSHIP_REFUSED = 3
56
+ EXIT_MISSING = 10
57
+ EXIT_FOREIGN = 11
58
+ EXIT_RETURNING = 12
59
+
60
+
61
+ class OwnershipError(RuntimeError):
62
+ """Raised when an operation is rejected because the caller's session
63
+ does not own the chat-history file. `state` is one of
64
+ `foreign` | `returning` | `missing`."""
65
+
66
+ def __init__(self, state: str, *, header_fp: str = "",
67
+ current_fp: str = "") -> None:
68
+ super().__init__(f"chat-history ownership refused: state={state}")
69
+ self.state = state
70
+ self.header_fp = header_fp
71
+ self.current_fp = current_fp
72
+
50
73
 
51
74
  def file_path() -> Path:
52
75
  return Path(os.environ.get("AGENT_CHAT_HISTORY_FILE") or DEFAULT_FILE)
@@ -119,14 +142,32 @@ def init(first_user_msg: str, freq: str = "per_phase", *,
119
142
  return header
120
143
 
121
144
 
122
- def append(entry: dict[str, Any], *, path: Path | None = None) -> None:
123
- """Append one entry. Entry must be a dict; `ts` is auto-filled."""
145
+ def append(entry: dict[str, Any], *, path: Path | None = None,
146
+ first_user_msg: str | None = None) -> None:
147
+ """Append one entry. Entry must be a dict; `ts` is auto-filled.
148
+
149
+ When `first_user_msg` is provided, the call validates ownership
150
+ before writing: only `state == match` proceeds. Any other state
151
+ (`foreign`, `returning`, `missing`) raises `OwnershipError`. This
152
+ is the second line of defense against silent writes to a foreign
153
+ session's file. Existing callers without `first_user_msg` keep the
154
+ legacy unguarded behavior for back-compat.
155
+ """
124
156
  if not isinstance(entry, dict) or not entry.get("t"):
125
157
  raise ValueError("entry must be a dict with non-empty 't' key")
126
158
  if entry["t"] == "header":
127
159
  raise ValueError("use init() to write the header, not append()")
128
- entry.setdefault("ts", _now())
129
160
  p = path or file_path()
161
+ if first_user_msg is not None:
162
+ state = ownership_state(first_user_msg, path=p)
163
+ if state != "match":
164
+ header = read_header(p) or {}
165
+ raise OwnershipError(
166
+ state,
167
+ header_fp=str(header.get("fp", "")),
168
+ current_fp=fingerprint(first_user_msg),
169
+ )
170
+ entry.setdefault("ts", _now())
130
171
  with p.open("a", encoding="utf-8") as fh:
131
172
  fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
132
173
 
@@ -334,6 +375,346 @@ def status(*, path: Path | None = None) -> dict[str, Any]:
334
375
  }
335
376
 
336
377
 
378
+ def _read_chat_history_enabled(settings_path: Path) -> bool:
379
+ """Read chat_history.enabled from .agent-settings.yml.
380
+
381
+ Returns False when the file is missing, malformed, lacks the
382
+ `chat_history` section, or sets enabled to false. Default-deny so
383
+ `turn-check` is safe to run from projects that have not opted in.
384
+ PyYAML is imported lazily — the rest of this module works without it.
385
+ """
386
+ if not settings_path.is_file():
387
+ return False
388
+ try:
389
+ import yaml # type: ignore[import-untyped]
390
+ except ImportError:
391
+ return True # fail open: settings file present but no parser
392
+ try:
393
+ with settings_path.open(encoding="utf-8") as fh:
394
+ data = yaml.safe_load(fh) or {}
395
+ except (OSError, yaml.YAMLError):
396
+ return False
397
+ section = data.get("chat_history") if isinstance(data, dict) else None
398
+ if not isinstance(section, dict):
399
+ return False
400
+ return bool(section.get("enabled", False))
401
+
402
+
403
+ VALID_HEARTBEAT_MODES = ("on", "off", "hybrid")
404
+ DRIFT_STATES = ("missing", "foreign", "returning")
405
+
406
+ # Hook events that the platform-hook wrapper accepts. Mapped to entry
407
+ # types in HOOK_EVENT_ENTRY_TYPE; cadence filtering in
408
+ # CADENCE_EVENTS decides whether the event actually lands in the log
409
+ # given chat_history.frequency.
410
+ VALID_HOOK_EVENTS = (
411
+ "session_start", "session_end", "user_prompt", "agent_response",
412
+ "tool_use", "phase", "stop",
413
+ )
414
+ HOOK_EVENT_ENTRY_TYPE = {
415
+ "user_prompt": "user",
416
+ "agent_response": "agent",
417
+ "tool_use": "tool",
418
+ "phase": "phase",
419
+ "stop": "agent",
420
+ "session_end": "phase",
421
+ }
422
+ # Which events actually trigger an append for each frequency. session_*
423
+ # events are control plane (sidecar / init), not log entries, so they
424
+ # are absent from these sets.
425
+ CADENCE_EVENTS = {
426
+ "per_turn": frozenset({"stop", "agent_response"}),
427
+ "per_phase": frozenset({"phase", "stop", "user_prompt"}),
428
+ "per_tool": frozenset({"tool_use"}),
429
+ }
430
+
431
+ # Per-platform mapping from the platform's native hook event name to our
432
+ # internal VALID_HOOK_EVENTS. Used by hook_dispatch() to translate
433
+ # stdin JSON payloads coming from Claude Code, Augment Code, Cursor,
434
+ # Cline, Windsurf, and Gemini CLI into a unified entry-point. Sourced
435
+ # from agents/contexts/chat-history-platform-hooks.md.
436
+ PLATFORM_EVENT_MAP: dict[str, dict[str, str]] = {
437
+ "claude": {
438
+ "SessionStart": "session_start",
439
+ "UserPromptSubmit": "user_prompt",
440
+ "PostToolUse": "tool_use",
441
+ "Stop": "stop",
442
+ "SessionEnd": "session_end",
443
+ "PreCompact": "phase",
444
+ },
445
+ "augment": {
446
+ "SessionStart": "session_start",
447
+ "Stop": "stop",
448
+ "PostToolUse": "tool_use",
449
+ "SessionEnd": "session_end",
450
+ },
451
+ "cursor": {
452
+ "sessionStart": "session_start",
453
+ "sessionEnd": "session_end",
454
+ "afterAgentResponse": "agent_response",
455
+ "stop": "stop",
456
+ "postToolUse": "tool_use",
457
+ "beforeSubmitPrompt": "user_prompt",
458
+ },
459
+ "cline": {
460
+ "TaskStart": "session_start",
461
+ "TaskComplete": "session_end",
462
+ "UserPromptSubmit": "user_prompt",
463
+ "PostToolUse": "tool_use",
464
+ },
465
+ "windsurf": {
466
+ "pre_user_prompt": "user_prompt",
467
+ "post_cascade_response": "agent_response",
468
+ "post_cascade_response_with_transcript": "agent_response",
469
+ "post_setup_worktree": "phase",
470
+ },
471
+ "gemini": {
472
+ "SessionStart": "session_start",
473
+ "AfterAgent": "agent_response",
474
+ "AfterTool": "tool_use",
475
+ "SessionEnd": "session_end",
476
+ },
477
+ # Generic / pass-through — the caller already speaks our internal
478
+ # event vocabulary. Useful for shell snippets that want to invoke
479
+ # the dispatcher with a known event regardless of platform.
480
+ "generic": {ev: ev for ev in VALID_HOOK_EVENTS},
481
+ }
482
+ VALID_PLATFORMS = tuple(PLATFORM_EVENT_MAP.keys())
483
+
484
+
485
+ def _read_chat_history_frequency(settings_path: Path) -> str:
486
+ """Read chat_history.frequency from .agent-settings.yml. Default per_phase."""
487
+ if not settings_path.is_file():
488
+ return "per_phase"
489
+ try:
490
+ import yaml # type: ignore[import-untyped]
491
+ except ImportError:
492
+ return "per_phase"
493
+ try:
494
+ with settings_path.open(encoding="utf-8") as fh:
495
+ data = yaml.safe_load(fh) or {}
496
+ except (OSError, yaml.YAMLError):
497
+ return "per_phase"
498
+ section = data.get("chat_history") if isinstance(data, dict) else None
499
+ if not isinstance(section, dict):
500
+ return "per_phase"
501
+ val = str(section.get("frequency", "per_phase")).lower()
502
+ return val if val in VALID_FREQS else "per_phase"
503
+
504
+
505
+ def sidecar_path(path: Path | None = None) -> Path:
506
+ """Return the path to the session sidecar (.agent-chat-history.session).
507
+
508
+ Sidecar carries the first-user-msg for the active session so hook
509
+ invocations after `session_start` don't need the agent to pass it
510
+ on every call. Lives next to the JSONL file.
511
+ """
512
+ base = path or file_path()
513
+ return base.with_name(base.name + ".session")
514
+
515
+
516
+ def read_sidecar(path: Path | None = None) -> dict[str, Any] | None:
517
+ """Read and parse the sidecar; returns None on missing or malformed."""
518
+ sp = sidecar_path(path)
519
+ if not sp.is_file():
520
+ return None
521
+ try:
522
+ with sp.open(encoding="utf-8") as fh:
523
+ data = json.load(fh)
524
+ return data if isinstance(data, dict) else None
525
+ except (OSError, json.JSONDecodeError):
526
+ return None
527
+
528
+
529
+ def write_sidecar(first_user_msg: str, *,
530
+ path: Path | None = None) -> dict[str, Any]:
531
+ """Write the session sidecar atomically. Overwrites on session_start."""
532
+ sp = sidecar_path(path)
533
+ sp.parent.mkdir(parents=True, exist_ok=True)
534
+ payload = {
535
+ "first_user_msg": first_user_msg,
536
+ "fp": fingerprint(first_user_msg),
537
+ "started_at": _now(),
538
+ }
539
+ tmp = sp.with_suffix(sp.suffix + ".tmp")
540
+ with tmp.open("w", encoding="utf-8") as fh:
541
+ json.dump(payload, fh, ensure_ascii=False)
542
+ tmp.replace(sp)
543
+ return payload
544
+
545
+
546
+ def _read_chat_history_heartbeat_mode(settings_path: Path) -> str:
547
+ """Read chat_history.heartbeat from .agent-settings.yml.
548
+
549
+ Returns one of 'on' | 'off' | 'hybrid'. Default 'hybrid' (marker
550
+ surfaces only on drift states — missing/foreign/returning — and
551
+ stays silent on 'ok'/'disabled'). Unknown values fall back to
552
+ 'hybrid'. Mirrors the default-deny policy of `_read_chat_history_enabled`
553
+ for the `enabled` flag, but here the default is the safer-by-design
554
+ hybrid mode rather than off.
555
+ """
556
+ if not settings_path.is_file():
557
+ return "hybrid"
558
+ try:
559
+ import yaml # type: ignore[import-untyped]
560
+ except ImportError:
561
+ return "hybrid"
562
+ try:
563
+ with settings_path.open(encoding="utf-8") as fh:
564
+ data = yaml.safe_load(fh) or {}
565
+ except (OSError, yaml.YAMLError):
566
+ return "hybrid"
567
+ section = data.get("chat_history") if isinstance(data, dict) else None
568
+ if not isinstance(section, dict):
569
+ return "hybrid"
570
+ raw = section.get("heartbeat", "hybrid")
571
+ # YAML 1.1 (PyYAML default) booleanizes bare on/off to True/False.
572
+ # Coerce back so users can write `heartbeat: on` without quoting.
573
+ if raw is True:
574
+ return "on"
575
+ if raw is False:
576
+ return "off"
577
+ val = str(raw).lower()
578
+ if val in VALID_HEARTBEAT_MODES:
579
+ return val
580
+ return "hybrid"
581
+
582
+
583
+ def turn_check(first_user_msg: str, *, path: Path | None = None,
584
+ settings_path: Path | None = None) -> dict[str, Any]:
585
+ """Compute the turn-start ownership state.
586
+
587
+ Returns a structured dict the CLI renders to stdout/stderr. Pure
588
+ function — no I/O outside the two paths it reads.
589
+ """
590
+ sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
591
+ if not _read_chat_history_enabled(sp):
592
+ return {"state": "disabled", "exit": EXIT_OK}
593
+ p = path or file_path()
594
+ state = ownership_state(first_user_msg, path=p)
595
+ if state == "match":
596
+ st = status(path=p)
597
+ return {
598
+ "state": "ok",
599
+ "exit": EXIT_OK,
600
+ "entries": st.get("entries", 0),
601
+ }
602
+ header = read_header(p) or {}
603
+ out: dict[str, Any] = {
604
+ "state": state,
605
+ "current_fp": fingerprint(first_user_msg),
606
+ "header_fp": str(header.get("fp", "")),
607
+ "preview": str(header.get("preview", "")),
608
+ }
609
+ if state == "missing":
610
+ out["exit"] = EXIT_MISSING
611
+ elif state == "foreign":
612
+ out["exit"] = EXIT_FOREIGN
613
+ st = status(path=p)
614
+ out["entries"] = st.get("entries", 0)
615
+ else: # returning
616
+ out["exit"] = EXIT_RETURNING
617
+ st = status(path=p)
618
+ out["entries"] = st.get("entries", 0)
619
+ return out
620
+
621
+
622
+ def _format_age(seconds: int) -> str:
623
+ """Render a relative duration as a compact human-readable string."""
624
+ if seconds < 0:
625
+ return "just now"
626
+ if seconds < 60:
627
+ return f"{seconds}s ago"
628
+ if seconds < 3600:
629
+ return f"{seconds // 60}m ago"
630
+ if seconds < 86400:
631
+ return f"{seconds // 3600}h ago"
632
+ return f"{seconds // 86400}d ago"
633
+
634
+
635
+ def _last_entry_age_seconds(path: Path) -> int | None:
636
+ """Return age of the latest non-header entry in seconds, or None.
637
+
638
+ Reads the file once, takes the last non-empty line, parses its `ts`
639
+ field. Tolerant of malformed lines and missing timestamps — returns
640
+ None instead of raising. Used by `heartbeat()` to surface stale
641
+ appends in the in-band marker.
642
+ """
643
+ if not path.is_file():
644
+ return None
645
+ last_line: str | None = None
646
+ try:
647
+ with path.open(encoding="utf-8") as fh:
648
+ for raw in fh:
649
+ stripped = raw.strip()
650
+ if stripped:
651
+ last_line = stripped
652
+ except OSError:
653
+ return None
654
+ if not last_line:
655
+ return None
656
+ try:
657
+ obj = json.loads(last_line)
658
+ except json.JSONDecodeError:
659
+ return None
660
+ if not isinstance(obj, dict) or obj.get("t") == "header":
661
+ return None
662
+ ts = obj.get("ts")
663
+ if not ts or not isinstance(ts, str):
664
+ return None
665
+ try:
666
+ parsed = dt.datetime.fromisoformat(ts)
667
+ except ValueError:
668
+ return None
669
+ if parsed.tzinfo is None:
670
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
671
+ now = dt.datetime.now(dt.timezone.utc)
672
+ return int((now - parsed).total_seconds())
673
+
674
+
675
+ def heartbeat(first_user_msg: str, *, path: Path | None = None,
676
+ settings_path: Path | None = None) -> dict[str, Any]:
677
+ """Compute the in-band reply marker proving the rule was executed.
678
+
679
+ The marker is a single-line string the agent must include verbatim
680
+ at the end of every reply. Fields surface state, entry count,
681
+ cadence, and age of the last entry — so a stale gap (no append
682
+ across two replies at `per_turn`/`per_phase`) is immediately
683
+ visible to the user without any out-of-band tooling.
684
+
685
+ Always returns exit-equivalent 0; this is observability, not a
686
+ gate. Ownership refusal lives in `append`, the turn-start gate
687
+ lives in `turn_check`. `heartbeat` only reports.
688
+ """
689
+ sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
690
+ if not _read_chat_history_enabled(sp):
691
+ return {"state": "disabled",
692
+ "marker": "📒 chat-history: disabled"}
693
+ p = path or file_path()
694
+ state = ownership_state(first_user_msg, path=p)
695
+ st = status(path=p)
696
+ entries = int(st.get("entries", 0)) if st.get("exists") else 0
697
+ header = st.get("header") or {}
698
+ freq = str(header.get("freq", "?")) if header else "?"
699
+ if state == "match":
700
+ age = _last_entry_age_seconds(p)
701
+ age_str = _format_age(age) if age is not None else "no entries"
702
+ marker = (f"📒 chat-history: ok · {entries} entries · "
703
+ f"{freq} · last {age_str}")
704
+ return {"state": "ok", "entries": entries, "freq": freq,
705
+ "last_age_seconds": age, "marker": marker}
706
+ if state == "missing":
707
+ return {"state": "missing",
708
+ "marker": "📒 chat-history: missing — run init"}
709
+ if state == "foreign":
710
+ return {"state": "foreign", "entries": entries,
711
+ "marker": (f"📒 chat-history: foreign · {entries} "
712
+ f"entries on file — render Foreign-Prompt")}
713
+ return {"state": "returning", "entries": entries,
714
+ "marker": (f"📒 chat-history: returning · {entries} "
715
+ f"entries on file — render Returning-Prompt")}
716
+
717
+
337
718
  def overflow_handle(max_kb: int, mode: str = "rotate", *,
338
719
  path: Path | None = None) -> dict[str, Any]:
339
720
  """Enforce max_kb. Returns {'action', 'kept', 'dropped'}.
@@ -378,20 +759,270 @@ def overflow_handle(max_kb: int, mode: str = "rotate", *,
378
759
  return {"action": "compress_marked", "kept": len(entries), "dropped": 0}
379
760
 
380
761
 
762
+ def hook_append(event: str, *,
763
+ first_user_msg: str | None = None,
764
+ payload: dict[str, Any] | None = None,
765
+ path: Path | None = None,
766
+ settings_path: Path | None = None) -> dict[str, Any]:
767
+ """Platform-hook entry point — wraps init/append/sidecar.
768
+
769
+ Designed for `SessionStart`, `UserPromptSubmit`, `PostToolUse`,
770
+ `Stop`, `SessionEnd` style hooks across platforms. Stateless: every
771
+ invocation reads the sidecar for the active session's first-user-msg.
772
+ The very first call (`event == "session_start"`) writes the sidecar
773
+ and initializes the JSONL header if missing.
774
+
775
+ Cadence-aware: events that don't match `chat_history.frequency`
776
+ are silently skipped. `enabled: false` short-circuits to a noop.
777
+
778
+ Returns a structured dict the CLI emits as JSON. Never raises for
779
+ non-fatal control-plane states (missing sidecar, cadence skip,
780
+ disabled) — these surface as `action` values so hooks can choose
781
+ fail_open vs fail_closed by inspecting the result.
782
+ """
783
+ if event not in VALID_HOOK_EVENTS:
784
+ raise ValueError(f"event must be one of {sorted(VALID_HOOK_EVENTS)}")
785
+ sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
786
+ if not _read_chat_history_enabled(sp):
787
+ return {"action": "disabled", "event": event}
788
+ p = path or file_path()
789
+ payload = payload or {}
790
+
791
+ if event == "session_start":
792
+ if not first_user_msg:
793
+ return {"action": "skipped_no_first_user_msg", "event": event}
794
+ write_sidecar(first_user_msg, path=p)
795
+ if not p.is_file() or read_header(p) is None:
796
+ freq = _read_chat_history_frequency(sp)
797
+ init(first_user_msg, freq=freq, path=p)
798
+ return {"action": "initialized", "event": event,
799
+ "fp": fingerprint(first_user_msg)}
800
+ return {"action": "sidecar_written", "event": event,
801
+ "fp": fingerprint(first_user_msg)}
802
+
803
+ side = read_sidecar(p)
804
+ fum = first_user_msg or (side or {}).get("first_user_msg")
805
+ if not fum:
806
+ return {"action": "skipped_no_sidecar", "event": event,
807
+ "hint": "session_start hook never ran or sidecar was deleted"}
808
+
809
+ if event == "session_end":
810
+ # Control plane only — touch sidecar's last-seen but do not append.
811
+ return {"action": "session_end_noop", "event": event}
812
+
813
+ freq = _read_chat_history_frequency(sp)
814
+ if event not in CADENCE_EVENTS.get(freq, frozenset()):
815
+ return {"action": "skipped_cadence", "event": event, "frequency": freq}
816
+
817
+ entry_type = HOOK_EVENT_ENTRY_TYPE.get(event, "agent")
818
+ entry: dict[str, Any] = {"t": entry_type}
819
+ text = str(payload.get("text", "")).strip()
820
+ if text:
821
+ entry["text"] = _preview(text, 200)
822
+ if event == "tool_use":
823
+ tool = payload.get("tool")
824
+ if tool:
825
+ entry["tool"] = str(tool)
826
+ for k in ("source", "phase", "decision"):
827
+ if payload.get(k):
828
+ entry[k] = str(payload[k])
829
+ try:
830
+ append(entry, path=p, first_user_msg=fum)
831
+ except OwnershipError as exc:
832
+ return {"action": "ownership_refused", "event": event,
833
+ "state": exc.state,
834
+ "header_fp": exc.header_fp[:8],
835
+ "current_fp": exc.current_fp[:8]}
836
+ return {"action": "appended", "event": event, "type": entry_type}
837
+
838
+
839
+ def _extract_hook_text(payload: dict[str, Any]) -> str:
840
+ """Pull a textual snippet out of a platform's hook payload.
841
+
842
+ Tries common field names across Claude Code, Augment Code, Cursor,
843
+ Cline, Windsurf, and Gemini CLI. Returns the first non-empty string
844
+ found, stripped; empty string when nothing usable is present.
845
+ """
846
+ for key in ("prompt", "user_prompt", "first_user_msg", "firstUserMsg",
847
+ "userMessage", "user_message", "text", "response", "message",
848
+ "content"):
849
+ v = payload.get(key)
850
+ if isinstance(v, str) and v.strip():
851
+ return v.strip()
852
+ # Tool response wrappers (Claude PostToolUse, etc.) — best-effort.
853
+ tr = payload.get("tool_response") or payload.get("toolResponse")
854
+ if isinstance(tr, dict):
855
+ for key in ("output", "stdout", "result", "text"):
856
+ v = tr.get(key)
857
+ if isinstance(v, str) and v.strip():
858
+ return v.strip()
859
+ return ""
860
+
861
+
862
+ def _extract_hook_tool(payload: dict[str, Any]) -> str:
863
+ """Pull the tool name out of a platform's hook payload."""
864
+ for key in ("tool_name", "toolName", "tool"):
865
+ v = payload.get(key)
866
+ if isinstance(v, str) and v.strip():
867
+ return v.strip()
868
+ return ""
869
+
870
+
871
+ def _extract_hook_event(payload: dict[str, Any]) -> str:
872
+ """Pull the platform's native hook event name out of the payload."""
873
+ for key in ("hook_event_name", "event", "eventName", "event_name"):
874
+ v = payload.get(key)
875
+ if isinstance(v, str) and v.strip():
876
+ return v.strip()
877
+ return ""
878
+
879
+
880
+ def hook_dispatch(platform: str, raw_json: str, *,
881
+ event_override: str | None = None,
882
+ path: Path | None = None,
883
+ settings_path: Path | None = None) -> dict[str, Any]:
884
+ """Read a platform's stdin JSON, translate to our hook vocabulary, dispatch.
885
+
886
+ Used by `chat_history.py hook-dispatch --platform <name>` so consumer
887
+ projects can wire their `.claude/settings.json` / `.augment/settings.json`
888
+ / `.cursor/hooks.json` etc. to a single command. The mapping comes from
889
+ PLATFORM_EVENT_MAP; unmapped events are silently skipped (returned as
890
+ `skipped_unmapped_event` so the caller can decide fail-open vs
891
+ fail-closed).
892
+
893
+ Bootstrap: when the platform fires the very first non-`session_start`
894
+ event (e.g. `UserPromptSubmit`) and no sidecar exists yet, the
895
+ dispatcher synthesizes a `session_start` first using the prompt as the
896
+ `first_user_msg`. This handles platforms whose `SessionStart` payload
897
+ does not carry the prompt itself.
898
+ """
899
+ if platform not in PLATFORM_EVENT_MAP:
900
+ raise ValueError(
901
+ f"unknown platform: {platform!r}; "
902
+ f"expected one of {sorted(VALID_PLATFORMS)}"
903
+ )
904
+ raw = (raw_json or "").strip()
905
+ if not raw:
906
+ payload: dict[str, Any] = {}
907
+ else:
908
+ try:
909
+ payload = json.loads(raw)
910
+ except json.JSONDecodeError as exc:
911
+ raise ValueError(f"invalid JSON on stdin: {exc}") from exc
912
+ if not isinstance(payload, dict):
913
+ raise ValueError("stdin JSON must decode to an object")
914
+
915
+ raw_event = (event_override or _extract_hook_event(payload) or "").strip()
916
+ event = PLATFORM_EVENT_MAP[platform].get(raw_event)
917
+ if not event:
918
+ return {"action": "skipped_unmapped_event", "platform": platform,
919
+ "raw_event": raw_event}
920
+
921
+ text = _extract_hook_text(payload)
922
+ tool = _extract_hook_tool(payload)
923
+ # The user's first message is what we hash for ownership. We can only
924
+ # extract it from prompt-bearing events; for stop / tool_use / *_end
925
+ # the sidecar must already exist.
926
+ fum = text if event in {"session_start", "user_prompt"} else None
927
+
928
+ hook_payload: dict[str, Any] = {"source": f"hook:{platform}:{raw_event}"}
929
+ if text and event != "session_start":
930
+ hook_payload["text"] = text
931
+ if tool:
932
+ hook_payload["tool"] = tool
933
+
934
+ p = path or file_path()
935
+
936
+ if event == "session_start":
937
+ return hook_append("session_start", first_user_msg=fum,
938
+ path=path, settings_path=settings_path)
939
+
940
+ # Bootstrap: the first non-session_start event from a platform whose
941
+ # SessionStart did not carry the prompt (e.g. Claude Code) needs an
942
+ # implicit init so ownership and the sidecar exist before append.
943
+ side = read_sidecar(p)
944
+ if side is None and fum:
945
+ hook_append("session_start", first_user_msg=fum,
946
+ path=path, settings_path=settings_path)
947
+
948
+ return hook_append(event, first_user_msg=fum, payload=hook_payload,
949
+ path=path, settings_path=settings_path)
950
+
951
+
381
952
  def _cmd_init(args) -> int:
382
953
  h = init(args.first_user_msg, freq=args.freq)
383
954
  print(json.dumps(h, ensure_ascii=False))
384
955
  return 0
385
956
 
386
957
 
958
+ def _cmd_hook_append(args) -> int:
959
+ payload: dict[str, Any] = {}
960
+ if args.payload:
961
+ try:
962
+ payload = json.loads(args.payload)
963
+ except json.JSONDecodeError as exc:
964
+ print(f"error: --payload must be valid JSON: {exc}",
965
+ file=sys.stderr)
966
+ return EXIT_BAD_ARGS
967
+ if not isinstance(payload, dict):
968
+ print("error: --payload must decode to a JSON object",
969
+ file=sys.stderr)
970
+ return EXIT_BAD_ARGS
971
+ settings_path = Path(args.settings) if args.settings else None
972
+ try:
973
+ result = hook_append(
974
+ args.event,
975
+ first_user_msg=args.first_user_msg,
976
+ payload=payload,
977
+ settings_path=settings_path,
978
+ )
979
+ except ValueError as exc:
980
+ print(f"error: {exc}", file=sys.stderr)
981
+ return EXIT_BAD_ARGS
982
+ print(json.dumps(result, ensure_ascii=False))
983
+ if result.get("action") == "ownership_refused":
984
+ return EXIT_OWNERSHIP_REFUSED
985
+ return EXIT_OK
986
+
987
+
988
+ def _cmd_hook_dispatch(args) -> int:
989
+ raw = sys.stdin.read() if not sys.stdin.isatty() else ""
990
+ settings_path = Path(args.settings) if args.settings else None
991
+ try:
992
+ result = hook_dispatch(
993
+ args.platform,
994
+ raw,
995
+ event_override=args.event,
996
+ settings_path=settings_path,
997
+ )
998
+ except ValueError as exc:
999
+ print(f"error: {exc}", file=sys.stderr)
1000
+ return EXIT_BAD_ARGS
1001
+ print(json.dumps(result, ensure_ascii=False))
1002
+ if result.get("action") == "ownership_refused":
1003
+ return EXIT_OWNERSHIP_REFUSED
1004
+ return EXIT_OK
1005
+
1006
+
387
1007
  def _cmd_append(args) -> int:
388
1008
  entry = json.loads(args.json) if args.json else {}
389
1009
  entry.setdefault("t", args.type)
390
1010
  if not entry.get("t"):
391
- print("error: --type or a 't' key in --json is required", file=sys.stderr)
392
- return 2
393
- append(entry)
394
- return 0
1011
+ print("error: --type or a 't' key in --json is required",
1012
+ file=sys.stderr)
1013
+ return EXIT_BAD_ARGS
1014
+ try:
1015
+ append(entry, first_user_msg=args.first_user_msg)
1016
+ except OwnershipError as exc:
1017
+ print(
1018
+ f"error: append refused — state={exc.state}; "
1019
+ f"header_fp={exc.header_fp[:8]} current_fp={exc.current_fp[:8]}. "
1020
+ f"Run `chat_history.py turn-check --first-user-msg \"...\"` "
1021
+ f"and resolve ownership before retrying.",
1022
+ file=sys.stderr,
1023
+ )
1024
+ return EXIT_OWNERSHIP_REFUSED
1025
+ return EXIT_OK
395
1026
 
396
1027
 
397
1028
  def _cmd_status(_args) -> int:
@@ -409,6 +1040,75 @@ def _cmd_state(args) -> int:
409
1040
  return 0
410
1041
 
411
1042
 
1043
+ def _format_turn_check_stdout(result: dict[str, Any]) -> str:
1044
+ """Render turn_check() result as a single key=value line for shell parsing."""
1045
+ state = result["state"]
1046
+ parts = [f"state={state}"]
1047
+ if "entries" in result:
1048
+ parts.append(f"entries={result['entries']}")
1049
+ if state in {"foreign", "returning"}:
1050
+ parts.append(f"header_fp={str(result.get('header_fp', ''))[:8]}")
1051
+ parts.append(f"current_fp={str(result.get('current_fp', ''))[:8]}")
1052
+ preview = str(result.get("preview", "")).replace('"', "'")
1053
+ if preview:
1054
+ parts.append(f'preview="{preview[:80]}"')
1055
+ return " ".join(parts)
1056
+
1057
+
1058
+ def _turn_check_action_hint(state: str) -> str:
1059
+ """Stderr hint telling the agent which prompt to render."""
1060
+ if state == "ok":
1061
+ return ""
1062
+ if state == "disabled":
1063
+ return ""
1064
+ if state == "missing":
1065
+ return ("ACTION REQUIRED: state=missing — run "
1066
+ "`chat_history.py init --first-user-msg \"...\" "
1067
+ "--freq <frequency-from-settings>` before any other reply.")
1068
+ if state == "foreign":
1069
+ return ("ACTION REQUIRED: state=foreign — render the Foreign-Prompt "
1070
+ "from the chat-history rule (3 numbered options: Resume / "
1071
+ "New start / Ignore) before any other reply. Do not append "
1072
+ "to this file until the user picks.")
1073
+ if state == "returning":
1074
+ return ("ACTION REQUIRED: state=returning — render the "
1075
+ "Returning-Prompt from the chat-history rule (3 numbered "
1076
+ "options: Merge / Replace / Continue) before any other "
1077
+ "reply. Do not append to this file until the user picks.")
1078
+ return f"ACTION REQUIRED: unknown state={state}"
1079
+
1080
+
1081
+ def _cmd_turn_check(args) -> int:
1082
+ settings_path = Path(args.settings) if args.settings else None
1083
+ result = turn_check(args.first_user_msg, settings_path=settings_path)
1084
+ print(_format_turn_check_stdout(result))
1085
+ hint = _turn_check_action_hint(result["state"])
1086
+ if hint:
1087
+ print(hint, file=sys.stderr)
1088
+ return int(result["exit"])
1089
+
1090
+
1091
+ def _cmd_heartbeat(args) -> int:
1092
+ settings_path = Path(args.settings) if args.settings else None
1093
+ result = heartbeat(args.first_user_msg, settings_path=settings_path)
1094
+ if args.json:
1095
+ # JSON consumers want the full record regardless of mode.
1096
+ print(json.dumps(result, ensure_ascii=False))
1097
+ return EXIT_OK
1098
+ mode = _read_chat_history_heartbeat_mode(
1099
+ settings_path or Path(DEFAULT_SETTINGS_FILE)
1100
+ )
1101
+ state = str(result.get("state", ""))
1102
+ # off → never print. hybrid → only on drift states.
1103
+ # on → always (current behavior).
1104
+ if mode == "off":
1105
+ return EXIT_OK
1106
+ if mode == "hybrid" and state not in DRIFT_STATES:
1107
+ return EXIT_OK
1108
+ print(result["marker"])
1109
+ return EXIT_OK
1110
+
1111
+
412
1112
  def _cmd_adopt(args) -> int:
413
1113
  h = adopt(args.first_user_msg)
414
1114
  print(json.dumps(h, ensure_ascii=False))
@@ -471,6 +1171,12 @@ def main(argv: list[str] | None = None) -> int:
471
1171
  p_app = sub.add_parser("append")
472
1172
  p_app.add_argument("--type", help="entry type (t field)")
473
1173
  p_app.add_argument("--json", help="JSON object with entry fields")
1174
+ p_app.add_argument(
1175
+ "--first-user-msg",
1176
+ default=None,
1177
+ help=("validate ownership before writing — refuses with exit "
1178
+ f"{EXIT_OWNERSHIP_REFUSED} on foreign/returning/missing"),
1179
+ )
474
1180
  p_app.set_defaults(func=_cmd_append)
475
1181
  sub.add_parser("status").set_defaults(func=_cmd_status)
476
1182
  p_chk = sub.add_parser("check")
@@ -479,6 +1185,36 @@ def main(argv: list[str] | None = None) -> int:
479
1185
  p_state = sub.add_parser("state")
480
1186
  p_state.add_argument("--first-user-msg", required=True)
481
1187
  p_state.set_defaults(func=_cmd_state)
1188
+ p_tc = sub.add_parser(
1189
+ "turn-check",
1190
+ help=("turn-start ownership gate; exit 0=ok/disabled, "
1191
+ f"{EXIT_MISSING}=missing, {EXIT_FOREIGN}=foreign, "
1192
+ f"{EXIT_RETURNING}=returning"),
1193
+ )
1194
+ p_tc.add_argument("--first-user-msg", required=True)
1195
+ p_tc.add_argument(
1196
+ "--settings",
1197
+ default=None,
1198
+ help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1199
+ )
1200
+ p_tc.set_defaults(func=_cmd_turn_check)
1201
+ p_hb = sub.add_parser(
1202
+ "heartbeat",
1203
+ help=("emit the in-band reply marker; always exit 0. "
1204
+ "Agent must include the stdout line verbatim in every reply."),
1205
+ )
1206
+ p_hb.add_argument("--first-user-msg", required=True)
1207
+ p_hb.add_argument(
1208
+ "--settings",
1209
+ default=None,
1210
+ help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1211
+ )
1212
+ p_hb.add_argument(
1213
+ "--json",
1214
+ action="store_true",
1215
+ help="emit the full result dict instead of just the marker",
1216
+ )
1217
+ p_hb.set_defaults(func=_cmd_heartbeat)
482
1218
  p_ado = sub.add_parser("adopt")
483
1219
  p_ado.add_argument("--first-user-msg", required=True)
484
1220
  p_ado.set_defaults(func=_cmd_adopt)
@@ -511,6 +1247,59 @@ def main(argv: list[str] | None = None) -> int:
511
1247
  p_rot.add_argument("--max-kb", type=int, default=256)
512
1248
  p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
513
1249
  p_rot.set_defaults(func=_cmd_rotate)
1250
+ p_hook = sub.add_parser(
1251
+ "hook-append",
1252
+ help=("platform-hook entry point — wraps init/append/sidecar; "
1253
+ "stateless after the first session_start call"),
1254
+ )
1255
+ p_hook.add_argument(
1256
+ "--event",
1257
+ required=True,
1258
+ choices=sorted(VALID_HOOK_EVENTS),
1259
+ help="hook event name (session_start required first)",
1260
+ )
1261
+ p_hook.add_argument(
1262
+ "--first-user-msg",
1263
+ default=None,
1264
+ help=("required on session_start; subsequent events read it from "
1265
+ "the sidecar"),
1266
+ )
1267
+ p_hook.add_argument(
1268
+ "--payload",
1269
+ default=None,
1270
+ help=("JSON object with event-specific fields "
1271
+ "(text/tool/source/phase/decision)"),
1272
+ )
1273
+ p_hook.add_argument(
1274
+ "--settings",
1275
+ default=None,
1276
+ help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1277
+ )
1278
+ p_hook.set_defaults(func=_cmd_hook_append)
1279
+ p_disp = sub.add_parser(
1280
+ "hook-dispatch",
1281
+ help=("platform-hook entry point — reads platform JSON from stdin, "
1282
+ "translates the native event name to our vocabulary, and "
1283
+ "invokes hook-append"),
1284
+ )
1285
+ p_disp.add_argument(
1286
+ "--platform",
1287
+ required=True,
1288
+ choices=sorted(VALID_PLATFORMS),
1289
+ help="source platform whose hook is firing",
1290
+ )
1291
+ p_disp.add_argument(
1292
+ "--event",
1293
+ default=None,
1294
+ help=("override the platform-native event name (default: read "
1295
+ "from stdin payload key hook_event_name / event)"),
1296
+ )
1297
+ p_disp.add_argument(
1298
+ "--settings",
1299
+ default=None,
1300
+ help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1301
+ )
1302
+ p_disp.set_defaults(func=_cmd_hook_dispatch)
514
1303
  args = ap.parse_args(argv)
515
1304
  return args.func(args)
516
1305