harnery 0.33.0 → 0.34.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 (657) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +4 -0
  3. package/dist/commands/adapter.js +2 -2
  4. package/dist/commands/agents.d.ts +144 -1
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +1220 -588
  7. package/dist/commands/artifacts.d.ts.map +1 -1
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/backup.d.ts +1 -1
  10. package/dist/commands/backup.d.ts.map +1 -1
  11. package/dist/commands/backup.js +29 -16
  12. package/dist/commands/browse.d.ts.map +1 -1
  13. package/dist/commands/browse.js +234 -12
  14. package/dist/commands/checkpoint.d.ts.map +1 -1
  15. package/dist/commands/checkpoint.js +3 -2
  16. package/dist/commands/claude-desktop.d.ts +1 -1
  17. package/dist/commands/claude-desktop.d.ts.map +1 -1
  18. package/dist/commands/claude-desktop.js +3 -74
  19. package/dist/commands/context.js +1 -6
  20. package/dist/commands/decision.d.ts.map +1 -1
  21. package/dist/commands/decision.js +22 -38
  22. package/dist/commands/docs.d.ts.map +1 -1
  23. package/dist/commands/docs.js +91 -28
  24. package/dist/commands/doctor.js +10 -1
  25. package/dist/commands/events.d.ts +5 -0
  26. package/dist/commands/events.d.ts.map +1 -0
  27. package/dist/commands/events.js +189 -0
  28. package/dist/commands/governor.d.ts.map +1 -1
  29. package/dist/commands/governor.js +13 -4
  30. package/dist/commands/init.d.ts +9 -2
  31. package/dist/commands/init.d.ts.map +1 -1
  32. package/dist/commands/init.js +223 -51
  33. package/dist/commands/journal.d.ts.map +1 -1
  34. package/dist/commands/journal.js +4 -13
  35. package/dist/commands/ledger-v3.d.ts +5 -0
  36. package/dist/commands/ledger-v3.d.ts.map +1 -0
  37. package/dist/commands/ledger-v3.js +91 -0
  38. package/dist/commands/presence.d.ts.map +1 -1
  39. package/dist/commands/presence.js +11 -5
  40. package/dist/commands/sync.d.ts +1 -1
  41. package/dist/commands/sync.js +1 -1
  42. package/dist/commands/web.d.ts +6 -0
  43. package/dist/commands/web.d.ts.map +1 -1
  44. package/dist/commands/web.js +67 -7
  45. package/dist/commands/work.d.ts.map +1 -1
  46. package/dist/commands/work.js +22 -5
  47. package/dist/core/adapter.d.ts +5 -0
  48. package/dist/core/adapter.d.ts.map +1 -0
  49. package/dist/core/adapter.js +1 -0
  50. package/dist/core/adapters/attest.d.ts +5 -2
  51. package/dist/core/adapters/attest.d.ts.map +1 -1
  52. package/dist/core/adapters/attest.js +8 -3
  53. package/dist/core/adapters/profiles.d.ts +6 -6
  54. package/dist/core/adapters/profiles.d.ts.map +1 -1
  55. package/dist/core/adapters/profiles.js +4 -4
  56. package/dist/core/agents/canonical-emit.d.ts +17 -15
  57. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  58. package/dist/core/agents/canonical-emit.js +22 -22
  59. package/dist/core/agents/cli.js +207 -234
  60. package/dist/core/agents/codex-archive-v3.d.ts +14 -0
  61. package/dist/core/agents/codex-archive-v3.d.ts.map +1 -0
  62. package/dist/core/agents/codex-archive-v3.js +110 -0
  63. package/dist/core/agents/coord-client.d.ts +19 -81
  64. package/dist/core/agents/coord-client.d.ts.map +1 -1
  65. package/dist/core/agents/coord-client.js +65 -155
  66. package/dist/core/agents/finalization.d.ts +4 -1
  67. package/dist/core/agents/finalization.d.ts.map +1 -1
  68. package/dist/core/agents/finalization.js +89 -32
  69. package/dist/core/agents/index.d.ts +4 -5
  70. package/dist/core/agents/index.d.ts.map +1 -1
  71. package/dist/core/agents/index.js +4 -5
  72. package/dist/core/agents/live-authority-v3.d.ts +59 -0
  73. package/dist/core/agents/live-authority-v3.d.ts.map +1 -0
  74. package/dist/core/agents/live-authority-v3.js +233 -0
  75. package/dist/core/agents/live-lifecycle-v3.d.ts +20 -0
  76. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -0
  77. package/dist/core/agents/live-lifecycle-v3.js +91 -0
  78. package/dist/core/agents/live-observation-v3.d.ts +25 -0
  79. package/dist/core/agents/live-observation-v3.d.ts.map +1 -0
  80. package/dist/core/agents/live-observation-v3.js +157 -0
  81. package/dist/core/agents/render/prompt-context.d.ts +4 -4
  82. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  83. package/dist/core/agents/render/prompt-context.js +23 -58
  84. package/dist/core/agents/render/session-context.d.ts +1 -1
  85. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  86. package/dist/core/agents/render/session-context.js +8 -32
  87. package/dist/core/agents/rules/claim-conflict.d.ts +3 -3
  88. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  89. package/dist/core/agents/rules/claim-conflict.js +79 -127
  90. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  91. package/dist/core/agents/rules/commit-conflict.js +3 -18
  92. package/dist/core/agents/rules/stop-hook.d.ts +9 -50
  93. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  94. package/dist/core/agents/rules/stop-hook.js +193 -236
  95. package/dist/core/agents/session-events.d.ts +5 -7
  96. package/dist/core/agents/session-events.d.ts.map +1 -1
  97. package/dist/core/agents/session-events.js +127 -95
  98. package/dist/core/agents/session-finalization-state-v3.d.ts +30 -0
  99. package/dist/core/agents/session-finalization-state-v3.d.ts.map +1 -0
  100. package/dist/core/agents/session-finalization-state-v3.js +40 -0
  101. package/dist/core/agents/session-finalizer-v3.d.ts +107 -0
  102. package/dist/core/agents/session-finalizer-v3.d.ts.map +1 -0
  103. package/dist/core/agents/session-finalizer-v3.js +734 -0
  104. package/dist/core/agents/session-name-display.d.ts +40 -0
  105. package/dist/core/agents/session-name-display.d.ts.map +1 -0
  106. package/dist/core/agents/session-name-display.js +119 -0
  107. package/dist/core/agents/state/activity-log.d.ts +3 -3
  108. package/dist/core/agents/state/activity-log.d.ts.map +1 -1
  109. package/dist/core/agents/state/activity-log.js +7 -16
  110. package/dist/core/agents/state/heartbeat-writer.d.ts +23 -55
  111. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  112. package/dist/core/agents/state/heartbeat-writer.js +56 -228
  113. package/dist/core/agents/state/live-coordination-view.d.ts +31 -0
  114. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -0
  115. package/dist/core/agents/state/live-coordination-view.js +209 -0
  116. package/dist/core/agents/state/session-state.d.ts +4 -3
  117. package/dist/core/agents/state/session-state.d.ts.map +1 -1
  118. package/dist/core/agents/state/session-state.js +18 -9
  119. package/dist/core/agents/state/stale-sweep.d.ts +1 -1
  120. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  121. package/dist/core/agents/state/stale-sweep.js +26 -15
  122. package/dist/core/artifacts/index.d.ts.map +1 -1
  123. package/dist/core/artifacts/index.js +5 -7
  124. package/dist/core/config.d.ts +33 -2
  125. package/dist/core/config.d.ts.map +1 -1
  126. package/dist/core/config.js +98 -2
  127. package/dist/core/context/index.d.ts +1 -4
  128. package/dist/core/context/index.d.ts.map +1 -1
  129. package/dist/core/context/index.js +9 -9
  130. package/dist/core/events/span-parent.d.ts +15 -0
  131. package/dist/core/events/span-parent.d.ts.map +1 -0
  132. package/dist/core/events/span-parent.js +34 -0
  133. package/dist/core/events/v3/advance.d.ts +14 -0
  134. package/dist/core/events/v3/advance.d.ts.map +1 -0
  135. package/dist/core/events/v3/advance.js +134 -0
  136. package/dist/core/events/v3/authority-outbox.d.ts +78 -0
  137. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -0
  138. package/dist/core/events/v3/authority-outbox.js +399 -0
  139. package/dist/core/events/v3/base-builder.d.ts +20 -0
  140. package/dist/core/events/v3/base-builder.d.ts.map +1 -0
  141. package/dist/core/events/v3/base-builder.js +29 -0
  142. package/dist/core/events/v3/base-contract.d.ts +7576 -0
  143. package/dist/core/events/v3/base-contract.d.ts.map +1 -0
  144. package/dist/core/events/v3/base-contract.js +647 -0
  145. package/dist/core/events/v3/bootstrap.d.ts +34 -0
  146. package/dist/core/events/v3/bootstrap.d.ts.map +1 -0
  147. package/dist/core/events/v3/bootstrap.js +175 -0
  148. package/dist/core/events/v3/builder.d.ts +20 -0
  149. package/dist/core/events/v3/builder.d.ts.map +1 -0
  150. package/dist/core/events/v3/builder.js +28 -0
  151. package/dist/core/events/v3/canonical.d.ts +31 -0
  152. package/dist/core/events/v3/canonical.d.ts.map +1 -0
  153. package/dist/core/events/v3/canonical.js +100 -0
  154. package/dist/core/events/v3/capabilities.d.ts +31 -0
  155. package/dist/core/events/v3/capabilities.d.ts.map +1 -0
  156. package/dist/core/events/v3/capabilities.js +107 -0
  157. package/dist/core/events/v3/capability-drift.d.ts +22 -0
  158. package/dist/core/events/v3/capability-drift.d.ts.map +1 -0
  159. package/dist/core/events/v3/capability-drift.js +134 -0
  160. package/dist/core/events/v3/catalog.d.ts +38 -0
  161. package/dist/core/events/v3/catalog.d.ts.map +1 -0
  162. package/dist/core/events/v3/catalog.js +135 -0
  163. package/dist/core/events/v3/clock-order.d.ts +34 -0
  164. package/dist/core/events/v3/clock-order.d.ts.map +1 -0
  165. package/dist/core/events/v3/clock-order.js +35 -0
  166. package/dist/core/events/v3/contract.d.ts +171 -0
  167. package/dist/core/events/v3/contract.d.ts.map +1 -0
  168. package/dist/core/events/v3/contract.js +180 -0
  169. package/dist/core/events/v3/control.d.ts +111 -0
  170. package/dist/core/events/v3/control.d.ts.map +1 -0
  171. package/dist/core/events/v3/control.js +419 -0
  172. package/dist/core/events/v3/coordination-view.d.ts +69 -0
  173. package/dist/core/events/v3/coordination-view.d.ts.map +1 -0
  174. package/dist/core/events/v3/coordination-view.js +119 -0
  175. package/dist/core/events/v3/economics.d.ts +77 -0
  176. package/dist/core/events/v3/economics.d.ts.map +1 -0
  177. package/dist/core/events/v3/economics.js +274 -0
  178. package/dist/core/events/v3/finalization-view.d.ts +26 -0
  179. package/dist/core/events/v3/finalization-view.d.ts.map +1 -0
  180. package/dist/core/events/v3/finalization-view.js +37 -0
  181. package/dist/core/events/v3/fingerprint-keys.d.ts +25 -0
  182. package/dist/core/events/v3/fingerprint-keys.d.ts.map +1 -0
  183. package/dist/core/events/v3/fingerprint-keys.js +217 -0
  184. package/dist/core/events/v3/generated.d.ts +4 -0
  185. package/dist/core/events/v3/generated.d.ts.map +1 -0
  186. package/dist/core/events/v3/generated.js +47 -0
  187. package/dist/core/events/v3/ids.d.ts +9 -0
  188. package/dist/core/events/v3/ids.d.ts.map +1 -0
  189. package/dist/core/events/v3/ids.js +25 -0
  190. package/dist/core/events/v3/index.d.ts +35 -0
  191. package/dist/core/events/v3/index.d.ts.map +1 -0
  192. package/dist/core/events/v3/index.js +33 -0
  193. package/dist/core/events/v3/latency.d.ts +63 -0
  194. package/dist/core/events/v3/latency.d.ts.map +1 -0
  195. package/dist/core/events/v3/latency.js +412 -0
  196. package/dist/core/events/v3/live-feed.d.ts +45 -0
  197. package/dist/core/events/v3/live-feed.d.ts.map +1 -0
  198. package/dist/core/events/v3/live-feed.js +246 -0
  199. package/dist/core/events/v3/live-observation.d.ts +35 -0
  200. package/dist/core/events/v3/live-observation.d.ts.map +1 -0
  201. package/dist/core/events/v3/live-observation.js +1 -0
  202. package/dist/core/events/v3/live-routing.d.ts +57 -0
  203. package/dist/core/events/v3/live-routing.d.ts.map +1 -0
  204. package/dist/core/events/v3/live-routing.js +107 -0
  205. package/dist/core/events/v3/producers/command-recorder.d.ts +67 -0
  206. package/dist/core/events/v3/producers/command-recorder.d.ts.map +1 -0
  207. package/dist/core/events/v3/producers/command-recorder.js +477 -0
  208. package/dist/core/events/v3/producers/command.d.ts +50 -0
  209. package/dist/core/events/v3/producers/command.d.ts.map +1 -0
  210. package/dist/core/events/v3/producers/command.js +135 -0
  211. package/dist/core/events/v3/producers/coordination-recorder.d.ts +53 -0
  212. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -0
  213. package/dist/core/events/v3/producers/coordination-recorder.js +376 -0
  214. package/dist/core/events/v3/producers/coordination.d.ts +90 -0
  215. package/dist/core/events/v3/producers/coordination.d.ts.map +1 -0
  216. package/dist/core/events/v3/producers/coordination.js +229 -0
  217. package/dist/core/events/v3/producers/hook-base.d.ts +54 -0
  218. package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -0
  219. package/dist/core/events/v3/producers/hook-base.js +424 -0
  220. package/dist/core/events/v3/producers/hook.d.ts +29 -0
  221. package/dist/core/events/v3/producers/hook.d.ts.map +1 -0
  222. package/dist/core/events/v3/producers/hook.js +71 -0
  223. package/dist/core/events/v3/producers/intake.d.ts +65 -0
  224. package/dist/core/events/v3/producers/intake.d.ts.map +1 -0
  225. package/dist/core/events/v3/producers/intake.js +244 -0
  226. package/dist/core/events/v3/producers/recorder.d.ts +240 -0
  227. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -0
  228. package/dist/core/events/v3/producers/recorder.js +2176 -0
  229. package/dist/core/events/v3/projection.d.ts +101 -0
  230. package/dist/core/events/v3/projection.d.ts.map +1 -0
  231. package/dist/core/events/v3/projection.js +479 -0
  232. package/dist/core/events/v3/reader.d.ts +75 -0
  233. package/dist/core/events/v3/reader.d.ts.map +1 -0
  234. package/dist/core/events/v3/reader.js +778 -0
  235. package/dist/core/events/v3/recovery-record.d.ts +47 -0
  236. package/dist/core/events/v3/recovery-record.d.ts.map +1 -0
  237. package/dist/core/events/v3/recovery-record.js +167 -0
  238. package/dist/core/events/v3/recovery.d.ts +22 -0
  239. package/dist/core/events/v3/recovery.d.ts.map +1 -0
  240. package/dist/core/events/v3/recovery.js +227 -0
  241. package/dist/core/events/v3/span-state.d.ts +52 -0
  242. package/dist/core/events/v3/span-state.d.ts.map +1 -0
  243. package/dist/core/events/v3/span-state.js +142 -0
  244. package/dist/core/events/v3/targets.d.ts +23 -0
  245. package/dist/core/events/v3/targets.d.ts.map +1 -0
  246. package/dist/core/events/v3/targets.js +134 -0
  247. package/dist/core/events/v3/turn-telemetry.d.ts +41 -0
  248. package/dist/core/events/v3/turn-telemetry.d.ts.map +1 -0
  249. package/dist/core/events/v3/turn-telemetry.js +168 -0
  250. package/dist/core/events/v3/validate.d.ts +9 -0
  251. package/dist/core/events/v3/validate.d.ts.map +1 -0
  252. package/dist/core/events/v3/validate.js +281 -0
  253. package/dist/core/events/v3/writer.d.ts +56 -0
  254. package/dist/core/events/v3/writer.d.ts.map +1 -0
  255. package/dist/core/events/v3/writer.js +335 -0
  256. package/dist/core/governor/index.d.ts +1 -1
  257. package/dist/core/governor/index.d.ts.map +1 -1
  258. package/dist/core/governor/index.js +1 -1
  259. package/dist/core/governor/plan-api.d.ts +2 -1
  260. package/dist/core/governor/plan-api.d.ts.map +1 -1
  261. package/dist/core/governor/plan-api.js +6 -3
  262. package/dist/core/governor/read.d.ts +1 -1
  263. package/dist/core/governor/read.d.ts.map +1 -1
  264. package/dist/core/governor/read.js +1 -1
  265. package/dist/core/governor/state.d.ts +9 -0
  266. package/dist/core/governor/state.d.ts.map +1 -1
  267. package/dist/core/governor/state.js +40 -3
  268. package/dist/core/guard/config.d.ts +5 -0
  269. package/dist/core/guard/config.d.ts.map +1 -0
  270. package/dist/core/guard/config.js +157 -0
  271. package/dist/core/guard/coordinator.d.ts +14 -0
  272. package/dist/core/guard/coordinator.d.ts.map +1 -0
  273. package/dist/core/guard/coordinator.js +173 -0
  274. package/dist/core/guard/evaluator.d.ts +4 -0
  275. package/dist/core/guard/evaluator.d.ts.map +1 -0
  276. package/dist/core/guard/evaluator.js +242 -0
  277. package/dist/core/guard/evidence-v3.d.ts +15 -0
  278. package/dist/core/guard/evidence-v3.d.ts.map +1 -0
  279. package/dist/core/guard/evidence-v3.js +171 -0
  280. package/dist/core/guard/index.d.ts +10 -0
  281. package/dist/core/guard/index.d.ts.map +1 -0
  282. package/dist/core/guard/index.js +6 -0
  283. package/dist/core/guard/live-source-v3.d.ts +40 -0
  284. package/dist/core/guard/live-source-v3.d.ts.map +1 -0
  285. package/dist/core/guard/live-source-v3.js +128 -0
  286. package/dist/core/guard/storage.d.ts +27 -0
  287. package/dist/core/guard/storage.d.ts.map +1 -0
  288. package/dist/core/guard/storage.js +140 -0
  289. package/dist/core/guard/types.d.ts +119 -0
  290. package/dist/core/guard/types.d.ts.map +1 -0
  291. package/dist/core/guard/types.js +1 -0
  292. package/dist/core/hooks/adapter/detect.d.ts +18 -1
  293. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  294. package/dist/core/hooks/adapter/detect.js +29 -0
  295. package/dist/core/hooks/adapter/events.d.ts +7 -4
  296. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  297. package/dist/core/hooks/adapter/events.js +12 -4
  298. package/dist/core/hooks/adapter/output.d.ts +2 -2
  299. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  300. package/dist/core/hooks/adapter/parse.d.ts +7 -4
  301. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  302. package/dist/core/hooks/adapter/parse.js +63 -25
  303. package/dist/core/hooks/adapter/wiring.d.ts +30 -4
  304. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  305. package/dist/core/hooks/adapter/wiring.js +89 -11
  306. package/dist/core/hooks/cli.d.ts +1 -1
  307. package/dist/core/hooks/cli.js +305 -444
  308. package/dist/core/hooks/effects/image-capture.d.ts +20 -34
  309. package/dist/core/hooks/effects/image-capture.d.ts.map +1 -1
  310. package/dist/core/hooks/effects/image-capture.js +212 -221
  311. package/dist/core/hooks/effects/index.d.ts +1 -9
  312. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  313. package/dist/core/hooks/effects/index.js +1 -24
  314. package/dist/core/hooks/events/input-hash.d.ts +7 -0
  315. package/dist/core/hooks/events/input-hash.d.ts.map +1 -0
  316. package/dist/core/hooks/events/input-hash.js +43 -0
  317. package/dist/core/hooks/index.d.ts +4 -3
  318. package/dist/core/hooks/index.d.ts.map +1 -1
  319. package/dist/core/hooks/index.js +2 -3
  320. package/dist/core/hooks/resolve/coord-root.d.ts +1 -1
  321. package/dist/core/hooks/resolve/coord-root.js +1 -1
  322. package/dist/core/hooks/resolve/owner.d.ts +16 -15
  323. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  324. package/dist/core/hooks/resolve/owner.js +51 -42
  325. package/dist/core/hooks/resolve/transcript.d.ts +33 -3
  326. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  327. package/dist/core/hooks/resolve/transcript.js +155 -14
  328. package/dist/core/hooks/session-name-presence.d.ts +9 -10
  329. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  330. package/dist/core/hooks/session-name-presence.js +15 -15
  331. package/dist/core/hooks/unsafe-cross-shell.d.ts +1 -1
  332. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -1
  333. package/dist/core/journal/index.d.ts +2 -2
  334. package/dist/core/journal/index.d.ts.map +1 -1
  335. package/dist/core/journal/index.js +9 -22
  336. package/dist/core/presence/blob.d.ts +4 -3
  337. package/dist/core/presence/blob.d.ts.map +1 -1
  338. package/dist/core/presence/blob.js +45 -44
  339. package/dist/core/presence/codec-digest.d.ts +29 -0
  340. package/dist/core/presence/codec-digest.d.ts.map +1 -0
  341. package/dist/core/presence/codec-digest.js +170 -0
  342. package/dist/core/presence/relay-client.d.ts +1 -1
  343. package/dist/core/presence/relay-client.d.ts.map +1 -1
  344. package/dist/core/presence/relay-client.js +5 -5
  345. package/dist/core/work/index.d.ts +1 -1
  346. package/dist/core/work/index.d.ts.map +1 -1
  347. package/dist/core/work/index.js +1 -1
  348. package/dist/core/work/read.d.ts +1 -1
  349. package/dist/core/work/read.d.ts.map +1 -1
  350. package/dist/core/work/read.js +1 -1
  351. package/dist/core/work/runner.d.ts.map +1 -1
  352. package/dist/core/work/runner.js +6 -0
  353. package/dist/core/work/state.d.ts +9 -0
  354. package/dist/core/work/state.d.ts.map +1 -1
  355. package/dist/core/work/state.js +17 -3
  356. package/dist/core/workflow/engine.js +24 -25
  357. package/dist/core/workflow/live-session-v3.d.ts +19 -0
  358. package/dist/core/workflow/live-session-v3.d.ts.map +1 -0
  359. package/dist/core/workflow/live-session-v3.js +86 -0
  360. package/dist/core/workflow/scope-id.d.ts +3 -0
  361. package/dist/core/workflow/scope-id.d.ts.map +1 -0
  362. package/dist/core/workflow/scope-id.js +5 -0
  363. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  364. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  365. package/dist/core/workflow/workspaces/leases.js +53 -19
  366. package/dist/index.d.ts +1 -1
  367. package/dist/index.js +1 -1
  368. package/dist/lib/browser/client.d.ts +41 -0
  369. package/dist/lib/browser/client.d.ts.map +1 -1
  370. package/dist/lib/browser/client.js +203 -0
  371. package/dist/lib/browser/critique.d.ts +28 -2
  372. package/dist/lib/browser/critique.d.ts.map +1 -1
  373. package/dist/lib/browser/critique.js +74 -18
  374. package/dist/lib/browser/geometry.d.ts +1 -1
  375. package/dist/lib/browser/geometry.d.ts.map +1 -1
  376. package/dist/lib/browser/geometry.js +130 -6
  377. package/dist/lib/browser/index.d.ts +3 -0
  378. package/dist/lib/browser/index.d.ts.map +1 -1
  379. package/dist/lib/browser/index.js +3 -0
  380. package/dist/lib/browser/qa-plan.d.ts +174 -0
  381. package/dist/lib/browser/qa-plan.d.ts.map +1 -0
  382. package/dist/lib/browser/qa-plan.js +321 -0
  383. package/dist/lib/browser/qa-reuse.d.ts +99 -0
  384. package/dist/lib/browser/qa-reuse.d.ts.map +1 -0
  385. package/dist/lib/browser/qa-reuse.js +222 -0
  386. package/dist/lib/browser/qa-snapshot.d.ts +64 -0
  387. package/dist/lib/browser/qa-snapshot.d.ts.map +1 -0
  388. package/dist/lib/browser/qa-snapshot.js +171 -0
  389. package/dist/lib/browser/runts.d.ts.map +1 -1
  390. package/dist/lib/browser/runts.js +22 -1
  391. package/dist/lib/browser/tiling.d.ts +71 -0
  392. package/dist/lib/browser/tiling.d.ts.map +1 -0
  393. package/dist/lib/browser/tiling.js +118 -0
  394. package/dist/lib/claude-desktop.d.ts +0 -47
  395. package/dist/lib/claude-desktop.d.ts.map +1 -1
  396. package/dist/lib/claude-desktop.js +1 -169
  397. package/dist/lib/context/index.d.ts +0 -3
  398. package/dist/lib/context/index.d.ts.map +1 -1
  399. package/dist/lib/context/index.js +20 -42
  400. package/dist/lib/council/index.d.ts +2 -2
  401. package/dist/lib/council/index.d.ts.map +1 -1
  402. package/dist/lib/council/index.js +9 -20
  403. package/dist/lib/docs-frontmatter.d.ts +1 -6
  404. package/dist/lib/docs-frontmatter.d.ts.map +1 -1
  405. package/dist/lib/docs-frontmatter.js +12 -78
  406. package/dist/lib/docs-index.d.ts +9 -0
  407. package/dist/lib/docs-index.d.ts.map +1 -1
  408. package/dist/lib/docs-index.js +32 -3
  409. package/dist/lib/docs-lint.d.ts.map +1 -1
  410. package/dist/lib/docs-lint.js +14 -30
  411. package/dist/lib/docs-metadata-audit.d.ts +19 -0
  412. package/dist/lib/docs-metadata-audit.d.ts.map +1 -0
  413. package/dist/lib/docs-metadata-audit.js +132 -0
  414. package/dist/lib/docs-metadata-managed.d.ts +23 -0
  415. package/dist/lib/docs-metadata-managed.d.ts.map +1 -0
  416. package/dist/lib/docs-metadata-managed.js +78 -0
  417. package/dist/lib/docs-metadata-sync.d.ts +20 -0
  418. package/dist/lib/docs-metadata-sync.d.ts.map +1 -0
  419. package/dist/lib/docs-metadata-sync.js +222 -0
  420. package/dist/lib/docs-metadata-v2-schema.d.ts +273 -0
  421. package/dist/lib/docs-metadata-v2-schema.d.ts.map +1 -0
  422. package/dist/lib/docs-metadata-v2-schema.js +134 -0
  423. package/dist/lib/docs-metadata-v2.d.ts +21 -0
  424. package/dist/lib/docs-metadata-v2.d.ts.map +1 -0
  425. package/dist/lib/docs-metadata-v2.js +424 -0
  426. package/dist/lib/docs-new.d.ts +11 -0
  427. package/dist/lib/docs-new.d.ts.map +1 -0
  428. package/dist/lib/docs-new.js +52 -0
  429. package/dist/lib/docs-sweep.d.ts.map +1 -1
  430. package/dist/lib/docs-sweep.js +45 -29
  431. package/dist/lib/headless/index.d.ts +98 -0
  432. package/dist/lib/headless/index.d.ts.map +1 -0
  433. package/dist/lib/headless/index.js +211 -0
  434. package/dist/lib/identities/assume.d.ts +4 -4
  435. package/dist/lib/identities/assume.d.ts.map +1 -1
  436. package/dist/lib/identities/assume.js +52 -68
  437. package/dist/lib/instructions/apply.d.ts +5 -4
  438. package/dist/lib/instructions/apply.d.ts.map +1 -1
  439. package/dist/lib/instructions/apply.js +32 -20
  440. package/dist/lib/instructions/bundle.d.ts +1 -1
  441. package/dist/lib/instructions/bundle.d.ts.map +1 -1
  442. package/dist/lib/instructions/bundle.js +1 -1
  443. package/dist/lib/instructions/splice.d.ts +0 -1
  444. package/dist/lib/instructions/splice.d.ts.map +1 -1
  445. package/dist/lib/instructions/splice.js +22 -6
  446. package/dist/lib/instructions/templates.d.ts +7 -5
  447. package/dist/lib/instructions/templates.d.ts.map +1 -1
  448. package/dist/lib/instructions/templates.js +79 -9
  449. package/package.json +26 -5
  450. package/schemas/config.schema.json +120 -2
  451. package/schemas/docs-metadata-v2.schema.json +441 -0
  452. package/schemas/event-v3.schema.json +24472 -0
  453. package/src/commander.ts +4 -0
  454. package/src/commands/adapter.ts +2 -2
  455. package/src/commands/agents.ts +1512 -592
  456. package/src/commands/artifacts.ts +7 -6
  457. package/src/commands/backup.ts +29 -21
  458. package/src/commands/browse.ts +349 -8
  459. package/src/commands/checkpoint.ts +4 -3
  460. package/src/commands/claude-desktop.ts +2 -101
  461. package/src/commands/context.ts +1 -6
  462. package/src/commands/decision.ts +27 -44
  463. package/src/commands/docs.ts +100 -33
  464. package/src/commands/doctor.ts +14 -1
  465. package/src/commands/events.ts +256 -0
  466. package/src/commands/governor.ts +18 -4
  467. package/src/commands/init.ts +253 -57
  468. package/src/commands/instructions.ts +1 -1
  469. package/src/commands/journal.ts +4 -13
  470. package/src/commands/ledger-v3.ts +111 -0
  471. package/src/commands/presence.ts +11 -10
  472. package/src/commands/sync.ts +1 -1
  473. package/src/commands/web.ts +67 -8
  474. package/src/commands/work.ts +28 -5
  475. package/src/core/adapter.ts +5 -0
  476. package/src/core/adapters/attest.ts +8 -3
  477. package/src/core/adapters/profiles.ts +4 -6
  478. package/src/core/agents/canonical-emit.ts +34 -25
  479. package/src/core/agents/cli.ts +236 -262
  480. package/src/core/agents/codex-archive-v3.ts +138 -0
  481. package/src/core/agents/coord-client.ts +75 -188
  482. package/src/core/agents/finalization.ts +120 -38
  483. package/src/core/agents/index.ts +4 -5
  484. package/src/core/agents/live-authority-v3.ts +375 -0
  485. package/src/core/agents/live-lifecycle-v3.ts +140 -0
  486. package/src/core/agents/live-observation-v3.ts +216 -0
  487. package/src/core/agents/render/prompt-context.ts +29 -60
  488. package/src/core/agents/render/session-context.ts +10 -31
  489. package/src/core/agents/rules/claim-conflict.ts +85 -126
  490. package/src/core/agents/rules/commit-conflict.ts +3 -14
  491. package/src/core/agents/rules/stop-hook.ts +239 -293
  492. package/src/core/agents/session-events.ts +137 -104
  493. package/src/core/agents/session-finalization-state-v3.ts +90 -0
  494. package/src/core/agents/session-finalizer-v3.ts +953 -0
  495. package/src/core/agents/session-name-display.ts +149 -0
  496. package/src/core/agents/state/activity-log.ts +6 -14
  497. package/src/core/agents/state/heartbeat-writer.ts +82 -277
  498. package/src/core/agents/state/live-coordination-view.ts +290 -0
  499. package/src/core/agents/state/session-state.ts +24 -12
  500. package/src/core/agents/state/stale-sweep.ts +40 -17
  501. package/src/core/artifacts/index.ts +4 -5
  502. package/src/core/config.ts +148 -3
  503. package/src/core/context/index.ts +9 -17
  504. package/src/core/events/span-parent.ts +46 -0
  505. package/src/core/events/v3/advance.ts +168 -0
  506. package/src/core/events/v3/authority-outbox.ts +548 -0
  507. package/src/core/events/v3/base-builder.ts +60 -0
  508. package/src/core/events/v3/base-contract.ts +882 -0
  509. package/src/core/events/v3/bootstrap.ts +226 -0
  510. package/src/core/events/v3/builder.ts +59 -0
  511. package/src/core/events/v3/canonical.ts +153 -0
  512. package/src/core/events/v3/capabilities.ts +171 -0
  513. package/src/core/events/v3/capability-drift.ts +177 -0
  514. package/src/core/events/v3/catalog.ts +202 -0
  515. package/src/core/events/v3/clock-order.ts +41 -0
  516. package/src/core/events/v3/contract.ts +365 -0
  517. package/src/core/events/v3/control.ts +576 -0
  518. package/src/core/events/v3/coordination-view.ts +193 -0
  519. package/src/core/events/v3/economics.ts +384 -0
  520. package/src/core/events/v3/finalization-view.ts +67 -0
  521. package/src/core/events/v3/fingerprint-keys.ts +260 -0
  522. package/src/core/events/v3/generated.ts +48 -0
  523. package/src/core/events/v3/ids.ts +33 -0
  524. package/src/core/events/v3/index.ts +322 -0
  525. package/src/core/events/v3/latency.ts +551 -0
  526. package/src/core/events/v3/live-feed.ts +319 -0
  527. package/src/core/events/v3/live-observation.ts +39 -0
  528. package/src/core/events/v3/live-routing.ts +135 -0
  529. package/src/core/events/v3/producers/command-recorder.ts +631 -0
  530. package/src/core/events/v3/producers/command.ts +201 -0
  531. package/src/core/events/v3/producers/coordination-recorder.ts +538 -0
  532. package/src/core/events/v3/producers/coordination.ts +355 -0
  533. package/src/core/events/v3/producers/hook-base.ts +578 -0
  534. package/src/core/events/v3/producers/hook.ts +119 -0
  535. package/src/core/events/v3/producers/intake.ts +319 -0
  536. package/src/core/events/v3/producers/recorder.ts +2805 -0
  537. package/src/core/events/v3/projection.ts +653 -0
  538. package/src/core/events/v3/reader.ts +1127 -0
  539. package/src/core/events/v3/recovery-record.ts +244 -0
  540. package/src/core/events/v3/recovery.ts +271 -0
  541. package/src/core/events/v3/span-state.ts +187 -0
  542. package/src/core/events/v3/targets.ts +182 -0
  543. package/src/core/events/v3/turn-telemetry.ts +259 -0
  544. package/src/core/events/v3/validate.ts +361 -0
  545. package/src/core/events/v3/writer.ts +419 -0
  546. package/src/core/governor/index.ts +3 -0
  547. package/src/core/governor/plan-api.ts +10 -2
  548. package/src/core/governor/read.ts +3 -0
  549. package/src/core/governor/state.ts +52 -5
  550. package/src/core/guard/config.ts +206 -0
  551. package/src/core/guard/coordinator.ts +227 -0
  552. package/src/core/guard/evaluator.ts +343 -0
  553. package/src/core/guard/evidence-v3.ts +227 -0
  554. package/src/core/guard/index.ts +35 -0
  555. package/src/core/guard/live-source-v3.ts +204 -0
  556. package/src/core/guard/storage.ts +188 -0
  557. package/src/core/guard/types.ts +152 -0
  558. package/src/core/hooks/adapter/detect.ts +28 -1
  559. package/src/core/hooks/adapter/events.ts +14 -6
  560. package/src/core/hooks/adapter/output.ts +7 -2
  561. package/src/core/hooks/adapter/parse.ts +80 -38
  562. package/src/core/hooks/adapter/wiring.ts +119 -13
  563. package/src/core/hooks/cli.ts +351 -466
  564. package/src/core/hooks/effects/image-capture.ts +228 -238
  565. package/src/core/hooks/effects/index.ts +1 -36
  566. package/src/core/hooks/events/input-hash.ts +47 -0
  567. package/src/core/hooks/index.ts +4 -11
  568. package/src/core/hooks/resolve/coord-root.ts +1 -1
  569. package/src/core/hooks/resolve/owner.ts +52 -45
  570. package/src/core/hooks/resolve/transcript.ts +165 -14
  571. package/src/core/hooks/session-name-presence.ts +14 -15
  572. package/src/core/hooks/unsafe-cross-shell.ts +1 -1
  573. package/src/core/journal/index.ts +15 -22
  574. package/src/core/presence/blob.ts +48 -41
  575. package/src/core/presence/codec-digest.ts +215 -0
  576. package/src/core/presence/relay-client.ts +5 -5
  577. package/src/core/work/index.ts +3 -0
  578. package/src/core/work/read.ts +3 -0
  579. package/src/core/work/runner.ts +6 -0
  580. package/src/core/work/state.ts +27 -3
  581. package/src/core/workflow/engine.ts +24 -24
  582. package/src/core/workflow/live-session-v3.ts +126 -0
  583. package/src/core/workflow/scope-id.ts +6 -0
  584. package/src/core/workflow/workspaces/leases.ts +56 -19
  585. package/src/index.ts +1 -1
  586. package/src/lib/browser/client.ts +230 -0
  587. package/src/lib/browser/critique.ts +104 -27
  588. package/src/lib/browser/geometry.ts +143 -14
  589. package/src/lib/browser/index.ts +34 -0
  590. package/src/lib/browser/qa-plan.ts +501 -0
  591. package/src/lib/browser/qa-reuse.ts +306 -0
  592. package/src/lib/browser/qa-snapshot.ts +223 -0
  593. package/src/lib/browser/runts.ts +27 -2
  594. package/src/lib/browser/tiling.ts +164 -0
  595. package/src/lib/claude-desktop.ts +1 -232
  596. package/src/lib/context/index.ts +22 -46
  597. package/src/lib/council/index.ts +9 -21
  598. package/src/lib/docs-frontmatter.ts +8 -79
  599. package/src/lib/docs-index.ts +33 -3
  600. package/src/lib/docs-lint.ts +15 -29
  601. package/src/lib/docs-metadata-audit.ts +166 -0
  602. package/src/lib/docs-metadata-managed.ts +83 -0
  603. package/src/lib/docs-metadata-sync.ts +262 -0
  604. package/src/lib/docs-metadata-v2-schema.ts +138 -0
  605. package/src/lib/docs-metadata-v2.ts +646 -0
  606. package/src/lib/docs-new.ts +63 -0
  607. package/src/lib/docs-sweep.ts +42 -28
  608. package/src/lib/headless/index.ts +287 -0
  609. package/src/lib/identities/assume.ts +67 -86
  610. package/src/lib/instructions/apply.ts +33 -20
  611. package/src/lib/instructions/bundle.ts +2 -2
  612. package/src/lib/instructions/splice.ts +21 -7
  613. package/src/lib/instructions/templates.ts +85 -13
  614. package/dist/core/agents/cli-emit.d.ts +0 -27
  615. package/dist/core/agents/cli-emit.d.ts.map +0 -1
  616. package/dist/core/agents/cli-emit.js +0 -57
  617. package/dist/core/agents/codex-replay.d.ts +0 -29
  618. package/dist/core/agents/codex-replay.d.ts.map +0 -1
  619. package/dist/core/agents/codex-replay.js +0 -138
  620. package/dist/core/agents/events/consume.d.ts +0 -82
  621. package/dist/core/agents/events/consume.d.ts.map +0 -1
  622. package/dist/core/agents/events/consume.js +0 -195
  623. package/dist/core/agents/events/emit.d.ts +0 -42
  624. package/dist/core/agents/events/emit.d.ts.map +0 -1
  625. package/dist/core/agents/events/emit.js +0 -75
  626. package/dist/core/agents/events/ulid.d.ts +0 -11
  627. package/dist/core/agents/events/ulid.d.ts.map +0 -1
  628. package/dist/core/agents/events/ulid.js +0 -47
  629. package/dist/core/agents/state/heartbeat-projector.d.ts +0 -76
  630. package/dist/core/agents/state/heartbeat-projector.d.ts.map +0 -1
  631. package/dist/core/agents/state/heartbeat-projector.js +0 -581
  632. package/dist/core/hooks/events/emit.d.ts +0 -31
  633. package/dist/core/hooks/events/emit.d.ts.map +0 -1
  634. package/dist/core/hooks/events/emit.js +0 -93
  635. package/dist/core/hooks/events/rotate.d.ts +0 -43
  636. package/dist/core/hooks/events/rotate.d.ts.map +0 -1
  637. package/dist/core/hooks/events/rotate.js +0 -142
  638. package/dist/core/hooks/events/schema.d.ts +0 -341
  639. package/dist/core/hooks/events/schema.d.ts.map +0 -1
  640. package/dist/core/hooks/events/schema.js +0 -12
  641. package/dist/core/hooks/events/ulid.d.ts +0 -10
  642. package/dist/core/hooks/events/ulid.d.ts.map +0 -1
  643. package/dist/core/hooks/events/ulid.js +0 -47
  644. package/dist/lib/docs-frontmatter-migrate.d.ts +0 -33
  645. package/dist/lib/docs-frontmatter-migrate.d.ts.map +0 -1
  646. package/dist/lib/docs-frontmatter-migrate.js +0 -364
  647. package/src/core/agents/cli-emit.ts +0 -64
  648. package/src/core/agents/codex-replay.ts +0 -163
  649. package/src/core/agents/events/consume.ts +0 -254
  650. package/src/core/agents/events/emit.ts +0 -115
  651. package/src/core/agents/events/ulid.ts +0 -51
  652. package/src/core/agents/state/heartbeat-projector.ts +0 -645
  653. package/src/core/hooks/events/emit.ts +0 -125
  654. package/src/core/hooks/events/rotate.ts +0 -151
  655. package/src/core/hooks/events/schema.ts +0 -582
  656. package/src/core/hooks/events/ulid.ts +0 -51
  657. package/src/lib/docs-frontmatter-migrate.ts +0 -427
