harnery 0.33.0 → 0.34.1

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 +1237 -590
  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 +306 -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 +161 -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 +1530 -593
  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 +352 -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 +171 -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)"}`);
1506
+ }
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}`);
1260
1512
  }
1261
- }, pollMs);
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,30 +1848,33 @@ 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
1626
1855
  // the session name" — it is never true with a null name (an empty first
1627
1856
  // declaration or a `--session-id` relay both keep the window open instead).
1628
- const suggestedName =
1857
+ const firstSuggestedName =
1629
1858
  !priorHb?.suggested_session_name && hb?.suggested_session_name
1630
1859
  ? hb.suggested_session_name
1631
1860
  : null;
1632
- const firstOfSession = suggestedName !== null;
1633
1861
 
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
- });
1862
+ // A readable but misclassified first display would otherwise leave the
1863
+ // session permanently latched: set-task is allowed through the gate, but a
1864
+ // normal repeat used to return no name and therefore could not establish a
1865
+ // fresh ordered transcript boundary. Re-emit the unchanged pending name as
1866
+ // an explicit retry. This does not rename the session or claim that the
1867
+ // display was seen; it only gives the next exact block new mint evidence.
1868
+ const retriedSuggestedName =
1869
+ !firstSuggestedName &&
1870
+ priorHb?.suggested_session_name &&
1871
+ priorHb.session_name_seen_for !== priorHb.suggested_session_name &&
1872
+ hb?.suggested_session_name === priorHb.suggested_session_name
1873
+ ? hb.suggested_session_name
1874
+ : null;
1875
+ const suggestedName = firstSuggestedName ?? retriedSuggestedName;
1876
+ const firstOfSession = firstSuggestedName !== null;
1877
+ const sessionNameRetry = retriedSuggestedName !== null;
1646
1878
 
1647
1879
  emit.data({
1648
1880
  instance_id: myOwner,
@@ -1650,13 +1882,14 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1650
1882
  task: hb?.task ?? null,
1651
1883
  cleared: !task || task.length === 0,
1652
1884
  first_of_session: firstOfSession,
1885
+ session_name_retry: sessionNameRetry,
1653
1886
  suggested_session_name: suggestedName,
1654
1887
  ...(lifecycleWarning ? { warning: lifecycleWarning } : {}),
1655
1888
  // Right-time instruction: the UserPromptSubmit nudge fires before the name
1656
1889
  // exists; this result is the moment the model holds the string.
1657
1890
  ...(suggestedName
1658
1891
  ? {
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.",
1892
+ note: SESSION_NAME_DISPLAY_NOTE,
1660
1893
  }
1661
1894
  : {}),
1662
1895
  });
@@ -1683,7 +1916,7 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1683
1916
  return;
1684
1917
  }
1685
1918
  if (!opts.sessionId) ensureCursorSession(root);
1686
- const myOwner = opts.sessionId ?? resolveOwner();
1919
+ const myOwner = opts.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
1687
1920
  if (!myOwner) {
1688
1921
  emit.error({
1689
1922
  code: "no_pidmap_entry",
@@ -1694,11 +1927,40 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1694
1927
  return;
1695
1928
  }
1696
1929
 
1697
- const hb = readHeartbeat(myOwner);
1930
+ let hb = readCurrentCoordinationRow(myOwner);
1931
+ let reopenedGeneration: ReturnType<typeof reopenLiveCoordinationGenerationV3> | undefined;
1698
1932
  if (!hb) {
1699
- emit.error({ code: "no_heartbeat", message: noHeartbeatMessage(myOwner) });
1700
- process.exitCode = 1;
1701
- return;
1933
+ if (state !== "active") {
1934
+ emit.error({
1935
+ code: "no_live_generation",
1936
+ message: `${noLiveGenerationMessage(myOwner)}; run \`${resolveBinName(root)} agents lifecycle active\` to open a fresh generation`,
1937
+ });
1938
+ process.exitCode = 1;
1939
+ return;
1940
+ }
1941
+ try {
1942
+ reopenedGeneration = reopenLiveCoordinationGenerationV3({
1943
+ coordRoot: root,
1944
+ owner: myOwner,
1945
+ nativeSessionId: nativeSessionIdentity(readHeartbeatCache(root, myOwner), myOwner),
1946
+ });
1947
+ } catch (error) {
1948
+ emit.error({
1949
+ code: "lifecycle_reopen_failed",
1950
+ message: error instanceof Error ? error.message : String(error),
1951
+ });
1952
+ process.exitCode = 1;
1953
+ return;
1954
+ }
1955
+ hb = readCurrentCoordinationRow(myOwner);
1956
+ if (!hb) {
1957
+ emit.error({
1958
+ code: "lifecycle_reopen_failed",
1959
+ message: "the fresh generation was recorded but did not project as live",
1960
+ });
1961
+ process.exitCode = 1;
1962
+ return;
1963
+ }
1702
1964
  }