@@ -4,7 +4,7 @@
4
4
  * harn agents whoami current agent's name + instance_id + claims
5
5
  * harn agents list all active agents (default: fold transients)
6
6
  * harn agents list --all include raw kind=transient rows
7
- * harn agents list --stale include heartbeats older than the freshness window
7
+ * harn agents list --stale include generations older than the freshness window
8
8
  * harn agents list --json JSON output (alias for --format json)
9
9
  * harn agents status end-of-turn status box (name + age + files + peers)
10
10
  * harn agents heal-events PIDMAP_HEAL telemetry (pid-map self-heal frequency)
@@ -29,7 +29,6 @@ import { basename, join, resolve } from "node:path";
29
29
  import type { Command } from "commander";
30
30
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
31
31
  import { coordBinPath } from "../core/agents/coord-bin.ts";
32
- import { readStreamTailBounded } from "../core/agents/events/consume.ts";
33
32
  import {
34
33
  checkGitFinalization,
35
34
  formatGitFinalizationFailure,
@@ -37,32 +36,72 @@ import {
37
36
  readSessionWriteClaims,
38
37
  } from "../core/agents/finalization.ts";
39
38
  import {
40
- emitCanonical,
41
- type Heartbeat,
39
+ emitEventV3,
42
40
  monorepoRoot,
43
41
  normalizeAdapter,
44
- readHeartbeat,
45
42
  resolveOwner,
46
43
  resolveOwnerBySessionEnv,
47
44
  resolveOwnerWithSource,
45
+ sessionIdentityFromEnv,
48
46
  } from "../core/agents/index.ts";
47
+ import { reopenLiveCoordinationGenerationV3 } from "../core/agents/live-authority-v3.ts";
48
+ import {
49
+ listSessionFinalizationRequestsV3,
50
+ observeHostDisappearedV3,
51
+ reconcileSessionFinalizationV3,
52
+ requestSessionEndExplicitV3,
53
+ type SessionFinalizationRequestV3,
54
+ } from "../core/agents/session-finalizer-v3.ts";
55
+ import { SESSION_NAME_DISPLAY_NOTE } from "../core/agents/session-name-display.ts";
49
56
  import {
50
57
  buildLifecycleSuggestedName,
51
58
  buildSuggestedName,
59
+ type Heartbeat,
60
+ readHeartbeat as readHeartbeatCache,
52
61
  } from "../core/agents/state/heartbeat-writer.ts";
62
+ import {
63
+ readLiveCoordinationRow,
64
+ readLiveCoordinationRows,
65
+ } from "../core/agents/state/live-coordination-view.ts";
53
66
  import {
54
67
  type AgentActivity,
55
68
  foldSessionState,
56
69
  type TaskState,
57
70
  } from "../core/agents/state/session-state.ts";
58
- import { coordFreshnessSeconds, resolveBinName } from "../core/config.ts";
71
+ import {
72
+ coordFreshnessSeconds,
73
+ resolveBinName,
74
+ sessionFinalizationConfig,
75
+ } from "../core/config.ts";
76
+ import type { EventTypeV3 } from "../core/events/v3/contract.ts";
77
+ import { readEventV3ControlState } from "../core/events/v3/control.ts";
78
+ import { projectCoordinationViewV3 } from "../core/events/v3/coordination-view.ts";
79
+ import { liveInstanceIdV3 } from "../core/events/v3/live-routing.ts";
80
+ import {
81
+ listHookIntakeGroupsV3,
82
+ listHookIntakeRecordsV3,
83
+ } from "../core/events/v3/producers/intake.ts";
84
+ import {
85
+ listHookProducerStateRecordsV3,
86
+ readHookProducerStateV3,
87
+ } from "../core/events/v3/producers/recorder.ts";
88
+ import { readLedgerV3 } from "../core/events/v3/reader.ts";
89
+ import { EVENT_V3_LEDGER_RELATIVE_ROOT } from "../core/events/v3/writer.ts";
90
+ import type { RunQualitySnapshot, RunQualityStatus } from "../core/guard/index.ts";
91
+ import { evaluateRunQualityIfDue } from "../core/guard/index.ts";
59
92
  import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
60
93
  import { registerContextCommand } from "./context.ts";
61
94
 
95
+ type LiveCoordinationRow = ReturnType<typeof readLiveCoordinationRows>[number];
96
+
97
+ function readCurrentCoordinationRow(instanceId: string): LiveCoordinationRow | null {
98
+ const root = monorepoRoot();
99
+ return root ? readLiveCoordinationRow(root, instanceId) : null;
100
+ }
101
+
62
102
  /** Cap for CLI scans of the unbounded event ledger (`trace` / `health`). Well
63
103
  * under V8's ~512MB max string length so a `readFileSync` of the whole file can
64
104
  * never throw; covers ample recent history for a diagnostic scan. */
65
- const STREAM_SCAN_CAP_BYTES = 128 * 1024 * 1024; // 128 MiB
66
105
 
67
106
  import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
68
107
  import { appendEntry, resolveOwnerByName } from "../core/journal/index.ts";
@@ -113,7 +152,7 @@ function freshnessCutoffSecs(): number {
113
152
  }
114
153
 
115
154
  const SUBAGENT_NOTE =
116
- "Bash identity is process-level in v1; if you're running inside a subagent, " +
155
+ "Bash identity is process-level; if you're running inside a subagent, " +
117
156
  "this resolves to the parent group's name, not the subagent's. A subagent-aware " +
118
157
  "bridge (per-shell marker file at .harnery/shells/<pid>) is out of scope.";
119
158
 
@@ -187,17 +226,14 @@ interface Row {
187
226
  started_at: string;
188
227
  last_heartbeat: string;
189
228
  files_touched: string[];
190
- last_tool?: string | null;
191
- last_tool_target?: string | null;
192
229
  task?: string | null;
193
230
  activity: AgentActivity;
194
231
  activity_updated_at?: string | null;
195
232
  activity_source?: string | null;
196
233
  task_state: TaskState;
234
+ task_state_scope: "current" | "historical";
197
235
  task_state_updated_at?: string | null;
198
236
  task_state_reason?: string | null;
199
- turn_summary?: string | null;
200
- turn_summary_updated_at?: string | null;
201
237
  platform?: string | null;
202
238
  /** Set on relation=remote rows: the machine label the row arrived from
203
239
  * via the cross-machine presence transport (ADR 0016). */
@@ -212,6 +248,20 @@ function taskStateOf(hb: Pick<Heartbeat, "task_state">): TaskState {
212
248
  return hb.task_state ?? "active";
213
249
  }
214
250
 
251
+ /** Producer joins use the private native session ID; `session_id` is the canonical V3 fingerprint. */
252
+ export function nativeSessionIdentity(
253
+ row: Pick<Heartbeat, "native_session_id" | "session_id"> | null | undefined,
254
+ fallback: string,
255
+ ): string {
256
+ if (row?.native_session_id) return row.native_session_id;
257
+ // A projection-only row carries the privacy-safe canonical session
258
+ // fingerprint, not the adapter's native session ID needed to join the hook
259
+ // producer. Until a disposable cache exists, the resolved owner is the only
260
+ // native identity available to command surfaces such as `agents status`.
261
+ if (row?.session_id && !/^sid_[a-f0-9]{64}$/.test(row.session_id)) return row.session_id;
262
+ return fallback;
263
+ }
264
+
215
265
  function lifecycleLabel(hb: Pick<Heartbeat, "task_state" | "task_state_reason">): string {
216
266
  const state = taskStateOf(hb);
217
267
  return state === "blocked" && hb.task_state_reason ? `${state}: ${hb.task_state_reason}` : state;
@@ -245,7 +295,7 @@ export function registerAgentsCommand(
245
295
  .command("list")
246
296
  .description("List all active agents (folds kind=transient by default)")
247
297
  .option("--all", "Include raw kind=transient rows (no fold)")
248
- .option("--stale", "Include heartbeats older than the freshness window")
298
+ .option("--stale", "Include generations older than the freshness window")
249
299
  .option("--json", "JSON output (alias for --format json)")
250
300
  .action((opts: { all?: boolean; stale?: boolean; json?: boolean }) => {
251
301
  runList(opts);
@@ -259,15 +309,21 @@ export function registerAgentsCommand(
259
309
  "--end-turn",
260
310
  "Treat this as the turn's closing status: issue the box only when this session's held paths are committed and their repositories are pushed",
261
311
  )
312
+ .option(
313
+ "--end-session",
314
+ "After the closing status, durably request an authoritative session end as soon as this exact turn closes (requires --end-turn)",
315
+ )
262
316
  .option(
263
317
  "--session-id <id>",
264
- "Lookup heartbeat by session_id directly, bypassing the ppid walk. " +
318
+ "Lookup the V3 generation by session_id directly, bypassing the ppid walk. " +
265
319
  "Use this when calling from a hook (the hook's process tree may not lead back to Claude Code's session pid). " +
266
320
  "The Stop hook payload includes session_id; pass it through.",
267
321
  )
268
- .action((opts: { endTurn?: boolean; json?: boolean; sessionId?: string }) => {
269
- runStatus(opts);
270
- });
322
+ .action(
323
+ (opts: { endTurn?: boolean; endSession?: boolean; json?: boolean; sessionId?: string }) => {
324
+ runStatus(opts);
325
+ },
326
+ );
271
327
 
272
328
  cmd
273
329
  .command("suggest-name [description...]")
@@ -280,7 +336,7 @@ export function registerAgentsCommand(
280
336
  .option("--json", "JSON output instead of the bare name")
281
337
  .option(
282
338
  "--session-id <id>",
283
- "Lookup heartbeat by session_id directly, bypassing the ppid walk.",
339
+ "Lookup the V3 generation by session_id directly, bypassing the ppid walk.",
284
340
  )
285
341
  .action((description: string[], opts: { json?: boolean; sessionId?: string }) => {
286
342
  runSuggestName(description, opts);
@@ -289,7 +345,7 @@ export function registerAgentsCommand(
289
345
  cmd
290
346
  .command("watch")
291
347
  .description(
292
- "Stream peer state changes in real time (file watcher on .harnery/active/). " +
348
+ "Stream peer state changes from the authoritative V3 coordination projection. " +
293
349
  "Prints one line per delta: started / ended / activity / file claim / task change.",
294
350
  )
295
351
  .option("--poll-ms <n>", "Debounce window after a change event", "200")
@@ -311,15 +367,15 @@ export function registerAgentsCommand(
311
367
  cmd
312
368
  .command("trace <name>")
313
369
  .description(
314
- "Reconstruct one agent's coordination lifecycle from events.ndjson: " +
315
- "session.start → prompts → turns → tools → heals/sweeps → claims → end, " +
370
+ "Reconstruct one agent's coordination lifecycle from the active canonical event ledger: " +
371
+ "session.started → prompts → turns → tools → observations → session.ended, " +
316
372
  "in chronological order. The answer to 'what happened to this agent / why did " +
317
373
  "it vanish?' without hand-grepping the stream. Accepts a name (agent-Foo or Foo) " +
318
374
  "or an instance_id.",
319
375
  )
320
376
  .option("--since <window>", "Only events newer than Nh|Nd (default: all)")
321
377
  .option("--limit <n>", "Show at most N most-recent events. Default: 200.", "200")
322
- .option("--all-tools", "Include tool.post_use + command.* (default: hidden as noise)")
378
+ .option("--all-tools", "Include tool.completed + command.* (default: hidden as noise)")
323
379
  .option("--json", "JSON envelope output")
324
380
  .action(
325
381
  (
@@ -356,6 +412,60 @@ export function registerAgentsCommand(
356
412
  runLifecycle(state, opts);
357
413
  });
358
414
 
415
+ cmd
416
+ .command("end")
417
+ .description(
418
+ "Finalize the current V3 session explicitly, or durably queue finalization until the current turn and tool spans close.",
419
+ )
420
+ .option("--session-id <id>", "Native adapter session id to finalize")
421
+ .option("--instance-id <id>", "Canonical V3 instance id to finalize")
422
+ .option(
423
+ "--outcome <outcome>",
424
+ "succeeded | failed | cancelled | timed_out | denied | interrupted | unknown",
425
+ "succeeded",
426
+ )
427
+ .action((opts: { sessionId?: string; instanceId?: string; outcome: string }) => {
428
+ runEndSession(opts);
429
+ });
430
+
431
+ cmd
432
+ .command("reconcile")
433
+ .description(
434
+ "Reconcile archive, idle, parent/run completion, stale, superseded, and host lifecycle signals into V3 session finalization.",
435
+ )
436
+ .option("--watch", "Keep reconciling until interrupted")
437
+ .option("--interval-seconds <n>", "Watch interval in seconds")
438
+ .option("--json", "JSON output")
439
+ .action(async (opts: { watch?: boolean; intervalSeconds?: string; json?: boolean }) => {
440
+ await runSessionReconcile(opts);
441
+ });
442
+
443
+ cmd
444
+ .command("observe-archive")
445
+ .description(
446
+ "Record an adapter archive or unarchive observation and reconcile it through the canonical V3 finalizer.",
447
+ )
448
+ .requiredOption("--adapter <id>", "claude-code | codex | cursor")
449
+ .requiredOption("--session-id <id>", "Native adapter session id")
450
+ .option("--unarchived", "Cancel a pending archive finalization")
451
+ .option("--observed-at <iso>", "Observation time (defaults to now)")
452
+ .action(
453
+ (opts: { adapter: string; sessionId: string; unarchived?: boolean; observedAt?: string }) =>
454
+ runObserveArchive(opts),
455
+ );
456
+
457
+ cmd
458
+ .command("observe-host-disappeared")
459
+ .description(
460
+ "Record a provisional host-loss observation; finalization follows only after the configured cascade grace period.",
461
+ )
462
+ .requiredOption("--instance-id <id>", "Canonical V3 instance id")
463
+ .requiredOption("--generation-id <id>", "Canonical V3 generation id")
464
+ .option("--observed-at <iso>", "Observation time (defaults to now)")
465
+ .action((opts: { instanceId: string; generationId: string; observedAt?: string }) => {
466
+ runObserveHostDisappeared(opts);
467
+ });
468
+
359
469
  cmd
360
470
  .command("release-claim <path>")
361
471
  .description(
@@ -467,18 +577,19 @@ export function registerAgentsCommand(
467
577
  .command("heal")
468
578
  .description(
469
579
  "Force a coord-layer recovery action on a specific agent. " +
470
- "Kinds: pidmap (force PIDMAP_HEAL), heartbeat (force HEARTBEAT_HEAL), " +
471
- "kill (rm the heartbeat file). Runs through the same heartbeat flock " +
472
- "the live hooks use, so it governs the operation safely.",
580
+ "Kinds: pidmap (repair process attribution) and cache (rebuild the " +
581
+ "disposable V3 coordination cache from the authoritative ledger).",
473
582
  )
474
583
  .requiredOption("--owner <id>", "Target agent's instance_id")
475
- .requiredOption("--kind <kind>", "pidmap | heartbeat | kill")
584
+ .requiredOption("--kind <kind>", "pidmap | cache")
476
585
  .option(
477
586
  "--session-id <id>",
478
- "(--kind heartbeat) session_id to stamp on the heartbeat. " +
479
- "Default: inherit from existing heartbeat if one exists. " +
480
- "Required when no heartbeat exists yet (a heartbeat without " +
481
- "session_id fails schema validation and pollutes the audit trail).",
587
+ "(--kind cache) native session id used to join the authoritative V3 generation.",
588
+ )
589
+ .option(
590
+ "--adapter <id>",
591
+ "(--kind cache) adapter used to validate the authoritative V3 generation: " +
592
+ "claude-code | cursor | codex.",
482
593
  )
483
594
  .option(
484
595
  "--pid <pid>",
@@ -489,13 +600,218 @@ export function registerAgentsCommand(
489
600
  )
490
601
  .option("--json", "JSON envelope output")
491
602
  .action(
492
- (opts: { owner: string; kind: string; sessionId?: string; pid?: string; json?: boolean }) => {
603
+ (opts: {
604
+ owner: string;
605
+ kind: string;
606
+ sessionId?: string;
607
+ adapter?: string;
608
+ pid?: string;
609
+ json?: boolean;
610
+ }) => {
493
611
  runHeal(opts);
494
612
  },
495
613
  );
496
614
  registerIdentityCommands(cmd);
497
615
  }
498
616
 
617
+ const SESSION_OUTCOMES = new Set([
618
+ "succeeded",
619
+ "failed",
620
+ "cancelled",
621
+ "timed_out",
622
+ "denied",
623
+ "interrupted",
624
+ "unknown",
625
+ ]);
626
+
627
+ function runEndSession(opts: { sessionId?: string; instanceId?: string; outcome: string }) {
628
+ const root = monorepoRoot();
629
+ if (!root) return failCommand("not_in_repo", "not in an agent session");
630
+ if (!SESSION_OUTCOMES.has(opts.outcome)) {
631
+ return failCommand("invalid_outcome", "invalid session outcome");
632
+ }
633
+ const target = opts.instanceId ?? opts.sessionId ?? resolveOwner();
634
+ if (!target) {
635
+ return failCommand(
636
+ "session_identity_missing",
637
+ "could not resolve the current session; pass --session-id or --instance-id",
638
+ );
639
+ }
640
+ const byInstance = listHookProducerStateRecordsV3(root, { includeTerminal: false }).filter(
641
+ ({ state }) => state.instance_id === target,
642
+ );
643
+ const byNative = (["claude-code", "codex", "cursor"] as const).flatMap((adapter) => {
644
+ const state = readHookProducerStateV3(root, adapter, target);
645
+ return state && !state.terminal ? [{ path: "", modified_at_ms: 0, state }] : [];
646
+ });
647
+ const matches = byInstance.length > 0 ? byInstance : byNative;
648
+ if (matches.length !== 1) {
649
+ return failCommand(
650
+ "session_identity_ambiguous",
651
+ `expected one live V3 generation for the target; found ${matches.length}`,
652
+ );
653
+ }
654
+ const record = matches[0];
655
+ if (!record) {
656
+ return failCommand("session_identity_missing", "live V3 generation disappeared");
657
+ }
658
+ const state = record.state;
659
+ if (state.delegations.length > 0) {
660
+ return failCommand(
661
+ "session_work_open",
662
+ `cannot finalize while ${state.delegations.length} delegated child(ren) remain open`,
663
+ );
664
+ }
665
+ const history = readSessionWriteClaims(root, state.instance_id, state.session_id);
666
+ const finalized = checkGitFinalization(root, history.paths, {
667
+ claimHistoryComplete: history.complete,
668
+ });
669
+ if (!finalized.ok) {
670
+ return failCommand(
671
+ "git_not_finalized",
672
+ formatGitFinalizationFailure(finalized, resolveBinName(root)),
673
+ );
674
+ }
675
+ const result = requestSessionEndExplicitV3({
676
+ coordRoot: root,
677
+ instance_id: state.instance_id,
678
+ generation_id: state.generation_id,
679
+ outcome: opts.outcome as
680
+ | "succeeded"
681
+ | "failed"
682
+ | "cancelled"
683
+ | "timed_out"
684
+ | "denied"
685
+ | "interrupted"
686
+ | "unknown",
687
+ coordination_finalized: true,
688
+ });
689
+ if (
690
+ result.state !== "recorded" &&
691
+ result.state !== "already_ended" &&
692
+ result.state !== "queued" &&
693
+ result.state !== "already_requested"
694
+ ) {
695
+ return failCommand("session_end_failed", JSON.stringify(result));
696
+ }
697
+ const terminalEventId =
698
+ result.state === "recorded"
699
+ ? result.event.event_id
700
+ : result.state === "already_ended"
701
+ ? result.event_id
702
+ : undefined;
703
+ const requestId =
704
+ result.state === "queued" || result.state === "already_requested"
705
+ ? result.request.request_id
706
+ : undefined;
707
+ emit.data({
708
+ ok: true,
709
+ state: result.state,
710
+ instance_id: state.instance_id,
711
+ generation_id: state.generation_id,
712
+ ...(terminalEventId ? { terminal_event_id: terminalEventId } : {}),
713
+ ...(requestId ? { request_id: requestId } : {}),
714
+ authority: "approved",
715
+ reason: "approved_explicit_end",
716
+ });
717
+ }
718
+
719
+ async function runSessionReconcile(opts: {
720
+ watch?: boolean;
721
+ intervalSeconds?: string;
722
+ json?: boolean;
723
+ }): Promise<void> {
724
+ const root = monorepoRoot();
725
+ if (!root) return failCommand("not_in_repo", "not in an agent session");
726
+ if (opts.json) emit.config({ format: "json" });
727
+ const interval = opts.intervalSeconds
728
+ ? Number.parseInt(opts.intervalSeconds, 10)
729
+ : sessionFinalizationConfig(root).reconcileIntervalSeconds;
730
+ if (!Number.isSafeInteger(interval) || interval < 1) {
731
+ return failCommand("invalid_interval", "interval-seconds must be a positive integer");
732
+ }
733
+ let stopped = false;
734
+ do {
735
+ emit.data(reconcileSessionFinalizationV3(root));
736
+ if (!opts.watch || stopped) return;
737
+ await new Promise<void>((resolvePromise) => {
738
+ const timer = setTimeout(resolvePromise, interval * 1_000);
739
+ const stop = () => {
740
+ stopped = true;
741
+ clearTimeout(timer);
742
+ process.off("SIGINT", stop);
743
+ process.off("SIGTERM", stop);
744
+ resolvePromise();
745
+ };
746
+ process.once("SIGINT", stop);
747
+ process.once("SIGTERM", stop);
748
+ });
749
+ } while (opts.watch && !stopped);
750
+ }
751
+
752
+ function runObserveArchive(opts: {
753
+ adapter: string;
754
+ sessionId: string;
755
+ unarchived?: boolean;
756
+ observedAt?: string;
757
+ }) {
758
+ const root = monorepoRoot();
759
+ if (!root) return failCommand("not_in_repo", "not in an agent session");
760
+ if (!(["claude-code", "codex", "cursor"] as string[]).includes(opts.adapter)) {
761
+ return failCommand("invalid_adapter", "adapter must be claude-code, codex, or cursor");
762
+ }
763
+ const adapter = opts.adapter as "claude-code" | "codex" | "cursor";
764
+ const observedAt = opts.observedAt ?? new Date().toISOString();
765
+ if (Number.isNaN(Date.parse(observedAt))) {
766
+ return failCommand("invalid_observed_at", "observed-at must be an ISO timestamp");
767
+ }
768
+ emit.data(
769
+ reconcileSessionFinalizationV3(root, {
770
+ now: new Date(observedAt),
771
+ archive_observations: [
772
+ {
773
+ adapter,
774
+ native_session_id: opts.sessionId,
775
+ archived: !opts.unarchived,
776
+ observed_at: observedAt,
777
+ },
778
+ ],
779
+ }),
780
+ );
781
+ }
782
+
783
+ function runObserveHostDisappeared(opts: {
784
+ instanceId: string;
785
+ generationId: string;
786
+ observedAt?: string;
787
+ }) {
788
+ const root = monorepoRoot();
789
+ if (!root) return failCommand("not_in_repo", "not in an agent session");
790
+ if (!/^inst_[A-Za-z0-9._-]+$/.test(opts.instanceId)) {
791
+ return failCommand("invalid_instance_id", "invalid V3 instance id");
792
+ }
793
+ if (!/^gen_[A-Za-z0-9._-]+$/.test(opts.generationId)) {
794
+ return failCommand("invalid_generation_id", "invalid V3 generation id");
795
+ }
796
+ const observedAt = opts.observedAt ?? new Date().toISOString();
797
+ if (Number.isNaN(Date.parse(observedAt))) {
798
+ return failCommand("invalid_observed_at", "observed-at must be an ISO timestamp");
799
+ }
800
+ emit.data(
801
+ observeHostDisappearedV3({
802
+ coordRoot: root,
803
+ instance_id: opts.instanceId as `inst_${string}`,
804
+ generation_id: opts.generationId as `gen_${string}`,
805
+ observed_at: observedAt,
806
+ }),
807
+ );
808
+ }
809
+
810
+ function failCommand(code: string, message: string): void {
811
+ emit.error({ code, message });
812
+ process.exitCode = 1;
813
+ }
814
+
499
815
  function registerIdentityCommands(parent: Command): void {
500
816
  const identity = parent
501
817
  .command("identity")
@@ -779,7 +1095,7 @@ export function registerCouncilCommands(parent: Command): void {
779
1095
  "archive it first (trash-can pattern). Without --yes this prints " +
780
1096
  "the paths that would be removed and exits 0 without touching " +
781
1097
  "anything. Does NOT touch target_doc, close_handoff_path, or " +
782
- "session-events.ndjson, which are owned by separate authors.",
1098
+ "the canonical V3 event ledger, which is owned independently.",
783
1099
  )
784
1100
  .option("-y, --yes", "Required to actually delete; without this, dry-run")
785
1101
  .option("--json", "JSON envelope output")
@@ -918,11 +1234,11 @@ function runWhoami(opts: { json?: boolean }): void {
918
1234
  process.exit(1);
919
1235
  }
920
1236
 
921
- const hb = readHeartbeat(myOwner);
1237
+ const hb = readLiveCoordinationRow(root, myOwner);
922
1238
  if (!hb) {
923
1239
  emit.error({
924
- code: "no_heartbeat",
925
- message: noHeartbeatMessage(myOwner),
1240
+ code: "no_live_generation",
1241
+ message: noLiveGenerationMessage(myOwner),
926
1242
  });
927
1243
  process.exit(1);
928
1244
  }
@@ -934,20 +1250,17 @@ function runWhoami(opts: { json?: boolean }): void {
934
1250
  session_id: hb.session_id,
935
1251
  kind: normalizeKind(hb.kind),
936
1252
  relation: "self",
937
- started_at: hb.started_at,
1253
+ started_at: hb.started_at ?? hb.last_heartbeat,
938
1254
  last_heartbeat: hb.last_heartbeat,
939
1255
  files_touched: hb.files_touched ?? [],
940
- last_tool: hb.last_tool ?? null,
941
- last_tool_target: hb.last_tool_target ?? null,
942
1256
  task: hb.task ?? null,
943
1257
  activity: activityOf(hb),
944
1258
  activity_updated_at: hb.activity_updated_at ?? null,
945
1259
  activity_source: hb.activity_source ?? null,
946
1260
  task_state: taskStateOf(hb),
1261
+ task_state_scope: "current",
947
1262
  task_state_updated_at: hb.task_state_updated_at ?? null,
948
1263
  task_state_reason: hb.task_state_reason ?? null,
949
- turn_summary: hb.turn_summary ?? null,
950
- turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
951
1264
  platform: hb.platform ?? "claude-code",
952
1265
  };
953
1266
 
@@ -971,38 +1284,19 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
971
1284
  process.exit(1);
972
1285
  }
973
1286
 
974
- const activeDir = resolve(root, ".harnery", "active");
975
- if (!existsSync(activeDir)) {
976
- emit.data({ rows: [], note: SUBAGENT_NOTE });
977
- return;
978
- }
979
-
980
1287
  // Resolve self for relation column; best-effort, missing → "unknown" on every row.
981
1288
  const myOwner = resolveOwner();
982
- const myHb = myOwner ? readHeartbeat(myOwner) : null;
1289
+ const myHb = myOwner ? readLiveCoordinationRow(root, myOwner) : null;
983
1290
  const mySession = myHb?.session_id ?? null;
984
1291
 
985
- // Read every heartbeat.
986
- const heartbeats: Heartbeat[] = [];
987
- for (const file of readdirSync(activeDir)) {
988
- if (!file.endsWith(".json")) continue;
989
- try {
990
- const raw = readFileSync(resolve(activeDir, file), "utf8");
991
- const parsed = JSON.parse(raw);
992
- if (parsed && typeof parsed.instance_id === "string") {
993
- heartbeats.push(parsed as Heartbeat);
994
- }
995
- } catch {
996
- // skip malformed
997
- }
998
- }
1292
+ const generations = readLiveCoordinationRows(root);
999
1293
 
1000
1294
  // Apply staleness filter unless --stale.
1001
1295
  const nowSec = Math.floor(Date.now() / 1000);
1002
1296
  const cutoff = nowSec - freshnessCutoffSecs();
1003
1297
  const live = opts.stale
1004
- ? heartbeats
1005
- : heartbeats.filter((h) => {
1298
+ ? generations
1299
+ : generations.filter((h) => {
1006
1300
  const ts = Date.parse(h.last_heartbeat);
1007
1301
  return Number.isFinite(ts) && ts / 1000 >= cutoff;
1008
1302
  });
@@ -1038,20 +1332,17 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1038
1332
  session_id: h.session_id,
1039
1333
  kind: "transient",
1040
1334
  relation: relationOf(h, myOwner ?? "", mySession),
1041
- started_at: h.started_at,
1335
+ started_at: h.started_at ?? h.last_heartbeat,
1042
1336
  last_heartbeat: h.last_heartbeat,
1043
1337
  files_touched: [...(h.files_touched ?? [])].sort(),
1044
- last_tool: h.last_tool ?? null,
1045
- last_tool_target: h.last_tool_target ?? null,
1046
1338
  task: h.task ?? null,
1047
1339
  activity: activityOf(h),
1048
1340
  activity_updated_at: h.activity_updated_at ?? null,
1049
1341
  activity_source: h.activity_source ?? null,
1050
1342
  task_state: taskStateOf(h),
1343
+ task_state_scope: "current",
1051
1344
  task_state_updated_at: h.task_state_updated_at ?? null,
1052
1345
  task_state_reason: h.task_state_reason ?? null,
1053
- turn_summary: h.turn_summary ?? null,
1054
- turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1055
1346
  platform: h.platform ?? "claude-code",
1056
1347
  });
1057
1348
  continue;
@@ -1069,32 +1360,28 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1069
1360
  session_id: h.session_id,
1070
1361
  kind: normalizeKind(h.kind),
1071
1362
  relation: relationOf(h, myOwner ?? "", mySession),
1072
- started_at: h.started_at,
1363
+ started_at: h.started_at ?? h.last_heartbeat,
1073
1364
  last_heartbeat: h.last_heartbeat,
1074
1365
  files_touched: files,
1075
- last_tool: h.last_tool ?? null,
1076
- last_tool_target: h.last_tool_target ?? null,
1077
1366
  task: h.task ?? null,
1078
1367
  activity: activityOf(h),
1079
1368
  activity_updated_at: h.activity_updated_at ?? null,
1080
1369
  activity_source: h.activity_source ?? null,
1081
1370
  task_state: taskStateOf(h),
1371
+ task_state_scope: "current",
1082
1372
  task_state_updated_at: h.task_state_updated_at ?? null,
1083
1373
  task_state_reason: h.task_state_reason ?? null,
1084
- turn_summary: h.turn_summary ?? null,
1085
- turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1086
1374
  platform: h.platform ?? "claude-code",
1087
1375
  });
1088
1376
  }
1089
1377
 
1090
- // Guard missing started_at: a heartbeat seeded from a stray event can lack it
1091
- // (legacy zombies), and an unguarded .localeCompare throws, which is exactly
1092
- // what made `harn agents list --all --stale` crash.
1378
+ // Guard missing started_at so a partial observational projection cannot make
1379
+ // `harn agents list --all --stale` throw while sorting.
1093
1380
  rows.sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
1094
1381
 
1095
1382
  // Cross-machine presence (ADR 0016): append sessions on OTHER machines from
1096
1383
  // the locally-fetched presence refs. Advisory rows (relation=remote) — they
1097
- // don't participate in local claim blocking in v1.
1384
+ // never participate in local claim blocking.
1098
1385
  for (const rm of readRemoteMachines(root)) {
1099
1386
  for (const a of rm.agents) {
1100
1387
  rows.push({
@@ -1106,17 +1393,14 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1106
1393
  started_at: a.started_at ?? "",
1107
1394
  last_heartbeat: a.last_heartbeat ?? "",
1108
1395
  files_touched: [...(a.files_touched ?? [])].sort(),
1109
- last_tool: a.last_tool ?? null,
1110
- last_tool_target: null,
1111
1396
  task: a.task ?? null,
1112
1397
  activity: a.activity ?? "unknown",
1113
1398
  activity_updated_at: null,
1114
1399
  activity_source: null,
1115
1400
  task_state: a.task_state ?? "active",
1401
+ task_state_scope: "current",
1116
1402
  task_state_updated_at: null,
1117
1403
  task_state_reason: a.task_state_reason ?? null,
1118
- turn_summary: a.turn_summary ?? null,
1119
- turn_summary_updated_at: null,
1120
1404
  platform: a.platform ?? "claude-code",
1121
1405
  machine: rm.machine,
1122
1406
  });
@@ -1131,7 +1415,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1131
1415
  }
1132
1416
 
1133
1417
  function relationOf(
1134
- peer: Heartbeat,
1418
+ peer: Pick<LiveCoordinationRow, "instance_id" | "session_id">,
1135
1419
  myOwner: string,
1136
1420
  mySession: string | null,
1137
1421
  ): "self" | "group" | "blocks" | "unknown" {
@@ -1147,22 +1431,18 @@ function normalizeKind(kind: string | undefined | null): string {
1147
1431
  }
1148
1432
 
1149
1433
  /**
1150
- * The `no_heartbeat` diagnostic, quoting the owner id in FULL.
1434
+ * The `no_live_generation` diagnostic, quoting the owner id in full.
1151
1435
  *
1152
- * This id is actionable: the reader's next move is `agents heal --kind
1153
- * heartbeat --owner <id>`, and heartbeats are keyed by the whole
1154
- * `instance_id` (`.harnery/active/<instance_id>.json`). An abbreviated id
1155
- * here reads as complete, so it gets copy-pasted into `--owner` and mints a
1156
- * heartbeat at a filename no reader resolves — an orphan that leaves the
1157
- * session looking unhealable. Abbreviate ids for display elsewhere, never in
1158
- * a message whose whole purpose is to hand the reader an id to pass back.
1436
+ * This id is actionable: the reader may pass it to cache repair or explicit
1437
+ * finalization. Abbreviate ids for display elsewhere, never in a diagnostic
1438
+ * whose purpose is to hand the caller a canonical identity.
1159
1439
  */
1160
- function noHeartbeatMessage(owner: string): string {
1161
- return `resolved owner=${owner} but no heartbeat exists at .harnery/active/${owner}.json`;
1440
+ function noLiveGenerationMessage(owner: string): string {
1441
+ return `resolved owner=${owner} but no authority-safe live V3 generation exists for it`;
1162
1442
  }
1163
1443
 
1164
1444
  /**
1165
- * The instance_id of a live heartbeat that `prefix` strictly prefixes, or null.
1445
+ * The instance_id of a live V3 generation that `prefix` strictly prefixes, or null.
1166
1446
  *
1167
1447
  * Used to recognize an abbreviated id handed back by a reader when the caller
1168
1448
  * supplied no canonical id of their own. Ambiguity is treated as "no answer":
@@ -1170,12 +1450,9 @@ function noHeartbeatMessage(owner: string): string {
1170
1450
  * and refusing with the wrong id in the message would be worse than the orphan.
1171
1451
  */
1172
1452
  function liveIdWithPrefix(root: string, prefix: string): string | null {
1173
- const activeDir = resolve(root, ".harnery", "active");
1174
- if (!existsSync(activeDir)) return null;
1175
1453
  const matches = new Set<string>();
1176
- for (const file of readdirSync(activeDir)) {
1177
- if (!file.endsWith(".json")) continue;
1178
- const id = file.slice(0, -".json".length);
1454
+ for (const row of readLiveCoordinationRows(root)) {
1455
+ const id = row.instance_id;
1179
1456
  if (id !== prefix && id.startsWith(prefix)) matches.add(id);
1180
1457
  }
1181
1458
  return matches.size === 1 ? (matches.values().next().value as string) : null;
@@ -1190,82 +1467,60 @@ async function runWatch(pollMs: number): Promise<void> {
1190
1467
  });
1191
1468
  process.exit(1);
1192
1469
  }
1193
- const activeDir = resolve(root, ".harnery", "active");
1194
- if (!existsSync(activeDir)) {
1195
- emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
1196
- process.exit(1);
1197
- }
1198
-
1199
- const fs = await import("node:fs");
1200
- const cache = new Map<string, Heartbeat>();
1470
+ const cache = new Map<string, LiveCoordinationRow>();
1201
1471
 
1202
1472
  // Seed cache + print an initial roster line per live peer.
1203
- const initial = listActiveHeartbeats(activeDir);
1204
- process.stderr.write(`watching ${activeDir} (Ctrl-C to exit)\n`); // lint-ok-emission: banner goes to stderr, stdout is the live stream
1473
+ const initial = listLiveGenerations(root);
1474
+ process.stderr.write("watching authoritative V3 coordination state (Ctrl-C to exit)\n"); // lint-ok-emission: banner goes to stderr, stdout is the live stream
1205
1475
  for (const h of initial) {
1206
1476
  cache.set(h.instance_id, h);
1207
1477
  emitWatchLine(
1208
- `agent-${h.name ?? "?"} present (${formatAge(secondsSince(h.started_at))} old${h.task ? `, task: "${h.task}"` : ""})`,
1478
+ `agent-${h.name ?? "?"} present (${formatAge(secondsSince(h.started_at ?? h.last_heartbeat))} old${h.task ? `, task: "${h.task}"` : ""})`,
1209
1479
  );
1210
1480
  }
1211
1481
 
1212
- let scheduled: NodeJS.Timeout | null = null;
1213
1482
  const rescan = () => {
1214
- if (scheduled) return;
1215
- scheduled = setTimeout(() => {
1216
- scheduled = null;
1217
- const current = new Map<string, Heartbeat>();
1218
- for (const h of listActiveHeartbeats(activeDir)) current.set(h.instance_id, h);
1219
-
1220
- // Removed agents.
1221
- for (const [id, old] of cache) {
1222
- if (!current.has(id)) {
1223
- emitWatchLine(`agent-${old.name ?? "?"} ended`);
1224
- cache.delete(id);
1225
- }
1483
+ const current = new Map<string, LiveCoordinationRow>();
1484
+ for (const h of listLiveGenerations(root)) current.set(h.instance_id, h);
1485
+
1486
+ // Removed agents.
1487
+ for (const [id, old] of cache) {
1488
+ if (!current.has(id)) {
1489
+ emitWatchLine(`agent-${old.name ?? "?"} ended`);
1490
+ cache.delete(id);
1226
1491
  }
1227
- // Added or changed agents.
1228
- for (const [id, h] of current) {
1229
- const prev = cache.get(id);
1230
- if (!prev) {
1231
- emitWatchLine(
1232
- `agent-${h.name ?? "?"} started (${formatAge(secondsSince(h.started_at))} old${h.task ? `, task: "${h.task}"` : ""})`,
1233
- );
1234
- cache.set(id, h);
1235
- continue;
1236
- }
1237
- // Diff fields we care about.
1238
- if ((prev.task ?? "") !== (h.task ?? "")) {
1239
- emitWatchLine(`agent-${h.name ?? "?"} task: ${h.task ? `"${h.task}"` : "(cleared)"}`);
1240
- }
1241
- if (
1242
- (prev.last_tool ?? "") !== (h.last_tool ?? "") ||
1243
- (prev.last_tool_target ?? "") !== (h.last_tool_target ?? "")
1244
- ) {
1245
- if (h.last_tool) {
1246
- const target = h.last_tool_target ? ` ${truncate(h.last_tool_target, 80)}` : "";
1247
- emitWatchLine(`agent-${h.name ?? "?"} ${h.last_tool}${target}`);
1248
- }
1249
- }
1250
- // File additions/removals.
1251
- const prevFiles = new Set(prev.files_touched ?? []);
1252
- const currFiles = new Set(h.files_touched ?? []);
1253
- for (const f of currFiles) {
1254
- if (!prevFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} +claim ${f}`);
1255
- }
1256
- for (const f of prevFiles) {
1257
- if (!currFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} -release ${f}`);
1258
- }
1492
+ }
1493
+ // Added or changed agents.
1494
+ for (const [id, h] of current) {
1495
+ const prev = cache.get(id);
1496
+ if (!prev) {
1497
+ emitWatchLine(
1498
+ `agent-${h.name ?? "?"} started (${formatAge(secondsSince(h.started_at ?? h.last_heartbeat))} old${h.task ? `, task: "${h.task}"` : ""})`,
1499
+ );
1259
1500
  cache.set(id, h);
1501
+ continue;
1502
+ }
1503
+ // Diff fields we care about.
1504
+ if ((prev.task ?? "") !== (h.task ?? "")) {
1505
+ emitWatchLine(`agent-${h.name ?? "?"} task: ${h.task ? `"${h.task}"` : "(cleared)"}`);
1260
1506
  }
1261
- }, pollMs);
1507
+ // File additions/removals.
1508
+ const prevFiles = new Set(prev.files_touched ?? []);
1509
+ const currFiles = new Set(h.files_touched ?? []);
1510
+ for (const f of currFiles) {
1511
+ if (!prevFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} +claim ${f}`);
1512
+ }
1513
+ for (const f of prevFiles) {
1514
+ if (!currFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} -release ${f}`);
1515
+ }
1516
+ cache.set(id, h);
1517
+ }
1262
1518
  };
1263
1519
 
1264
- const watcher = fs.watch(activeDir, { persistent: true }, () => rescan());
1265
-
1266
1520
  await new Promise<void>((resolveP) => {
1521
+ const timer = setInterval(rescan, Math.max(100, pollMs));
1267
1522
  const stop = () => {
1268
- watcher.close();
1523
+ clearInterval(timer);
1269
1524
  resolveP();
1270
1525
  };
1271
1526
  process.on("SIGINT", stop);
@@ -1273,23 +1528,13 @@ async function runWatch(pollMs: number): Promise<void> {
1273
1528
  });
1274
1529
  }
1275
1530
 
1276
- function listActiveHeartbeats(activeDir: string): Heartbeat[] {
1277
- const out: Heartbeat[] = [];
1531
+ function listLiveGenerations(root: string): LiveCoordinationRow[] {
1278
1532
  const nowSec = Math.floor(Date.now() / 1000);
1279
1533
  const cutoff = nowSec - freshnessCutoffSecs();
1280
- for (const file of readdirSync(activeDir)) {
1281
- if (!file.endsWith(".json")) continue;
1282
- try {
1283
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1284
- const parsed = JSON.parse(raw) as Heartbeat;
1285
- if (!parsed || typeof parsed.instance_id !== "string") continue;
1286
- const ts = Date.parse(parsed.last_heartbeat);
1287
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) out.push(parsed);
1288
- } catch {
1289
- // skip
1290
- }
1291
- }
1292
- return out;
1534
+ return readLiveCoordinationRows(root).filter((row) => {
1535
+ const ts = Date.parse(row.last_heartbeat);
1536
+ return Number.isFinite(ts) && ts / 1000 >= cutoff;
1537
+ });
1293
1538
  }
1294
1539
 
1295
1540
  function secondsSince(iso: string): number {
@@ -1311,29 +1556,14 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1311
1556
  });
1312
1557
  process.exit(1);
1313
1558
  }
1314
- const activeDir = resolve(root, ".harnery", "active");
1315
- if (!existsSync(activeDir)) {
1316
- emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
1317
- process.exit(1);
1318
- }
1319
-
1320
- // Read all heartbeats; match by name (case-insensitive). Apply freshness filter.
1321
- const matches: Heartbeat[] = [];
1559
+ // Match authority-safe V3 generations by resolved display name.
1322
1560
  const nowSec = Math.floor(Date.now() / 1000);
1323
1561
  const cutoff = nowSec - freshnessCutoffSecs();
1324
- for (const file of readdirSync(activeDir)) {
1325
- if (!file.endsWith(".json")) continue;
1326
- try {
1327
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1328
- const parsed = JSON.parse(raw) as Heartbeat;
1329
- if (!parsed || typeof parsed.instance_id !== "string") continue;
1330
- if ((parsed.name ?? "").toLowerCase() !== name.toLowerCase()) continue;
1331
- const ts = Date.parse(parsed.last_heartbeat);
1332
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) matches.push(parsed);
1333
- } catch {
1334
- // skip malformed
1335
- }
1336
- }
1562
+ const matches = readLiveCoordinationRows(root).filter((row) => {
1563
+ if ((row.name ?? "").toLowerCase() !== name.toLowerCase()) return false;
1564
+ const ts = Date.parse(row.last_heartbeat);
1565
+ return Number.isFinite(ts) && ts / 1000 >= cutoff;
1566
+ });
1337
1567
 
1338
1568
  if (matches.length === 0) {
1339
1569
  emit.error({
@@ -1365,7 +1595,7 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1365
1595
  const report = null as PeerReport | null;
1366
1596
  const bqError = null as string | null;
1367
1597
 
1368
- const startedAtMs = Date.parse(hb.started_at);
1598
+ const startedAtMs = Date.parse(hb.started_at ?? hb.last_heartbeat);
1369
1599
  const ageSecs = Number.isFinite(startedAtMs)
1370
1600
  ? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
1371
1601
  : 0;
@@ -1386,14 +1616,11 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1386
1616
  activity_updated_at: hb.activity_updated_at ?? null,
1387
1617
  activity_source: hb.activity_source ?? null,
1388
1618
  task_state: taskStateOf(hb),
1619
+ task_state_scope: "current" as const,
1389
1620
  task_state_updated_at: hb.task_state_updated_at ?? null,
1390
1621
  task_state_reason: hb.task_state_reason ?? null,
1391
- turn_summary: hb.turn_summary ?? null,
1392
- turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
1393
1622
  title: report?.title ?? null,
1394
1623
  files_held: hb.files_touched ?? [],
1395
- last_tool: hb.last_tool ?? null,
1396
- last_tool_target: hb.last_tool_target ?? null,
1397
1624
  recent_prompts: report?.recent_prompts ?? [],
1398
1625
  recent_tools: report?.recent_tools ?? [],
1399
1626
  tool_counts: report?.tool_counts ?? [],
@@ -1423,10 +1650,6 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1423
1650
  lines.push(
1424
1651
  ` lifecycle: ${data.task_state}${data.task_state === "blocked" && data.task_state_reason ? `: ${data.task_state_reason}` : ""}`,
1425
1652
  );
1426
- if (data.last_tool) {
1427
- const target = data.last_tool_target ? ` ${truncate(data.last_tool_target, 80)}` : "";
1428
- lines.push(` last activity: ${data.last_tool}${target}`);
1429
- }
1430
1653
  if (data.files_held.length > 0) {
1431
1654
  lines.push(` holds ${data.files_held.length} file(s):`);
1432
1655
  for (const f of data.files_held.slice(0, 10)) lines.push(` ${f}`);
@@ -1581,13 +1804,19 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1581
1804
  // Identity: prefer explicit --session-id (the ppid-walk-free escape hatch,
1582
1805
  // mirrors `status`), fall back to the ppid walk. Cursor shell tool calls
1583
1806
  // don't descend from a pid-map-registered anchor, so the walk can miss there.
1807
+ // Final fallback: the adapter/connector-stamped session id from the
1808
+ // environment. set-task is the session's REGISTRATION point — a fresh
1809
+ // (bridge) session has no heartbeat yet, so the heartbeat-validated resolver
1810
+ // returns null by design, and erroring here orphans the session's first
1811
+ // ritual command. The env id carries the same trust as an explicit
1812
+ // --session-id, and set-task mints the heartbeat exactly as that path does.
1584
1813
  if (!opts?.sessionId) ensureCursorSession(root);
1585
- const myOwner = opts?.sessionId ?? resolveOwner();
1814
+ const myOwner = opts?.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
1586
1815
  if (!myOwner) {
1587
1816
  emit.error({
1588
1817
  code: "no_pidmap_entry",
1589
1818
  message:
1590
- "not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
1819
+ "not in an agent session; no session-id environment identity and the ppid walk found no pid-map entry (pass --session-id to bypass)",
1591
1820
  });
1592
1821
  process.exit(1);
1593
1822
  }
@@ -1598,7 +1827,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1598
1827
  // naming window, and subagent/workflow kinds are never named — see
1599
1828
  // heartbeat-writer.setTask. This call is the naming call exactly when the
1600
1829
  // stamp appears across the mutation.
1601
- const priorHb = readHeartbeat(myOwner);
1830
+ const priorHb = readCurrentCoordinationRow(myOwner);
1602
1831
  const normalizedTask = task.length > 0 ? task : undefined;
1603
1832
  const lifecycleWarning =
1604
1833
  priorHb && (priorHb.task_state ?? "active") !== "active" && priorHb.task !== normalizedTask
@@ -1619,7 +1848,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1619
1848
  process.exit(1);
1620
1849
  }
1621
1850
 
1622
- const hb = readHeartbeat(myOwner);
1851
+ const hb = readCurrentCoordinationRow(myOwner);
1623
1852
 
1624
1853
  // The naming call: the mutation just stamped `suggested_session_name` for
1625
1854
  // the first time. `first_of_session: true` means exactly "this call produced
@@ -1631,19 +1860,6 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1631
1860
  : null;
1632
1861
  const firstOfSession = suggestedName !== null;
1633
1862
 
1634
- emitCanonical({
1635
- type: "state.task_set",
1636
- owner: myOwner,
1637
- session: hb?.session_id ?? myOwner,
1638
- adapter: normalizeAdapter(hb?.platform),
1639
- data: {
1640
- task,
1641
- cleared: !task || task.length === 0,
1642
- first_of_session: firstOfSession,
1643
- suggested_session_name: suggestedName,
1644
- },
1645
- });
1646
-
1647
1863
  emit.data({
1648
1864
  instance_id: myOwner,
1649
1865
  name: hb?.name ?? null,
@@ -1656,7 +1872,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1656
1872
  // exists; this result is the moment the model holds the string.
1657
1873
  ...(suggestedName
1658
1874
  ? {
1659
- note: "Reproduce suggested_session_name verbatim, by itself, in a fenced code block at the top of your reply — the operator copies it as the session/tab title.",
1875
+ note: SESSION_NAME_DISPLAY_NOTE,
1660
1876
  }
1661
1877
  : {}),
1662
1878
  });
@@ -1683,7 +1899,7 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1683
1899
  return;
1684
1900
  }
1685
1901
  if (!opts.sessionId) ensureCursorSession(root);
1686
- const myOwner = opts.sessionId ?? resolveOwner();
1902
+ const myOwner = opts.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
1687
1903
  if (!myOwner) {
1688
1904
  emit.error({
1689
1905
  code: "no_pidmap_entry",
@@ -1694,11 +1910,40 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1694
1910
  return;
1695
1911
  }
1696
1912
 
1697
- const hb = readHeartbeat(myOwner);
1913
+ let hb = readCurrentCoordinationRow(myOwner);
1914
+ let reopenedGeneration: ReturnType<typeof reopenLiveCoordinationGenerationV3> | undefined;
1698
1915
  if (!hb) {
1699
- emit.error({ code: "no_heartbeat", message: noHeartbeatMessage(myOwner) });
1700
- process.exitCode = 1;
1701
- return;
1916
+ if (state !== "active") {
1917
+ emit.error({
1918
+ code: "no_live_generation",
1919
+ message: `${noLiveGenerationMessage(myOwner)}; run \`${resolveBinName(root)} agents lifecycle active\` to open a fresh generation`,
1920
+ });
1921
+ process.exitCode = 1;
1922
+ return;
1923
+ }
1924
+ try {
1925
+ reopenedGeneration = reopenLiveCoordinationGenerationV3({
1926
+ coordRoot: root,
1927
+ owner: myOwner,
1928
+ nativeSessionId: nativeSessionIdentity(readHeartbeatCache(root, myOwner), myOwner),
1929
+ });
1930
+ } catch (error) {
1931
+ emit.error({
1932
+ code: "lifecycle_reopen_failed",
1933
+ message: error instanceof Error ? error.message : String(error),
1934
+ });
1935
+ process.exitCode = 1;
1936
+ return;
1937
+ }
1938
+ hb = readCurrentCoordinationRow(myOwner);
1939
+ if (!hb) {
1940
+ emit.error({
1941
+ code: "lifecycle_reopen_failed",
1942
+ message: "the fresh generation was recorded but did not project as live",
1943
+ });
1944
+ process.exitCode = 1;
1945
+ return;
1946
+ }
1702
1947
  }
1703
1948
  if (hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) {
1704
1949
  emit.error({
@@ -1729,6 +1974,22 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1729
1974
 
1730
1975
  const priorState: TaskState = hb.task_state ?? "active";
1731
1976
  const priorReason = hb.task_state_reason || undefined;
1977
+ if (reopenedGeneration) {
1978
+ emit.data({
1979
+ instance_id: myOwner,
1980
+ task_state: "active",
1981
+ prior_state: null,
1982
+ reason: null,
1983
+ changed: true,
1984
+ generation_reopened: true,
1985
+ prior_generation_id: reopenedGeneration.prior_generation_id,
1986
+ generation_id: reopenedGeneration.generation_id,
1987
+ name_reminted: false,
1988
+ suggested_session_name: null,
1989
+ git_finalization_checked: false,
1990
+ });
1991
+ return;
1992
+ }
1732
1993
  if (priorState === state && priorReason === reason) {
1733
1994
  emit.data({
1734
1995
  instance_id: myOwner,
@@ -1743,7 +2004,11 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1743
2004
 
1744
2005
  let finalization: GitFinalizationResult | null = null;
1745
2006
  if (state === "done") {
1746
- const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
2007
+ const history = readSessionWriteClaims(
2008
+ root,
2009
+ hb.instance_id,
2010
+ nativeSessionIdentity(hb, myOwner),
2011
+ );
1747
2012
  const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1748
2013
  finalization = checkGitFinalization(root, touchedPaths, {
1749
2014
  claimHistoryComplete: history.complete,
@@ -1761,19 +2026,16 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1761
2026
  }
1762
2027
  }
1763
2028
 
1764
- const suggestedName = buildLifecycleSuggestedName(hb.name ?? "unknown", hb.task, state);
2029
+ const suggestedName = buildLifecycleSuggestedName(hb.suggested_session_name, state);
1765
2030
  const nameReminted = suggestedName !== null && suggestedName !== hb.suggested_session_name;
1766
- const emitted = emitCanonical({
1767
- type: "state.task_state",
2031
+ const emitted = emitEventV3({
1768
2032
  owner: myOwner,
1769
- session: hb.session_id ?? myOwner,
2033
+ session: nativeSessionIdentity(hb, myOwner),
1770
2034
  adapter: normalizeAdapter(hb.platform),
1771
- data: {
1772
- state,
1773
- reason: reason ?? null,
1774
- prior_state: priorState,
1775
- name_reminted: nameReminted,
1776
- git_finalization_checked: finalization !== null,
2035
+ observation: {
2036
+ event_type: "coord.lifecycle_changed",
2037
+ new_state: state,
2038
+ ...(reason ? { reason } : {}),
1777
2039
  ...(nameReminted ? { suggested_session_name: suggestedName } : {}),
1778
2040
  },
1779
2041
  });
@@ -1797,7 +2059,7 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1797
2059
  git_finalization_checked: finalization !== null,
1798
2060
  ...(nameReminted
1799
2061
  ? {
1800
- note: "Reproduce suggested_session_name verbatim, by itself, in a fenced code block at the top of your reply — the operator copies it as the session/tab title.",
2062
+ note: SESSION_NAME_DISPLAY_NOTE,
1801
2063
  }
1802
2064
  : {}),
1803
2065
  });
@@ -1829,7 +2091,7 @@ function runSuggestName(
1829
2091
  process.exit(1);
1830
2092
  }
1831
2093
 
1832
- const hb = readHeartbeat(myOwner);
2094
+ const hb = readCurrentCoordinationRow(myOwner);
1833
2095
  const agentName = hb?.name || "unknown";
1834
2096
  // Description resolution: an explicit arg wins; with no arg, fall back to the
1835
2097
  // agent's current declared task, so a bare `suggest-name` reprints the running
@@ -1867,7 +2129,12 @@ function runSuggestName(
1867
2129
  process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
1868
2130
  }
1869
2131
 
1870
- function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string }): void {
2132
+ function runStatus(opts: {
2133
+ endTurn?: boolean;
2134
+ endSession?: boolean;
2135
+ json?: boolean;
2136
+ sessionId?: string;
2137
+ }): void {
1871
2138
  const root = monorepoRoot();
1872
2139
  if (!root) {
1873
2140
  emit.error({
@@ -1876,6 +2143,14 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1876
2143
  });
1877
2144
  process.exit(1);
1878
2145
  }
2146
+ if (opts.endSession && !opts.endTurn) {
2147
+ emit.error({
2148
+ code: "end_session_requires_end_turn",
2149
+ message: "--end-session requires --end-turn so Git finalization is verified first",
2150
+ });
2151
+ process.exitCode = 1;
2152
+ return;
2153
+ }
1879
2154
 
1880
2155
  // Identity resolution: prefer explicit --session-id (hook-friendly), fall
1881
2156
  // back to ppid walk for interactive shell usage.
@@ -1890,18 +2165,30 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1890
2165
  process.exit(1);
1891
2166
  }
1892
2167
 
1893
- const hb = readHeartbeat(myOwner);
2168
+ const hb = readCurrentCoordinationRow(myOwner);
1894
2169
  if (!hb) {
1895
2170
  emit.error({
1896
- code: "no_heartbeat",
1897
- message: noHeartbeatMessage(myOwner),
2171
+ code: "no_live_generation",
2172
+ message: noLiveGenerationMessage(myOwner),
1898
2173
  });
1899
2174
  process.exit(1);
1900
2175
  }
1901
2176
 
2177
+ // Report-only run quality: cheap config/cursor due check first, then a
2178
+ // non-blocking lazy evaluation. Shadow mode deliberately exposes no severity.
2179
+ const qualityEvaluation = evaluateRunQualityIfDue(root, new Date(), myOwner);
2180
+ const quality = qualityForStatus(
2181
+ qualityEvaluation.config.requested_mode,
2182
+ qualityEvaluation.snapshot,
2183
+ );
2184
+
1902
2185
  let finalization: GitFinalizationResult | null = null;
1903
2186
  if (opts.endTurn) {
1904
- const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
2187
+ const history = readSessionWriteClaims(
2188
+ root,
2189
+ hb.instance_id,
2190
+ nativeSessionIdentity(hb, myOwner),
2191
+ );
1905
2192
  const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1906
2193
  finalization = checkGitFinalization(root, touchedPaths, {
1907
2194
  claimHistoryComplete: history.complete,
@@ -1916,48 +2203,60 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1916
2203
  }
1917
2204
  }
1918
2205
 
1919
- emitCanonical({
1920
- type: "state.status_checked",
2206
+ emitEventV3({
1921
2207
  owner: myOwner,
1922
- session: hb.session_id ?? myOwner,
2208
+ session: nativeSessionIdentity(hb, myOwner),
1923
2209
  adapter: normalizeAdapter(hb.platform),
1924
- data: {
1925
- format: opts.json ? "json" : "box",
1926
- git_finalization_checked: opts.endTurn === true,
1927
- agent_count: 0, // computed below, not yet available here; Phase 5 verdict reads owner-scope only
1928
- included_self: true,
2210
+ observation: {
2211
+ event_type: "coord.status_observed",
2212
+ status: opts.endTurn ? "end_turn_checked" : opts.json ? "json_checked" : "box_checked",
1929
2213
  },
1930
2214
  });
1931
2215
 
1932
- const startedAtMs = Date.parse(hb.started_at);
2216
+ let sessionEnd:
2217
+ | { state: "queued" | "already_requested"; request_id: string }
2218
+ | { state: "recorded" | "already_ended"; terminal_event_id?: string }
2219
+ | undefined;
2220
+ if (opts.endSession) {
2221
+ const canonicalInstanceId = liveInstanceIdV3(hb.instance_id);
2222
+ const records = listHookProducerStateRecordsV3(root, { includeTerminal: true }).filter(
2223
+ ({ state }) => state.instance_id === canonicalInstanceId,
2224
+ );
2225
+ if (records.length !== 1 || !records[0]) {
2226
+ emit.error({
2227
+ code: "session_identity_ambiguous",
2228
+ message: `expected one V3 generation for ${canonicalInstanceId}; found ${records.length}`,
2229
+ });
2230
+ process.exitCode = 1;
2231
+ return;
2232
+ }
2233
+ const state = records[0].state;
2234
+ const requested = requestSessionEndExplicitV3({
2235
+ coordRoot: root,
2236
+ instance_id: state.instance_id,
2237
+ generation_id: state.generation_id,
2238
+ outcome: "succeeded",
2239
+ coordination_finalized: true,
2240
+ });
2241
+ if (requested.state === "queued" || requested.state === "already_requested") {
2242
+ sessionEnd = { state: requested.state, request_id: requested.request.request_id };
2243
+ } else if (requested.state === "recorded") {
2244
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event.event_id };
2245
+ } else if (requested.state === "already_ended") {
2246
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event_id };
2247
+ } else {
2248
+ emit.error({ code: "session_end_failed", message: JSON.stringify(requested) });
2249
+ process.exitCode = 1;
2250
+ return;
2251
+ }
2252
+ }
2253
+
2254
+ const startedAtMs = Date.parse(hb.started_at ?? hb.last_heartbeat);
1933
2255
  const ageSecs = Number.isFinite(startedAtMs)
1934
2256
  ? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
1935
2257
  : 0;
1936
2258
 
1937
- const activeDir = resolve(root, ".harnery", "active");
1938
- const nowSec = Math.floor(Date.now() / 1000);
1939
- const cutoff = nowSec - freshnessCutoffSecs();
1940
- const livePeers: Heartbeat[] = [];
1941
- let peersStale = 0;
1942
- if (existsSync(activeDir)) {
1943
- for (const file of readdirSync(activeDir)) {
1944
- if (!file.endsWith(".json")) continue;
1945
- try {
1946
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1947
- const peer = JSON.parse(raw) as Heartbeat;
1948
- if (!peer || typeof peer.instance_id !== "string") continue;
1949
- if (peer.instance_id === myOwner) continue;
1950
- const ts = Date.parse(peer.last_heartbeat);
1951
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) {
1952
- livePeers.push(peer);
1953
- } else {
1954
- peersStale++;
1955
- }
1956
- } catch {
1957
- // skip malformed
1958
- }
1959
- }
1960
- }
2259
+ const { livePeers, stale: peersStale } = collectStatusPeerHealth(root, myOwner);
1961
2260
 
1962
2261
  // Sort: file-holders first (by file count desc), then idle peers by recency.
1963
2262
  livePeers.sort((a, b) => {
@@ -1977,7 +2276,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1977
2276
  const remoteMachines = readRemoteMachines(root);
1978
2277
  const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
1979
2278
 
1980
- const ctxUsage = readContextUsage(hb.session_id, hb.platform);
2279
+ const ctxUsage = readContextUsage(hb.native_session_id ?? hb.session_id, hb.platform);
1981
2280
  let ctxStr: string;
1982
2281
  if (!ctxUsage) {
1983
2282
  ctxStr = "unavailable";
@@ -2014,6 +2313,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2014
2313
  activity_updated_at: hb.activity_updated_at ?? null,
2015
2314
  activity_source: hb.activity_source ?? null,
2016
2315
  task_state: taskStateOf(hb),
2316
+ task_state_scope: "current" as const,
2017
2317
  task_state_updated_at: hb.task_state_updated_at ?? null,
2018
2318
  task_state_reason: hb.task_state_reason ?? null,
2019
2319
  files_held: filesHeld,
@@ -2040,7 +2340,9 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2040
2340
  context_window: ctxUsage?.window ?? null,
2041
2341
  timestamp_iso: new Date().toISOString(),
2042
2342
  timestamp_local: timeStr,
2343
+ ...(quality ? { quality } : {}),
2043
2344
  ...(finalization ? { finalization } : {}),
2345
+ ...(sessionEnd ? { session_end: sessionEnd } : {}),
2044
2346
  };
2045
2347
 
2046
2348
  if (opts.json) {
@@ -2058,10 +2360,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2058
2360
  ["peers", peersStr],
2059
2361
  ["time", timeStr],
2060
2362
  ];
2061
- // task + turn_summary get full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
2062
- if (hb.turn_summary && hb.turn_summary.length > 0) {
2063
- rows.splice(1, 0, ["last turn", hb.turn_summary]);
2064
- }
2363
+ // Task gets full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
2065
2364
  if (hb.task && hb.task.length > 0) {
2066
2365
  rows.splice(1, 0, ["task", hb.task]);
2067
2366
  }
@@ -2076,11 +2375,59 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2076
2375
  : `${pendingCouncils.length} pending (${pendingCouncils[0]}, +${pendingCouncils.length - 1})`;
2077
2376
  rows.splice(idx, 0, ["council", summary]);
2078
2377
  }
2378
+ if (quality) {
2379
+ const idx = rows.findIndex((row) => row[0] === "time");
2380
+ rows.splice(idx, 0, ["quality", formatRunQuality(quality.status, quality.signal_ids)]);
2381
+ }
2382
+ if (sessionEnd) {
2383
+ const idx = rows.findIndex((row) => row[0] === "time");
2384
+ rows.splice(idx, 0, [
2385
+ "session end",
2386
+ sessionEnd.state === "queued" || sessionEnd.state === "already_requested"
2387
+ ? "queued after this turn"
2388
+ : "recorded",
2389
+ ]);
2390
+ }
2079
2391
  // Box rendering needs predictable stdout regardless of TTY/pipe detection:
2080
2392
  // agent runs this via Bash (no TTY) and pastes captured stdout into chat.
2081
2393
  process.stdout.write(`${formatBox(displayName, rows)}\n`); // lint-ok-emission: chat-paste path; emit.text() auto-suppresses non-TTY
2082
2394
  }
2083
2395
 
2396
+ function qualityForStatus(
2397
+ mode: "off" | "shadow" | "report",
2398
+ snapshot: RunQualitySnapshot | null,
2399
+ ): {
2400
+ status: RunQualityStatus;
2401
+ signal_ids: string[];
2402
+ evaluated_at: string | null;
2403
+ fresh: boolean;
2404
+ } | null {
2405
+ if (mode !== "report") return null;
2406
+ const fresh = !!snapshot && Date.parse(snapshot.expires_at) > Date.now();
2407
+ if (!snapshot || !fresh) {
2408
+ return {
2409
+ status: "unknown",
2410
+ signal_ids: [],
2411
+ evaluated_at: snapshot?.evaluated_at ?? null,
2412
+ fresh: false,
2413
+ };
2414
+ }
2415
+ return {
2416
+ status: snapshot.status,
2417
+ signal_ids: snapshot.signals
2418
+ .filter((signal) => signal.state === "active" && signal.severity !== "none")
2419
+ .map((signal) => signal.id)
2420
+ .sort()
2421
+ .slice(0, 3),
2422
+ evaluated_at: snapshot.evaluated_at,
2423
+ fresh: true,
2424
+ };
2425
+ }
2426
+
2427
+ function formatRunQuality(status: RunQualityStatus, signalIds: string[]): string {
2428
+ return signalIds.length > 0 ? `${status} (${signalIds.join(", ")})` : status;
2429
+ }
2430
+
2084
2431
  function formatList(items: string[], cap: number, emptyLabel: string): string {
2085
2432
  if (items.length === 0) return emptyLabel;
2086
2433
  if (items.length <= cap) return items.join(", ");
@@ -2089,7 +2436,7 @@ function formatList(items: string[], cap: number, emptyLabel: string): string {
2089
2436
  }
2090
2437
 
2091
2438
  function formatPeers(
2092
- peers: Heartbeat[],
2439
+ peers: StatusPeerHeartbeat[],
2093
2440
  cap: number,
2094
2441
  staleCount: number,
2095
2442
  remoteMachines: RemoteMachine[] = [],
@@ -2122,6 +2469,38 @@ function formatPeers(
2122
2469
  return main;
2123
2470
  }
2124
2471
 
2472
+ type StatusPeerHeartbeat = {
2473
+ instance_id: string;
2474
+ last_heartbeat: string;
2475
+ files_touched?: string[];
2476
+ name?: string;
2477
+ platform?: string;
2478
+ };
2479
+
2480
+ export function collectStatusPeerHealth(
2481
+ root: string,
2482
+ myOwner: string,
2483
+ nowMs = Date.now(),
2484
+ ): { livePeers: StatusPeerHeartbeat[]; stale: number } {
2485
+ let rows: StatusPeerHeartbeat[] = [];
2486
+ try {
2487
+ rows = readLiveCoordinationRows(root);
2488
+ } catch {
2489
+ // V3 authority failures must not resurrect disposable cache rows.
2490
+ }
2491
+
2492
+ const cutoffMs = nowMs - freshnessCutoffSecs() * 1000;
2493
+ const livePeers: StatusPeerHeartbeat[] = [];
2494
+ let stale = 0;
2495
+ for (const peer of rows) {
2496
+ if (peer.instance_id === myOwner) continue;
2497
+ const observedAtMs = Date.parse(peer.last_heartbeat);
2498
+ if (Number.isFinite(observedAtMs) && observedAtMs >= cutoffMs) livePeers.push(peer);
2499
+ else stale += 1;
2500
+ }
2501
+ return { livePeers, stale };
2502
+ }
2503
+
2125
2504
  function fmtTokens(n: number): string {
2126
2505
  if (n < 1000) return `${n}`;
2127
2506
  if (n < 1000000) return `${Math.round(n / 1000)}K`;
@@ -2371,46 +2750,69 @@ interface HealEvent {
2371
2750
  platform: string;
2372
2751
  }
2373
2752
 
2374
- /**
2375
- * One canonical event envelope from `.harnery/events.ndjson` (loose shape: we
2376
- * only read the fields the health/heal aggregators need).
2377
- */
2378
- interface CanonicalEvent {
2379
- event_type: string;
2753
+ /** Bounded diagnostic event shape projected from the canonical V3 ledger. */
2754
+ export interface CanonicalEvent {
2755
+ event_type: EventTypeV3;
2380
2756
  ts: string;
2381
2757
  instance_id?: string;
2382
2758
  adapter?: string;
2383
- data?: Record<string, unknown>;
2759
+ payload?: Record<string, unknown>;
2384
2760
  }
2385
2761
 
2386
- /**
2387
- * Read canonical events in a time window. The heal + council telemetry the
2388
- * health/heal commands report lives here. Full-file read + ts filter is fine
2389
- * for an on-demand diagnostic; events.ndjson is the canonical store.
2390
- */
2391
- function readCanonicalEventsInWindow(root: string, cutoffMs: number): CanonicalEvent[] {
2392
- const p = resolve(root, ".harnery", "events.ndjson");
2393
- if (!existsSync(p)) return [];
2394
- let raw: string;
2395
- try {
2396
- raw = readFileSync(p, "utf8");
2397
- } catch {
2398
- return [];
2399
- }
2400
- const out: CanonicalEvent[] = [];
2401
- for (const line of raw.split("\n")) {
2402
- if (!line) continue;
2403
- try {
2404
- const ev = JSON.parse(line) as CanonicalEvent;
2405
- if (!ev.event_type || !ev.ts) continue;
2406
- const tsMs = Date.parse(ev.ts);
2762
+ export interface AgentDiagnosticEventRead {
2763
+ source: "v3";
2764
+ authoritative: boolean;
2765
+ reason?: string;
2766
+ truncated: boolean;
2767
+ bytes: number;
2768
+ events: CanonicalEvent[];
2769
+ }
2770
+
2771
+ /** Read validated canonical V3 events. */
2772
+ export function readAgentDiagnosticEventsInWindow(
2773
+ root: string,
2774
+ cutoffMs: number,
2775
+ ): AgentDiagnosticEventRead {
2776
+ const control = readEventV3ControlState(root);
2777
+ if (control.state === "candidate" || control.state === "active") {
2778
+ const ledger = readLedgerV3(root);
2779
+ if (!ledger.complete) {
2780
+ return {
2781
+ source: "v3",
2782
+ authoritative: false,
2783
+ reason: `V3 ledger validation failed: ${ledger.diagnostics.map((item) => item.code).join(", ") || "incomplete"}`,
2784
+ truncated: false,
2785
+ bytes: ledger.bytes,
2786
+ events: [],
2787
+ };
2788
+ }
2789
+ const events: CanonicalEvent[] = [];
2790
+ for (const { event } of ledger.events) {
2791
+ const tsMs = Date.parse(event.time.recorded_at);
2407
2792
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2408
- out.push(ev);
2409
- } catch {
2410
- /* skip malformed */
2793
+ events.push({
2794
+ event_type: event.event_type,
2795
+ ts: event.time.recorded_at,
2796
+ instance_id: event.scope.instance_id,
2797
+ payload: event.payload as Record<string, unknown>,
2798
+ });
2411
2799
  }
2800
+ return {
2801
+ source: "v3",
2802
+ authoritative: true,
2803
+ truncated: false,
2804
+ bytes: ledger.bytes,
2805
+ events,
2806
+ };
2412
2807
  }
2413
- return out;
2808
+ return {
2809
+ source: "v3",
2810
+ authoritative: false,
2811
+ reason: `V3 control state is ${control.state}`,
2812
+ truncated: false,
2813
+ bytes: 0,
2814
+ events: [],
2815
+ };
2414
2816
  }
2415
2817
 
2416
2818
  /** Normalize adapter event data into the heartbeat platform value. */
@@ -2418,19 +2820,21 @@ function adapterToPlatform(adapter: string | undefined): string {
2418
2820
  if (adapter === "claude-code") return "claude-code";
2419
2821
  if (adapter === "cursor") return "cursor";
2420
2822
  if (adapter === "codex") return "codex";
2421
- return "claude-code";
2823
+ return "unknown";
2422
2824
  }
2423
2825
 
2424
2826
  /** Project a canonical health.* event into the HealEvent shape the aggregators
2425
2827
  * already consume. Returns null for non-heal events. instance_id → display name
2426
2828
  * via `nameById` (full-UUID keyed). */
2427
2829
  function canonicalToHealEvent(ev: CanonicalEvent, nameById: Map<string, string>): HealEvent | null {
2428
- if (ev.event_type !== "health.pidmap_heal" && ev.event_type !== "health.heartbeat_heal") {
2830
+ if (ev.event_type !== "health.observed") {
2429
2831
  return null;
2430
2832
  }
2431
- const kind = ev.event_type === "health.pidmap_heal" ? "pidmap" : "heartbeat";
2432
- const data = ev.data ?? {};
2433
- const reason: "missing" | "stale" = data.reason === "stale" ? "stale" : "missing";
2833
+ const data = ev.payload ?? {};
2834
+ const subsystem = typeof data.subsystem === "string" ? data.subsystem : "";
2835
+ if (subsystem !== "pidmap" && subsystem !== "heartbeat") return null;
2836
+ const kind = subsystem;
2837
+ const reason: "missing" | "stale" = data.condition === "stale" ? "stale" : "missing";
2434
2838
  const instanceId = ev.instance_id ?? "";
2435
2839
  const name = nameById.get(instanceId);
2436
2840
  const agent = name ? `agent-${name}` : `agent-${instanceId.slice(0, 8) || "unknown"}`;
@@ -2511,13 +2915,12 @@ function runHealEvents(opts: {
2511
2915
  process.exit(1);
2512
2916
  }
2513
2917
 
2514
- // Heal telemetry lives in the canonical .harnery/events.ndjson stream
2515
- // (health.pidmap_heal / health.heartbeat_heal), emitted by the writer on
2516
- // actual self-heal writes.
2918
+ // Heal telemetry is read from the canonical V3 ledger.
2517
2919
  const cutoffMs = Date.now() - sinceSecs * 1000;
2518
2920
  const nameById = buildNameById(root);
2519
2921
  const events: HealEvent[] = [];
2520
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
2922
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
2923
+ for (const ev of diagnosticEvents.events) {
2521
2924
  const heal = canonicalToHealEvent(ev, nameById);
2522
2925
  if (heal) events.push(heal);
2523
2926
  }
@@ -2554,6 +2957,11 @@ function runHealEvents(opts: {
2554
2957
 
2555
2958
  const data = {
2556
2959
  since: opts.since,
2960
+ telemetry: {
2961
+ source: diagnosticEvents.source,
2962
+ authoritative: diagnosticEvents.authoritative,
2963
+ reason: diagnosticEvents.reason,
2964
+ },
2557
2965
  total: events.length,
2558
2966
  by_reason: byReason,
2559
2967
  by_kind: byKind,
@@ -2565,7 +2973,17 @@ function runHealEvents(opts: {
2565
2973
 
2566
2974
  if (opts.csv) {
2567
2975
  emit.config({ format: "csv" });
2568
- emit.data(recent);
2976
+ emit.data(
2977
+ data.telemetry.authoritative
2978
+ ? recent
2979
+ : [
2980
+ {
2981
+ status: "unavailable",
2982
+ source: data.telemetry.source,
2983
+ reason: data.telemetry.reason,
2984
+ },
2985
+ ],
2986
+ );
2569
2987
  return;
2570
2988
  }
2571
2989
  if (opts.json) {
@@ -2580,6 +2998,13 @@ function runHealEvents(opts: {
2580
2998
  lines.push(
2581
2999
  `Heal events: ${events.length} total in last ${opts.since} (health.pidmap_heal + health.heartbeat_heal)`,
2582
3000
  );
3001
+ if (!data.telemetry.authoritative) {
3002
+ lines.push(
3003
+ ` unavailable: ${data.telemetry.reason ?? "diagnostic event source is incomplete"}`,
3004
+ );
3005
+ emit.text(`${lines.join("\n")}\n`);
3006
+ return;
3007
+ }
2583
3008
  lines.push("");
2584
3009
  if (events.length === 0) {
2585
3010
  lines.push(" (none; pid-map/heartbeat drift is not happening in this window)");
@@ -2631,7 +3056,13 @@ function runHealEvents(opts: {
2631
3056
  interface HealthReport {
2632
3057
  since: string;
2633
3058
  generated_at: string;
3059
+ event_telemetry: {
3060
+ source: "v3";
3061
+ authoritative: boolean;
3062
+ reason?: string;
3063
+ };
2634
3064
  active_agents: {
3065
+ source: "event-ledger-v3";
2635
3066
  total: number;
2636
3067
  by_platform: Record<string, number>;
2637
3068
  by_kind: Record<string, number>;
@@ -2661,11 +3092,19 @@ interface HealthReport {
2661
3092
  // bug (e.g. a stop-projection crash that caused ~200 errors/day until it was fixed).
2662
3093
  hook_errors: {
2663
3094
  total: number;
3095
+ last_1h: number;
3096
+ latest_at: string | null;
2664
3097
  by_phase: Record<string, number>;
3098
+ by_error: Record<string, number>;
2665
3099
  top: Array<{ phase: string; count: number; sample: string }>;
3100
+ top_errors: Array<{ error: string; count: number; phase: string }>;
3101
+ recent_top_errors: Array<{ error: string; count: number; phase: string }>;
2666
3102
  };
2667
3103
  // Canonical event stream growth + drain lag.
2668
3104
  stream: {
3105
+ source: "v3";
3106
+ authoritative: boolean;
3107
+ reason?: string;
2669
3108
  bytes: number;
2670
3109
  lines: number;
2671
3110
  cursor_backlog: number;
@@ -2677,31 +3116,269 @@ interface HealthReport {
2677
3116
  count: number;
2678
3117
  samples: string[];
2679
3118
  };
3119
+ // V3 event-ledger producer health: open tool spans, pending finalization
3120
+ // requests, intake/diagnostics spool depth, span-count pressure.
3121
+ event_ledger: EventLedgerHealthV3;
2680
3122
  anomalies: string[];
2681
3123
  }
2682
3124
 
3125
+ /** Open-span soft watermark per producer state. The producer-state reader
3126
+ * hard-caps at 256 spans (a state file beyond it fails to load), so surfacing
3127
+ * pressure at half that gives room to act before reads start failing. */
3128
+ const SPAN_PRESSURE_SOFT_WATERMARK = 128;
3129
+
3130
+ export type EventLedgerHealthV3 =
3131
+ | { state: "unavailable"; reason: string }
3132
+ | {
3133
+ state: "live";
3134
+ mode: "candidate" | "active";
3135
+ open_spans: {
3136
+ total: number;
3137
+ generations: Array<{
3138
+ instance_id: string;
3139
+ generation_id: string;
3140
+ adapter: string;
3141
+ span_count: number;
3142
+ /** False = spans are open with NO open turn: the orphan signature
3143
+ * (a turn ended without its tool spans being closed). */
3144
+ turn_open: boolean;
3145
+ }>;
3146
+ };
3147
+ pending_finalizations: Array<{
3148
+ request_id: string;
3149
+ trigger: string;
3150
+ generation_id: string;
3151
+ age_ms: number;
3152
+ allowed_open_span_count: number;
3153
+ }>;
3154
+ intake_spool: {
3155
+ total: number;
3156
+ groups: Array<{ adapter: string; session_hash: string; count: number }>;
3157
+ };
3158
+ diagnostics_spool: {
3159
+ total: number;
3160
+ last_24h: number;
3161
+ last_1h: number;
3162
+ latest_at: string | null;
3163
+ by_category: Record<string, { total: number; last_24h: number }>;
3164
+ recent_by_category: Record<string, { last_1h: number; latest_at: string | null }>;
3165
+ };
3166
+ span_pressure: Array<{ instance_id: string; generation_id: string; span_count: number }>;
3167
+ collection_errors: string[];
3168
+ };
3169
+
3170
+ function errorText(error: unknown): string {
3171
+ return error instanceof Error ? error.message : String(error);
3172
+ }
3173
+
3174
+ /**
3175
+ * Read-only health counters for the V3 event ledger's producer surfaces. Never
3176
+ * mutates ledger state (no control repair, no spool drain) and never throws:
3177
+ * a non-live route returns `{ state: "unavailable" }`, and each sub-surface
3178
+ * that fails to read lands in `collection_errors` instead of aborting the rest.
3179
+ */
3180
+ export function collectEventLedgerHealthV3(root: string, nowMs = Date.now()): EventLedgerHealthV3 {
3181
+ let control: ReturnType<typeof readEventV3ControlState>;
3182
+ try {
3183
+ control = readEventV3ControlState(root);
3184
+ } catch (error) {
3185
+ return { state: "unavailable", reason: `control read failed: ${errorText(error)}` };
3186
+ }
3187
+ if (control.state !== "candidate" && control.state !== "active") {
3188
+ return {
3189
+ state: "unavailable",
3190
+ reason: `${control.state}: ${control.reason}`,
3191
+ };
3192
+ }
3193
+
3194
+ const collectionErrors: string[] = [];
3195
+
3196
+ // 1) Open tool spans per live (non-terminal) generation + span-count pressure.
3197
+ const generations: Extract<EventLedgerHealthV3, { state: "live" }>["open_spans"]["generations"] =
3198
+ [];
3199
+ const spanPressure: Extract<EventLedgerHealthV3, { state: "live" }>["span_pressure"] = [];
3200
+ try {
3201
+ for (const { state } of listHookProducerStateRecordsV3(root)) {
3202
+ if (state.spans.length >= SPAN_PRESSURE_SOFT_WATERMARK) {
3203
+ spanPressure.push({
3204
+ instance_id: state.instance_id,
3205
+ generation_id: state.generation_id,
3206
+ span_count: state.spans.length,
3207
+ });
3208
+ }
3209
+ if (state.spans.length === 0) continue;
3210
+ generations.push({
3211
+ instance_id: state.instance_id,
3212
+ generation_id: state.generation_id,
3213
+ adapter: state.adapter,
3214
+ span_count: state.spans.length,
3215
+ turn_open: Boolean(state.current_turn_id),
3216
+ });
3217
+ }
3218
+ } catch (error) {
3219
+ collectionErrors.push(`producer states unreadable: ${errorText(error)}`);
3220
+ }
3221
+
3222
+ // 2) Pending finalization requests with age + trigger.
3223
+ const pending: Extract<EventLedgerHealthV3, { state: "live" }>["pending_finalizations"] = [];
3224
+ try {
3225
+ for (const request of listSessionFinalizationRequestsV3(root)) {
3226
+ if (request.status !== "pending") continue;
3227
+ const observedMs = Date.parse(request.observed_at);
3228
+ pending.push({
3229
+ request_id: request.request_id,
3230
+ trigger: request.trigger,
3231
+ generation_id: request.generation_id,
3232
+ age_ms: Number.isFinite(observedMs) ? Math.max(0, nowMs - observedMs) : -1,
3233
+ allowed_open_span_count: request.allowed_open_span_ids?.length ?? 0,
3234
+ });
3235
+ }
3236
+ } catch (error) {
3237
+ collectionErrors.push(`finalization requests unreadable: ${errorText(error)}`);
3238
+ }
3239
+
3240
+ // 3) Intake spool depth (queued hook signals awaiting a lease-holder drain).
3241
+ let intakeTotal = 0;
3242
+ const intakeGroups: Extract<EventLedgerHealthV3, { state: "live" }>["intake_spool"]["groups"] =
3243
+ [];
3244
+ try {
3245
+ for (const group of listHookIntakeGroupsV3(root)) {
3246
+ const count = listHookIntakeRecordsV3(group.directory).length;
3247
+ if (count === 0) continue;
3248
+ intakeTotal += count;
3249
+ intakeGroups.push({ adapter: group.adapter, session_hash: group.session_hash, count });
3250
+ }
3251
+ } catch (error) {
3252
+ collectionErrors.push(`intake spool unreadable: ${errorText(error)}`);
3253
+ }
3254
+
3255
+ // 4) Diagnostics spool counts by category. Filenames only (never the
3256
+ // contents): `<category>-<orderkey>.json` where the orderkey leads with a
3257
+ // zero-padded epoch-ms, which also gives the last-24h split for free.
3258
+ const byCategory: Record<string, { total: number; last_24h: number }> = {};
3259
+ const recentByCategory: Extract<
3260
+ EventLedgerHealthV3,
3261
+ { state: "live" }
3262
+ >["diagnostics_spool"]["recent_by_category"] = {};
3263
+ let diagnosticsTotal = 0;
3264
+ let diagnostics24h = 0;
3265
+ let diagnostics1h = 0;
3266
+ let latestDiagnosticMs = Number.NEGATIVE_INFINITY;
3267
+ try {
3268
+ const diagnosticsDir = join(resolve(root), EVENT_V3_LEDGER_RELATIVE_ROOT, "diagnostics");
3269
+ if (existsSync(diagnosticsDir)) {
3270
+ const dayAgoMs = nowMs - 24 * 60 * 60 * 1000;
3271
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
3272
+ for (const name of readdirSync(diagnosticsDir)) {
3273
+ const match = /^(.+)-(\d{15})-\d{20}-\d+-[0-9a-f-]+\.json$/.exec(name);
3274
+ const category = match?.[1];
3275
+ const epochMs = Number(match?.[2]);
3276
+ if (!category) continue;
3277
+ const entry = byCategory[category] ?? { total: 0, last_24h: 0 };
3278
+ const recent = recentByCategory[category] ?? { last_1h: 0, latest_at: null };
3279
+ byCategory[category] = entry;
3280
+ recentByCategory[category] = recent;
3281
+ entry.total += 1;
3282
+ diagnosticsTotal += 1;
3283
+ if (Number.isFinite(epochMs)) {
3284
+ const recordedAt = new Date(epochMs).toISOString();
3285
+ if (!recent.latest_at || epochMs > Date.parse(recent.latest_at)) {
3286
+ recent.latest_at = recordedAt;
3287
+ }
3288
+ latestDiagnosticMs = Math.max(latestDiagnosticMs, epochMs);
3289
+ if (epochMs >= dayAgoMs) {
3290
+ entry.last_24h += 1;
3291
+ diagnostics24h += 1;
3292
+ }
3293
+ if (epochMs >= hourAgoMs) {
3294
+ recent.last_1h += 1;
3295
+ diagnostics1h += 1;
3296
+ }
3297
+ }
3298
+ }
3299
+ }
3300
+ } catch (error) {
3301
+ collectionErrors.push(`diagnostics spool unreadable: ${errorText(error)}`);
3302
+ }
3303
+
3304
+ return {
3305
+ state: "live",
3306
+ mode: control.state,
3307
+ open_spans: {
3308
+ total: generations.reduce((sum, generation) => sum + generation.span_count, 0),
3309
+ generations,
3310
+ },
3311
+ pending_finalizations: pending,
3312
+ intake_spool: { total: intakeTotal, groups: intakeGroups },
3313
+ diagnostics_spool: {
3314
+ total: diagnosticsTotal,
3315
+ last_24h: diagnostics24h,
3316
+ last_1h: diagnostics1h,
3317
+ latest_at: Number.isFinite(latestDiagnosticMs)
3318
+ ? new Date(latestDiagnosticMs).toISOString()
3319
+ : null,
3320
+ by_category: byCategory,
3321
+ recent_by_category: recentByCategory,
3322
+ },
3323
+ span_pressure: spanPressure,
3324
+ collection_errors: collectionErrors,
3325
+ };
3326
+ }
3327
+
2683
3328
  /** Tally agent-hook failures (.harnery/debug/agent-hook.errors.ndjson) in the
2684
- * window, grouped by `phase`. Each line is {ts, error, phase, ...}. A dominant
2685
- * phase points straight at a systemic hook bug. */
2686
- function readHookErrors(
3329
+ * window by exact error and phase, while separating current-hour failures from
3330
+ * historical rows. Each line is {ts, error, phase, ...}. */
3331
+ export function readHookErrors(
2687
3332
  root: string,
2688
3333
  cutoffMs: number,
3334
+ nowMs = Date.now(),
2689
3335
  ): {
2690
3336
  total: number;
3337
+ last1h: number;
3338
+ latestAt: string | null;
2691
3339
  byPhase: Record<string, number>;
3340
+ byError: Record<string, number>;
2692
3341
  top: Array<{ phase: string; count: number; sample: string }>;
3342
+ topErrors: Array<{ error: string; count: number; phase: string }>;
3343
+ recentTopErrors: Array<{ error: string; count: number; phase: string }>;
2693
3344
  } {
2694
3345
  const p = resolve(root, ".harnery", "debug", "agent-hook.errors.ndjson");
2695
3346
  const byPhase: Record<string, number> = {};
3347
+ const byError: Record<string, number> = {};
2696
3348
  const sampleByPhase: Record<string, string> = {};
3349
+ const errorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
3350
+ const recentErrorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
2697
3351
  let total = 0;
2698
- if (!existsSync(p)) return { total: 0, byPhase, top: [] };
3352
+ let last1h = 0;
3353
+ let latestMs = Number.NEGATIVE_INFINITY;
3354
+ if (!existsSync(p)) {
3355
+ return {
3356
+ total: 0,
3357
+ last1h: 0,
3358
+ latestAt: null,
3359
+ byPhase,
3360
+ byError,
3361
+ top: [],
3362
+ topErrors: [],
3363
+ recentTopErrors: [],
3364
+ };
3365
+ }
2699
3366
  let raw: string;
2700
3367
  try {
2701
3368
  raw = readFileSync(p, "utf8");
2702
3369
  } catch {
2703
- return { total: 0, byPhase, top: [] };
3370
+ return {
3371
+ total: 0,
3372
+ last1h: 0,
3373
+ latestAt: null,
3374
+ byPhase,
3375
+ byError,
3376
+ top: [],
3377
+ topErrors: [],
3378
+ recentTopErrors: [],
3379
+ };
2704
3380
  }
3381
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
2705
3382
  for (const line of raw.split("\n")) {
2706
3383
  if (!line.trim()) continue;
2707
3384
  try {
@@ -2709,8 +3386,21 @@ function readHookErrors(
2709
3386
  const tsMs = e.ts ? Date.parse(e.ts) : Number.NaN;
2710
3387
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2711
3388
  const phase = e.phase ?? "(unknown)";
3389
+ const error = e.error ?? "(unknown)";
2712
3390
  byPhase[phase] = (byPhase[phase] ?? 0) + 1;
3391
+ byError[error] = (byError[error] ?? 0) + 1;
2713
3392
  if (!sampleByPhase[phase] && e.error) sampleByPhase[phase] = e.error;
3393
+ const errorPhaseKey = `${error}\0${phase}`;
3394
+ const errorPhase = errorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3395
+ errorPhase.count += 1;
3396
+ errorPhaseCounts.set(errorPhaseKey, errorPhase);
3397
+ latestMs = Math.max(latestMs, tsMs);
3398
+ if (tsMs >= hourAgoMs) {
3399
+ last1h += 1;
3400
+ const recent = recentErrorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3401
+ recent.count += 1;
3402
+ recentErrorPhaseCounts.set(errorPhaseKey, recent);
3403
+ }
2714
3404
  total++;
2715
3405
  } catch {
2716
3406
  /* skip malformed */
@@ -2720,130 +3410,181 @@ function readHookErrors(
2720
3410
  .sort((a, b) => b[1] - a[1])
2721
3411
  .slice(0, 5)
2722
3412
  .map(([phase, count]) => ({ phase, count, sample: sampleByPhase[phase] ?? "" }));
2723
- return { total, byPhase, top };
3413
+ const topErrors = [...errorPhaseCounts.values()]
3414
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3415
+ .slice(0, 5);
3416
+ const recentTopErrors = [...recentErrorPhaseCounts.values()]
3417
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3418
+ .slice(0, 5);
3419
+ return {
3420
+ total,
3421
+ last1h,
3422
+ latestAt: Number.isFinite(latestMs) ? new Date(latestMs).toISOString() : null,
3423
+ byPhase,
3424
+ byError,
3425
+ top,
3426
+ topErrors,
3427
+ recentTopErrors,
3428
+ };
2724
3429
  }
2725
3430
 
2726
3431
  /** Canonical event stream size + drain lag (events appended after the cursor). */
2727
- function readStreamStats(root: string): { bytes: number; lines: number; cursor_backlog: number } {
2728
- const streamPath = resolve(root, ".harnery", "events.ndjson");
2729
- if (!existsSync(streamPath)) return { bytes: 0, lines: 0, cursor_backlog: 0 };
2730
- let bytes = 0;
2731
- try {
2732
- bytes = statSync(streamPath).size;
2733
- } catch {
2734
- /* ignore */
2735
- }
2736
- let cursor: string | null = null;
2737
- const cursorPath = resolve(root, ".harnery", ".events-cursor");
2738
- if (existsSync(cursorPath)) {
2739
- try {
2740
- cursor = readFileSync(cursorPath, "utf8").trim() || null;
2741
- } catch {
2742
- /* ignore */
2743
- }
2744
- }
2745
- let lines = 0;
2746
- let backlog = 0;
2747
- let seenCursor = cursor === null; // no cursor → everything is "backlog"
2748
- try {
2749
- // Bounded tail read: the ledger grows without bound and a whole-file
2750
- // readFileSync throws past V8's ~512MB string limit. lines/backlog are then
2751
- // scoped to the scanned window; `bytes` (full size, via statSync above) still
2752
- // reflects the true stream size for the health rollup.
2753
- const { text } = readStreamTailBounded(streamPath, STREAM_SCAN_CAP_BYTES);
2754
- for (const line of text.split("\n")) {
2755
- if (!line.trim()) continue;
2756
- lines++;
2757
- if (seenCursor) {
2758
- backlog++;
2759
- } else if (cursor && line.includes(`"event_id":"${cursor}"`)) {
2760
- seenCursor = true;
2761
- }
2762
- }
2763
- } catch {
2764
- /* ignore */
2765
- }
2766
- return { bytes, lines, cursor_backlog: backlog };
3432
+ function readStreamStats(root: string): HealthReport["stream"] {
3433
+ const read = readAgentDiagnosticEventsInWindow(root, 0);
3434
+ return {
3435
+ source: "v3",
3436
+ authoritative: read.authoritative,
3437
+ ...(read.reason ? { reason: read.reason } : {}),
3438
+ bytes: read.bytes,
3439
+ lines: read.events.length,
3440
+ cursor_backlog: 0,
3441
+ };
2767
3442
  }
2768
3443
 
2769
3444
  /** One rendered line in a trace. */
2770
- interface TraceEntry {
3445
+ export interface TraceEntry {
2771
3446
  ts: string;
2772
3447
  event_type: string;
2773
3448
  detail: string;
2774
3449
  }
2775
3450
 
3451
+ export function traceInstanceIdsForEventSource(
3452
+ nativeInstanceIds: readonly string[],
3453
+ source: "v3",
3454
+ ): string[] {
3455
+ void source;
3456
+ return nativeInstanceIds.map((instanceId) => liveInstanceIdV3(instanceId));
3457
+ }
3458
+
2776
3459
  /** Map a canonical event to a concise trace line, or null to drop it. */
2777
- function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
2778
- const d = (ev.data ?? {}) as Record<string, unknown>;
3460
+ export function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
3461
+ const d = (ev.payload ?? {}) as Record<string, unknown>;
2779
3462
  const s = (k: string): string => (typeof d[k] === "string" ? (d[k] as string) : "");
3463
+ const object = (value: unknown): Record<string, unknown> =>
3464
+ typeof value === "object" && value !== null && !Array.isArray(value)
3465
+ ? (value as Record<string, unknown>)
3466
+ : {};
2780
3467
  const clip = (v: string, n = 70): string => (v.length <= n ? v : `${v.slice(0, n - 1)}…`);
3468
+ const recovery =
3469
+ typeof d.recovery === "object" && d.recovery !== null
3470
+ ? (d.recovery as { reason?: unknown })
3471
+ : null;
3472
+ const recoveryReason = typeof recovery?.reason === "string" ? recovery.reason : "";
3473
+ const recoveryDetail = recoveryReason ? ` · RECOVERY reason=${recoveryReason}` : "";
2781
3474
  let detail = "";
2782
3475
  switch (ev.event_type) {
2783
- case "session.start":
2784
- detail = `${s("source") || "startup"}${s("model") ? ` · model=${s("model")}` : ""}${s("name") ? ` · ${s("name")}` : ""}`;
3476
+ case "session.started":
3477
+ detail = "generation started";
3478
+ break;
3479
+ case "session.resumed":
3480
+ detail = "generation resumed";
2785
3481
  break;
2786
- case "session.end":
2787
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3482
+ case "session.ended":
3483
+ detail = `outcome=${s("outcome") || "unknown"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
2788
3484
  break;
2789
- case "subagent.start":
2790
- detail = `${s("agent_type") || "subagent"}${s("name") ? ` · ${s("name")}` : ""}`;
3485
+ case "session.termination_observed":
3486
+ detail = `provisional ${s("observation") || "termination"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
2791
3487
  break;
2792
- case "subagent.stop":
2793
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3488
+ case "agent.delegated":
3489
+ detail = `delegated child=${s("child_generation_id") || "unknown"}`;
2794
3490
  break;
2795
- case "user_prompt.submit":
2796
- detail = clip(s("prompt_text") || s("prompt"));
3491
+ case "agent.started":
3492
+ detail = `child started=${s("child_generation_id") || "unknown"}`;
2797
3493
  break;
2798
- case "turn.stop":
2799
- detail = `status_box=${d.status_box_present ?? "?"}${s("turn_summary") ? ` · ${clip(s("turn_summary"), 50)}` : ""}`;
3494
+ case "agent.completed":
3495
+ detail = `child completed=${s("child_generation_id") || "unknown"} · outcome=${s("outcome") || "unknown"}`;
2800
3496
  break;
2801
- case "tool.pre_use":
2802
- detail = `${s("tool_name")}${s("tool_target") || s("intent") ? ` · ${clip(s("tool_target") || s("intent"), 60)}` : ""}`;
3497
+ case "turn.started":
3498
+ detail = `intent=${s("intent_kind") || "unknown"}`;
2803
3499
  break;
2804
- case "state.task_set":
2805
- detail = d.cleared ? "(cleared)" : clip(s("task"));
3500
+ case "turn.completed":
3501
+ detail = `outcome=${s("outcome") || "unknown"}`;
2806
3502
  break;
2807
- case "state.task_state":
2808
- detail = `${s("prior_state") || "active"} → ${s("state") || "active"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
3503
+ case "tool.requested": {
3504
+ const tool = object(d.tool);
3505
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} requested (content omitted)${recoveryDetail}`;
2809
3506
  break;
2810
- case "interaction.input_requested":
2811
- detail = s("request_kind") || s("tool_name") || "operator input requested";
3507
+ }
3508
+ case "tool.completed": {
3509
+ const tool = object(d.tool);
3510
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} · outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3511
+ break;
3512
+ }
3513
+ case "command.started":
3514
+ if (!allTools) return null;
3515
+ detail = "command started (content omitted)";
3516
+ break;
3517
+ case "command.completed":
3518
+ if (!allTools && !recoveryReason) return null;
3519
+ detail = `outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3520
+ break;
3521
+ case "coord.task_changed":
3522
+ case "coord.lifecycle_changed":
3523
+ case "coord.presence_changed":
3524
+ detail = `${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
3525
+ break;
3526
+ case "coord.status_observed":
3527
+ detail = s("status") || "status observed";
3528
+ break;
3529
+ case "coord.claim_changed": {
3530
+ const target = object(d.target);
3531
+ detail = `${s("operation") || "changed"}${typeof target.display === "string" ? ` · ${clip(target.display)}` : ""}`;
3532
+ break;
3533
+ }
3534
+ case "coord.message_observed":
3535
+ detail = `${s("direction") || "observed"} · peer=${s("peer_instance_id") || "unknown"}`;
3536
+ break;
3537
+ case "coord.identity_attested":
3538
+ detail = `${s("identity_id") || "identity"} · method=${s("method") || "unknown"}`;
3539
+ break;
3540
+ case "wait.started":
3541
+ detail = s("kind") || "wait started";
3542
+ break;
3543
+ case "wait.ended":
3544
+ detail = `outcome=${s("outcome") || "unknown"}`;
3545
+ break;
3546
+ case "artifact.observed": {
3547
+ const artifact = object(d.artifact);
3548
+ detail = `operation=${s("operation") || "observed"}${typeof artifact.kind === "string" ? ` · kind=${artifact.kind}` : ""}`;
2812
3549
  break;
2813
- case "state.status_checked":
2814
- detail = "status box rendered";
3550
+ }
3551
+ case "progress.observed":
3552
+ detail = s("kind") || "progress";
3553
+ break;
3554
+ case "health.observed":
3555
+ detail = `${s("subsystem") || "unknown"} · ${s("severity") || "unknown"} · ${s("condition") || "unknown"}`;
2815
3556
  break;
2816
- case "identity.assumed":
2817
- detail = `${s("previous_name") || "unknown"} → ${s("name") || "unknown"}${s("agent_id") ? ` · persona=${s("agent_id").slice(0, 8)}…` : ""}`;
3557
+ case "council.state_changed":
3558
+ detail = `${s("council_id") || "council"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2818
3559
  break;
2819
- case "claim.acquire":
2820
- case "claim.release":
2821
- case "claim.conflict":
2822
- detail = clip(s("path"));
3560
+ case "decision.state_changed":
3561
+ detail = `${s("decision_id") || "decision"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2823
3562
  break;
2824
- case "health.heartbeat_heal":
2825
- case "health.pidmap_heal":
2826
- detail = `reason=${s("reason")}`;
3563
+ case "lifecycle.recovered":
3564
+ detail = `recovered=${s("recovery_kind") || "unknown"}`;
2827
3565
  break;
2828
- case "health.heartbeat_swept":
2829
- detail = `reason=${s("reason")}${d.age_secs !== undefined ? ` · age=${d.age_secs}s` : ""}`;
3566
+ case "lifecycle.sweep_observed":
3567
+ detail = `${s("observation") || "sweep"}${typeof d.age_ms === "number" ? ` · age=${Math.round(d.age_ms / 1000)}s` : ""}`;
2830
3568
  break;
2831
3569
  default:
2832
- // Noise unless --all-tools: per-line command.* + tool.post_use.
2833
- if (!allTools) return null;
2834
- if (
2835
- ev.event_type === "tool.post_use" ||
2836
- ev.event_type === "tool.post_use_failure" ||
2837
- ev.event_type.startsWith("command.")
2838
- ) {
2839
- detail = s("tool_name") || "";
2840
- break;
2841
- }
2842
3570
  return null;
2843
3571
  }
2844
3572
  return { ts: ev.ts, event_type: ev.event_type, detail };
2845
3573
  }
2846
3574
 
3575
+ export function pendingFinalizationTraceEntries(
3576
+ requests: readonly SessionFinalizationRequestV3[],
3577
+ instanceId: string,
3578
+ ): TraceEntry[] {
3579
+ return requests
3580
+ .filter((request) => request.status === "pending" && request.instance_id === instanceId)
3581
+ .map((request) => ({
3582
+ ts: request.observed_at,
3583
+ event_type: "session.finalization_pending",
3584
+ detail: `trigger=${request.trigger} · request=${request.request_id}${request.allowed_open_span_ids?.length ? ` · allowed_open_spans=${request.allowed_open_span_ids.length}` : ""}`,
3585
+ }));
3586
+ }
3587
+
2847
3588
  function runTrace(
2848
3589
  name: string,
2849
3590
  opts: { since?: string; limit: string; allTools?: boolean; json?: boolean },
@@ -2872,7 +3613,7 @@ function runTrace(
2872
3613
  if (candidates.length === 1) targetId = candidates[0]![0];
2873
3614
  else if (candidates.length > 1)
2874
3615
  targetId = candidates.map(([id]) => id).join("\x00"); // sentinel; resolved below
2875
- else if (/^[0-9a-f-]{8,}$/i.test(wanted)) targetId = wanted; // looks like an id not in history
3616
+ else if (/^(?:inst_)?[0-9a-z._-]{8,}$/i.test(wanted)) targetId = wanted;
2876
3617
  }
2877
3618
  if (!targetId) {
2878
3619
  emit.error({
@@ -2884,37 +3625,16 @@ function runTrace(
2884
3625
 
2885
3626
  const sinceMs = opts.since ? Date.now() - (parseWindowSecs(opts.since) ?? 0) * 1000 : 0;
2886
3627
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 200);
2887
- const candidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
2888
-
2889
- // Scan the stream tail once, bucket events by instance_id for the candidates.
2890
- // Bounded read: the ledger grows without bound and a whole-file readFileSync
2891
- // throws past V8's ~512MB string limit. A trace of an agent whose events all
2892
- // predate the window is reported as truncated rather than crashing.
2893
- const streamPath = resolve(root, ".harnery", "events.ndjson");
3628
+ const diagnosticRead = readAgentDiagnosticEventsInWindow(root, sinceMs);
3629
+ const nativeCandidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
3630
+ const candidateIds = traceInstanceIdsForEventSource(nativeCandidateIds, diagnosticRead.source);
2894
3631
  const byId = new Map<string, CanonicalEvent[]>();
2895
- const { text: streamText, truncated: streamTruncated } = readStreamTailBounded(
2896
- streamPath,
2897
- STREAM_SCAN_CAP_BYTES,
2898
- );
2899
- for (const line of streamText.split("\n")) {
2900
- if (!line) continue;
2901
- try {
2902
- const ev = JSON.parse(line) as CanonicalEvent;
2903
- if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
2904
- if (sinceMs && Date.parse(ev.ts) < sinceMs) continue;
2905
- const arr = byId.get(ev.instance_id) ?? [];
2906
- arr.push(ev);
2907
- byId.set(ev.instance_id, arr);
2908
- } catch {
2909
- /* skip */
2910
- }
3632
+ for (const ev of diagnosticRead.events) {
3633
+ if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
3634
+ const arr = byId.get(ev.instance_id) ?? [];
3635
+ arr.push(ev);
3636
+ byId.set(ev.instance_id, arr);
2911
3637
  }
2912
- if (streamTruncated) {
2913
- process.stderr.write(
2914
- `note: event ledger exceeds ${Math.round(STREAM_SCAN_CAP_BYTES / 1024 / 1024)}MB; traced only the most recent window (older events omitted)\n`,
2915
- );
2916
- }
2917
-
2918
3638
  // If the name mapped to multiple instances, trace the one with the latest event.
2919
3639
  let resolvedId = candidateIds[0]!;
2920
3640
  if (candidateIds.length > 1) {
@@ -2930,25 +3650,84 @@ function runTrace(
2930
3650
  }
2931
3651
 
2932
3652
  const events = byId.get(resolvedId) ?? [];
2933
- const state = foldSessionState(events, { instance_id: resolvedId });
2934
- const lines = events
2935
- .map((ev) => traceLine(ev, !!opts.allTools))
2936
- .filter((l): l is TraceEntry => l !== null)
3653
+ let state = foldSessionState(events, { instance_id: resolvedId });
3654
+ let sessionState: "live" | "ended" = events.some((event) => event.event_type === "session.ended")
3655
+ ? "ended"
3656
+ : "live";
3657
+ if (diagnosticRead.source === "v3" && diagnosticRead.authoritative) {
3658
+ try {
3659
+ const view = projectCoordinationViewV3(readLedgerV3(root));
3660
+ const liveGeneration = view.instances[resolvedId];
3661
+ const terminalGeneration = Object.values(view.terminal_generations).find(
3662
+ (candidate) => candidate.instance_id === resolvedId,
3663
+ );
3664
+ const generation = liveGeneration ?? terminalGeneration;
3665
+ if (generation) {
3666
+ sessionState = terminalGeneration && !liveGeneration ? "ended" : "live";
3667
+ const lifecycleState = generation.lifecycle_state;
3668
+ state = {
3669
+ activity: generation.activity === "terminal" ? "idle" : generation.activity,
3670
+ activity_updated_at: generation.last_observed_at,
3671
+ activity_source: "event-v3-coordination-view",
3672
+ task_state:
3673
+ lifecycleState === "active" || lifecycleState === "blocked" || lifecycleState === "done"
3674
+ ? lifecycleState
3675
+ : "active",
3676
+ task_state_updated_at: generation.lifecycle_state_updated_at,
3677
+ };
3678
+ }
3679
+ } catch {
3680
+ diagnosticRead.authoritative = false;
3681
+ diagnosticRead.reason = "V3 coordination projection is unavailable";
3682
+ }
3683
+ }
3684
+ let pendingFinalizations: SessionFinalizationRequestV3[] = [];
3685
+ if (diagnosticRead.source === "v3") {
3686
+ try {
3687
+ pendingFinalizations = listSessionFinalizationRequestsV3(root).filter(
3688
+ (request) => request.status === "pending" && request.instance_id === resolvedId,
3689
+ );
3690
+ } catch {
3691
+ diagnosticRead.authoritative = false;
3692
+ diagnosticRead.reason = "V3 finalization requests are unreadable";
3693
+ }
3694
+ }
3695
+ const lines = [
3696
+ ...events
3697
+ .map((ev) => traceLine(ev, !!opts.allTools))
3698
+ .filter((l): l is TraceEntry => l !== null),
3699
+ ...pendingFinalizationTraceEntries(pendingFinalizations, resolvedId),
3700
+ ]
2937
3701
  // Sort by timestamp, not file order: codex replays events (original ts,
2938
3702
  // appended later), so append-order ≠ chronological order.
2939
3703
  .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
2940
3704
  const shown = lines.slice(-limit);
2941
- const displayName = nameById.get(resolvedId) ?? resolvedId.slice(0, 8);
3705
+ const nativeResolvedId =
3706
+ diagnosticRead.source === "v3"
3707
+ ? nativeCandidateIds[candidateIds.indexOf(resolvedId)]
3708
+ : resolvedId;
3709
+ const displayName =
3710
+ nameById.get(resolvedId) ??
3711
+ (nativeResolvedId ? nameById.get(nativeResolvedId) : undefined) ??
3712
+ resolvedId.slice(0, 8);
2942
3713
 
2943
3714
  const result = {
3715
+ event_source: {
3716
+ contract: diagnosticRead.source,
3717
+ authoritative: diagnosticRead.authoritative,
3718
+ reason: diagnosticRead.reason ?? null,
3719
+ },
2944
3720
  name: displayName,
2945
3721
  instance_id: resolvedId,
2946
3722
  other_instances: candidateIds.filter((id) => id !== resolvedId),
3723
+ pending_finalizations: pendingFinalizations,
2947
3724
  total_events: events.length,
3725
+ session_state: sessionState,
2948
3726
  activity: state.activity,
2949
3727
  activity_updated_at: state.activity_updated_at ?? null,
2950
3728
  activity_source: state.activity_source ?? null,
2951
3729
  task_state: state.task_state,
3730
+ task_state_scope: sessionState === "ended" ? "historical" : "current",
2952
3731
  task_state_updated_at: state.task_state_updated_at ?? null,
2953
3732
  task_state_reason: state.task_state_reason ?? null,
2954
3733
  shown: shown.length,
@@ -2963,8 +3742,11 @@ function runTrace(
2963
3742
  const header = `Trace: agent-${displayName} (${resolvedId.slice(0, 8)}…) ${events.length} events${result.other_instances.length ? ` · ${result.other_instances.length} older instance(s) of this name` : ""}`;
2964
3743
  process.stdout.write(`${header}\n`); // lint-ok-emission: human trace view
2965
3744
  process.stdout.write(
2966
- ` activity=${state.activity} · lifecycle=${state.task_state}${state.task_state === "blocked" && state.task_state_reason ? `: ${state.task_state_reason}` : ""}\n`,
3745
+ ` activity=${state.activity} · session=${sessionState} · lifecycle=${sessionState === "ended" ? `historical(${state.task_state})` : state.task_state}${sessionState !== "ended" && state.task_state === "blocked" && state.task_state_reason ? `: ${state.task_state_reason}` : ""}\n`,
2967
3746
  ); // lint-ok-emission: human trace view
3747
+ if (!diagnosticRead.authoritative) {
3748
+ process.stdout.write(` unavailable: ${diagnosticRead.reason ?? "event source incomplete"}\n`); // lint-ok-emission: explicit no-fallback diagnostic
3749
+ }
2968
3750
  if (shown.length === 0) {
2969
3751
  process.stdout.write(" (no events)\n"); // lint-ok-emission: human trace view
2970
3752
  return;
@@ -2975,6 +3757,73 @@ function runTrace(
2975
3757
  }
2976
3758
  }
2977
3759
 
3760
+ export interface ActiveAgentHealthSummary {
3761
+ source: "event-ledger-v3";
3762
+ total: number;
3763
+ by_platform: Record<string, number>;
3764
+ by_kind: Record<string, number>;
3765
+ by_schema_version: Record<string, number>;
3766
+ stale: number;
3767
+ }
3768
+
3769
+ type ActiveHealthHeartbeat = {
3770
+ instance_id: string;
3771
+ platform?: string;
3772
+ kind?: string;
3773
+ schema_version?: number;
3774
+ last_heartbeat?: string;
3775
+ };
3776
+
3777
+ /**
3778
+ * V3 generations, not disposable heartbeat caches, are the active-agent authority.
3779
+ */
3780
+ export function collectActiveAgentHealth(
3781
+ root: string,
3782
+ nowMs = Date.now(),
3783
+ ): ActiveAgentHealthSummary {
3784
+ const source: ActiveAgentHealthSummary["source"] = "event-ledger-v3";
3785
+ let heartbeats: ActiveHealthHeartbeat[] = [];
3786
+ try {
3787
+ const control = readEventV3ControlState(root);
3788
+ if (control.state === "candidate" || control.state === "active") {
3789
+ heartbeats = readLiveCoordinationRows(root);
3790
+ }
3791
+ } catch {
3792
+ // The event-ledger section reports the authority read failure. Do not
3793
+ // substitute stale cache rows for a live V3 route.
3794
+ }
3795
+
3796
+ const byPlatform: Record<string, number> = {};
3797
+ const byKind: Record<string, number> = {};
3798
+ const bySchema: Record<string, number> = {};
3799
+ let stale = 0;
3800
+ for (const heartbeat of heartbeats) {
3801
+ const platform = formatPlatformLabel(heartbeat.platform);
3802
+ byPlatform[platform] = (byPlatform[platform] ?? 0) + 1;
3803
+ const kind = heartbeat.kind ?? "unknown";
3804
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
3805
+ // These rows exist only after an authority-safe V3 projection. The
3806
+ // disposable heartbeat file has its own cache-format version, which must
3807
+ // not be reported as the ledger schema version.
3808
+ bySchema.v3 = (bySchema.v3 ?? 0) + 1;
3809
+ const lastObservedMs = heartbeat.last_heartbeat
3810
+ ? Date.parse(heartbeat.last_heartbeat)
3811
+ : Number.NaN;
3812
+ const ageMs = Number.isFinite(lastObservedMs)
3813
+ ? nowMs - lastObservedMs
3814
+ : Number.POSITIVE_INFINITY;
3815
+ if (ageMs > freshnessCutoffSecs() * 1000) stale += 1;
3816
+ }
3817
+ return {
3818
+ source,
3819
+ total: heartbeats.length,
3820
+ by_platform: byPlatform,
3821
+ by_kind: byKind,
3822
+ by_schema_version: bySchema,
3823
+ stale,
3824
+ };
3825
+ }
3826
+
2978
3827
  function runHealth(opts: { since: string; json?: boolean }): void {
2979
3828
  if (opts.json) emit.config({ format: "json" });
2980
3829
 
@@ -2996,12 +3845,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2996
3845
  process.exit(1);
2997
3846
  }
2998
3847
 
2999
- const cutoffMs = Date.now() - sinceSecs * 1000;
3848
+ const nowMs = Date.now();
3849
+ const cutoffMs = nowMs - sinceSecs * 1000;
3000
3850
  const activeDir = resolve(root, ".harnery/active");
3001
3851
  const councilsDir = resolve(root, ".harnery/councils");
3002
3852
 
3003
- // Coordination telemetry reads from the canonical events.ndjson stream.
3004
- // Heals come from health.*; council window-activity from council.*. The
3853
+ // Coordination telemetry reads from the canonical V3 ledger.
3854
+ // Heals come from health.observed; council activity from council.state_changed.
3005
3855
  const heal: HealEvent[] = [];
3006
3856
  let councilAdvanced = 0;
3007
3857
  let councilClosed = 0;
@@ -3010,33 +3860,33 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3010
3860
  const sweptByReason: Record<string, number> = {};
3011
3861
 
3012
3862
  const nameById = buildNameById(root);
3013
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
3863
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
3864
+ for (const ev of diagnosticEvents.events) {
3014
3865
  const healEv = canonicalToHealEvent(ev, nameById);
3015
3866
  if (healEv) {
3016
3867
  heal.push(healEv);
3017
3868
  continue;
3018
3869
  }
3870
+ if (ev.event_type === "council.state_changed") {
3871
+ const state = String(ev.payload?.new_state ?? "");
3872
+ if (state === "round_open") councilAdvanced++;
3873
+ if (state === "closed") councilClosed++;
3874
+ if (state === "archived") councilArchived++;
3875
+ continue;
3876
+ }
3019
3877
  switch (ev.event_type) {
3020
- case "council.round_open":
3021
- councilAdvanced++;
3022
- break;
3023
- case "council.close":
3024
- councilClosed++;
3025
- break;
3026
- case "council.archive":
3027
- councilArchived++;
3028
- break;
3029
- case "health.heartbeat_swept": {
3878
+ case "lifecycle.sweep_observed": {
3030
3879
  sweptTotal++;
3031
- const reason = String((ev.data as { reason?: unknown })?.reason ?? "unknown");
3880
+ const reason = String(ev.payload?.observation ?? "unknown");
3032
3881
  sweptByReason[reason] = (sweptByReason[reason] ?? 0) + 1;
3033
3882
  break;
3034
3883
  }
3035
3884
  }
3036
3885
  }
3037
3886
 
3038
- const hookErrors = readHookErrors(root, cutoffMs);
3887
+ const hookErrors = readHookErrors(root, cutoffMs, nowMs);
3039
3888
  const stream = readStreamStats(root);
3889
+ const eventLedger = collectEventLedgerHealthV3(root);
3040
3890
 
3041
3891
  // Canonical health.* events carry the full instance_id, already resolved to
3042
3892
  // `agent-<name>` (or `agent-<hex8>` fallback) by canonicalToHealEvent via
@@ -3056,19 +3906,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3056
3906
  .slice(0, 5)
3057
3907
  .map(([agent, count]) => ({ agent, count }));
3058
3908
 
3059
- // Active heartbeats: scan ALL files in active/, classify fresh vs stale ourselves.
3060
- const activeByPlatform: Record<string, number> = {};
3061
- const activeByKind: Record<string, number> = {};
3062
- const activeBySchema: Record<string, number> = {};
3063
- let activeTotal = 0;
3064
- let staleHeartbeats = 0;
3909
+ const activeAgents = collectActiveAgentHealth(root, nowMs);
3065
3910
  // Zombies: files in active/ that are broken: unparseable, nameless, or an
3066
3911
  // absurd (epoch-ish) last_heartbeat. These show as `agent-unknown` ghosts and
3067
3912
  // mean dead files the sweep isn't reaping.
3068
3913
  let zombieCount = 0;
3069
3914
  const zombieSamples: string[] = [];
3070
3915
  const ABSURD_AGE_MS = 24 * 60 * 60 * 1000; // > 1 day = clearly not a live, self-healing agent
3071
- const nowMs = Date.now();
3072
3916
  if (existsSync(activeDir)) {
3073
3917
  for (const file of readdirSync(activeDir)) {
3074
3918
  if (!file.endsWith(".json")) continue;
@@ -3085,17 +3929,8 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3085
3929
  zombieSamples.push(`${idFromFile.slice(0, 12)} (unparseable/no-id)`);
3086
3930
  continue;
3087
3931
  }
3088
- activeTotal++;
3089
- const platform = formatPlatformLabel(hb.platform);
3090
- activeByPlatform[platform] = (activeByPlatform[platform] ?? 0) + 1;
3091
- const kind = hb.kind ?? "unknown";
3092
- activeByKind[kind] = (activeByKind[kind] ?? 0) + 1;
3093
- const sv = (hb as { schema_version?: number }).schema_version;
3094
- const schemaKey = sv === undefined ? "v0" : `v${sv}`;
3095
- activeBySchema[schemaKey] = (activeBySchema[schemaKey] ?? 0) + 1;
3096
3932
  const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
3097
3933
  const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
3098
- if (ageMs > freshnessCutoffSecs() * 1000) staleHeartbeats++;
3099
3934
  // Zombie heuristics on a parseable heartbeat: no name, or an age so large
3100
3935
  // it can only be a broken/epoch timestamp (a real agent would have healed).
3101
3936
  if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
@@ -3127,37 +3962,38 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3127
3962
  );
3128
3963
  }
3129
3964
  }
3130
- if (staleHeartbeats > 0) {
3965
+ if (activeAgents.stale > 0) {
3966
+ const noun = activeAgents.source === "event-ledger-v3" ? "V3 generation" : "heartbeat";
3967
+ const action =
3968
+ activeAgents.source === "event-ledger-v3"
3969
+ ? "lifecycle reconciliation may be delayed"
3970
+ : "heal mechanism may not be firing";
3131
3971
  anomalies.push(
3132
- `${staleHeartbeats} active heartbeat(s) older than ${Math.floor(freshnessCutoffSecs() / 60)}min; heal mechanism may not be firing`,
3972
+ `${activeAgents.stale} active ${noun}(s) without activity for ${Math.floor(freshnessCutoffSecs() / 60)}min; ${action}`,
3133
3973
  );
3134
3974
  }
3135
- const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
3975
+ const expectedSchema = "v3";
3976
+ const unexpectedSchemas = Object.keys(activeAgents.by_schema_version).filter(
3977
+ (schema) => schema !== expectedSchema,
3978
+ );
3136
3979
  if (unexpectedSchemas.length > 0) {
3137
3980
  anomalies.push(
3138
- `Unexpected heartbeat schema versions in use: ${unexpectedSchemas.join(", ")} (expected v1)`,
3981
+ `Unexpected active-agent schema versions in use: ${unexpectedSchemas.join(", ")} (expected ${expectedSchema})`,
3139
3982
  );
3140
3983
  }
3141
- // agent-hook failures: a dominant phase is the fastest pointer to a systemic
3142
- // hook bug (this is the signal that would have surfaced the stop-projection
3143
- // crash immediately instead of after an hour of log-grepping).
3144
- if (hookErrors.total > 0) {
3145
- const top = hookErrors.top[0];
3146
- const detail = top
3147
- ? `: top phase '${top.phase}' x${top.count}${top.sample ? ` (${top.sample.slice(0, 80)})` : ""}`
3148
- : "";
3149
- anomalies.push(`agent-hook errored ${hookErrors.total}x in ${opts.since}${detail}`);
3984
+ // Only recent hook failures are active anomalies. Historical rows remain in
3985
+ // the report with exact error signatures and their latest timestamp.
3986
+ if (hookErrors.last1h > 0) {
3987
+ const top = hookErrors.recentTopErrors[0];
3988
+ const detail = top ? `: top '${top.error.slice(0, 80)}' x${top.count} (${top.phase})` : "";
3989
+ anomalies.push(`agent-hook errored ${hookErrors.last1h}x in the last hour${detail}`);
3150
3990
  }
3151
3991
  if (stream.cursor_backlog > 500) {
3152
3992
  anomalies.push(
3153
3993
  `projection cursor is ${stream.cursor_backlog} events behind; drain lagging (stop projection may be failing)`,
3154
3994
  );
3155
3995
  }
3156
- // NB: raw stream size is NOT an anomaly. events.ndjson is a deliberate
3157
- // append-only ledger (names + forensics for the life of the log), and
3158
- // consumeSince tail-reads it, so size no longer drives latency. The
3159
- // bytes/lines still surface in the summary line for visibility;
3160
- // cursor_backlog above is the real drain-lag signal.
3996
+ // Raw V3 stream size is not an anomaly; catalog rotation bounds readers.
3161
3997
  if ((sweptByReason.unparseable ?? 0) > 0) {
3162
3998
  anomalies.push(
3163
3999
  `${sweptByReason.unparseable} heartbeat(s) swept as unparseable in ${opts.since}; possible corruption or a non-atomic writer`,
@@ -3168,16 +4004,46 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3168
4004
  `${zombieCount} zombie heartbeat(s) in active/ (${zombieSamples.join(", ")}); broken files the sweep isn't reaping`,
3169
4005
  );
3170
4006
  }
4007
+ if (eventLedger.state === "live") {
4008
+ // Open spans whose generation has no open turn: the turn ended without its
4009
+ // tool spans closing, so an explicit end can only queue, never finalize.
4010
+ const orphanGenerations = eventLedger.open_spans.generations.filter(
4011
+ (generation) => !generation.turn_open,
4012
+ );
4013
+ if (orphanGenerations.length > 0) {
4014
+ const orphanSpans = orphanGenerations.reduce(
4015
+ (sum, generation) => sum + generation.span_count,
4016
+ 0,
4017
+ );
4018
+ anomalies.push(
4019
+ `${orphanSpans} open tool span(s) across ${orphanGenerations.length} generation(s) with no open turn; orphaned spans block clean session end`,
4020
+ );
4021
+ }
4022
+ for (const pressure of eventLedger.span_pressure) {
4023
+ anomalies.push(
4024
+ `${pressure.instance_id} (${pressure.generation_id}) holds ${pressure.span_count} open spans (soft watermark ${SPAN_PRESSURE_SOFT_WATERMARK}; the reader's 256-span cap makes the state unreadable)`,
4025
+ );
4026
+ }
4027
+ for (const collectionError of eventLedger.collection_errors) {
4028
+ anomalies.push(`event-ledger health: ${collectionError}`);
4029
+ }
4030
+ }
3171
4031
 
3172
4032
  const report: HealthReport = {
3173
4033
  since: opts.since,
3174
4034
  generated_at: new Date().toISOString(),
4035
+ event_telemetry: {
4036
+ source: diagnosticEvents.source,
4037
+ authoritative: diagnosticEvents.authoritative,
4038
+ ...(diagnosticEvents.reason ? { reason: diagnosticEvents.reason } : {}),
4039
+ },
3175
4040
  active_agents: {
3176
- total: activeTotal,
3177
- by_platform: activeByPlatform,
3178
- by_kind: activeByKind,
3179
- by_schema_version: activeBySchema,
3180
- stale: staleHeartbeats,
4041
+ source: activeAgents.source,
4042
+ total: activeAgents.total,
4043
+ by_platform: activeAgents.by_platform,
4044
+ by_kind: activeAgents.by_kind,
4045
+ by_schema_version: activeAgents.by_schema_version,
4046
+ stale: activeAgents.stale,
3181
4047
  },
3182
4048
  heal_events: {
3183
4049
  total: heal.length,
@@ -3193,9 +4059,19 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3193
4059
  closed_in_window: councilClosed,
3194
4060
  },
3195
4061
  swept_events: { total: sweptTotal, by_reason: sweptByReason },
3196
- hook_errors: { total: hookErrors.total, by_phase: hookErrors.byPhase, top: hookErrors.top },
4062
+ hook_errors: {
4063
+ total: hookErrors.total,
4064
+ last_1h: hookErrors.last1h,
4065
+ latest_at: hookErrors.latestAt,
4066
+ by_phase: hookErrors.byPhase,
4067
+ by_error: hookErrors.byError,
4068
+ top: hookErrors.top,
4069
+ top_errors: hookErrors.topErrors,
4070
+ recent_top_errors: hookErrors.recentTopErrors,
4071
+ },
3197
4072
  stream,
3198
4073
  zombies: { count: zombieCount, samples: zombieSamples },
4074
+ event_ledger: eventLedger,
3199
4075
  anomalies,
3200
4076
  };
3201
4077
 
@@ -3237,7 +4113,9 @@ function renderHealthBox(report: HealthReport): void {
3237
4113
  if (report.councils.archived_in_window > 0)
3238
4114
  councilParts.push(`${report.councils.archived_in_window} archived`);
3239
4115
 
3240
- const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""}`;
4116
+ const activeSource =
4117
+ report.active_agents.source === "event-ledger-v3" ? "ledger" : "heartbeat cache";
4118
+ const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""} · ${activeSource}`;
3241
4119
 
3242
4120
  const sweptReasonStr = Object.entries(report.swept_events.by_reason)
3243
4121
  .map(([r, n]) => `${r} ${n}`)
@@ -3245,11 +4123,36 @@ function renderHealthBox(report: HealthReport): void {
3245
4123
  const hookErrStr =
3246
4124
  report.hook_errors.total === 0
3247
4125
  ? "0"
3248
- : `${report.hook_errors.total}${report.hook_errors.top[0] ? ` (${report.hook_errors.top[0].phase} x${report.hook_errors.top[0].count})` : ""}`;
4126
+ : `${report.hook_errors.total} in window · ${report.hook_errors.last_1h} in 1h`;
3249
4127
  const streamStr = `${(report.stream.bytes / 1048576).toFixed(1)}MB · ${report.stream.lines} lines · ${report.stream.cursor_backlog} behind`;
3250
4128
 
4129
+ const ledger = report.event_ledger;
4130
+ let ledgerStr: string;
4131
+ if (ledger.state !== "live") {
4132
+ ledgerStr = `unavailable (${ledger.reason})`;
4133
+ } else {
4134
+ const orphanGenerations = ledger.open_spans.generations.filter(
4135
+ (generation) => !generation.turn_open,
4136
+ ).length;
4137
+ const pendingAges = ledger.pending_finalizations
4138
+ .map((request) =>
4139
+ request.age_ms < 0 ? "age?" : formatAge(Math.floor(request.age_ms / 1000)),
4140
+ )
4141
+ .join(", ");
4142
+ ledgerStr = [
4143
+ `open spans ${ledger.open_spans.total}${orphanGenerations > 0 ? ` (${orphanGenerations} gen turn-closed)` : ""}`,
4144
+ `pending ends ${ledger.pending_finalizations.length}${pendingAges ? ` (${pendingAges})` : ""}`,
4145
+ `intake ${ledger.intake_spool.total}`,
4146
+ `diagnostics ${ledger.diagnostics_spool.total} (${ledger.diagnostics_spool.last_1h} in 1h / ${ledger.diagnostics_spool.last_24h} in 24h)`,
4147
+ ].join(" · ");
4148
+ }
4149
+
3251
4150
  const rows: Array<[string, string]> = [
3252
4151
  ["window", `last ${report.since}`],
4152
+ [
4153
+ "event source",
4154
+ `${report.event_telemetry.source.toUpperCase()} · ${report.event_telemetry.authoritative ? "validated" : `unavailable (${report.event_telemetry.reason ?? "incomplete"})`}`,
4155
+ ],
3253
4156
  ["active", activeStr],
3254
4157
  [
3255
4158
  "heals",
@@ -3259,6 +4162,7 @@ function renderHealthBox(report: HealthReport): void {
3259
4162
  ["swept", `${report.swept_events.total}${sweptReasonStr ? ` (${sweptReasonStr})` : ""}`],
3260
4163
  ["hook errors", hookErrStr],
3261
4164
  ["stream", streamStr],
4165
+ ["event ledger", ledgerStr],
3262
4166
  [
3263
4167
  "zombies",
3264
4168
  report.zombies.count === 0
@@ -3383,6 +4287,7 @@ function runAdapterProbe(
3383
4287
  replayExitCode = result.exitCode;
3384
4288
  data.samples = samples;
3385
4289
  data.samples_summary = result.summary;
4290
+ if (result.note) data.samples_note = result.note;
3386
4291
  }
3387
4292
 
3388
4293
  emit.data(data);
@@ -3402,7 +4307,9 @@ function runAdapterProbe(
3402
4307
  | undefined;
3403
4308
  lines.push("");
3404
4309
  if (samples.length === 0) {
3405
- lines.push(` Sample replay: no .json fixtures found under ${sampleDir}`);
4310
+ lines.push(
4311
+ ` Sample replay: ${String(data.samples_note ?? `no .json fixtures found under ${sampleDir}`)}`,
4312
+ );
3406
4313
  } else {
3407
4314
  lines.push(
3408
4315
  ` Sample replay (${samples.length} fixture${samples.length === 1 ? "" : "s"}):`,
@@ -3459,6 +4366,7 @@ function replayAdapterSamples(
3459
4366
  samples: SampleReplayResult[];
3460
4367
  exitCode: number;
3461
4368
  summary: { total: number; pass: number; fail: number; skipped: number };
4369
+ note?: string;
3462
4370
  } {
3463
4371
  const sampleDir = resolve(root, relativeSampleDir);
3464
4372
  if (!existsSync(sampleDir)) {
@@ -3466,6 +4374,7 @@ function replayAdapterSamples(
3466
4374
  samples: [],
3467
4375
  exitCode: 0,
3468
4376
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4377
+ note: `sample directory not found: ${relativeSampleDir} — replay verified nothing`,
3469
4378
  };
3470
4379
  }
3471
4380
 
@@ -3479,6 +4388,7 @@ function replayAdapterSamples(
3479
4388
  samples: [],
3480
4389
  exitCode: 0,
3481
4390
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4391
+ note: `no .json fixtures under ${relativeSampleDir} — replay verified nothing`,
3482
4392
  };
3483
4393
  }
3484
4394
 
@@ -3704,11 +4614,26 @@ function runPing(name: string, message: string, opts: { json?: boolean }): void
3704
4614
  });
3705
4615
  process.exit(1);
3706
4616
  }
3707
- const myHb = readHeartbeat(myOwner);
4617
+ const myHb = readCurrentCoordinationRow(myOwner);
3708
4618
  const fromName = myHb?.name ?? "anonymous";
3709
4619
  const body = `from agent-${fromName}: ${message.trim()}`;
3710
4620
  const doc = appendEntry(peerOwner, "handoff", body);
3711
4621
 
4622
+ // Canonical delivery record: sender is the envelope owner, recipient rides
4623
+ // in data, so read-only observers can render the communication without
4624
+ // joining journal files.
4625
+ emitEventV3({
4626
+ owner: myOwner,
4627
+ session: nativeSessionIdentity(myHb, myOwner),
4628
+ adapter: normalizeAdapter(myHb?.platform),
4629
+ observation: {
4630
+ event_type: "coord.message_observed",
4631
+ direction: "sent",
4632
+ subject: peerOwner,
4633
+ body: message.trim(),
4634
+ },
4635
+ });
4636
+
3712
4637
  const data = {
3713
4638
  peer: name,
3714
4639
  peer_instance_id: peerOwner,
@@ -3766,7 +4691,7 @@ async function runWait(
3766
4691
 
3767
4692
  let lastProgressMs = 0;
3768
4693
  while (true) {
3769
- const hb = readHeartbeat(peerOwner);
4694
+ const hb = readCurrentCoordinationRow(peerOwner);
3770
4695
  const now = Date.now();
3771
4696
  const elapsedMs = now - startMs;
3772
4697
 
@@ -3806,9 +4731,8 @@ async function runWait(
3806
4731
  const progressGapMs = Math.max(pollMs, 30_000);
3807
4732
  if (!opts.quiet && now - lastProgressMs >= progressGapMs) {
3808
4733
  lastProgressMs = now;
3809
- const lastTool = hb.last_tool ? `, last=${hb.last_tool}` : "";
3810
4734
  const elapsedStr = formatAge(Math.floor(elapsedMs / 1000));
3811
- const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking${lastTool}\n`;
4735
+ const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking\n`;
3812
4736
  process.stderr.write(progress); // lint-ok-emission: per-poll progress heartbeat to stderr
3813
4737
  }
3814
4738
 
@@ -3849,6 +4773,7 @@ function runHeal(opts: {
3849
4773
  owner: string;
3850
4774
  kind: string;
3851
4775
  sessionId?: string;
4776
+ adapter?: string;
3852
4777
  pid?: string;
3853
4778
  json?: boolean;
3854
4779
  }): void {
@@ -3860,10 +4785,22 @@ function runHeal(opts: {
3860
4785
  emit.error({ code: "missing_owner", message: "--owner is required" });
3861
4786
  process.exit(1);
3862
4787
  }
3863
- if (kind !== "pidmap" && kind !== "heartbeat" && kind !== "kill") {
4788
+ if (kind !== "pidmap" && kind !== "cache") {
3864
4789
  emit.error({
3865
4790
  code: "bad_kind",
3866
- message: "--kind must be one of: pidmap, heartbeat, kill",
4791
+ message: "--kind must be one of: pidmap, cache",
4792
+ });
4793
+ process.exit(1);
4794
+ }
4795
+ if (
4796
+ opts.adapter &&
4797
+ opts.adapter !== "claude-code" &&
4798
+ opts.adapter !== "cursor" &&
4799
+ opts.adapter !== "codex"
4800
+ ) {
4801
+ emit.error({
4802
+ code: "bad_adapter",
4803
+ message: "--adapter must be one of: claude-code, cursor, codex",
3867
4804
  });
3868
4805
  process.exit(1);
3869
4806
  }
@@ -3877,10 +4814,9 @@ function runHeal(opts: {
3877
4814
  process.exit(1);
3878
4815
  }
3879
4816
 
3880
- const action =
3881
- kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
4817
+ const action = kind === "pidmap" ? "heal-pidmap" : "repair-coordination-cache";
3882
4818
 
3883
- // Refuse to mint a heartbeat at a TRUNCATED owner id.
4819
+ // Refuse to materialize a V3 cache at a truncated owner id.
3884
4820
  //
3885
4821
  // Heartbeats are keyed by the whole instance_id, so healing at an
3886
4822
  // abbreviated id writes `.harnery/active/<prefix>.json` while every reader
@@ -3900,19 +4836,18 @@ function runHeal(opts: {
3900
4836
  // whose instance_id this id is a prefix of settles it just as well, and is
3901
4837
  // present in precisely the case that matters (the session the reader was
3902
4838
  // trying to heal is registered, just not under the abbreviated name).
3903
- if (kind === "heartbeat" && !readHeartbeat(owner)) {
4839
+ if (kind === "cache" && !readHeartbeatCache(root, owner)) {
3904
4840
  const canonical =
3905
4841
  opts.sessionId && opts.sessionId.trim() !== owner && opts.sessionId.trim().startsWith(owner)
3906
4842
  ? opts.sessionId.trim()
3907
4843
  : liveIdWithPrefix(root, owner);
3908
4844
  if (canonical) {
3909
- const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live heartbeat";
4845
+ const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live V3 generation";
3910
4846
  emit.error({
3911
4847
  code: "truncated_owner",
3912
4848
  message:
3913
- `--owner ${owner} is a prefix of ${canonical} (${source}), and no heartbeat exists ` +
3914
- `at .harnery/active/${owner}.json. Healing here would create a heartbeat no reader ` +
3915
- `resolves. Re-run with --owner ${canonical}.`,
4849
+ `--owner ${owner} is a prefix of ${canonical} (${source}). Cache repair requires ` +
4850
+ `the canonical instance id; re-run with --owner ${canonical}.`,
3916
4851
  });
3917
4852
  process.exit(1);
3918
4853
  }
@@ -3920,14 +4855,13 @@ function runHeal(opts: {
3920
4855
 
3921
4856
  // Build positional args. agent-coord's arg layout:
3922
4857
  // heal-pidmap <instance_id> [<pid>]
3923
- // heal-heartbeat <instance_id> [<session_id>]
3924
- // kill-heartbeat <instance_id>
4858
+ // repair-coordination-cache <instance_id> [<session_id>]
3925
4859
  const helperArgs: string[] = [action, owner];
3926
4860
  if (kind === "pidmap" && opts.pid) helperArgs.push(opts.pid);
3927
- if (kind === "heartbeat" && opts.sessionId) helperArgs.push(opts.sessionId);
4861
+ if (kind === "cache" && opts.sessionId) helperArgs.push(opts.sessionId);
4862
+ if (kind === "cache" && opts.adapter) helperArgs.push(`--adapter=${opts.adapter}`);
3928
4863
 
3929
- // heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
3930
- // bundled agent-coord binary.
4864
+ // Both recovery actions are handled by the bundled agent-coord binary.
3931
4865
  const helper = agentCoordOrExit(root);
3932
4866
  const proc = spawnSync(helper, helperArgs, {
3933
4867
  encoding: "utf8",
@@ -3942,27 +4876,22 @@ function runHeal(opts: {
3942
4876
  process.exit(1);
3943
4877
  }
3944
4878
 
3945
- // Re-read the heartbeat to surface post-action state (or null if killed).
4879
+ // Read the derived cache to surface post-action state.
3946
4880
  let after: Heartbeat | null = null;
3947
4881
  try {
3948
- after = readHeartbeat(owner);
4882
+ after = readHeartbeatCache(root, owner);
3949
4883
  } catch {
3950
4884
  after = null;
3951
4885
  }
3952
4886
 
3953
- // Outcome semantics differ per kind. kill-heartbeat targets the heartbeat
3954
- // file directly; heal-heartbeat upserts it; heal-pidmap touches a pid-map
3955
- // row whose existence is independent of the heartbeat. Reporting on
3956
- // "heartbeat present after" for the pidmap path was misleading: it's
3957
- // unrelated to whether the heal succeeded.
3958
4887
  const outcome =
3959
4888
  kind === "pidmap"
3960
4889
  ? proc.status === 0
3961
4890
  ? "ok"
3962
4891
  : "failed"
3963
4892
  : after
3964
- ? "heartbeat_present"
3965
- : "heartbeat_absent";
4893
+ ? "cache_present"
4894
+ : "cache_absent";
3966
4895
 
3967
4896
  emit.data({
3968
4897
  rows: [
@@ -3985,36 +4914,40 @@ function runHeal(opts: {
3985
4914
  if (kind === "pidmap") {
3986
4915
  emit.text(`agent-coord ${action} ok\n`);
3987
4916
  } else {
3988
- emit.text(`agent-coord ${action} ok: heartbeat ${after ? "present" : "absent"} after\n`);
4917
+ emit.text(`agent-coord ${action} ok: cache ${after ? "present" : "absent"} after\n`);
3989
4918
  }
3990
4919
  }
3991
4920
 
3992
- // Canonical health.* emission is owned by the writer (heartbeat-writer.ts
3993
- // healPidmap/healHeartbeat), so it fires inside the agent-coord subprocess
4921
+ // Canonical health.* emission is owned by the cache writer, so it fires
4922
+ // inside the agent-coord subprocess
3994
4923
  // above on actual writes only: write-only telemetry, no double-emit, no
3995
4924
  // event when an already-correct heal no-ops. (Previously emitted here
3996
4925
  // unconditionally on every `harn agents heal`, which over-counted no-op heals.)
3997
4926
  }
3998
4927
 
3999
4928
  /**
4000
- * Shared emitter for council.* events. Looks up the running agent's
4001
- * heartbeat so each event carries a real instance_id / session_id; falls
4002
- * through silently if no session (CI / direct invocation).
4929
+ * Emit one V3 council audit transition keyed to the exact durable manifest.
4930
+ * Falls through silently when no live session can attest the observation.
4003
4931
  */
4004
4932
  function emitCouncilStateEvent(
4005
- type: string,
4006
4933
  manifest: CouncilManifest,
4007
- extraData: Record<string, unknown>,
4934
+ newState: string,
4935
+ priorState?: string,
4008
4936
  ): void {
4009
4937
  const myOwner = resolveOwner();
4010
4938
  if (!myOwner) return;
4011
- const hb = readHeartbeat(myOwner);
4012
- emitCanonical({
4013
- type,
4939
+ const hb = readCurrentCoordinationRow(myOwner);
4940
+ emitEventV3({
4014
4941
  owner: myOwner,
4015
- session: hb?.session_id ?? myOwner,
4942
+ session: nativeSessionIdentity(hb, myOwner),
4016
4943
  adapter: normalizeAdapter(hb?.platform),
4017
- data: { council_id: manifest.council_id, ...extraData },
4944
+ observation: {
4945
+ event_type: "council.state_changed",
4946
+ council_id: manifest.council_id,
4947
+ ...(priorState ? { prior_state: priorState } : {}),
4948
+ new_state: newState,
4949
+ record: manifest,
4950
+ },
4018
4951
  });
4019
4952
  }
4020
4953
 
@@ -4071,7 +5004,7 @@ function runCouncilCreate(
4071
5004
  if (opts.createdBy?.trim()) {
4072
5005
  createdBy = normalizeAgentName(opts.createdBy);
4073
5006
  } else if (myOwner) {
4074
- const myHb = readHeartbeat(myOwner);
5007
+ const myHb = readCurrentCoordinationRow(myOwner);
4075
5008
  if (myHb?.name) {
4076
5009
  createdBy = normalizeAgentName(myHb.name);
4077
5010
  }
@@ -4132,17 +5065,16 @@ function runCouncilCreate(
4132
5065
  writeManifest(manifest);
4133
5066
 
4134
5067
  if (myOwner) {
4135
- const myHbForEmit = readHeartbeat(myOwner);
4136
- emitCanonical({
4137
- type: "council.open",
5068
+ const myHbForEmit = readCurrentCoordinationRow(myOwner);
5069
+ emitEventV3({
4138
5070
  owner: myOwner,
4139
- session: myHbForEmit?.session_id ?? myOwner,
5071
+ session: nativeSessionIdentity(myHbForEmit, myOwner),
4140
5072
  adapter: normalizeAdapter(myHbForEmit?.platform),
4141
- data: {
4142
- council_id: councilId,
4143
- topic: trimmedObjective,
4144
- members,
4145
- target_doc: manifest.target_doc ?? undefined,
5073
+ observation: {
5074
+ event_type: "council.state_changed",
5075
+ council_id: manifest.council_id,
5076
+ new_state: "active",
5077
+ record: manifest,
4146
5078
  },
4147
5079
  });
4148
5080
  }
@@ -4155,7 +5087,7 @@ function runCouncilCreate(
4155
5087
  const skippedMembers: string[] = [];
4156
5088
  for (const memberName of members) {
4157
5089
  const bareName = memberName.replace(/^agent-/, "");
4158
- if (myOwner && readHeartbeat(myOwner)?.name === bareName) {
5090
+ if (myOwner && readCurrentCoordinationRow(myOwner)?.name === bareName) {
4159
5091
  // Convener is themselves a member; skip the self-ping
4160
5092
  continue;
4161
5093
  }
@@ -4220,7 +5152,7 @@ function runCouncilList(opts: { status?: string; mine?: boolean; json?: boolean
4220
5152
  if (opts.mine) {
4221
5153
  const myOwner = resolveOwner();
4222
5154
  if (myOwner) {
4223
- const myHb = readHeartbeat(myOwner);
5155
+ const myHb = readCurrentCoordinationRow(myOwner);
4224
5156
  if (myHb?.name) myName = normalizeAgentName(myHb.name);
4225
5157
  }
4226
5158
  if (!myName) {
@@ -4397,7 +5329,7 @@ function runCouncilClose(id: string, opts: { json?: boolean }): void {
4397
5329
  closed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
4398
5330
  };
4399
5331
  writeManifest(next);
4400
- emitCouncilStateEvent("council.close", next, { closed_at: next.closed_at! });
5332
+ emitCouncilStateEvent(next, "closed", manifest.status);
4401
5333
 
4402
5334
  // Build the transcript: every round's contributions in order.
4403
5335
  const transcript = buildTranscript(next);
@@ -4442,7 +5374,7 @@ function runCouncilArchive(id: string, opts: { json?: boolean }): void {
4442
5374
  // updated status. moveToArchive then physically relocates the artifacts.
4443
5375
  writeManifest(next);
4444
5376
  moveToArchive(next.council_id);
4445
- emitCouncilStateEvent("council.archive", next, {});
5377
+ emitCouncilStateEvent(next, "archived", manifest.status);
4446
5378
 
4447
5379
  emit.data({
4448
5380
  rows: [
@@ -4501,7 +5433,7 @@ function runCouncilUnarchive(id: string, opts: { json?: boolean }): void {
4501
5433
  // (allows re-running for testing).
4502
5434
  moveFromArchive(next.council_id);
4503
5435
  writeManifest(next);
4504
- emitCouncilStateEvent("council.unarchive", next, { restored_status: restoredStatus });
5436
+ emitCouncilStateEvent(next, restoredStatus, "archived");
4505
5437
 
4506
5438
  emit.data({
4507
5439
  rows: [
@@ -4563,7 +5495,7 @@ function runCouncilDelete(id: string, opts: { yes?: boolean; json?: boolean }):
4563
5495
 
4564
5496
  const removed = deleteArchivedCouncil(manifest.council_id);
4565
5497
  if (removed) {
4566
- emitCouncilStateEvent("council.delete", manifest, {});
5498
+ emitCouncilStateEvent(manifest, "deleted", "archived");
4567
5499
  }
4568
5500
 
4569
5501
  emit.data({
@@ -4753,7 +5685,7 @@ function runCouncilContribute(
4753
5685
  try {
4754
5686
  const myOwner = resolveOwner();
4755
5687
  if (myOwner) {
4756
- const myHb = readHeartbeat(myOwner);
5688
+ const myHb = readCurrentCoordinationRow(myOwner);
4757
5689
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4758
5690
  }
4759
5691
  } catch {
@@ -4769,7 +5701,7 @@ function runCouncilContribute(
4769
5701
  });
4770
5702
  process.exit(1);
4771
5703
  }
4772
- const myHb = readHeartbeat(myOwner);
5704
+ const myHb = readCurrentCoordinationRow(myOwner);
4773
5705
  if (!myHb?.name) {
4774
5706
  emit.error({
4775
5707
  code: "no_self_name",
@@ -4822,11 +5754,7 @@ function runCouncilContribute(
4822
5754
  }
4823
5755
 
4824
5756
  const path = writeContribution(manifest.council_id, manifest.current_round, myName, body);
4825
- emitCouncilStateEvent("council.contribution", manifest, {
4826
- round_no: manifest.current_round,
4827
- member: myName,
4828
- body_summary: body.length > 1000 ? `${body.slice(0, 997)}...` : body,
4829
- });
5757
+ emitCouncilStateEvent(manifest, "contribution_recorded", manifest.round_status);
4830
5758
 
4831
5759
  // Update manifest: if all members have now contributed, flip round_status.
4832
5760
  const contributorsNow = contributorsInRound(manifest.council_id, manifest.current_round);
@@ -4836,14 +5764,10 @@ function runCouncilContribute(
4836
5764
  if (allIn) {
4837
5765
  nextManifest = { ...manifest, round_status: "collected" };
4838
5766
  writeManifest(nextManifest);
4839
- emitCouncilStateEvent("council.round_close", nextManifest, {
4840
- round_no: nextManifest.current_round,
4841
- });
5767
+ emitCouncilStateEvent(nextManifest, "round_closed", manifest.round_status);
4842
5768
  if (manifest.auto_advance) {
4843
5769
  nextManifest = advanceCouncil(nextManifest, /*force=*/ false);
4844
- emitCouncilStateEvent("council.round_open", nextManifest, {
4845
- round_no: nextManifest.current_round,
4846
- });
5770
+ emitCouncilStateEvent(nextManifest, "round_open", "round_closed");
4847
5771
  autoAdvanced = true;
4848
5772
  }
4849
5773
  }
@@ -4934,7 +5858,7 @@ function runCouncilPrompt(
4934
5858
  try {
4935
5859
  const myOwner = resolveOwner();
4936
5860
  if (myOwner) {
4937
- const myHb = readHeartbeat(myOwner);
5861
+ const myHb = readCurrentCoordinationRow(myOwner);
4938
5862
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4939
5863
  }
4940
5864
  } catch {
@@ -4950,7 +5874,7 @@ function runCouncilPrompt(
4950
5874
  });
4951
5875
  process.exit(1);
4952
5876
  }
4953
- const myHb = readHeartbeat(myOwner);
5877
+ const myHb = readCurrentCoordinationRow(myOwner);
4954
5878
  if (!myHb?.name) {
4955
5879
  emit.error({
4956
5880
  code: "no_self_name",
@@ -5115,12 +6039,8 @@ function runCouncilAdvance(id: string, opts: { force?: boolean; json?: boolean }
5115
6039
  }
5116
6040
 
5117
6041
  const next = advanceCouncil(manifest, !!opts.force);
5118
- emitCouncilStateEvent("council.round_close", manifest, {
5119
- round_no: manifest.current_round,
5120
- });
5121
- emitCouncilStateEvent("council.round_open", next, {
5122
- round_no: next.current_round,
5123
- });
6042
+ emitCouncilStateEvent(manifest, "round_closed", manifest.round_status);
6043
+ emitCouncilStateEvent(next, "round_open", "round_closed");
5124
6044
 
5125
6045
  emit.data({
5126
6046
  rows: [
@@ -5166,7 +6086,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
5166
6086
  // (Convener already knows; we skip pinging them if they convened it from
5167
6087
  // their own session.)
5168
6088
  const myOwner = resolveOwner();
5169
- const myName = myOwner ? normalizeAgentName(readHeartbeat(myOwner)?.name ?? "") : "";
6089
+ const myName = myOwner ? normalizeAgentName(readCurrentCoordinationRow(myOwner)?.name ?? "") : "";
5170
6090
  for (const memberName of next.members) {
5171
6091
  if (memberName === myName) continue;
5172
6092
  const bareName = memberName.replace(/^agent-/, "");
@@ -5190,7 +6110,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
5190
6110
  // Hard cap on box width. Values longer than the per-row budget word-wrap to
5191
6111
  // continuation lines (blank key column, value resumes indented). Picked to
5192
6112
  // stay readable in narrow terminals + chat clients while giving long
5193
- // turn_summary / task values room to breathe.
6113
+ // Task values need room to breathe.
5194
6114
  const MAX_BOX_CONTENT_WIDTH = 100;
5195
6115
 
5196
6116
  function formatBox(title: string, rows: Array<[string, string]>): string {