1703
1965
  if (hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) {
1704
1966
  emit.error({
@@ -1729,6 +1991,22 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1729
1991
 
1730
1992
  const priorState: TaskState = hb.task_state ?? "active";
1731
1993
  const priorReason = hb.task_state_reason || undefined;
1994
+ if (reopenedGeneration) {
1995
+ emit.data({
1996
+ instance_id: myOwner,
1997
+ task_state: "active",
1998
+ prior_state: null,
1999
+ reason: null,
2000
+ changed: true,
2001
+ generation_reopened: true,
2002
+ prior_generation_id: reopenedGeneration.prior_generation_id,
2003
+ generation_id: reopenedGeneration.generation_id,
2004
+ name_reminted: false,
2005
+ suggested_session_name: null,
2006
+ git_finalization_checked: false,
2007
+ });
2008
+ return;
2009
+ }
1732
2010
  if (priorState === state && priorReason === reason) {
1733
2011
  emit.data({
1734
2012
  instance_id: myOwner,
@@ -1743,7 +2021,11 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1743
2021
 
1744
2022
  let finalization: GitFinalizationResult | null = null;
1745
2023
  if (state === "done") {
1746
- const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
2024
+ const history = readSessionWriteClaims(
2025
+ root,
2026
+ hb.instance_id,
2027
+ nativeSessionIdentity(hb, myOwner),
2028
+ );
1747
2029
  const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1748
2030
  finalization = checkGitFinalization(root, touchedPaths, {
1749
2031
  claimHistoryComplete: history.complete,
@@ -1761,19 +2043,16 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1761
2043
  }
1762
2044
  }
1763
2045
 
1764
- const suggestedName = buildLifecycleSuggestedName(hb.name ?? "unknown", hb.task, state);
2046
+ const suggestedName = buildLifecycleSuggestedName(hb.suggested_session_name, state);
1765
2047
  const nameReminted = suggestedName !== null && suggestedName !== hb.suggested_session_name;
1766
- const emitted = emitCanonical({
1767
- type: "state.task_state",
2048
+ const emitted = emitEventV3({
1768
2049
  owner: myOwner,
1769
- session: hb.session_id ?? myOwner,
2050
+ session: nativeSessionIdentity(hb, myOwner),
1770
2051
  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,
2052
+ observation: {
2053
+ event_type: "coord.lifecycle_changed",
2054
+ new_state: state,
2055
+ ...(reason ? { reason } : {}),
1777
2056
  ...(nameReminted ? { suggested_session_name: suggestedName } : {}),
1778
2057
  },
1779
2058
  });
@@ -1797,7 +2076,7 @@ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: str
1797
2076
  git_finalization_checked: finalization !== null,
1798
2077
  ...(nameReminted
1799
2078
  ? {
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.",
2079
+ note: SESSION_NAME_DISPLAY_NOTE,
1801
2080
  }
1802
2081
  : {}),
1803
2082
  });
@@ -1829,7 +2108,7 @@ function runSuggestName(
1829
2108
  process.exit(1);
1830
2109
  }
1831
2110
 
1832
- const hb = readHeartbeat(myOwner);
2111
+ const hb = readCurrentCoordinationRow(myOwner);
1833
2112
  const agentName = hb?.name || "unknown";
1834
2113
  // Description resolution: an explicit arg wins; with no arg, fall back to the
1835
2114
  // agent's current declared task, so a bare `suggest-name` reprints the running
@@ -1867,7 +2146,12 @@ function runSuggestName(
1867
2146
  process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
1868
2147
  }
1869
2148
 
1870
- function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string }): void {
2149
+ function runStatus(opts: {
2150
+ endTurn?: boolean;
2151
+ endSession?: boolean;
2152
+ json?: boolean;
2153
+ sessionId?: string;
2154
+ }): void {
1871
2155
  const root = monorepoRoot();
1872
2156
  if (!root) {
1873
2157
  emit.error({
@@ -1876,6 +2160,14 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1876
2160
  });
1877
2161
  process.exit(1);
1878
2162
  }
2163
+ if (opts.endSession && !opts.endTurn) {
2164
+ emit.error({
2165
+ code: "end_session_requires_end_turn",
2166
+ message: "--end-session requires --end-turn so Git finalization is verified first",
2167
+ });
2168
+ process.exitCode = 1;
2169
+ return;
2170
+ }
1879
2171
 
1880
2172
  // Identity resolution: prefer explicit --session-id (hook-friendly), fall
1881
2173
  // back to ppid walk for interactive shell usage.
@@ -1890,18 +2182,30 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1890
2182
  process.exit(1);
1891
2183
  }
1892
2184
 
1893
- const hb = readHeartbeat(myOwner);
2185
+ const hb = readCurrentCoordinationRow(myOwner);
1894
2186
  if (!hb) {
1895
2187
  emit.error({
1896
- code: "no_heartbeat",
1897
- message: noHeartbeatMessage(myOwner),
2188
+ code: "no_live_generation",
2189
+ message: noLiveGenerationMessage(myOwner),
1898
2190
  });
1899
2191
  process.exit(1);
1900
2192
  }
1901
2193
 
2194
+ // Report-only run quality: cheap config/cursor due check first, then a
2195
+ // non-blocking lazy evaluation. Shadow mode deliberately exposes no severity.
2196
+ const qualityEvaluation = evaluateRunQualityIfDue(root, new Date(), myOwner);
2197
+ const quality = qualityForStatus(
2198
+ qualityEvaluation.config.requested_mode,
2199
+ qualityEvaluation.snapshot,
2200
+ );
2201
+
1902
2202
  let finalization: GitFinalizationResult | null = null;
1903
2203
  if (opts.endTurn) {
1904
- const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
2204
+ const history = readSessionWriteClaims(
2205
+ root,
2206
+ hb.instance_id,
2207
+ nativeSessionIdentity(hb, myOwner),
2208
+ );
1905
2209
  const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1906
2210
  finalization = checkGitFinalization(root, touchedPaths, {
1907
2211
  claimHistoryComplete: history.complete,
@@ -1916,48 +2220,60 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1916
2220
  }
1917
2221
  }
1918
2222
 
1919
- emitCanonical({
1920
- type: "state.status_checked",
2223
+ emitEventV3({
1921
2224
  owner: myOwner,
1922
- session: hb.session_id ?? myOwner,
2225
+ session: nativeSessionIdentity(hb, myOwner),
1923
2226
  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,
2227
+ observation: {
2228
+ event_type: "coord.status_observed",
2229
+ status: opts.endTurn ? "end_turn_checked" : opts.json ? "json_checked" : "box_checked",
1929
2230
  },
1930
2231
  });
1931
2232
 
1932
- const startedAtMs = Date.parse(hb.started_at);
2233
+ let sessionEnd:
2234
+ | { state: "queued" | "already_requested"; request_id: string }
2235
+ | { state: "recorded" | "already_ended"; terminal_event_id?: string }
2236
+ | undefined;
2237
+ if (opts.endSession) {
2238
+ const canonicalInstanceId = liveInstanceIdV3(hb.instance_id);
2239
+ const records = listHookProducerStateRecordsV3(root, { includeTerminal: true }).filter(
2240
+ ({ state }) => state.instance_id === canonicalInstanceId,
2241
+ );
2242
+ if (records.length !== 1 || !records[0]) {
2243
+ emit.error({
2244
+ code: "session_identity_ambiguous",
2245
+ message: `expected one V3 generation for ${canonicalInstanceId}; found ${records.length}`,
2246
+ });
2247
+ process.exitCode = 1;
2248
+ return;
2249
+ }
2250
+ const state = records[0].state;
2251
+ const requested = requestSessionEndExplicitV3({
2252
+ coordRoot: root,
2253
+ instance_id: state.instance_id,
2254
+ generation_id: state.generation_id,
2255
+ outcome: "succeeded",
2256
+ coordination_finalized: true,
2257
+ });
2258
+ if (requested.state === "queued" || requested.state === "already_requested") {
2259
+ sessionEnd = { state: requested.state, request_id: requested.request.request_id };
2260
+ } else if (requested.state === "recorded") {
2261
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event.event_id };
2262
+ } else if (requested.state === "already_ended") {
2263
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event_id };
2264
+ } else {
2265
+ emit.error({ code: "session_end_failed", message: JSON.stringify(requested) });
2266
+ process.exitCode = 1;
2267
+ return;
2268
+ }
2269
+ }
2270
+
2271
+ const startedAtMs = Date.parse(hb.started_at ?? hb.last_heartbeat);
1933
2272
  const ageSecs = Number.isFinite(startedAtMs)
1934
2273
  ? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
1935
2274
  : 0;
1936
2275
 
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
- }
2276
+ const { livePeers, stale: peersStale } = collectStatusPeerHealth(root, myOwner);
1961
2277
 
1962
2278
  // Sort: file-holders first (by file count desc), then idle peers by recency.
1963
2279
  livePeers.sort((a, b) => {
@@ -1977,7 +2293,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1977
2293
  const remoteMachines = readRemoteMachines(root);
1978
2294
  const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
1979
2295
 
1980
- const ctxUsage = readContextUsage(hb.session_id, hb.platform);
2296
+ const ctxUsage = readContextUsage(hb.native_session_id ?? hb.session_id, hb.platform);
1981
2297
  let ctxStr: string;
1982
2298
  if (!ctxUsage) {
1983
2299
  ctxStr = "unavailable";
@@ -2014,6 +2330,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2014
2330
  activity_updated_at: hb.activity_updated_at ?? null,
2015
2331
  activity_source: hb.activity_source ?? null,
2016
2332
  task_state: taskStateOf(hb),
2333
+ task_state_scope: "current" as const,
2017
2334
  task_state_updated_at: hb.task_state_updated_at ?? null,
2018
2335
  task_state_reason: hb.task_state_reason ?? null,
2019
2336
  files_held: filesHeld,
@@ -2040,7 +2357,9 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2040
2357
  context_window: ctxUsage?.window ?? null,
2041
2358
  timestamp_iso: new Date().toISOString(),
2042
2359
  timestamp_local: timeStr,
2360
+ ...(quality ? { quality } : {}),
2043
2361
  ...(finalization ? { finalization } : {}),
2362
+ ...(sessionEnd ? { session_end: sessionEnd } : {}),
2044
2363
  };
2045
2364
 
2046
2365
  if (opts.json) {
@@ -2058,10 +2377,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2058
2377
  ["peers", peersStr],
2059
2378
  ["time", timeStr],
2060
2379
  ];
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
- }
2380
+ // Task gets full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
2065
2381
  if (hb.task && hb.task.length > 0) {
2066
2382
  rows.splice(1, 0, ["task", hb.task]);
2067
2383
  }
@@ -2076,11 +2392,59 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
2076
2392
  : `${pendingCouncils.length} pending (${pendingCouncils[0]}, +${pendingCouncils.length - 1})`;
2077
2393
  rows.splice(idx, 0, ["council", summary]);
2078
2394
  }
2395
+ if (quality) {
2396
+ const idx = rows.findIndex((row) => row[0] === "time");
2397
+ rows.splice(idx, 0, ["quality", formatRunQuality(quality.status, quality.signal_ids)]);
2398
+ }
2399
+ if (sessionEnd) {
2400
+ const idx = rows.findIndex((row) => row[0] === "time");
2401
+ rows.splice(idx, 0, [
2402
+ "session end",
2403
+ sessionEnd.state === "queued" || sessionEnd.state === "already_requested"
2404
+ ? "queued after this turn"
2405
+ : "recorded",
2406
+ ]);
2407
+ }
2079
2408
  // Box rendering needs predictable stdout regardless of TTY/pipe detection:
2080
2409
  // agent runs this via Bash (no TTY) and pastes captured stdout into chat.
2081
2410
  process.stdout.write(`${formatBox(displayName, rows)}\n`); // lint-ok-emission: chat-paste path; emit.text() auto-suppresses non-TTY
2082
2411
  }
2083
2412
 
2413
+ function qualityForStatus(
2414
+ mode: "off" | "shadow" | "report",
2415
+ snapshot: RunQualitySnapshot | null,
2416
+ ): {
2417
+ status: RunQualityStatus;
2418
+ signal_ids: string[];
2419
+ evaluated_at: string | null;
2420
+ fresh: boolean;
2421
+ } | null {
2422
+ if (mode !== "report") return null;
2423
+ const fresh = !!snapshot && Date.parse(snapshot.expires_at) > Date.now();
2424
+ if (!snapshot || !fresh) {
2425
+ return {
2426
+ status: "unknown",
2427
+ signal_ids: [],
2428
+ evaluated_at: snapshot?.evaluated_at ?? null,
2429
+ fresh: false,
2430
+ };
2431
+ }
2432
+ return {
2433
+ status: snapshot.status,
2434
+ signal_ids: snapshot.signals
2435
+ .filter((signal) => signal.state === "active" && signal.severity !== "none")
2436
+ .map((signal) => signal.id)
2437
+ .sort()
2438
+ .slice(0, 3),
2439
+ evaluated_at: snapshot.evaluated_at,
2440
+ fresh: true,
2441
+ };
2442
+ }
2443
+
2444
+ function formatRunQuality(status: RunQualityStatus, signalIds: string[]): string {
2445
+ return signalIds.length > 0 ? `${status} (${signalIds.join(", ")})` : status;
2446
+ }
2447
+
2084
2448
  function formatList(items: string[], cap: number, emptyLabel: string): string {
2085
2449
  if (items.length === 0) return emptyLabel;
2086
2450
  if (items.length <= cap) return items.join(", ");
@@ -2089,7 +2453,7 @@ function formatList(items: string[], cap: number, emptyLabel: string): string {
2089
2453
  }
2090
2454
 
2091
2455
  function formatPeers(
2092
- peers: Heartbeat[],
2456
+ peers: StatusPeerHeartbeat[],
2093
2457
  cap: number,
2094
2458
  staleCount: number,
2095
2459
  remoteMachines: RemoteMachine[] = [],
@@ -2122,6 +2486,38 @@ function formatPeers(
2122
2486
  return main;
2123
2487
  }
2124
2488
 
2489
+ type StatusPeerHeartbeat = {
2490
+ instance_id: string;
2491
+ last_heartbeat: string;
2492
+ files_touched?: string[];
2493
+ name?: string;
2494
+ platform?: string;
2495
+ };
2496
+
2497
+ export function collectStatusPeerHealth(
2498
+ root: string,
2499
+ myOwner: string,
2500
+ nowMs = Date.now(),
2501
+ ): { livePeers: StatusPeerHeartbeat[]; stale: number } {
2502
+ let rows: StatusPeerHeartbeat[] = [];
2503
+ try {
2504
+ rows = readLiveCoordinationRows(root);
2505
+ } catch {
2506
+ // V3 authority failures must not resurrect disposable cache rows.
2507
+ }
2508
+
2509
+ const cutoffMs = nowMs - freshnessCutoffSecs() * 1000;
2510
+ const livePeers: StatusPeerHeartbeat[] = [];
2511
+ let stale = 0;
2512
+ for (const peer of rows) {
2513
+ if (peer.instance_id === myOwner) continue;
2514
+ const observedAtMs = Date.parse(peer.last_heartbeat);
2515
+ if (Number.isFinite(observedAtMs) && observedAtMs >= cutoffMs) livePeers.push(peer);
2516
+ else stale += 1;
2517
+ }
2518
+ return { livePeers, stale };
2519
+ }
2520
+
2125
2521
  function fmtTokens(n: number): string {
2126
2522
  if (n < 1000) return `${n}`;
2127
2523
  if (n < 1000000) return `${Math.round(n / 1000)}K`;
@@ -2371,46 +2767,69 @@ interface HealEvent {
2371
2767
  platform: string;
2372
2768
  }
2373
2769
 
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;
2770
+ /** Bounded diagnostic event shape projected from the canonical V3 ledger. */
2771
+ export interface CanonicalEvent {
2772
+ event_type: EventTypeV3;
2380
2773
  ts: string;
2381
2774
  instance_id?: string;
2382
2775
  adapter?: string;
2383
- data?: Record<string, unknown>;
2776
+ payload?: Record<string, unknown>;
2384
2777
  }
2385
2778
 
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);
2779
+ export interface AgentDiagnosticEventRead {
2780
+ source: "v3";
2781
+ authoritative: boolean;
2782
+ reason?: string;
2783
+ truncated: boolean;
2784
+ bytes: number;
2785
+ events: CanonicalEvent[];
2786
+ }
2787
+
2788
+ /** Read validated canonical V3 events. */
2789
+ export function readAgentDiagnosticEventsInWindow(
2790
+ root: string,
2791
+ cutoffMs: number,
2792
+ ): AgentDiagnosticEventRead {
2793
+ const control = readEventV3ControlState(root);
2794
+ if (control.state === "candidate" || control.state === "active") {
2795
+ const ledger = readLedgerV3(root);
2796
+ if (!ledger.complete) {
2797
+ return {
2798
+ source: "v3",
2799
+ authoritative: false,
2800
+ reason: `V3 ledger validation failed: ${ledger.diagnostics.map((item) => item.code).join(", ") || "incomplete"}`,
2801
+ truncated: false,
2802
+ bytes: ledger.bytes,
2803
+ events: [],
2804
+ };
2805
+ }
2806
+ const events: CanonicalEvent[] = [];
2807
+ for (const { event } of ledger.events) {
2808
+ const tsMs = Date.parse(event.time.recorded_at);
2407
2809
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2408
- out.push(ev);
2409
- } catch {
2410
- /* skip malformed */
2810
+ events.push({
2811
+ event_type: event.event_type,
2812
+ ts: event.time.recorded_at,
2813
+ instance_id: event.scope.instance_id,
2814
+ payload: event.payload as Record<string, unknown>,
2815
+ });
2411
2816
  }
2817
+ return {
2818
+ source: "v3",
2819
+ authoritative: true,
2820
+ truncated: false,
2821
+ bytes: ledger.bytes,
2822
+ events,
2823
+ };
2412
2824
  }
2413
- return out;
2825
+ return {
2826
+ source: "v3",
2827
+ authoritative: false,
2828
+ reason: `V3 control state is ${control.state}`,
2829
+ truncated: false,
2830
+ bytes: 0,
2831
+ events: [],
2832
+ };
2414
2833
  }
2415
2834
 
2416
2835
  /** Normalize adapter event data into the heartbeat platform value. */
@@ -2418,19 +2837,21 @@ function adapterToPlatform(adapter: string | undefined): string {
2418
2837
  if (adapter === "claude-code") return "claude-code";
2419
2838
  if (adapter === "cursor") return "cursor";
2420
2839
  if (adapter === "codex") return "codex";
2421
- return "claude-code";
2840
+ return "unknown";
2422
2841
  }
2423
2842
 
2424
2843
  /** Project a canonical health.* event into the HealEvent shape the aggregators
2425
2844
  * already consume. Returns null for non-heal events. instance_id → display name
2426
2845
  * via `nameById` (full-UUID keyed). */
2427
2846
  function canonicalToHealEvent(ev: CanonicalEvent, nameById: Map<string, string>): HealEvent | null {
2428
- if (ev.event_type !== "health.pidmap_heal" && ev.event_type !== "health.heartbeat_heal") {
2847
+ if (ev.event_type !== "health.observed") {
2429
2848
  return null;
2430
2849
  }
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";
2850
+ const data = ev.payload ?? {};
2851
+ const subsystem = typeof data.subsystem === "string" ? data.subsystem : "";
2852
+ if (subsystem !== "pidmap" && subsystem !== "heartbeat") return null;
2853
+ const kind = subsystem;
2854
+ const reason: "missing" | "stale" = data.condition === "stale" ? "stale" : "missing";
2434
2855
  const instanceId = ev.instance_id ?? "";
2435
2856
  const name = nameById.get(instanceId);
2436
2857
  const agent = name ? `agent-${name}` : `agent-${instanceId.slice(0, 8) || "unknown"}`;
@@ -2511,13 +2932,12 @@ function runHealEvents(opts: {
2511
2932
  process.exit(1);
2512
2933
  }
2513
2934
 
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.
2935
+ // Heal telemetry is read from the canonical V3 ledger.
2517
2936
  const cutoffMs = Date.now() - sinceSecs * 1000;
2518
2937
  const nameById = buildNameById(root);
2519
2938
  const events: HealEvent[] = [];
2520
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
2939
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
2940
+ for (const ev of diagnosticEvents.events) {
2521
2941
  const heal = canonicalToHealEvent(ev, nameById);
2522
2942
  if (heal) events.push(heal);
2523
2943
  }
@@ -2554,6 +2974,11 @@ function runHealEvents(opts: {
2554
2974
 
2555
2975
  const data = {
2556
2976
  since: opts.since,
2977
+ telemetry: {
2978
+ source: diagnosticEvents.source,
2979
+ authoritative: diagnosticEvents.authoritative,
2980
+ reason: diagnosticEvents.reason,
2981
+ },
2557
2982
  total: events.length,
2558
2983
  by_reason: byReason,
2559
2984
  by_kind: byKind,
@@ -2565,7 +2990,17 @@ function runHealEvents(opts: {
2565
2990
 
2566
2991
  if (opts.csv) {
2567
2992
  emit.config({ format: "csv" });
2568
- emit.data(recent);
2993
+ emit.data(
2994
+ data.telemetry.authoritative
2995
+ ? recent
2996
+ : [
2997
+ {
2998
+ status: "unavailable",
2999
+ source: data.telemetry.source,
3000
+ reason: data.telemetry.reason,
3001
+ },
3002
+ ],
3003
+ );
2569
3004
  return;
2570
3005
  }
2571
3006
  if (opts.json) {
@@ -2580,6 +3015,13 @@ function runHealEvents(opts: {
2580
3015
  lines.push(
2581
3016
  `Heal events: ${events.length} total in last ${opts.since} (health.pidmap_heal + health.heartbeat_heal)`,
2582
3017
  );
3018
+ if (!data.telemetry.authoritative) {
3019
+ lines.push(
3020
+ ` unavailable: ${data.telemetry.reason ?? "diagnostic event source is incomplete"}`,
3021
+ );
3022
+ emit.text(`${lines.join("\n")}\n`);
3023
+ return;
3024
+ }
2583
3025
  lines.push("");
2584
3026
  if (events.length === 0) {
2585
3027
  lines.push(" (none; pid-map/heartbeat drift is not happening in this window)");
@@ -2631,7 +3073,13 @@ function runHealEvents(opts: {
2631
3073
  interface HealthReport {
2632
3074
  since: string;
2633
3075
  generated_at: string;
3076
+ event_telemetry: {
3077
+ source: "v3";
3078
+ authoritative: boolean;
3079
+ reason?: string;
3080
+ };
2634
3081
  active_agents: {
3082
+ source: "event-ledger-v3";
2635
3083
  total: number;
2636
3084
  by_platform: Record<string, number>;
2637
3085
  by_kind: Record<string, number>;
@@ -2661,11 +3109,19 @@ interface HealthReport {
2661
3109
  // bug (e.g. a stop-projection crash that caused ~200 errors/day until it was fixed).
2662
3110
  hook_errors: {
2663
3111
  total: number;
3112
+ last_1h: number;
3113
+ latest_at: string | null;
2664
3114
  by_phase: Record<string, number>;
3115
+ by_error: Record<string, number>;
2665
3116
  top: Array<{ phase: string; count: number; sample: string }>;
3117
+ top_errors: Array<{ error: string; count: number; phase: string }>;
3118
+ recent_top_errors: Array<{ error: string; count: number; phase: string }>;
2666
3119
  };
2667
3120
  // Canonical event stream growth + drain lag.
2668
3121
  stream: {
3122
+ source: "v3";
3123
+ authoritative: boolean;
3124
+ reason?: string;
2669
3125
  bytes: number;
2670
3126
  lines: number;
2671
3127
  cursor_backlog: number;
@@ -2677,31 +3133,269 @@ interface HealthReport {
2677
3133
  count: number;
2678
3134
  samples: string[];
2679
3135
  };
3136
+ // V3 event-ledger producer health: open tool spans, pending finalization
3137
+ // requests, intake/diagnostics spool depth, span-count pressure.
3138
+ event_ledger: EventLedgerHealthV3;
2680
3139
  anomalies: string[];
2681
3140
  }
2682
3141
 
3142
+ /** Open-span soft watermark per producer state. The producer-state reader
3143
+ * hard-caps at 256 spans (a state file beyond it fails to load), so surfacing
3144
+ * pressure at half that gives room to act before reads start failing. */
3145
+ const SPAN_PRESSURE_SOFT_WATERMARK = 128;
3146
+
3147
+ export type EventLedgerHealthV3 =
3148
+ | { state: "unavailable"; reason: string }
3149
+ | {
3150
+ state: "live";
3151
+ mode: "candidate" | "active";
3152
+ open_spans: {
3153
+ total: number;
3154
+ generations: Array<{
3155
+ instance_id: string;
3156
+ generation_id: string;
3157
+ adapter: string;
3158
+ span_count: number;
3159
+ /** False = spans are open with NO open turn: the orphan signature
3160
+ * (a turn ended without its tool spans being closed). */
3161
+ turn_open: boolean;
3162
+ }>;
3163
+ };
3164
+ pending_finalizations: Array<{
3165
+ request_id: string;
3166
+ trigger: string;
3167
+ generation_id: string;
3168
+ age_ms: number;
3169
+ allowed_open_span_count: number;
3170
+ }>;
3171
+ intake_spool: {
3172
+ total: number;
3173
+ groups: Array<{ adapter: string; session_hash: string; count: number }>;
3174
+ };
3175
+ diagnostics_spool: {
3176
+ total: number;
3177
+ last_24h: number;
3178
+ last_1h: number;
3179
+ latest_at: string | null;
3180
+ by_category: Record<string, { total: number; last_24h: number }>;
3181
+ recent_by_category: Record<string, { last_1h: number; latest_at: string | null }>;
3182
+ };
3183
+ span_pressure: Array<{ instance_id: string; generation_id: string; span_count: number }>;
3184
+ collection_errors: string[];
3185
+ };
3186
+
3187
+ function errorText(error: unknown): string {
3188
+ return error instanceof Error ? error.message : String(error);
3189
+ }
3190
+
3191
+ /**
3192
+ * Read-only health counters for the V3 event ledger's producer surfaces. Never
3193
+ * mutates ledger state (no control repair, no spool drain) and never throws:
3194
+ * a non-live route returns `{ state: "unavailable" }`, and each sub-surface
3195
+ * that fails to read lands in `collection_errors` instead of aborting the rest.
3196
+ */
3197
+ export function collectEventLedgerHealthV3(root: string, nowMs = Date.now()): EventLedgerHealthV3 {
3198
+ let control: ReturnType<typeof readEventV3ControlState>;
3199
+ try {
3200
+ control = readEventV3ControlState(root);
3201
+ } catch (error) {
3202
+ return { state: "unavailable", reason: `control read failed: ${errorText(error)}` };
3203
+ }
3204
+ if (control.state !== "candidate" && control.state !== "active") {
3205
+ return {
3206
+ state: "unavailable",
3207
+ reason: `${control.state}: ${control.reason}`,
3208
+ };
3209
+ }
3210
+
3211
+ const collectionErrors: string[] = [];
3212
+
3213
+ // 1) Open tool spans per live (non-terminal) generation + span-count pressure.
3214
+ const generations: Extract<EventLedgerHealthV3, { state: "live" }>["open_spans"]["generations"] =
3215
+ [];
3216
+ const spanPressure: Extract<EventLedgerHealthV3, { state: "live" }>["span_pressure"] = [];
3217
+ try {
3218
+ for (const { state } of listHookProducerStateRecordsV3(root)) {
3219
+ if (state.spans.length >= SPAN_PRESSURE_SOFT_WATERMARK) {
3220
+ spanPressure.push({
3221
+ instance_id: state.instance_id,
3222
+ generation_id: state.generation_id,
3223
+ span_count: state.spans.length,
3224
+ });
3225
+ }
3226
+ if (state.spans.length === 0) continue;
3227
+ generations.push({
3228
+ instance_id: state.instance_id,
3229
+ generation_id: state.generation_id,
3230
+ adapter: state.adapter,
3231
+ span_count: state.spans.length,
3232
+ turn_open: Boolean(state.current_turn_id),
3233
+ });
3234
+ }
3235
+ } catch (error) {
3236
+ collectionErrors.push(`producer states unreadable: ${errorText(error)}`);
3237
+ }
3238
+
3239
+ // 2) Pending finalization requests with age + trigger.
3240
+ const pending: Extract<EventLedgerHealthV3, { state: "live" }>["pending_finalizations"] = [];
3241
+ try {
3242
+ for (const request of listSessionFinalizationRequestsV3(root)) {
3243
+ if (request.status !== "pending") continue;
3244
+ const observedMs = Date.parse(request.observed_at);
3245
+ pending.push({
3246
+ request_id: request.request_id,
3247
+ trigger: request.trigger,
3248
+ generation_id: request.generation_id,
3249
+ age_ms: Number.isFinite(observedMs) ? Math.max(0, nowMs - observedMs) : -1,
3250
+ allowed_open_span_count: request.allowed_open_span_ids?.length ?? 0,
3251
+ });
3252
+ }
3253
+ } catch (error) {
3254
+ collectionErrors.push(`finalization requests unreadable: ${errorText(error)}`);
3255
+ }
3256
+
3257
+ // 3) Intake spool depth (queued hook signals awaiting a lease-holder drain).
3258
+ let intakeTotal = 0;
3259
+ const intakeGroups: Extract<EventLedgerHealthV3, { state: "live" }>["intake_spool"]["groups"] =
3260
+ [];
3261
+ try {
3262
+ for (const group of listHookIntakeGroupsV3(root)) {
3263
+ const count = listHookIntakeRecordsV3(group.directory).length;
3264
+ if (count === 0) continue;
3265
+ intakeTotal += count;
3266
+ intakeGroups.push({ adapter: group.adapter, session_hash: group.session_hash, count });
3267
+ }
3268
+ } catch (error) {
3269
+ collectionErrors.push(`intake spool unreadable: ${errorText(error)}`);
3270
+ }
3271
+
3272
+ // 4) Diagnostics spool counts by category. Filenames only (never the
3273
+ // contents): `<category>-<orderkey>.json` where the orderkey leads with a
3274
+ // zero-padded epoch-ms, which also gives the last-24h split for free.
3275
+ const byCategory: Record<string, { total: number; last_24h: number }> = {};
3276
+ const recentByCategory: Extract<
3277
+ EventLedgerHealthV3,
3278
+ { state: "live" }
3279
+ >["diagnostics_spool"]["recent_by_category"] = {};
3280
+ let diagnosticsTotal = 0;
3281
+ let diagnostics24h = 0;
3282
+ let diagnostics1h = 0;
3283
+ let latestDiagnosticMs = Number.NEGATIVE_INFINITY;
3284
+ try {
3285
+ const diagnosticsDir = join(resolve(root), EVENT_V3_LEDGER_RELATIVE_ROOT, "diagnostics");
3286
+ if (existsSync(diagnosticsDir)) {
3287
+ const dayAgoMs = nowMs - 24 * 60 * 60 * 1000;
3288
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
3289
+ for (const name of readdirSync(diagnosticsDir)) {
3290
+ const match = /^(.+)-(\d{15})-\d{20}-\d+-[0-9a-f-]+\.json$/.exec(name);
3291
+ const category = match?.[1];
3292
+ const epochMs = Number(match?.[2]);
3293
+ if (!category) continue;
3294
+ const entry = byCategory[category] ?? { total: 0, last_24h: 0 };
3295
+ const recent = recentByCategory[category] ?? { last_1h: 0, latest_at: null };
3296
+ byCategory[category] = entry;
3297
+ recentByCategory[category] = recent;
3298
+ entry.total += 1;
3299
+ diagnosticsTotal += 1;
3300
+ if (Number.isFinite(epochMs)) {
3301
+ const recordedAt = new Date(epochMs).toISOString();
3302
+ if (!recent.latest_at || epochMs > Date.parse(recent.latest_at)) {
3303
+ recent.latest_at = recordedAt;
3304
+ }
3305
+ latestDiagnosticMs = Math.max(latestDiagnosticMs, epochMs);
3306
+ if (epochMs >= dayAgoMs) {
3307
+ entry.last_24h += 1;
3308
+ diagnostics24h += 1;
3309
+ }
3310
+ if (epochMs >= hourAgoMs) {
3311
+ recent.last_1h += 1;
3312
+ diagnostics1h += 1;
3313
+ }
3314
+ }
3315
+ }
3316
+ }
3317
+ } catch (error) {
3318
+ collectionErrors.push(`diagnostics spool unreadable: ${errorText(error)}`);
3319
+ }
3320
+
3321
+ return {
3322
+ state: "live",
3323
+ mode: control.state,
3324
+ open_spans: {
3325
+ total: generations.reduce((sum, generation) => sum + generation.span_count, 0),
3326
+ generations,
3327
+ },
3328
+ pending_finalizations: pending,
3329
+ intake_spool: { total: intakeTotal, groups: intakeGroups },
3330
+ diagnostics_spool: {
3331
+ total: diagnosticsTotal,
3332
+ last_24h: diagnostics24h,
3333
+ last_1h: diagnostics1h,
3334
+ latest_at: Number.isFinite(latestDiagnosticMs)
3335
+ ? new Date(latestDiagnosticMs).toISOString()
3336
+ : null,
3337
+ by_category: byCategory,
3338
+ recent_by_category: recentByCategory,
3339
+ },
3340
+ span_pressure: spanPressure,
3341
+ collection_errors: collectionErrors,
3342
+ };
3343
+ }
3344
+
2683
3345
  /** 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(
3346
+ * window by exact error and phase, while separating current-hour failures from
3347
+ * historical rows. Each line is {ts, error, phase, ...}. */
3348
+ export function readHookErrors(
2687
3349
  root: string,
2688
3350
  cutoffMs: number,
3351
+ nowMs = Date.now(),
2689
3352
  ): {
2690
3353
  total: number;
3354
+ last1h: number;
3355
+ latestAt: string | null;
2691
3356
  byPhase: Record<string, number>;
3357
+ byError: Record<string, number>;
2692
3358
  top: Array<{ phase: string; count: number; sample: string }>;
3359
+ topErrors: Array<{ error: string; count: number; phase: string }>;
3360
+ recentTopErrors: Array<{ error: string; count: number; phase: string }>;
2693
3361
  } {
2694
3362
  const p = resolve(root, ".harnery", "debug", "agent-hook.errors.ndjson");
2695
3363
  const byPhase: Record<string, number> = {};
3364
+ const byError: Record<string, number> = {};
2696
3365
  const sampleByPhase: Record<string, string> = {};
3366
+ const errorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
3367
+ const recentErrorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
2697
3368
  let total = 0;
2698
- if (!existsSync(p)) return { total: 0, byPhase, top: [] };
3369
+ let last1h = 0;
3370
+ let latestMs = Number.NEGATIVE_INFINITY;
3371
+ if (!existsSync(p)) {
3372
+ return {
3373
+ total: 0,
3374
+ last1h: 0,
3375
+ latestAt: null,
3376
+ byPhase,
3377
+ byError,
3378
+ top: [],
3379
+ topErrors: [],
3380
+ recentTopErrors: [],
3381
+ };
3382
+ }
2699
3383
  let raw: string;
2700
3384
  try {
2701
3385
  raw = readFileSync(p, "utf8");
2702
3386
  } catch {
2703
- return { total: 0, byPhase, top: [] };
3387
+ return {
3388
+ total: 0,
3389
+ last1h: 0,
3390
+ latestAt: null,
3391
+ byPhase,
3392
+ byError,
3393
+ top: [],
3394
+ topErrors: [],
3395
+ recentTopErrors: [],
3396
+ };
2704
3397
  }
3398
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
2705
3399
  for (const line of raw.split("\n")) {
2706
3400
  if (!line.trim()) continue;
2707
3401
  try {
@@ -2709,8 +3403,21 @@ function readHookErrors(
2709
3403
  const tsMs = e.ts ? Date.parse(e.ts) : Number.NaN;
2710
3404
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2711
3405
  const phase = e.phase ?? "(unknown)";
3406
+ const error = e.error ?? "(unknown)";
2712
3407
  byPhase[phase] = (byPhase[phase] ?? 0) + 1;
3408
+ byError[error] = (byError[error] ?? 0) + 1;
2713
3409
  if (!sampleByPhase[phase] && e.error) sampleByPhase[phase] = e.error;
3410
+ const errorPhaseKey = `${error}\0${phase}`;
3411
+ const errorPhase = errorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3412
+ errorPhase.count += 1;
3413
+ errorPhaseCounts.set(errorPhaseKey, errorPhase);
3414
+ latestMs = Math.max(latestMs, tsMs);
3415
+ if (tsMs >= hourAgoMs) {
3416
+ last1h += 1;
3417
+ const recent = recentErrorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3418
+ recent.count += 1;
3419
+ recentErrorPhaseCounts.set(errorPhaseKey, recent);
3420
+ }
2714
3421
  total++;
2715
3422
  } catch {
2716
3423
  /* skip malformed */
@@ -2720,130 +3427,181 @@ function readHookErrors(
2720
3427
  .sort((a, b) => b[1] - a[1])
2721
3428
  .slice(0, 5)
2722
3429
  .map(([phase, count]) => ({ phase, count, sample: sampleByPhase[phase] ?? "" }));
2723
- return { total, byPhase, top };
3430
+ const topErrors = [...errorPhaseCounts.values()]
3431
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3432
+ .slice(0, 5);
3433
+ const recentTopErrors = [...recentErrorPhaseCounts.values()]
3434
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3435
+ .slice(0, 5);
3436
+ return {
3437
+ total,
3438
+ last1h,
3439
+ latestAt: Number.isFinite(latestMs) ? new Date(latestMs).toISOString() : null,
3440
+ byPhase,
3441
+ byError,
3442
+ top,
3443
+ topErrors,
3444
+ recentTopErrors,
3445
+ };
2724
3446
  }
2725
3447
 
2726
3448
  /** 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 };
3449
+ function readStreamStats(root: string): HealthReport["stream"] {
3450
+ const read = readAgentDiagnosticEventsInWindow(root, 0);
3451
+ return {
3452
+ source: "v3",
3453
+ authoritative: read.authoritative,
3454
+ ...(read.reason ? { reason: read.reason } : {}),
3455
+ bytes: read.bytes,
3456
+ lines: read.events.length,
3457
+ cursor_backlog: 0,
3458
+ };
2767
3459
  }
2768
3460
 
2769
3461
  /** One rendered line in a trace. */
2770
- interface TraceEntry {
3462
+ export interface TraceEntry {
2771
3463
  ts: string;
2772
3464
  event_type: string;
2773
3465
  detail: string;
2774
3466
  }
2775
3467
 
3468
+ export function traceInstanceIdsForEventSource(
3469
+ nativeInstanceIds: readonly string[],
3470
+ source: "v3",
3471
+ ): string[] {
3472
+ void source;
3473
+ return nativeInstanceIds.map((instanceId) => liveInstanceIdV3(instanceId));
3474
+ }
3475
+
2776
3476
  /** 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>;
3477
+ export function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
3478
+ const d = (ev.payload ?? {}) as Record<string, unknown>;
2779
3479
  const s = (k: string): string => (typeof d[k] === "string" ? (d[k] as string) : "");
3480
+ const object = (value: unknown): Record<string, unknown> =>
3481
+ typeof value === "object" && value !== null && !Array.isArray(value)
3482
+ ? (value as Record<string, unknown>)
3483
+ : {};
2780
3484
  const clip = (v: string, n = 70): string => (v.length <= n ? v : `${v.slice(0, n - 1)}…`);
3485
+ const recovery =
3486
+ typeof d.recovery === "object" && d.recovery !== null
3487
+ ? (d.recovery as { reason?: unknown })
3488
+ : null;
3489
+ const recoveryReason = typeof recovery?.reason === "string" ? recovery.reason : "";
3490
+ const recoveryDetail = recoveryReason ? ` · RECOVERY reason=${recoveryReason}` : "";
2781
3491
  let detail = "";
2782
3492
  switch (ev.event_type) {
2783
- case "session.start":
2784
- detail = `${s("source") || "startup"}${s("model") ? ` · model=${s("model")}` : ""}${s("name") ? ` · ${s("name")}` : ""}`;
3493
+ case "session.started":
3494
+ detail = "generation started";
3495
+ break;
3496
+ case "session.resumed":
3497
+ detail = "generation resumed";
2785
3498
  break;
2786
- case "session.end":
2787
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3499
+ case "session.ended":
3500
+ detail = `outcome=${s("outcome") || "unknown"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
2788
3501
  break;
2789
- case "subagent.start":
2790
- detail = `${s("agent_type") || "subagent"}${s("name") ? ` · ${s("name")}` : ""}`;
3502
+ case "session.termination_observed":
3503
+ detail = `provisional ${s("observation") || "termination"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
2791
3504
  break;
2792
- case "subagent.stop":
2793
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3505
+ case "agent.delegated":
3506
+ detail = `delegated child=${s("child_generation_id") || "unknown"}`;
2794
3507
  break;
2795
- case "user_prompt.submit":
2796
- detail = clip(s("prompt_text") || s("prompt"));
3508
+ case "agent.started":
3509
+ detail = `child started=${s("child_generation_id") || "unknown"}`;
2797
3510
  break;
2798
- case "turn.stop":
2799
- detail = `status_box=${d.status_box_present ?? "?"}${s("turn_summary") ? ` · ${clip(s("turn_summary"), 50)}` : ""}`;
3511
+ case "agent.completed":
3512
+ detail = `child completed=${s("child_generation_id") || "unknown"} · outcome=${s("outcome") || "unknown"}`;
2800
3513
  break;
2801
- case "tool.pre_use":
2802
- detail = `${s("tool_name")}${s("tool_target") || s("intent") ? ` · ${clip(s("tool_target") || s("intent"), 60)}` : ""}`;
3514
+ case "turn.started":
3515
+ detail = `intent=${s("intent_kind") || "unknown"}`;
2803
3516
  break;
2804
- case "state.task_set":
2805
- detail = d.cleared ? "(cleared)" : clip(s("task"));
3517
+ case "turn.completed":
3518
+ detail = `outcome=${s("outcome") || "unknown"}`;
2806
3519
  break;
2807
- case "state.task_state":
2808
- detail = `${s("prior_state") || "active"} → ${s("state") || "active"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
3520
+ case "tool.requested": {
3521
+ const tool = object(d.tool);
3522
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} requested (content omitted)${recoveryDetail}`;
2809
3523
  break;
2810
- case "interaction.input_requested":
2811
- detail = s("request_kind") || s("tool_name") || "operator input requested";
3524
+ }
3525
+ case "tool.completed": {
3526
+ const tool = object(d.tool);
3527
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} · outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3528
+ break;
3529
+ }
3530
+ case "command.started":
3531
+ if (!allTools) return null;
3532
+ detail = "command started (content omitted)";
3533
+ break;
3534
+ case "command.completed":
3535
+ if (!allTools && !recoveryReason) return null;
3536
+ detail = `outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3537
+ break;
3538
+ case "coord.task_changed":
3539
+ case "coord.lifecycle_changed":
3540
+ case "coord.presence_changed":
3541
+ detail = `${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
3542
+ break;
3543
+ case "coord.status_observed":
3544
+ detail = s("status") || "status observed";
3545
+ break;
3546
+ case "coord.claim_changed": {
3547
+ const target = object(d.target);
3548
+ detail = `${s("operation") || "changed"}${typeof target.display === "string" ? ` · ${clip(target.display)}` : ""}`;
3549
+ break;
3550
+ }
3551
+ case "coord.message_observed":
3552
+ detail = `${s("direction") || "observed"} · peer=${s("peer_instance_id") || "unknown"}`;
3553
+ break;
3554
+ case "coord.identity_attested":
3555
+ detail = `${s("identity_id") || "identity"} · method=${s("method") || "unknown"}`;
3556
+ break;
3557
+ case "wait.started":
3558
+ detail = s("kind") || "wait started";
3559
+ break;
3560
+ case "wait.ended":
3561
+ detail = `outcome=${s("outcome") || "unknown"}`;
3562
+ break;
3563
+ case "artifact.observed": {
3564
+ const artifact = object(d.artifact);
3565
+ detail = `operation=${s("operation") || "observed"}${typeof artifact.kind === "string" ? ` · kind=${artifact.kind}` : ""}`;
2812
3566
  break;
2813
- case "state.status_checked":
2814
- detail = "status box rendered";
3567
+ }
3568
+ case "progress.observed":
3569
+ detail = s("kind") || "progress";
3570
+ break;
3571
+ case "health.observed":
3572
+ detail = `${s("subsystem") || "unknown"} · ${s("severity") || "unknown"} · ${s("condition") || "unknown"}`;
2815
3573
  break;
2816
- case "identity.assumed":
2817
- detail = `${s("previous_name") || "unknown"} → ${s("name") || "unknown"}${s("agent_id") ? ` · persona=${s("agent_id").slice(0, 8)}…` : ""}`;
3574
+ case "council.state_changed":
3575
+ detail = `${s("council_id") || "council"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2818
3576
  break;
2819
- case "claim.acquire":
2820
- case "claim.release":
2821
- case "claim.conflict":
2822
- detail = clip(s("path"));
3577
+ case "decision.state_changed":
3578
+ detail = `${s("decision_id") || "decision"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2823
3579
  break;
2824
- case "health.heartbeat_heal":
2825
- case "health.pidmap_heal":
2826
- detail = `reason=${s("reason")}`;
3580
+ case "lifecycle.recovered":
3581
+ detail = `recovered=${s("recovery_kind") || "unknown"}`;
2827
3582
  break;
2828
- case "health.heartbeat_swept":
2829
- detail = `reason=${s("reason")}${d.age_secs !== undefined ? ` · age=${d.age_secs}s` : ""}`;
3583
+ case "lifecycle.sweep_observed":
3584
+ detail = `${s("observation") || "sweep"}${typeof d.age_ms === "number" ? ` · age=${Math.round(d.age_ms / 1000)}s` : ""}`;
2830
3585
  break;
2831
3586
  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
3587
  return null;
2843
3588
  }
2844
3589
  return { ts: ev.ts, event_type: ev.event_type, detail };
2845
3590
  }
2846
3591
 
3592
+ export function pendingFinalizationTraceEntries(
3593
+ requests: readonly SessionFinalizationRequestV3[],
3594
+ instanceId: string,
3595
+ ): TraceEntry[] {
3596
+ return requests
3597
+ .filter((request) => request.status === "pending" && request.instance_id === instanceId)
3598
+ .map((request) => ({
3599
+ ts: request.observed_at,
3600
+ event_type: "session.finalization_pending",
3601
+ detail: `trigger=${request.trigger} · request=${request.request_id}${request.allowed_open_span_ids?.length ? ` · allowed_open_spans=${request.allowed_open_span_ids.length}` : ""}`,
3602
+ }));
3603
+ }
3604
+
2847
3605
  function runTrace(
2848
3606
  name: string,
2849
3607
  opts: { since?: string; limit: string; allTools?: boolean; json?: boolean },
@@ -2872,7 +3630,7 @@ function runTrace(
2872
3630
  if (candidates.length === 1) targetId = candidates[0]![0];
2873
3631
  else if (candidates.length > 1)
2874
3632
  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
3633
+ else if (/^(?:inst_)?[0-9a-z._-]{8,}$/i.test(wanted)) targetId = wanted;
2876
3634
  }
2877
3635
  if (!targetId) {
2878
3636
  emit.error({
@@ -2884,37 +3642,16 @@ function runTrace(
2884
3642
 
2885
3643
  const sinceMs = opts.since ? Date.now() - (parseWindowSecs(opts.since) ?? 0) * 1000 : 0;
2886
3644
  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");
3645
+ const diagnosticRead = readAgentDiagnosticEventsInWindow(root, sinceMs);
3646
+ const nativeCandidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
3647
+ const candidateIds = traceInstanceIdsForEventSource(nativeCandidateIds, diagnosticRead.source);
2894
3648
  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
- }
3649
+ for (const ev of diagnosticRead.events) {
3650
+ if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
3651
+ const arr = byId.get(ev.instance_id) ?? [];
3652
+ arr.push(ev);
3653
+ byId.set(ev.instance_id, arr);
2911
3654
  }
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
3655
  // If the name mapped to multiple instances, trace the one with the latest event.
2919
3656
  let resolvedId = candidateIds[0]!;
2920
3657
  if (candidateIds.length > 1) {
@@ -2930,25 +3667,84 @@ function runTrace(
2930
3667
  }
2931
3668
 
2932
3669
  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)
3670
+ let state = foldSessionState(events, { instance_id: resolvedId });
3671
+ let sessionState: "live" | "ended" = events.some((event) => event.event_type === "session.ended")
3672
+ ? "ended"
3673
+ : "live";
3674
+ if (diagnosticRead.source === "v3" && diagnosticRead.authoritative) {
3675
+ try {
3676
+ const view = projectCoordinationViewV3(readLedgerV3(root));
3677
+ const liveGeneration = view.instances[resolvedId];
3678
+ const terminalGeneration = Object.values(view.terminal_generations).find(
3679
+ (candidate) => candidate.instance_id === resolvedId,
3680
+ );
3681
+ const generation = liveGeneration ?? terminalGeneration;
3682
+ if (generation) {
3683
+ sessionState = terminalGeneration && !liveGeneration ? "ended" : "live";
3684
+ const lifecycleState = generation.lifecycle_state;
3685
+ state = {
3686
+ activity: generation.activity === "terminal" ? "idle" : generation.activity,
3687
+ activity_updated_at: generation.last_observed_at,
3688
+ activity_source: "event-v3-coordination-view",
3689
+ task_state:
3690
+ lifecycleState === "active" || lifecycleState === "blocked" || lifecycleState === "done"
3691
+ ? lifecycleState
3692
+ : "active",
3693
+ task_state_updated_at: generation.lifecycle_state_updated_at,
3694
+ };
3695
+ }
3696
+ } catch {
3697
+ diagnosticRead.authoritative = false;
3698
+ diagnosticRead.reason = "V3 coordination projection is unavailable";
3699
+ }
3700
+ }
3701
+ let pendingFinalizations: SessionFinalizationRequestV3[] = [];
3702
+ if (diagnosticRead.source === "v3") {
3703
+ try {
3704
+ pendingFinalizations = listSessionFinalizationRequestsV3(root).filter(
3705
+ (request) => request.status === "pending" && request.instance_id === resolvedId,
3706
+ );
3707
+ } catch {
3708
+ diagnosticRead.authoritative = false;
3709
+ diagnosticRead.reason = "V3 finalization requests are unreadable";
3710
+ }
3711
+ }
3712
+ const lines = [
3713
+ ...events
3714
+ .map((ev) => traceLine(ev, !!opts.allTools))
3715
+ .filter((l): l is TraceEntry => l !== null),
3716
+ ...pendingFinalizationTraceEntries(pendingFinalizations, resolvedId),
3717
+ ]
2937
3718
  // Sort by timestamp, not file order: codex replays events (original ts,
2938
3719
  // appended later), so append-order ≠ chronological order.
2939
3720
  .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
2940
3721
  const shown = lines.slice(-limit);
2941
- const displayName = nameById.get(resolvedId) ?? resolvedId.slice(0, 8);
3722
+ const nativeResolvedId =
3723
+ diagnosticRead.source === "v3"
3724
+ ? nativeCandidateIds[candidateIds.indexOf(resolvedId)]
3725
+ : resolvedId;
3726
+ const displayName =
3727
+ nameById.get(resolvedId) ??
3728
+ (nativeResolvedId ? nameById.get(nativeResolvedId) : undefined) ??
3729
+ resolvedId.slice(0, 8);
2942
3730
 
2943
3731
  const result = {
3732
+ event_source: {
3733
+ contract: diagnosticRead.source,
3734
+ authoritative: diagnosticRead.authoritative,
3735
+ reason: diagnosticRead.reason ?? null,
3736
+ },
2944
3737
  name: displayName,
2945
3738
  instance_id: resolvedId,
2946
3739
  other_instances: candidateIds.filter((id) => id !== resolvedId),
3740
+ pending_finalizations: pendingFinalizations,
2947
3741
  total_events: events.length,
3742
+ session_state: sessionState,
2948
3743
  activity: state.activity,
2949
3744
  activity_updated_at: state.activity_updated_at ?? null,
2950
3745
  activity_source: state.activity_source ?? null,
2951
3746
  task_state: state.task_state,
3747
+ task_state_scope: sessionState === "ended" ? "historical" : "current",
2952
3748
  task_state_updated_at: state.task_state_updated_at ?? null,
2953
3749
  task_state_reason: state.task_state_reason ?? null,
2954
3750
  shown: shown.length,
@@ -2963,8 +3759,11 @@ function runTrace(
2963
3759
  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
3760
  process.stdout.write(`${header}\n`); // lint-ok-emission: human trace view
2965
3761
  process.stdout.write(
2966
- ` activity=${state.activity} · lifecycle=${state.task_state}${state.task_state === "blocked" && state.task_state_reason ? `: ${state.task_state_reason}` : ""}\n`,
3762
+ ` 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
3763
  ); // lint-ok-emission: human trace view
3764
+ if (!diagnosticRead.authoritative) {
3765
+ process.stdout.write(` unavailable: ${diagnosticRead.reason ?? "event source incomplete"}\n`); // lint-ok-emission: explicit no-fallback diagnostic
3766
+ }
2968
3767
  if (shown.length === 0) {
2969
3768
  process.stdout.write(" (no events)\n"); // lint-ok-emission: human trace view
2970
3769
  return;
@@ -2975,6 +3774,73 @@ function runTrace(
2975
3774
  }
2976
3775
  }
2977
3776
 
3777
+ export interface ActiveAgentHealthSummary {
3778
+ source: "event-ledger-v3";
3779
+ total: number;
3780
+ by_platform: Record<string, number>;
3781
+ by_kind: Record<string, number>;
3782
+ by_schema_version: Record<string, number>;
3783
+ stale: number;
3784
+ }
3785
+
3786
+ type ActiveHealthHeartbeat = {
3787
+ instance_id: string;
3788
+ platform?: string;
3789
+ kind?: string;
3790
+ schema_version?: number;
3791
+ last_heartbeat?: string;
3792
+ };
3793
+
3794
+ /**
3795
+ * V3 generations, not disposable heartbeat caches, are the active-agent authority.
3796
+ */
3797
+ export function collectActiveAgentHealth(
3798
+ root: string,
3799
+ nowMs = Date.now(),
3800
+ ): ActiveAgentHealthSummary {
3801
+ const source: ActiveAgentHealthSummary["source"] = "event-ledger-v3";
3802
+ let heartbeats: ActiveHealthHeartbeat[] = [];
3803
+ try {
3804
+ const control = readEventV3ControlState(root);
3805
+ if (control.state === "candidate" || control.state === "active") {
3806
+ heartbeats = readLiveCoordinationRows(root);
3807
+ }
3808
+ } catch {
3809
+ // The event-ledger section reports the authority read failure. Do not
3810
+ // substitute stale cache rows for a live V3 route.
3811
+ }
3812
+
3813
+ const byPlatform: Record<string, number> = {};
3814
+ const byKind: Record<string, number> = {};
3815
+ const bySchema: Record<string, number> = {};
3816
+ let stale = 0;
3817
+ for (const heartbeat of heartbeats) {
3818
+ const platform = formatPlatformLabel(heartbeat.platform);
3819
+ byPlatform[platform] = (byPlatform[platform] ?? 0) + 1;
3820
+ const kind = heartbeat.kind ?? "unknown";
3821
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
3822
+ // These rows exist only after an authority-safe V3 projection. The
3823
+ // disposable heartbeat file has its own cache-format version, which must
3824
+ // not be reported as the ledger schema version.
3825
+ bySchema.v3 = (bySchema.v3 ?? 0) + 1;
3826
+ const lastObservedMs = heartbeat.last_heartbeat
3827
+ ? Date.parse(heartbeat.last_heartbeat)
3828
+ : Number.NaN;
3829
+ const ageMs = Number.isFinite(lastObservedMs)
3830
+ ? nowMs - lastObservedMs
3831
+ : Number.POSITIVE_INFINITY;
3832
+ if (ageMs > freshnessCutoffSecs() * 1000) stale += 1;
3833
+ }
3834
+ return {
3835
+ source,
3836
+ total: heartbeats.length,
3837
+ by_platform: byPlatform,
3838
+ by_kind: byKind,
3839
+ by_schema_version: bySchema,
3840
+ stale,
3841
+ };
3842
+ }
3843
+
2978
3844
  function runHealth(opts: { since: string; json?: boolean }): void {
2979
3845
  if (opts.json) emit.config({ format: "json" });
2980
3846
 
@@ -2996,12 +3862,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2996
3862
  process.exit(1);
2997
3863
  }
2998
3864
 
2999
- const cutoffMs = Date.now() - sinceSecs * 1000;
3865
+ const nowMs = Date.now();
3866
+ const cutoffMs = nowMs - sinceSecs * 1000;
3000
3867
  const activeDir = resolve(root, ".harnery/active");
3001
3868
  const councilsDir = resolve(root, ".harnery/councils");
3002
3869
 
3003
- // Coordination telemetry reads from the canonical events.ndjson stream.
3004
- // Heals come from health.*; council window-activity from council.*. The
3870
+ // Coordination telemetry reads from the canonical V3 ledger.
3871
+ // Heals come from health.observed; council activity from council.state_changed.
3005
3872
  const heal: HealEvent[] = [];
3006
3873
  let councilAdvanced = 0;
3007
3874
  let councilClosed = 0;
@@ -3010,33 +3877,33 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3010
3877
  const sweptByReason: Record<string, number> = {};
3011
3878
 
3012
3879
  const nameById = buildNameById(root);
3013
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
3880
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
3881
+ for (const ev of diagnosticEvents.events) {
3014
3882
  const healEv = canonicalToHealEvent(ev, nameById);
3015
3883
  if (healEv) {
3016
3884
  heal.push(healEv);
3017
3885
  continue;
3018
3886
  }
3887
+ if (ev.event_type === "council.state_changed") {
3888
+ const state = String(ev.payload?.new_state ?? "");
3889
+ if (state === "round_open") councilAdvanced++;
3890
+ if (state === "closed") councilClosed++;
3891
+ if (state === "archived") councilArchived++;
3892
+ continue;
3893
+ }
3019
3894
  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": {
3895
+ case "lifecycle.sweep_observed": {
3030
3896
  sweptTotal++;
3031
- const reason = String((ev.data as { reason?: unknown })?.reason ?? "unknown");
3897
+ const reason = String(ev.payload?.observation ?? "unknown");
3032
3898
  sweptByReason[reason] = (sweptByReason[reason] ?? 0) + 1;
3033
3899
  break;
3034
3900
  }
3035
3901
  }
3036
3902
  }
3037
3903
 
3038
- const hookErrors = readHookErrors(root, cutoffMs);
3904
+ const hookErrors = readHookErrors(root, cutoffMs, nowMs);
3039
3905
  const stream = readStreamStats(root);
3906
+ const eventLedger = collectEventLedgerHealthV3(root);
3040
3907
 
3041
3908
  // Canonical health.* events carry the full instance_id, already resolved to
3042
3909
  // `agent-<name>` (or `agent-<hex8>` fallback) by canonicalToHealEvent via
@@ -3056,19 +3923,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3056
3923
  .slice(0, 5)
3057
3924
  .map(([agent, count]) => ({ agent, count }));
3058
3925
 
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;
3926
+ const activeAgents = collectActiveAgentHealth(root, nowMs);
3065
3927
  // Zombies: files in active/ that are broken: unparseable, nameless, or an
3066
3928
  // absurd (epoch-ish) last_heartbeat. These show as `agent-unknown` ghosts and
3067
3929
  // mean dead files the sweep isn't reaping.
3068
3930
  let zombieCount = 0;
3069
3931
  const zombieSamples: string[] = [];
3070
3932
  const ABSURD_AGE_MS = 24 * 60 * 60 * 1000; // > 1 day = clearly not a live, self-healing agent
3071
- const nowMs = Date.now();
3072
3933
  if (existsSync(activeDir)) {
3073
3934
  for (const file of readdirSync(activeDir)) {
3074
3935
  if (!file.endsWith(".json")) continue;
@@ -3085,17 +3946,8 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3085
3946
  zombieSamples.push(`${idFromFile.slice(0, 12)} (unparseable/no-id)`);
3086
3947
  continue;
3087
3948
  }
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
3949
  const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
3097
3950
  const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
3098
- if (ageMs > freshnessCutoffSecs() * 1000) staleHeartbeats++;
3099
3951
  // Zombie heuristics on a parseable heartbeat: no name, or an age so large
3100
3952
  // it can only be a broken/epoch timestamp (a real agent would have healed).
3101
3953
  if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
@@ -3127,37 +3979,38 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3127
3979
  );
3128
3980
  }
3129
3981
  }
3130
- if (staleHeartbeats > 0) {
3982
+ if (activeAgents.stale > 0) {
3983
+ const noun = activeAgents.source === "event-ledger-v3" ? "V3 generation" : "heartbeat";
3984
+ const action =
3985
+ activeAgents.source === "event-ledger-v3"
3986
+ ? "lifecycle reconciliation may be delayed"
3987
+ : "heal mechanism may not be firing";
3131
3988
  anomalies.push(
3132
- `${staleHeartbeats} active heartbeat(s) older than ${Math.floor(freshnessCutoffSecs() / 60)}min; heal mechanism may not be firing`,
3989
+ `${activeAgents.stale} active ${noun}(s) without activity for ${Math.floor(freshnessCutoffSecs() / 60)}min; ${action}`,
3133
3990
  );
3134
3991
  }
3135
- const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
3992
+ const expectedSchema = "v3";
3993
+ const unexpectedSchemas = Object.keys(activeAgents.by_schema_version).filter(
3994
+ (schema) => schema !== expectedSchema,
3995
+ );
3136
3996
  if (unexpectedSchemas.length > 0) {
3137
3997
  anomalies.push(
3138
- `Unexpected heartbeat schema versions in use: ${unexpectedSchemas.join(", ")} (expected v1)`,
3998
+ `Unexpected active-agent schema versions in use: ${unexpectedSchemas.join(", ")} (expected ${expectedSchema})`,
3139
3999
  );
3140
4000
  }
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}`);
4001
+ // Only recent hook failures are active anomalies. Historical rows remain in
4002
+ // the report with exact error signatures and their latest timestamp.
4003
+ if (hookErrors.last1h > 0) {
4004
+ const top = hookErrors.recentTopErrors[0];
4005
+ const detail = top ? `: top '${top.error.slice(0, 80)}' x${top.count} (${top.phase})` : "";
4006
+ anomalies.push(`agent-hook errored ${hookErrors.last1h}x in the last hour${detail}`);
3150
4007
  }
3151
4008
  if (stream.cursor_backlog > 500) {
3152
4009
  anomalies.push(
3153
4010
  `projection cursor is ${stream.cursor_backlog} events behind; drain lagging (stop projection may be failing)`,
3154
4011
  );
3155
4012
  }
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.
4013
+ // Raw V3 stream size is not an anomaly; catalog rotation bounds readers.
3161
4014
  if ((sweptByReason.unparseable ?? 0) > 0) {
3162
4015
  anomalies.push(
3163
4016
  `${sweptByReason.unparseable} heartbeat(s) swept as unparseable in ${opts.since}; possible corruption or a non-atomic writer`,
@@ -3168,16 +4021,46 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3168
4021
  `${zombieCount} zombie heartbeat(s) in active/ (${zombieSamples.join(", ")}); broken files the sweep isn't reaping`,
3169
4022
  );
3170
4023
  }
4024
+ if (eventLedger.state === "live") {
4025
+ // Open spans whose generation has no open turn: the turn ended without its
4026
+ // tool spans closing, so an explicit end can only queue, never finalize.
4027
+ const orphanGenerations = eventLedger.open_spans.generations.filter(
4028
+ (generation) => !generation.turn_open,
4029
+ );
4030
+ if (orphanGenerations.length > 0) {
4031
+ const orphanSpans = orphanGenerations.reduce(
4032
+ (sum, generation) => sum + generation.span_count,
4033
+ 0,
4034
+ );
4035
+ anomalies.push(
4036
+ `${orphanSpans} open tool span(s) across ${orphanGenerations.length} generation(s) with no open turn; orphaned spans block clean session end`,
4037
+ );
4038
+ }
4039
+ for (const pressure of eventLedger.span_pressure) {
4040
+ anomalies.push(
4041
+ `${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)`,
4042
+ );
4043
+ }
4044
+ for (const collectionError of eventLedger.collection_errors) {
4045
+ anomalies.push(`event-ledger health: ${collectionError}`);
4046
+ }
4047
+ }
3171
4048
 
3172
4049
  const report: HealthReport = {
3173
4050
  since: opts.since,
3174
4051
  generated_at: new Date().toISOString(),
4052
+ event_telemetry: {
4053
+ source: diagnosticEvents.source,
4054
+ authoritative: diagnosticEvents.authoritative,
4055
+ ...(diagnosticEvents.reason ? { reason: diagnosticEvents.reason } : {}),
4056
+ },
3175
4057
  active_agents: {
3176
- total: activeTotal,
3177
- by_platform: activeByPlatform,
3178
- by_kind: activeByKind,
3179
- by_schema_version: activeBySchema,
3180
- stale: staleHeartbeats,
4058
+ source: activeAgents.source,
4059
+ total: activeAgents.total,
4060
+ by_platform: activeAgents.by_platform,
4061
+ by_kind: activeAgents.by_kind,
4062
+ by_schema_version: activeAgents.by_schema_version,
4063
+ stale: activeAgents.stale,
3181
4064
  },
3182
4065
  heal_events: {
3183
4066
  total: heal.length,
@@ -3193,9 +4076,19 @@ function runHealth(opts: { since: string; json?: boolean }): void {
3193
4076
  closed_in_window: councilClosed,
3194
4077
  },
3195
4078
  swept_events: { total: sweptTotal, by_reason: sweptByReason },
3196
- hook_errors: { total: hookErrors.total, by_phase: hookErrors.byPhase, top: hookErrors.top },
4079
+ hook_errors: {
4080
+ total: hookErrors.total,
4081
+ last_1h: hookErrors.last1h,
4082
+ latest_at: hookErrors.latestAt,
4083
+ by_phase: hookErrors.byPhase,
4084
+ by_error: hookErrors.byError,
4085
+ top: hookErrors.top,
4086
+ top_errors: hookErrors.topErrors,
4087
+ recent_top_errors: hookErrors.recentTopErrors,
4088
+ },
3197
4089
  stream,
3198
4090
  zombies: { count: zombieCount, samples: zombieSamples },
4091
+ event_ledger: eventLedger,
3199
4092
  anomalies,
3200
4093
  };
3201
4094
 
@@ -3237,7 +4130,9 @@ function renderHealthBox(report: HealthReport): void {
3237
4130
  if (report.councils.archived_in_window > 0)
3238
4131
  councilParts.push(`${report.councils.archived_in_window} archived`);
3239
4132
 
3240
- const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""}`;
4133
+ const activeSource =
4134
+ report.active_agents.source === "event-ledger-v3" ? "ledger" : "heartbeat cache";
4135
+ const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""} · ${activeSource}`;
3241
4136
 
3242
4137
  const sweptReasonStr = Object.entries(report.swept_events.by_reason)
3243
4138
  .map(([r, n]) => `${r} ${n}`)
@@ -3245,11 +4140,36 @@ function renderHealthBox(report: HealthReport): void {
3245
4140
  const hookErrStr =
3246
4141
  report.hook_errors.total === 0
3247
4142
  ? "0"
3248
- : `${report.hook_errors.total}${report.hook_errors.top[0] ? ` (${report.hook_errors.top[0].phase} x${report.hook_errors.top[0].count})` : ""}`;
4143
+ : `${report.hook_errors.total} in window · ${report.hook_errors.last_1h} in 1h`;
3249
4144
  const streamStr = `${(report.stream.bytes / 1048576).toFixed(1)}MB · ${report.stream.lines} lines · ${report.stream.cursor_backlog} behind`;
3250
4145
 
4146
+ const ledger = report.event_ledger;
4147
+ let ledgerStr: string;
4148
+ if (ledger.state !== "live") {
4149
+ ledgerStr = `unavailable (${ledger.reason})`;
4150
+ } else {
4151
+ const orphanGenerations = ledger.open_spans.generations.filter(
4152
+ (generation) => !generation.turn_open,
4153
+ ).length;
4154
+ const pendingAges = ledger.pending_finalizations
4155
+ .map((request) =>
4156
+ request.age_ms < 0 ? "age?" : formatAge(Math.floor(request.age_ms / 1000)),
4157
+ )
4158
+ .join(", ");
4159
+ ledgerStr = [
4160
+ `open spans ${ledger.open_spans.total}${orphanGenerations > 0 ? ` (${orphanGenerations} gen turn-closed)` : ""}`,
4161
+ `pending ends ${ledger.pending_finalizations.length}${pendingAges ? ` (${pendingAges})` : ""}`,
4162
+ `intake ${ledger.intake_spool.total}`,
4163
+ `diagnostics ${ledger.diagnostics_spool.total} (${ledger.diagnostics_spool.last_1h} in 1h / ${ledger.diagnostics_spool.last_24h} in 24h)`,
4164
+ ].join(" · ");
4165
+ }
4166
+
3251
4167
  const rows: Array<[string, string]> = [
3252
4168
  ["window", `last ${report.since}`],
4169
+ [
4170
+ "event source",
4171
+ `${report.event_telemetry.source.toUpperCase()} · ${report.event_telemetry.authoritative ? "validated" : `unavailable (${report.event_telemetry.reason ?? "incomplete"})`}`,
4172
+ ],
3253
4173
  ["active", activeStr],
3254
4174
  [
3255
4175
  "heals",
@@ -3259,6 +4179,7 @@ function renderHealthBox(report: HealthReport): void {
3259
4179
  ["swept", `${report.swept_events.total}${sweptReasonStr ? ` (${sweptReasonStr})` : ""}`],
3260
4180
  ["hook errors", hookErrStr],
3261
4181
  ["stream", streamStr],
4182
+ ["event ledger", ledgerStr],
3262
4183
  [
3263
4184
  "zombies",
3264
4185
  report.zombies.count === 0
@@ -3383,6 +4304,7 @@ function runAdapterProbe(
3383
4304
  replayExitCode = result.exitCode;
3384
4305
  data.samples = samples;
3385
4306
  data.samples_summary = result.summary;
4307
+ if (result.note) data.samples_note = result.note;
3386
4308
  }
3387
4309
 
3388
4310
  emit.data(data);
@@ -3402,7 +4324,9 @@ function runAdapterProbe(
3402
4324
  | undefined;
3403
4325
  lines.push("");
3404
4326
  if (samples.length === 0) {
3405
- lines.push(` Sample replay: no .json fixtures found under ${sampleDir}`);
4327
+ lines.push(
4328
+ ` Sample replay: ${String(data.samples_note ?? `no .json fixtures found under ${sampleDir}`)}`,
4329
+ );
3406
4330
  } else {
3407
4331
  lines.push(
3408
4332
  ` Sample replay (${samples.length} fixture${samples.length === 1 ? "" : "s"}):`,
@@ -3459,6 +4383,7 @@ function replayAdapterSamples(
3459
4383
  samples: SampleReplayResult[];
3460
4384
  exitCode: number;
3461
4385
  summary: { total: number; pass: number; fail: number; skipped: number };
4386
+ note?: string;
3462
4387
  } {
3463
4388
  const sampleDir = resolve(root, relativeSampleDir);
3464
4389
  if (!existsSync(sampleDir)) {
@@ -3466,6 +4391,7 @@ function replayAdapterSamples(
3466
4391
  samples: [],
3467
4392
  exitCode: 0,
3468
4393
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4394
+ note: `sample directory not found: ${relativeSampleDir} — replay verified nothing`,
3469
4395
  };
3470
4396
  }
3471
4397
 
@@ -3479,6 +4405,7 @@ function replayAdapterSamples(
3479
4405
  samples: [],
3480
4406
  exitCode: 0,
3481
4407
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4408
+ note: `no .json fixtures under ${relativeSampleDir} — replay verified nothing`,
3482
4409
  };
3483
4410
  }
3484
4411
 
@@ -3704,11 +4631,26 @@ function runPing(name: string, message: string, opts: { json?: boolean }): void
3704
4631
  });
3705
4632
  process.exit(1);
3706
4633
  }
3707
- const myHb = readHeartbeat(myOwner);
4634
+ const myHb = readCurrentCoordinationRow(myOwner);
3708
4635
  const fromName = myHb?.name ?? "anonymous";
3709
4636
  const body = `from agent-${fromName}: ${message.trim()}`;
3710
4637
  const doc = appendEntry(peerOwner, "handoff", body);
3711
4638
 
4639
+ // Canonical delivery record: sender is the envelope owner, recipient rides
4640
+ // in data, so read-only observers can render the communication without
4641
+ // joining journal files.
4642
+ emitEventV3({
4643
+ owner: myOwner,
4644
+ session: nativeSessionIdentity(myHb, myOwner),
4645
+ adapter: normalizeAdapter(myHb?.platform),
4646
+ observation: {
4647
+ event_type: "coord.message_observed",
4648
+ direction: "sent",
4649
+ subject: peerOwner,
4650
+ body: message.trim(),
4651
+ },
4652
+ });
4653
+
3712
4654
  const data = {
3713
4655
  peer: name,
3714
4656
  peer_instance_id: peerOwner,
@@ -3766,7 +4708,7 @@ async function runWait(
3766
4708
 
3767
4709
  let lastProgressMs = 0;
3768
4710
  while (true) {
3769
- const hb = readHeartbeat(peerOwner);
4711
+ const hb = readCurrentCoordinationRow(peerOwner);
3770
4712
  const now = Date.now();
3771
4713
  const elapsedMs = now - startMs;
3772
4714
 
@@ -3806,9 +4748,8 @@ async function runWait(
3806
4748
  const progressGapMs = Math.max(pollMs, 30_000);
3807
4749
  if (!opts.quiet && now - lastProgressMs >= progressGapMs) {
3808
4750
  lastProgressMs = now;
3809
- const lastTool = hb.last_tool ? `, last=${hb.last_tool}` : "";
3810
4751
  const elapsedStr = formatAge(Math.floor(elapsedMs / 1000));
3811
- const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking${lastTool}\n`;
4752
+ const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking\n`;
3812
4753
  process.stderr.write(progress); // lint-ok-emission: per-poll progress heartbeat to stderr
3813
4754
  }
3814
4755
 
@@ -3849,6 +4790,7 @@ function runHeal(opts: {
3849
4790
  owner: string;
3850
4791
  kind: string;
3851
4792
  sessionId?: string;
4793
+ adapter?: string;
3852
4794
  pid?: string;
3853
4795
  json?: boolean;
3854
4796
  }): void {
@@ -3860,10 +4802,22 @@ function runHeal(opts: {
3860
4802
  emit.error({ code: "missing_owner", message: "--owner is required" });
3861
4803
  process.exit(1);
3862
4804
  }
3863
- if (kind !== "pidmap" && kind !== "heartbeat" && kind !== "kill") {
4805
+ if (kind !== "pidmap" && kind !== "cache") {
3864
4806
  emit.error({
3865
4807
  code: "bad_kind",
3866
- message: "--kind must be one of: pidmap, heartbeat, kill",
4808
+ message: "--kind must be one of: pidmap, cache",
4809
+ });
4810
+ process.exit(1);
4811
+ }
4812
+ if (
4813
+ opts.adapter &&
4814
+ opts.adapter !== "claude-code" &&
4815
+ opts.adapter !== "cursor" &&
4816
+ opts.adapter !== "codex"
4817
+ ) {
4818
+ emit.error({
4819
+ code: "bad_adapter",
4820
+ message: "--adapter must be one of: claude-code, cursor, codex",
3867
4821
  });
3868
4822
  process.exit(1);
3869
4823
  }
@@ -3877,10 +4831,9 @@ function runHeal(opts: {
3877
4831
  process.exit(1);
3878
4832
  }
3879
4833
 
3880
- const action =
3881
- kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
4834
+ const action = kind === "pidmap" ? "heal-pidmap" : "repair-coordination-cache";
3882
4835
 
3883
- // Refuse to mint a heartbeat at a TRUNCATED owner id.
4836
+ // Refuse to materialize a V3 cache at a truncated owner id.
3884
4837
  //
3885
4838
  // Heartbeats are keyed by the whole instance_id, so healing at an
3886
4839
  // abbreviated id writes `.harnery/active/<prefix>.json` while every reader
@@ -3900,19 +4853,18 @@ function runHeal(opts: {
3900
4853
  // whose instance_id this id is a prefix of settles it just as well, and is
3901
4854
  // present in precisely the case that matters (the session the reader was
3902
4855
  // trying to heal is registered, just not under the abbreviated name).
3903
- if (kind === "heartbeat" && !readHeartbeat(owner)) {
4856
+ if (kind === "cache" && !readHeartbeatCache(root, owner)) {
3904
4857
  const canonical =
3905
4858
  opts.sessionId && opts.sessionId.trim() !== owner && opts.sessionId.trim().startsWith(owner)
3906
4859
  ? opts.sessionId.trim()
3907
4860
  : liveIdWithPrefix(root, owner);
3908
4861
  if (canonical) {
3909
- const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live heartbeat";
4862
+ const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live V3 generation";
3910
4863
  emit.error({
3911
4864
  code: "truncated_owner",
3912
4865
  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}.`,
4866
+ `--owner ${owner} is a prefix of ${canonical} (${source}). Cache repair requires ` +
4867
+ `the canonical instance id; re-run with --owner ${canonical}.`,
3916
4868
  });
3917
4869
  process.exit(1);
3918
4870
  }
@@ -3920,14 +4872,13 @@ function runHeal(opts: {
3920
4872
 
3921
4873
  // Build positional args. agent-coord's arg layout:
3922
4874
  // heal-pidmap <instance_id> [<pid>]
3923
- // heal-heartbeat <instance_id> [<session_id>]
3924
- // kill-heartbeat <instance_id>
4875
+ // repair-coordination-cache <instance_id> [<session_id>]
3925
4876
  const helperArgs: string[] = [action, owner];
3926
4877
  if (kind === "pidmap" && opts.pid) helperArgs.push(opts.pid);
3927
- if (kind === "heartbeat" && opts.sessionId) helperArgs.push(opts.sessionId);
4878
+ if (kind === "cache" && opts.sessionId) helperArgs.push(opts.sessionId);
4879
+ if (kind === "cache" && opts.adapter) helperArgs.push(`--adapter=${opts.adapter}`);
3928
4880
 
3929
- // heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
3930
- // bundled agent-coord binary.
4881
+ // Both recovery actions are handled by the bundled agent-coord binary.
3931
4882
  const helper = agentCoordOrExit(root);
3932
4883
  const proc = spawnSync(helper, helperArgs, {
3933
4884
  encoding: "utf8",
@@ -3942,27 +4893,22 @@ function runHeal(opts: {
3942
4893
  process.exit(1);
3943
4894
  }
3944
4895
 
3945
- // Re-read the heartbeat to surface post-action state (or null if killed).
4896
+ // Read the derived cache to surface post-action state.
3946
4897
  let after: Heartbeat | null = null;
3947
4898
  try {
3948
- after = readHeartbeat(owner);
4899
+ after = readHeartbeatCache(root, owner);
3949
4900
  } catch {
3950
4901
  after = null;
3951
4902
  }
3952
4903
 
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
4904
  const outcome =
3959
4905
  kind === "pidmap"
3960
4906
  ? proc.status === 0
3961
4907
  ? "ok"
3962
4908
  : "failed"
3963
4909
  : after
3964
- ? "heartbeat_present"
3965
- : "heartbeat_absent";
4910
+ ? "cache_present"
4911
+ : "cache_absent";
3966
4912
 
3967
4913
  emit.data({
3968
4914
  rows: [
@@ -3985,36 +4931,40 @@ function runHeal(opts: {
3985
4931
  if (kind === "pidmap") {
3986
4932
  emit.text(`agent-coord ${action} ok\n`);
3987
4933
  } else {
3988
- emit.text(`agent-coord ${action} ok: heartbeat ${after ? "present" : "absent"} after\n`);
4934
+ emit.text(`agent-coord ${action} ok: cache ${after ? "present" : "absent"} after\n`);
3989
4935
  }
3990
4936
  }
3991
4937
 
3992
- // Canonical health.* emission is owned by the writer (heartbeat-writer.ts
3993
- // healPidmap/healHeartbeat), so it fires inside the agent-coord subprocess
4938
+ // Canonical health.* emission is owned by the cache writer, so it fires
4939
+ // inside the agent-coord subprocess
3994
4940
  // above on actual writes only: write-only telemetry, no double-emit, no
3995
4941
  // event when an already-correct heal no-ops. (Previously emitted here
3996
4942
  // unconditionally on every `harn agents heal`, which over-counted no-op heals.)
3997
4943
  }
3998
4944
 
3999
4945
  /**
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).
4946
+ * Emit one V3 council audit transition keyed to the exact durable manifest.
4947
+ * Falls through silently when no live session can attest the observation.
4003
4948
  */
4004
4949
  function emitCouncilStateEvent(
4005
- type: string,
4006
4950
  manifest: CouncilManifest,
4007
- extraData: Record<string, unknown>,
4951
+ newState: string,
4952
+ priorState?: string,
4008
4953
  ): void {
4009
4954
  const myOwner = resolveOwner();
4010
4955
  if (!myOwner) return;
4011
- const hb = readHeartbeat(myOwner);
4012
- emitCanonical({
4013
- type,
4956
+ const hb = readCurrentCoordinationRow(myOwner);
4957
+ emitEventV3({
4014
4958
  owner: myOwner,
4015
- session: hb?.session_id ?? myOwner,
4959
+ session: nativeSessionIdentity(hb, myOwner),
4016
4960
  adapter: normalizeAdapter(hb?.platform),
4017
- data: { council_id: manifest.council_id, ...extraData },
4961
+ observation: {
4962
+ event_type: "council.state_changed",
4963
+ council_id: manifest.council_id,
4964
+ ...(priorState ? { prior_state: priorState } : {}),
4965
+ new_state: newState,
4966
+ record: manifest,
4967
+ },
4018
4968
  });
4019
4969
  }
4020
4970
 
@@ -4071,7 +5021,7 @@ function runCouncilCreate(
4071
5021
  if (opts.createdBy?.trim()) {
4072
5022
  createdBy = normalizeAgentName(opts.createdBy);
4073
5023
  } else if (myOwner) {
4074
- const myHb = readHeartbeat(myOwner);
5024
+ const myHb = readCurrentCoordinationRow(myOwner);
4075
5025
  if (myHb?.name) {
4076
5026
  createdBy = normalizeAgentName(myHb.name);
4077
5027
  }
@@ -4132,17 +5082,16 @@ function runCouncilCreate(
4132
5082
  writeManifest(manifest);
4133
5083
 
4134
5084
  if (myOwner) {
4135
- const myHbForEmit = readHeartbeat(myOwner);
4136
- emitCanonical({
4137
- type: "council.open",
5085
+ const myHbForEmit = readCurrentCoordinationRow(myOwner);
5086
+ emitEventV3({
4138
5087
  owner: myOwner,
4139
- session: myHbForEmit?.session_id ?? myOwner,
5088
+ session: nativeSessionIdentity(myHbForEmit, myOwner),
4140
5089
  adapter: normalizeAdapter(myHbForEmit?.platform),
4141
- data: {
4142
- council_id: councilId,
4143
- topic: trimmedObjective,
4144
- members,
4145
- target_doc: manifest.target_doc ?? undefined,
5090
+ observation: {
5091
+ event_type: "council.state_changed",
5092
+ council_id: manifest.council_id,
5093
+ new_state: "active",
5094
+ record: manifest,
4146
5095
  },
4147
5096
  });
4148
5097
  }
@@ -4155,7 +5104,7 @@ function runCouncilCreate(
4155
5104
  const skippedMembers: string[] = [];
4156
5105
  for (const memberName of members) {
4157
5106
  const bareName = memberName.replace(/^agent-/, "");
4158
- if (myOwner && readHeartbeat(myOwner)?.name === bareName) {
5107
+ if (myOwner && readCurrentCoordinationRow(myOwner)?.name === bareName) {
4159
5108
  // Convener is themselves a member; skip the self-ping
4160
5109
  continue;
4161
5110
  }
@@ -4220,7 +5169,7 @@ function runCouncilList(opts: { status?: string; mine?: boolean; json?: boolean
4220
5169
  if (opts.mine) {
4221
5170
  const myOwner = resolveOwner();
4222
5171
  if (myOwner) {
4223
- const myHb = readHeartbeat(myOwner);
5172
+ const myHb = readCurrentCoordinationRow(myOwner);
4224
5173
  if (myHb?.name) myName = normalizeAgentName(myHb.name);
4225
5174
  }
4226
5175
  if (!myName) {
@@ -4397,7 +5346,7 @@ function runCouncilClose(id: string, opts: { json?: boolean }): void {
4397
5346
  closed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
4398
5347
  };
4399
5348
  writeManifest(next);
4400
- emitCouncilStateEvent("council.close", next, { closed_at: next.closed_at! });
5349
+ emitCouncilStateEvent(next, "closed", manifest.status);
4401
5350
 
4402
5351
  // Build the transcript: every round's contributions in order.
4403
5352
  const transcript = buildTranscript(next);
@@ -4442,7 +5391,7 @@ function runCouncilArchive(id: string, opts: { json?: boolean }): void {
4442
5391
  // updated status. moveToArchive then physically relocates the artifacts.
4443
5392
  writeManifest(next);
4444
5393
  moveToArchive(next.council_id);
4445
- emitCouncilStateEvent("council.archive", next, {});
5394
+ emitCouncilStateEvent(next, "archived", manifest.status);
4446
5395
 
4447
5396
  emit.data({
4448
5397
  rows: [
@@ -4501,7 +5450,7 @@ function runCouncilUnarchive(id: string, opts: { json?: boolean }): void {
4501
5450
  // (allows re-running for testing).
4502
5451
  moveFromArchive(next.council_id);
4503
5452
  writeManifest(next);
4504
- emitCouncilStateEvent("council.unarchive", next, { restored_status: restoredStatus });
5453
+ emitCouncilStateEvent(next, restoredStatus, "archived");
4505
5454
 
4506
5455
  emit.data({
4507
5456
  rows: [
@@ -4563,7 +5512,7 @@ function runCouncilDelete(id: string, opts: { yes?: boolean; json?: boolean }):
4563
5512
 
4564
5513
  const removed = deleteArchivedCouncil(manifest.council_id);
4565
5514
  if (removed) {
4566
- emitCouncilStateEvent("council.delete", manifest, {});
5515
+ emitCouncilStateEvent(manifest, "deleted", "archived");
4567
5516
  }
4568
5517
 
4569
5518
  emit.data({
@@ -4753,7 +5702,7 @@ function runCouncilContribute(
4753
5702
  try {
4754
5703
  const myOwner = resolveOwner();
4755
5704
  if (myOwner) {
4756
- const myHb = readHeartbeat(myOwner);
5705
+ const myHb = readCurrentCoordinationRow(myOwner);
4757
5706
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4758
5707
  }
4759
5708
  } catch {
@@ -4769,7 +5718,7 @@ function runCouncilContribute(
4769
5718
  });
4770
5719
  process.exit(1);
4771
5720
  }
4772
- const myHb = readHeartbeat(myOwner);
5721
+ const myHb = readCurrentCoordinationRow(myOwner);
4773
5722
  if (!myHb?.name) {
4774
5723
  emit.error({
4775
5724
  code: "no_self_name",
@@ -4822,11 +5771,7 @@ function runCouncilContribute(
4822
5771
  }
4823
5772
 
4824
5773
  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
- });
5774
+ emitCouncilStateEvent(manifest, "contribution_recorded", manifest.round_status);
4830
5775
 
4831
5776
  // Update manifest: if all members have now contributed, flip round_status.
4832
5777
  const contributorsNow = contributorsInRound(manifest.council_id, manifest.current_round);
@@ -4836,14 +5781,10 @@ function runCouncilContribute(
4836
5781
  if (allIn) {
4837
5782
  nextManifest = { ...manifest, round_status: "collected" };
4838
5783
  writeManifest(nextManifest);
4839
- emitCouncilStateEvent("council.round_close", nextManifest, {
4840
- round_no: nextManifest.current_round,
4841
- });
5784
+ emitCouncilStateEvent(nextManifest, "round_closed", manifest.round_status);
4842
5785
  if (manifest.auto_advance) {
4843
5786
  nextManifest = advanceCouncil(nextManifest, /*force=*/ false);
4844
- emitCouncilStateEvent("council.round_open", nextManifest, {
4845
- round_no: nextManifest.current_round,
4846
- });
5787
+ emitCouncilStateEvent(nextManifest, "round_open", "round_closed");
4847
5788
  autoAdvanced = true;
4848
5789
  }
4849
5790
  }
@@ -4934,7 +5875,7 @@ function runCouncilPrompt(
4934
5875
  try {
4935
5876
  const myOwner = resolveOwner();
4936
5877
  if (myOwner) {
4937
- const myHb = readHeartbeat(myOwner);
5878
+ const myHb = readCurrentCoordinationRow(myOwner);
4938
5879
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4939
5880
  }
4940
5881
  } catch {
@@ -4950,7 +5891,7 @@ function runCouncilPrompt(
4950
5891
  });
4951
5892
  process.exit(1);
4952
5893
  }
4953
- const myHb = readHeartbeat(myOwner);
5894
+ const myHb = readCurrentCoordinationRow(myOwner);
4954
5895
  if (!myHb?.name) {
4955
5896
  emit.error({
4956
5897
  code: "no_self_name",
@@ -5115,12 +6056,8 @@ function runCouncilAdvance(id: string, opts: { force?: boolean; json?: boolean }
5115
6056
  }
5116
6057
 
5117
6058
  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
- });
6059
+ emitCouncilStateEvent(manifest, "round_closed", manifest.round_status);
6060
+ emitCouncilStateEvent(next, "round_open", "round_closed");
5124
6061
 
5125
6062
  emit.data({
5126
6063
  rows: [
@@ -5166,7 +6103,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
5166
6103
  // (Convener already knows; we skip pinging them if they convened it from
5167
6104
  // their own session.)
5168
6105
  const myOwner = resolveOwner();
5169
- const myName = myOwner ? normalizeAgentName(readHeartbeat(myOwner)?.name ?? "") : "";
6106
+ const myName = myOwner ? normalizeAgentName(readCurrentCoordinationRow(myOwner)?.name ?? "") : "";
5170
6107
  for (const memberName of next.members) {
5171
6108
  if (memberName === myName) continue;
5172
6109
  const bareName = memberName.replace(/^agent-/, "");
@@ -5190,7 +6127,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
5190
6127
  // Hard cap on box width. Values longer than the per-row budget word-wrap to
5191
6128
  // continuation lines (blank key column, value resumes indented). Picked to
5192
6129
  // stay readable in narrow terminals + chat clients while giving long
5193
- // turn_summary / task values room to breathe.
6130
+ // Task values need room to breathe.
5194
6131
  const MAX_BOX_CONTENT_WIDTH = 100;
5195
6132
 
5196
6133
  function formatBox(title: string, rows: Array<[string, string]>): string {