harnery 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (665) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -1
  4. package/dist/commands/adapter.js +2 -2
  5. package/dist/commands/agents.d.ts +144 -1
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +1408 -565
  8. package/dist/commands/artifacts.d.ts.map +1 -1
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/backup.d.ts +1 -1
  11. package/dist/commands/backup.d.ts.map +1 -1
  12. package/dist/commands/backup.js +29 -16
  13. package/dist/commands/browse.d.ts.map +1 -1
  14. package/dist/commands/browse.js +234 -12
  15. package/dist/commands/checkpoint.d.ts.map +1 -1
  16. package/dist/commands/checkpoint.js +3 -2
  17. package/dist/commands/claude-desktop.d.ts +2 -2
  18. package/dist/commands/claude-desktop.d.ts.map +1 -1
  19. package/dist/commands/claude-desktop.js +2 -2
  20. package/dist/commands/context.js +1 -6
  21. package/dist/commands/decision.d.ts.map +1 -1
  22. package/dist/commands/decision.js +22 -38
  23. package/dist/commands/docs.d.ts.map +1 -1
  24. package/dist/commands/docs.js +91 -28
  25. package/dist/commands/doctor.d.ts +4 -2
  26. package/dist/commands/doctor.d.ts.map +1 -1
  27. package/dist/commands/doctor.js +55 -7
  28. package/dist/commands/events.d.ts +5 -0
  29. package/dist/commands/events.d.ts.map +1 -0
  30. package/dist/commands/events.js +189 -0
  31. package/dist/commands/governor.d.ts.map +1 -1
  32. package/dist/commands/governor.js +13 -4
  33. package/dist/commands/init.d.ts +10 -2
  34. package/dist/commands/init.d.ts.map +1 -1
  35. package/dist/commands/init.js +233 -51
  36. package/dist/commands/instructions.d.ts +4 -0
  37. package/dist/commands/instructions.d.ts.map +1 -0
  38. package/dist/commands/instructions.js +100 -0
  39. package/dist/commands/journal.d.ts.map +1 -1
  40. package/dist/commands/journal.js +4 -13
  41. package/dist/commands/ledger-v3.d.ts +5 -0
  42. package/dist/commands/ledger-v3.d.ts.map +1 -0
  43. package/dist/commands/ledger-v3.js +91 -0
  44. package/dist/commands/presence.d.ts.map +1 -1
  45. package/dist/commands/presence.js +15 -6
  46. package/dist/commands/sync.d.ts +1 -1
  47. package/dist/commands/sync.js +1 -1
  48. package/dist/commands/web.d.ts +6 -0
  49. package/dist/commands/web.d.ts.map +1 -1
  50. package/dist/commands/web.js +67 -7
  51. package/dist/commands/work.d.ts.map +1 -1
  52. package/dist/commands/work.js +22 -5
  53. package/dist/core/adapter.d.ts +5 -0
  54. package/dist/core/adapter.d.ts.map +1 -0
  55. package/dist/core/adapter.js +1 -0
  56. package/dist/core/adapters/attest.d.ts +5 -2
  57. package/dist/core/adapters/attest.d.ts.map +1 -1
  58. package/dist/core/adapters/attest.js +8 -3
  59. package/dist/core/adapters/profiles.d.ts +6 -6
  60. package/dist/core/adapters/profiles.d.ts.map +1 -1
  61. package/dist/core/adapters/profiles.js +4 -4
  62. package/dist/core/agents/canonical-emit.d.ts +17 -15
  63. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  64. package/dist/core/agents/canonical-emit.js +27 -25
  65. package/dist/core/agents/cli.js +212 -235
  66. package/dist/core/agents/codex-archive-v3.d.ts +14 -0
  67. package/dist/core/agents/codex-archive-v3.d.ts.map +1 -0
  68. package/dist/core/agents/codex-archive-v3.js +110 -0
  69. package/dist/core/agents/coord-client.d.ts +19 -70
  70. package/dist/core/agents/coord-client.d.ts.map +1 -1
  71. package/dist/core/agents/coord-client.js +65 -155
  72. package/dist/core/agents/finalization.d.ts +4 -1
  73. package/dist/core/agents/finalization.d.ts.map +1 -1
  74. package/dist/core/agents/finalization.js +89 -32
  75. package/dist/core/agents/index.d.ts +5 -5
  76. package/dist/core/agents/index.d.ts.map +1 -1
  77. package/dist/core/agents/index.js +5 -5
  78. package/dist/core/agents/live-authority-v3.d.ts +59 -0
  79. package/dist/core/agents/live-authority-v3.d.ts.map +1 -0
  80. package/dist/core/agents/live-authority-v3.js +233 -0
  81. package/dist/core/agents/live-lifecycle-v3.d.ts +20 -0
  82. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -0
  83. package/dist/core/agents/live-lifecycle-v3.js +91 -0
  84. package/dist/core/agents/live-observation-v3.d.ts +25 -0
  85. package/dist/core/agents/live-observation-v3.d.ts.map +1 -0
  86. package/dist/core/agents/live-observation-v3.js +157 -0
  87. package/dist/core/agents/render/prompt-context.d.ts +10 -4
  88. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  89. package/dist/core/agents/render/prompt-context.js +61 -55
  90. package/dist/core/agents/render/session-context.d.ts +1 -1
  91. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  92. package/dist/core/agents/render/session-context.js +15 -33
  93. package/dist/core/agents/rules/claim-conflict.d.ts +3 -3
  94. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  95. package/dist/core/agents/rules/claim-conflict.js +79 -127
  96. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  97. package/dist/core/agents/rules/commit-conflict.js +3 -18
  98. package/dist/core/agents/rules/stop-hook.d.ts +9 -50
  99. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  100. package/dist/core/agents/rules/stop-hook.js +193 -236
  101. package/dist/core/agents/session-events.d.ts +5 -7
  102. package/dist/core/agents/session-events.d.ts.map +1 -1
  103. package/dist/core/agents/session-events.js +127 -95
  104. package/dist/core/agents/session-finalization-state-v3.d.ts +30 -0
  105. package/dist/core/agents/session-finalization-state-v3.d.ts.map +1 -0
  106. package/dist/core/agents/session-finalization-state-v3.js +40 -0
  107. package/dist/core/agents/session-finalizer-v3.d.ts +107 -0
  108. package/dist/core/agents/session-finalizer-v3.d.ts.map +1 -0
  109. package/dist/core/agents/session-finalizer-v3.js +734 -0
  110. package/dist/core/agents/session-name-display.d.ts +40 -0
  111. package/dist/core/agents/session-name-display.d.ts.map +1 -0
  112. package/dist/core/agents/session-name-display.js +119 -0
  113. package/dist/core/agents/state/activity-log.d.ts +3 -3
  114. package/dist/core/agents/state/activity-log.d.ts.map +1 -1
  115. package/dist/core/agents/state/activity-log.js +7 -16
  116. package/dist/core/agents/state/heartbeat-writer.d.ts +29 -46
  117. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  118. package/dist/core/agents/state/heartbeat-writer.js +59 -199
  119. package/dist/core/agents/state/live-coordination-view.d.ts +31 -0
  120. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -0
  121. package/dist/core/agents/state/live-coordination-view.js +209 -0
  122. package/dist/core/agents/state/session-state.d.ts +45 -0
  123. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  124. package/dist/core/agents/state/session-state.js +90 -0
  125. package/dist/core/agents/state/stale-sweep.d.ts +1 -1
  126. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  127. package/dist/core/agents/state/stale-sweep.js +26 -15
  128. package/dist/core/artifacts/index.d.ts.map +1 -1
  129. package/dist/core/artifacts/index.js +5 -7
  130. package/dist/core/config.d.ts +33 -2
  131. package/dist/core/config.d.ts.map +1 -1
  132. package/dist/core/config.js +98 -2
  133. package/dist/core/context/index.d.ts +1 -4
  134. package/dist/core/context/index.d.ts.map +1 -1
  135. package/dist/core/context/index.js +9 -9
  136. package/dist/core/events/span-parent.d.ts +15 -0
  137. package/dist/core/events/span-parent.d.ts.map +1 -0
  138. package/dist/core/events/span-parent.js +34 -0
  139. package/dist/core/events/v3/advance.d.ts +14 -0
  140. package/dist/core/events/v3/advance.d.ts.map +1 -0
  141. package/dist/core/events/v3/advance.js +134 -0
  142. package/dist/core/events/v3/authority-outbox.d.ts +78 -0
  143. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -0
  144. package/dist/core/events/v3/authority-outbox.js +399 -0
  145. package/dist/core/events/v3/base-builder.d.ts +20 -0
  146. package/dist/core/events/v3/base-builder.d.ts.map +1 -0
  147. package/dist/core/events/v3/base-builder.js +29 -0
  148. package/dist/core/events/v3/base-contract.d.ts +7576 -0
  149. package/dist/core/events/v3/base-contract.d.ts.map +1 -0
  150. package/dist/core/events/v3/base-contract.js +647 -0
  151. package/dist/core/events/v3/bootstrap.d.ts +34 -0
  152. package/dist/core/events/v3/bootstrap.d.ts.map +1 -0
  153. package/dist/core/events/v3/bootstrap.js +175 -0
  154. package/dist/core/events/v3/builder.d.ts +20 -0
  155. package/dist/core/events/v3/builder.d.ts.map +1 -0
  156. package/dist/core/events/v3/builder.js +28 -0
  157. package/dist/core/events/v3/canonical.d.ts +31 -0
  158. package/dist/core/events/v3/canonical.d.ts.map +1 -0
  159. package/dist/core/events/v3/canonical.js +100 -0
  160. package/dist/core/events/v3/capabilities.d.ts +31 -0
  161. package/dist/core/events/v3/capabilities.d.ts.map +1 -0
  162. package/dist/core/events/v3/capabilities.js +107 -0
  163. package/dist/core/events/v3/capability-drift.d.ts +22 -0
  164. package/dist/core/events/v3/capability-drift.d.ts.map +1 -0
  165. package/dist/core/events/v3/capability-drift.js +134 -0
  166. package/dist/core/events/v3/catalog.d.ts +38 -0
  167. package/dist/core/events/v3/catalog.d.ts.map +1 -0
  168. package/dist/core/events/v3/catalog.js +135 -0
  169. package/dist/core/events/v3/clock-order.d.ts +34 -0
  170. package/dist/core/events/v3/clock-order.d.ts.map +1 -0
  171. package/dist/core/events/v3/clock-order.js +35 -0
  172. package/dist/core/events/v3/contract.d.ts +171 -0
  173. package/dist/core/events/v3/contract.d.ts.map +1 -0
  174. package/dist/core/events/v3/contract.js +180 -0
  175. package/dist/core/events/v3/control.d.ts +111 -0
  176. package/dist/core/events/v3/control.d.ts.map +1 -0
  177. package/dist/core/events/v3/control.js +419 -0
  178. package/dist/core/events/v3/coordination-view.d.ts +69 -0
  179. package/dist/core/events/v3/coordination-view.d.ts.map +1 -0
  180. package/dist/core/events/v3/coordination-view.js +119 -0
  181. package/dist/core/events/v3/economics.d.ts +77 -0
  182. package/dist/core/events/v3/economics.d.ts.map +1 -0
  183. package/dist/core/events/v3/economics.js +274 -0
  184. package/dist/core/events/v3/finalization-view.d.ts +26 -0
  185. package/dist/core/events/v3/finalization-view.d.ts.map +1 -0
  186. package/dist/core/events/v3/finalization-view.js +37 -0
  187. package/dist/core/events/v3/fingerprint-keys.d.ts +25 -0
  188. package/dist/core/events/v3/fingerprint-keys.d.ts.map +1 -0
  189. package/dist/core/events/v3/fingerprint-keys.js +217 -0
  190. package/dist/core/events/v3/generated.d.ts +4 -0
  191. package/dist/core/events/v3/generated.d.ts.map +1 -0
  192. package/dist/core/events/v3/generated.js +47 -0
  193. package/dist/core/events/v3/ids.d.ts +9 -0
  194. package/dist/core/events/v3/ids.d.ts.map +1 -0
  195. package/dist/core/events/v3/ids.js +25 -0
  196. package/dist/core/events/v3/index.d.ts +35 -0
  197. package/dist/core/events/v3/index.d.ts.map +1 -0
  198. package/dist/core/events/v3/index.js +33 -0
  199. package/dist/core/events/v3/latency.d.ts +63 -0
  200. package/dist/core/events/v3/latency.d.ts.map +1 -0
  201. package/dist/core/events/v3/latency.js +412 -0
  202. package/dist/core/events/v3/live-feed.d.ts +45 -0
  203. package/dist/core/events/v3/live-feed.d.ts.map +1 -0
  204. package/dist/core/events/v3/live-feed.js +246 -0
  205. package/dist/core/events/v3/live-observation.d.ts +35 -0
  206. package/dist/core/events/v3/live-observation.d.ts.map +1 -0
  207. package/dist/core/events/v3/live-observation.js +1 -0
  208. package/dist/core/events/v3/live-routing.d.ts +57 -0
  209. package/dist/core/events/v3/live-routing.d.ts.map +1 -0
  210. package/dist/core/events/v3/live-routing.js +107 -0
  211. package/dist/core/events/v3/producers/command-recorder.d.ts +67 -0
  212. package/dist/core/events/v3/producers/command-recorder.d.ts.map +1 -0
  213. package/dist/core/events/v3/producers/command-recorder.js +477 -0
  214. package/dist/core/events/v3/producers/command.d.ts +50 -0
  215. package/dist/core/events/v3/producers/command.d.ts.map +1 -0
  216. package/dist/core/events/v3/producers/command.js +135 -0
  217. package/dist/core/events/v3/producers/coordination-recorder.d.ts +53 -0
  218. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -0
  219. package/dist/core/events/v3/producers/coordination-recorder.js +376 -0
  220. package/dist/core/events/v3/producers/coordination.d.ts +90 -0
  221. package/dist/core/events/v3/producers/coordination.d.ts.map +1 -0
  222. package/dist/core/events/v3/producers/coordination.js +229 -0
  223. package/dist/core/events/v3/producers/hook-base.d.ts +54 -0
  224. package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -0
  225. package/dist/core/events/v3/producers/hook-base.js +424 -0
  226. package/dist/core/events/v3/producers/hook.d.ts +29 -0
  227. package/dist/core/events/v3/producers/hook.d.ts.map +1 -0
  228. package/dist/core/events/v3/producers/hook.js +71 -0
  229. package/dist/core/events/v3/producers/intake.d.ts +65 -0
  230. package/dist/core/events/v3/producers/intake.d.ts.map +1 -0
  231. package/dist/core/events/v3/producers/intake.js +244 -0
  232. package/dist/core/events/v3/producers/recorder.d.ts +240 -0
  233. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -0
  234. package/dist/core/events/v3/producers/recorder.js +2176 -0
  235. package/dist/core/events/v3/projection.d.ts +101 -0
  236. package/dist/core/events/v3/projection.d.ts.map +1 -0
  237. package/dist/core/events/v3/projection.js +479 -0
  238. package/dist/core/events/v3/reader.d.ts +75 -0
  239. package/dist/core/events/v3/reader.d.ts.map +1 -0
  240. package/dist/core/events/v3/reader.js +778 -0
  241. package/dist/core/events/v3/recovery-record.d.ts +47 -0
  242. package/dist/core/events/v3/recovery-record.d.ts.map +1 -0
  243. package/dist/core/events/v3/recovery-record.js +167 -0
  244. package/dist/core/events/v3/recovery.d.ts +22 -0
  245. package/dist/core/events/v3/recovery.d.ts.map +1 -0
  246. package/dist/core/events/v3/recovery.js +227 -0
  247. package/dist/core/events/v3/span-state.d.ts +52 -0
  248. package/dist/core/events/v3/span-state.d.ts.map +1 -0
  249. package/dist/core/events/v3/span-state.js +142 -0
  250. package/dist/core/events/v3/targets.d.ts +23 -0
  251. package/dist/core/events/v3/targets.d.ts.map +1 -0
  252. package/dist/core/events/v3/targets.js +134 -0
  253. package/dist/core/events/v3/turn-telemetry.d.ts +41 -0
  254. package/dist/core/events/v3/turn-telemetry.d.ts.map +1 -0
  255. package/dist/core/events/v3/turn-telemetry.js +168 -0
  256. package/dist/core/events/v3/validate.d.ts +9 -0
  257. package/dist/core/events/v3/validate.d.ts.map +1 -0
  258. package/dist/core/events/v3/validate.js +281 -0
  259. package/dist/core/events/v3/writer.d.ts +56 -0
  260. package/dist/core/events/v3/writer.d.ts.map +1 -0
  261. package/dist/core/events/v3/writer.js +335 -0
  262. package/dist/core/governor/index.d.ts +1 -1
  263. package/dist/core/governor/index.d.ts.map +1 -1
  264. package/dist/core/governor/index.js +1 -1
  265. package/dist/core/governor/plan-api.d.ts +2 -1
  266. package/dist/core/governor/plan-api.d.ts.map +1 -1
  267. package/dist/core/governor/plan-api.js +6 -3
  268. package/dist/core/governor/read.d.ts +1 -1
  269. package/dist/core/governor/read.d.ts.map +1 -1
  270. package/dist/core/governor/read.js +1 -1
  271. package/dist/core/governor/state.d.ts +9 -0
  272. package/dist/core/governor/state.d.ts.map +1 -1
  273. package/dist/core/governor/state.js +40 -3
  274. package/dist/core/guard/config.d.ts +5 -0
  275. package/dist/core/guard/config.d.ts.map +1 -0
  276. package/dist/core/guard/config.js +157 -0
  277. package/dist/core/guard/coordinator.d.ts +14 -0
  278. package/dist/core/guard/coordinator.d.ts.map +1 -0
  279. package/dist/core/guard/coordinator.js +173 -0
  280. package/dist/core/guard/evaluator.d.ts +4 -0
  281. package/dist/core/guard/evaluator.d.ts.map +1 -0
  282. package/dist/core/guard/evaluator.js +242 -0
  283. package/dist/core/guard/evidence-v3.d.ts +15 -0
  284. package/dist/core/guard/evidence-v3.d.ts.map +1 -0
  285. package/dist/core/guard/evidence-v3.js +171 -0
  286. package/dist/core/guard/index.d.ts +10 -0
  287. package/dist/core/guard/index.d.ts.map +1 -0
  288. package/dist/core/guard/index.js +6 -0
  289. package/dist/core/guard/live-source-v3.d.ts +40 -0
  290. package/dist/core/guard/live-source-v3.d.ts.map +1 -0
  291. package/dist/core/guard/live-source-v3.js +128 -0
  292. package/dist/core/guard/storage.d.ts +27 -0
  293. package/dist/core/guard/storage.d.ts.map +1 -0
  294. package/dist/core/guard/storage.js +140 -0
  295. package/dist/core/guard/types.d.ts +119 -0
  296. package/dist/core/guard/types.d.ts.map +1 -0
  297. package/dist/core/guard/types.js +1 -0
  298. package/dist/core/hooks/adapter/detect.d.ts +18 -1
  299. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  300. package/dist/core/hooks/adapter/detect.js +29 -0
  301. package/dist/core/hooks/adapter/events.d.ts +7 -4
  302. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  303. package/dist/core/hooks/adapter/events.js +13 -4
  304. package/dist/core/hooks/adapter/output.d.ts +2 -2
  305. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  306. package/dist/core/hooks/adapter/parse.d.ts +7 -4
  307. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  308. package/dist/core/hooks/adapter/parse.js +64 -24
  309. package/dist/core/hooks/adapter/wiring.d.ts +30 -4
  310. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  311. package/dist/core/hooks/adapter/wiring.js +89 -11
  312. package/dist/core/hooks/cli.d.ts +1 -1
  313. package/dist/core/hooks/cli.js +345 -400
  314. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  315. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  316. package/dist/core/hooks/codex-authorization.js +199 -0
  317. package/dist/core/hooks/effects/image-capture.d.ts +20 -34
  318. package/dist/core/hooks/effects/image-capture.d.ts.map +1 -1
  319. package/dist/core/hooks/effects/image-capture.js +212 -221
  320. package/dist/core/hooks/effects/index.d.ts +1 -9
  321. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  322. package/dist/core/hooks/effects/index.js +1 -24
  323. package/dist/core/hooks/events/input-hash.d.ts +7 -0
  324. package/dist/core/hooks/events/input-hash.d.ts.map +1 -0
  325. package/dist/core/hooks/events/input-hash.js +43 -0
  326. package/dist/core/hooks/index.d.ts +4 -3
  327. package/dist/core/hooks/index.d.ts.map +1 -1
  328. package/dist/core/hooks/index.js +2 -3
  329. package/dist/core/hooks/resolve/coord-root.d.ts +1 -1
  330. package/dist/core/hooks/resolve/coord-root.js +1 -1
  331. package/dist/core/hooks/resolve/owner.d.ts +16 -15
  332. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  333. package/dist/core/hooks/resolve/owner.js +51 -42
  334. package/dist/core/hooks/resolve/transcript.d.ts +33 -3
  335. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  336. package/dist/core/hooks/resolve/transcript.js +155 -14
  337. package/dist/core/hooks/session-name-presence.d.ts +9 -10
  338. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  339. package/dist/core/hooks/session-name-presence.js +15 -15
  340. package/dist/core/hooks/unsafe-cross-shell.d.ts +1 -1
  341. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -1
  342. package/dist/core/journal/index.d.ts +2 -2
  343. package/dist/core/journal/index.d.ts.map +1 -1
  344. package/dist/core/journal/index.js +9 -22
  345. package/dist/core/presence/blob.d.ts +8 -3
  346. package/dist/core/presence/blob.d.ts.map +1 -1
  347. package/dist/core/presence/blob.js +54 -41
  348. package/dist/core/presence/codec-digest.d.ts +29 -0
  349. package/dist/core/presence/codec-digest.d.ts.map +1 -0
  350. package/dist/core/presence/codec-digest.js +170 -0
  351. package/dist/core/presence/index.js +15 -1
  352. package/dist/core/presence/relay-client.d.ts +1 -1
  353. package/dist/core/presence/relay-client.d.ts.map +1 -1
  354. package/dist/core/presence/relay-client.js +5 -5
  355. package/dist/core/work/index.d.ts +1 -1
  356. package/dist/core/work/index.d.ts.map +1 -1
  357. package/dist/core/work/index.js +1 -1
  358. package/dist/core/work/read.d.ts +1 -1
  359. package/dist/core/work/read.d.ts.map +1 -1
  360. package/dist/core/work/read.js +1 -1
  361. package/dist/core/work/runner.d.ts.map +1 -1
  362. package/dist/core/work/runner.js +6 -0
  363. package/dist/core/work/state.d.ts +9 -0
  364. package/dist/core/work/state.d.ts.map +1 -1
  365. package/dist/core/work/state.js +17 -3
  366. package/dist/core/workflow/engine.js +24 -25
  367. package/dist/core/workflow/live-session-v3.d.ts +19 -0
  368. package/dist/core/workflow/live-session-v3.d.ts.map +1 -0
  369. package/dist/core/workflow/live-session-v3.js +86 -0
  370. package/dist/core/workflow/scope-id.d.ts +3 -0
  371. package/dist/core/workflow/scope-id.d.ts.map +1 -0
  372. package/dist/core/workflow/scope-id.js +5 -0
  373. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  374. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  375. package/dist/core/workflow/workspaces/leases.js +53 -19
  376. package/dist/index.d.ts +1 -1
  377. package/dist/index.js +1 -1
  378. package/dist/lib/browser/client.d.ts +41 -0
  379. package/dist/lib/browser/client.d.ts.map +1 -1
  380. package/dist/lib/browser/client.js +203 -0
  381. package/dist/lib/browser/critique.d.ts +28 -2
  382. package/dist/lib/browser/critique.d.ts.map +1 -1
  383. package/dist/lib/browser/critique.js +74 -18
  384. package/dist/lib/browser/geometry.d.ts +1 -1
  385. package/dist/lib/browser/geometry.d.ts.map +1 -1
  386. package/dist/lib/browser/geometry.js +130 -6
  387. package/dist/lib/browser/index.d.ts +3 -0
  388. package/dist/lib/browser/index.d.ts.map +1 -1
  389. package/dist/lib/browser/index.js +3 -0
  390. package/dist/lib/browser/qa-plan.d.ts +174 -0
  391. package/dist/lib/browser/qa-plan.d.ts.map +1 -0
  392. package/dist/lib/browser/qa-plan.js +321 -0
  393. package/dist/lib/browser/qa-reuse.d.ts +99 -0
  394. package/dist/lib/browser/qa-reuse.d.ts.map +1 -0
  395. package/dist/lib/browser/qa-reuse.js +222 -0
  396. package/dist/lib/browser/qa-snapshot.d.ts +64 -0
  397. package/dist/lib/browser/qa-snapshot.d.ts.map +1 -0
  398. package/dist/lib/browser/qa-snapshot.js +171 -0
  399. package/dist/lib/browser/runts.d.ts.map +1 -1
  400. package/dist/lib/browser/runts.js +22 -1
  401. package/dist/lib/browser/tiling.d.ts +71 -0
  402. package/dist/lib/browser/tiling.d.ts.map +1 -0
  403. package/dist/lib/browser/tiling.js +118 -0
  404. package/dist/lib/context/index.d.ts +0 -3
  405. package/dist/lib/context/index.d.ts.map +1 -1
  406. package/dist/lib/context/index.js +20 -42
  407. package/dist/lib/council/index.d.ts +2 -2
  408. package/dist/lib/council/index.d.ts.map +1 -1
  409. package/dist/lib/council/index.js +9 -20
  410. package/dist/lib/docs-frontmatter.d.ts +1 -6
  411. package/dist/lib/docs-frontmatter.d.ts.map +1 -1
  412. package/dist/lib/docs-frontmatter.js +12 -78
  413. package/dist/lib/docs-index.d.ts +9 -0
  414. package/dist/lib/docs-index.d.ts.map +1 -1
  415. package/dist/lib/docs-index.js +32 -3
  416. package/dist/lib/docs-lint.d.ts.map +1 -1
  417. package/dist/lib/docs-lint.js +14 -30
  418. package/dist/lib/docs-metadata-audit.d.ts +19 -0
  419. package/dist/lib/docs-metadata-audit.d.ts.map +1 -0
  420. package/dist/lib/docs-metadata-audit.js +132 -0
  421. package/dist/lib/docs-metadata-managed.d.ts +23 -0
  422. package/dist/lib/docs-metadata-managed.d.ts.map +1 -0
  423. package/dist/lib/docs-metadata-managed.js +78 -0
  424. package/dist/lib/docs-metadata-sync.d.ts +20 -0
  425. package/dist/lib/docs-metadata-sync.d.ts.map +1 -0
  426. package/dist/lib/docs-metadata-sync.js +222 -0
  427. package/dist/lib/docs-metadata-v2-schema.d.ts +273 -0
  428. package/dist/lib/docs-metadata-v2-schema.d.ts.map +1 -0
  429. package/dist/lib/docs-metadata-v2-schema.js +134 -0
  430. package/dist/lib/docs-metadata-v2.d.ts +21 -0
  431. package/dist/lib/docs-metadata-v2.d.ts.map +1 -0
  432. package/dist/lib/docs-metadata-v2.js +424 -0
  433. package/dist/lib/docs-new.d.ts +11 -0
  434. package/dist/lib/docs-new.d.ts.map +1 -0
  435. package/dist/lib/docs-new.js +52 -0
  436. package/dist/lib/docs-sweep.d.ts.map +1 -1
  437. package/dist/lib/docs-sweep.js +45 -29
  438. package/dist/lib/headless/index.d.ts +98 -0
  439. package/dist/lib/headless/index.d.ts.map +1 -0
  440. package/dist/lib/headless/index.js +211 -0
  441. package/dist/lib/identities/assume.d.ts +4 -4
  442. package/dist/lib/identities/assume.d.ts.map +1 -1
  443. package/dist/lib/identities/assume.js +52 -68
  444. package/dist/lib/instructions/apply.d.ts +5 -4
  445. package/dist/lib/instructions/apply.d.ts.map +1 -1
  446. package/dist/lib/instructions/apply.js +32 -20
  447. package/dist/lib/instructions/bundle.d.ts +33 -0
  448. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  449. package/dist/lib/instructions/bundle.js +149 -0
  450. package/dist/lib/instructions/splice.d.ts +0 -1
  451. package/dist/lib/instructions/splice.d.ts.map +1 -1
  452. package/dist/lib/instructions/splice.js +22 -6
  453. package/dist/lib/instructions/templates.d.ts +7 -5
  454. package/dist/lib/instructions/templates.d.ts.map +1 -1
  455. package/dist/lib/instructions/templates.js +79 -9
  456. package/package.json +27 -6
  457. package/schemas/config.schema.json +120 -2
  458. package/schemas/docs-metadata-v2.schema.json +441 -0
  459. package/schemas/event-v3.schema.json +24472 -0
  460. package/src/commander.ts +7 -1
  461. package/src/commands/adapter.ts +2 -2
  462. package/src/commands/agents.ts +1737 -570
  463. package/src/commands/artifacts.ts +7 -6
  464. package/src/commands/backup.ts +29 -21
  465. package/src/commands/browse.ts +349 -8
  466. package/src/commands/checkpoint.ts +4 -3
  467. package/src/commands/claude-desktop.ts +7 -5
  468. package/src/commands/context.ts +1 -6
  469. package/src/commands/decision.ts +27 -44
  470. package/src/commands/docs.ts +100 -33
  471. package/src/commands/doctor.ts +73 -8
  472. package/src/commands/events.ts +256 -0
  473. package/src/commands/governor.ts +18 -4
  474. package/src/commands/init.ts +264 -57
  475. package/src/commands/instructions.ts +129 -0
  476. package/src/commands/journal.ts +4 -13
  477. package/src/commands/ledger-v3.ts +111 -0
  478. package/src/commands/presence.ts +18 -11
  479. package/src/commands/sync.ts +1 -1
  480. package/src/commands/web.ts +67 -8
  481. package/src/commands/work.ts +28 -5
  482. package/src/core/adapter.ts +5 -0
  483. package/src/core/adapters/attest.ts +8 -3
  484. package/src/core/adapters/profiles.ts +4 -6
  485. package/src/core/agents/canonical-emit.ts +39 -28
  486. package/src/core/agents/cli.ts +242 -263
  487. package/src/core/agents/codex-archive-v3.ts +138 -0
  488. package/src/core/agents/coord-client.ts +75 -177
  489. package/src/core/agents/finalization.ts +120 -38
  490. package/src/core/agents/index.ts +5 -5
  491. package/src/core/agents/live-authority-v3.ts +375 -0
  492. package/src/core/agents/live-lifecycle-v3.ts +140 -0
  493. package/src/core/agents/live-observation-v3.ts +216 -0
  494. package/src/core/agents/render/prompt-context.ts +89 -55
  495. package/src/core/agents/render/session-context.ts +23 -32
  496. package/src/core/agents/rules/claim-conflict.ts +85 -126
  497. package/src/core/agents/rules/commit-conflict.ts +3 -14
  498. package/src/core/agents/rules/stop-hook.ts +239 -293
  499. package/src/core/agents/session-events.ts +137 -104
  500. package/src/core/agents/session-finalization-state-v3.ts +90 -0
  501. package/src/core/agents/session-finalizer-v3.ts +953 -0
  502. package/src/core/agents/session-name-display.ts +149 -0
  503. package/src/core/agents/state/activity-log.ts +6 -14
  504. package/src/core/agents/state/heartbeat-writer.ts +98 -242
  505. package/src/core/agents/state/live-coordination-view.ts +290 -0
  506. package/src/core/agents/state/session-state.ts +143 -0
  507. package/src/core/agents/state/stale-sweep.ts +40 -17
  508. package/src/core/artifacts/index.ts +4 -5
  509. package/src/core/config.ts +148 -3
  510. package/src/core/context/index.ts +9 -17
  511. package/src/core/events/span-parent.ts +46 -0
  512. package/src/core/events/v3/advance.ts +168 -0
  513. package/src/core/events/v3/authority-outbox.ts +548 -0
  514. package/src/core/events/v3/base-builder.ts +60 -0
  515. package/src/core/events/v3/base-contract.ts +882 -0
  516. package/src/core/events/v3/bootstrap.ts +226 -0
  517. package/src/core/events/v3/builder.ts +59 -0
  518. package/src/core/events/v3/canonical.ts +153 -0
  519. package/src/core/events/v3/capabilities.ts +171 -0
  520. package/src/core/events/v3/capability-drift.ts +177 -0
  521. package/src/core/events/v3/catalog.ts +202 -0
  522. package/src/core/events/v3/clock-order.ts +41 -0
  523. package/src/core/events/v3/contract.ts +365 -0
  524. package/src/core/events/v3/control.ts +576 -0
  525. package/src/core/events/v3/coordination-view.ts +193 -0
  526. package/src/core/events/v3/economics.ts +384 -0
  527. package/src/core/events/v3/finalization-view.ts +67 -0
  528. package/src/core/events/v3/fingerprint-keys.ts +260 -0
  529. package/src/core/events/v3/generated.ts +48 -0
  530. package/src/core/events/v3/ids.ts +33 -0
  531. package/src/core/events/v3/index.ts +322 -0
  532. package/src/core/events/v3/latency.ts +551 -0
  533. package/src/core/events/v3/live-feed.ts +319 -0
  534. package/src/core/events/v3/live-observation.ts +39 -0
  535. package/src/core/events/v3/live-routing.ts +135 -0
  536. package/src/core/events/v3/producers/command-recorder.ts +631 -0
  537. package/src/core/events/v3/producers/command.ts +201 -0
  538. package/src/core/events/v3/producers/coordination-recorder.ts +538 -0
  539. package/src/core/events/v3/producers/coordination.ts +355 -0
  540. package/src/core/events/v3/producers/hook-base.ts +578 -0
  541. package/src/core/events/v3/producers/hook.ts +119 -0
  542. package/src/core/events/v3/producers/intake.ts +319 -0
  543. package/src/core/events/v3/producers/recorder.ts +2805 -0
  544. package/src/core/events/v3/projection.ts +653 -0
  545. package/src/core/events/v3/reader.ts +1127 -0
  546. package/src/core/events/v3/recovery-record.ts +244 -0
  547. package/src/core/events/v3/recovery.ts +271 -0
  548. package/src/core/events/v3/span-state.ts +187 -0
  549. package/src/core/events/v3/targets.ts +182 -0
  550. package/src/core/events/v3/turn-telemetry.ts +259 -0
  551. package/src/core/events/v3/validate.ts +361 -0
  552. package/src/core/events/v3/writer.ts +419 -0
  553. package/src/core/governor/index.ts +3 -0
  554. package/src/core/governor/plan-api.ts +10 -2
  555. package/src/core/governor/read.ts +3 -0
  556. package/src/core/governor/state.ts +52 -5
  557. package/src/core/guard/config.ts +206 -0
  558. package/src/core/guard/coordinator.ts +227 -0
  559. package/src/core/guard/evaluator.ts +343 -0
  560. package/src/core/guard/evidence-v3.ts +227 -0
  561. package/src/core/guard/index.ts +35 -0
  562. package/src/core/guard/live-source-v3.ts +204 -0
  563. package/src/core/guard/storage.ts +188 -0
  564. package/src/core/guard/types.ts +152 -0
  565. package/src/core/hooks/adapter/detect.ts +28 -1
  566. package/src/core/hooks/adapter/events.ts +15 -6
  567. package/src/core/hooks/adapter/output.ts +7 -2
  568. package/src/core/hooks/adapter/parse.ts +81 -36
  569. package/src/core/hooks/adapter/wiring.ts +119 -13
  570. package/src/core/hooks/cli.ts +398 -422
  571. package/src/core/hooks/codex-authorization.ts +253 -0
  572. package/src/core/hooks/effects/image-capture.ts +228 -238
  573. package/src/core/hooks/effects/index.ts +1 -36
  574. package/src/core/hooks/events/input-hash.ts +47 -0
  575. package/src/core/hooks/index.ts +4 -11
  576. package/src/core/hooks/resolve/coord-root.ts +1 -1
  577. package/src/core/hooks/resolve/owner.ts +52 -45
  578. package/src/core/hooks/resolve/transcript.ts +165 -14
  579. package/src/core/hooks/session-name-presence.ts +14 -15
  580. package/src/core/hooks/unsafe-cross-shell.ts +1 -1
  581. package/src/core/journal/index.ts +15 -22
  582. package/src/core/presence/blob.ts +63 -38
  583. package/src/core/presence/codec-digest.ts +215 -0
  584. package/src/core/presence/index.ts +18 -1
  585. package/src/core/presence/relay-client.ts +5 -5
  586. package/src/core/work/index.ts +3 -0
  587. package/src/core/work/read.ts +3 -0
  588. package/src/core/work/runner.ts +6 -0
  589. package/src/core/work/state.ts +27 -3
  590. package/src/core/workflow/engine.ts +24 -24
  591. package/src/core/workflow/live-session-v3.ts +126 -0
  592. package/src/core/workflow/scope-id.ts +6 -0
  593. package/src/core/workflow/workspaces/leases.ts +56 -19
  594. package/src/index.ts +1 -1
  595. package/src/lib/browser/client.ts +230 -0
  596. package/src/lib/browser/critique.ts +104 -27
  597. package/src/lib/browser/geometry.ts +143 -14
  598. package/src/lib/browser/index.ts +34 -0
  599. package/src/lib/browser/qa-plan.ts +501 -0
  600. package/src/lib/browser/qa-reuse.ts +306 -0
  601. package/src/lib/browser/qa-snapshot.ts +223 -0
  602. package/src/lib/browser/runts.ts +27 -2
  603. package/src/lib/browser/tiling.ts +164 -0
  604. package/src/lib/context/index.ts +22 -46
  605. package/src/lib/council/index.ts +9 -21
  606. package/src/lib/docs-frontmatter.ts +8 -79
  607. package/src/lib/docs-index.ts +33 -3
  608. package/src/lib/docs-lint.ts +15 -29
  609. package/src/lib/docs-metadata-audit.ts +166 -0
  610. package/src/lib/docs-metadata-managed.ts +83 -0
  611. package/src/lib/docs-metadata-sync.ts +262 -0
  612. package/src/lib/docs-metadata-v2-schema.ts +138 -0
  613. package/src/lib/docs-metadata-v2.ts +646 -0
  614. package/src/lib/docs-new.ts +63 -0
  615. package/src/lib/docs-sweep.ts +42 -28
  616. package/src/lib/headless/index.ts +287 -0
  617. package/src/lib/identities/assume.ts +67 -86
  618. package/src/lib/instructions/apply.ts +33 -20
  619. package/src/lib/instructions/bundle.ts +199 -0
  620. package/src/lib/instructions/splice.ts +21 -7
  621. package/src/lib/instructions/templates.ts +85 -13
  622. package/dist/core/agents/cli-emit.d.ts +0 -27
  623. package/dist/core/agents/cli-emit.d.ts.map +0 -1
  624. package/dist/core/agents/cli-emit.js +0 -57
  625. package/dist/core/agents/codex-replay.d.ts +0 -29
  626. package/dist/core/agents/codex-replay.d.ts.map +0 -1
  627. package/dist/core/agents/codex-replay.js +0 -138
  628. package/dist/core/agents/events/consume.d.ts +0 -82
  629. package/dist/core/agents/events/consume.d.ts.map +0 -1
  630. package/dist/core/agents/events/consume.js +0 -195
  631. package/dist/core/agents/events/emit.d.ts +0 -42
  632. package/dist/core/agents/events/emit.d.ts.map +0 -1
  633. package/dist/core/agents/events/emit.js +0 -75
  634. package/dist/core/agents/events/ulid.d.ts +0 -11
  635. package/dist/core/agents/events/ulid.d.ts.map +0 -1
  636. package/dist/core/agents/events/ulid.js +0 -47
  637. package/dist/core/agents/state/heartbeat-projector.d.ts +0 -65
  638. package/dist/core/agents/state/heartbeat-projector.d.ts.map +0 -1
  639. package/dist/core/agents/state/heartbeat-projector.js +0 -527
  640. package/dist/core/hooks/events/emit.d.ts +0 -31
  641. package/dist/core/hooks/events/emit.d.ts.map +0 -1
  642. package/dist/core/hooks/events/emit.js +0 -93
  643. package/dist/core/hooks/events/rotate.d.ts +0 -43
  644. package/dist/core/hooks/events/rotate.d.ts.map +0 -1
  645. package/dist/core/hooks/events/rotate.js +0 -142
  646. package/dist/core/hooks/events/schema.d.ts +0 -312
  647. package/dist/core/hooks/events/schema.d.ts.map +0 -1
  648. package/dist/core/hooks/events/schema.js +0 -12
  649. package/dist/core/hooks/events/ulid.d.ts +0 -10
  650. package/dist/core/hooks/events/ulid.d.ts.map +0 -1
  651. package/dist/core/hooks/events/ulid.js +0 -47
  652. package/dist/lib/docs-frontmatter-migrate.d.ts +0 -33
  653. package/dist/lib/docs-frontmatter-migrate.d.ts.map +0 -1
  654. package/dist/lib/docs-frontmatter-migrate.js +0 -364
  655. package/src/core/agents/cli-emit.ts +0 -64
  656. package/src/core/agents/codex-replay.ts +0 -163
  657. package/src/core/agents/events/consume.ts +0 -254
  658. package/src/core/agents/events/emit.ts +0 -115
  659. package/src/core/agents/events/ulid.ts +0 -51
  660. package/src/core/agents/state/heartbeat-projector.ts +0 -582
  661. package/src/core/hooks/events/emit.ts +0 -125
  662. package/src/core/hooks/events/rotate.ts +0 -151
  663. package/src/core/hooks/events/schema.ts +0 -538
  664. package/src/core/hooks/events/ulid.ts +0 -51
  665. 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,24 +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";
49
- import { buildSuggestedName } from "../core/agents/state/heartbeat-writer.ts";
50
- import { coordFreshnessSeconds, resolveBinName } from "../core/config.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";
56
+ import {
57
+ buildLifecycleSuggestedName,
58
+ buildSuggestedName,
59
+ type Heartbeat,
60
+ readHeartbeat as readHeartbeatCache,
61
+ } from "../core/agents/state/heartbeat-writer.ts";
62
+ import {
63
+ readLiveCoordinationRow,
64
+ readLiveCoordinationRows,
65
+ } from "../core/agents/state/live-coordination-view.ts";
66
+ import {
67
+ type AgentActivity,
68
+ foldSessionState,
69
+ type TaskState,
70
+ } from "../core/agents/state/session-state.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";
51
92
  import { type RemoteMachine, readRemoteMachines } from "../core/presence/index.ts";
52
93
  import { registerContextCommand } from "./context.ts";
53
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
+
54
102
  /** Cap for CLI scans of the unbounded event ledger (`trace` / `health`). Well
55
103
  * under V8's ~512MB max string length so a `readFileSync` of the whole file can
56
104
  * never throw; covers ample recent history for a diagnostic scan. */
57
- const STREAM_SCAN_CAP_BYTES = 128 * 1024 * 1024; // 128 MiB
58
105
 
59
106
  import { parsePsChainLine } from "../core/hooks/resolve/anchor.ts";
60
107
  import { appendEntry, resolveOwnerByName } from "../core/journal/index.ts";
@@ -105,7 +152,7 @@ function freshnessCutoffSecs(): number {
105
152
  }
106
153
 
107
154
  const SUBAGENT_NOTE =
108
- "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, " +
109
156
  "this resolves to the parent group's name, not the subagent's. A subagent-aware " +
110
157
  "bridge (per-shell marker file at .harnery/shells/<pid>) is out of scope.";
111
158
 
@@ -179,17 +226,47 @@ interface Row {
179
226
  started_at: string;
180
227
  last_heartbeat: string;
181
228
  files_touched: string[];
182
- last_tool?: string | null;
183
- last_tool_target?: string | null;
184
229
  task?: string | null;
185
- turn_summary?: string | null;
186
- turn_summary_updated_at?: string | null;
230
+ activity: AgentActivity;
231
+ activity_updated_at?: string | null;
232
+ activity_source?: string | null;
233
+ task_state: TaskState;
234
+ task_state_scope: "current" | "historical";
235
+ task_state_updated_at?: string | null;
236
+ task_state_reason?: string | null;
187
237
  platform?: string | null;
188
238
  /** Set on relation=remote rows: the machine label the row arrived from
189
239
  * via the cross-machine presence transport (ADR 0016). */
190
240
  machine?: string | null;
191
241
  }
192
242
 
243
+ function activityOf(hb: Pick<Heartbeat, "activity">): AgentActivity {
244
+ return hb.activity ?? "unknown";
245
+ }
246
+
247
+ function taskStateOf(hb: Pick<Heartbeat, "task_state">): TaskState {
248
+ return hb.task_state ?? "active";
249
+ }
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
+
265
+ function lifecycleLabel(hb: Pick<Heartbeat, "task_state" | "task_state_reason">): string {
266
+ const state = taskStateOf(hb);
267
+ return state === "blocked" && hb.task_state_reason ? `${state}: ${hb.task_state_reason}` : state;
268
+ }
269
+
193
270
  let emit: EmitContext;
194
271
 
195
272
  export function registerAgentsCommand(
@@ -218,7 +295,7 @@ export function registerAgentsCommand(
218
295
  .command("list")
219
296
  .description("List all active agents (folds kind=transient by default)")
220
297
  .option("--all", "Include raw kind=transient rows (no fold)")
221
- .option("--stale", "Include heartbeats older than the freshness window")
298
+ .option("--stale", "Include generations older than the freshness window")
222
299
  .option("--json", "JSON output (alias for --format json)")
223
300
  .action((opts: { all?: boolean; stale?: boolean; json?: boolean }) => {
224
301
  runList(opts);
@@ -232,15 +309,21 @@ export function registerAgentsCommand(
232
309
  "--end-turn",
233
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",
234
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
+ )
235
316
  .option(
236
317
  "--session-id <id>",
237
- "Lookup heartbeat by session_id directly, bypassing the ppid walk. " +
318
+ "Lookup the V3 generation by session_id directly, bypassing the ppid walk. " +
238
319
  "Use this when calling from a hook (the hook's process tree may not lead back to Claude Code's session pid). " +
239
320
  "The Stop hook payload includes session_id; pass it through.",
240
321
  )
241
- .action((opts: { endTurn?: boolean; json?: boolean; sessionId?: string }) => {
242
- runStatus(opts);
243
- });
322
+ .action(
323
+ (opts: { endTurn?: boolean; endSession?: boolean; json?: boolean; sessionId?: string }) => {
324
+ runStatus(opts);
325
+ },
326
+ );
244
327
 
245
328
  cmd
246
329
  .command("suggest-name [description...]")
@@ -253,7 +336,7 @@ export function registerAgentsCommand(
253
336
  .option("--json", "JSON output instead of the bare name")
254
337
  .option(
255
338
  "--session-id <id>",
256
- "Lookup heartbeat by session_id directly, bypassing the ppid walk.",
339
+ "Lookup the V3 generation by session_id directly, bypassing the ppid walk.",
257
340
  )
258
341
  .action((description: string[], opts: { json?: boolean; sessionId?: string }) => {
259
342
  runSuggestName(description, opts);
@@ -262,7 +345,7 @@ export function registerAgentsCommand(
262
345
  cmd
263
346
  .command("watch")
264
347
  .description(
265
- "Stream peer state changes in real time (file watcher on .harnery/active/). " +
348
+ "Stream peer state changes from the authoritative V3 coordination projection. " +
266
349
  "Prints one line per delta: started / ended / activity / file claim / task change.",
267
350
  )
268
351
  .option("--poll-ms <n>", "Debounce window after a change event", "200")
@@ -284,15 +367,15 @@ export function registerAgentsCommand(
284
367
  cmd
285
368
  .command("trace <name>")
286
369
  .description(
287
- "Reconstruct one agent's coordination lifecycle from events.ndjson: " +
288
- "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, " +
289
372
  "in chronological order. The answer to 'what happened to this agent / why did " +
290
373
  "it vanish?' without hand-grepping the stream. Accepts a name (agent-Foo or Foo) " +
291
374
  "or an instance_id.",
292
375
  )
293
376
  .option("--since <window>", "Only events newer than Nh|Nd (default: all)")
294
377
  .option("--limit <n>", "Show at most N most-recent events. Default: 200.", "200")
295
- .option("--all-tools", "Include tool.post_use + command.* (default: hidden as noise)")
378
+ .option("--all-tools", "Include tool.completed + command.* (default: hidden as noise)")
296
379
  .option("--json", "JSON envelope output")
297
380
  .action(
298
381
  (
@@ -317,6 +400,72 @@ export function registerAgentsCommand(
317
400
  runSetTask(text.join(" "), opts);
318
401
  });
319
402
 
403
+ cmd
404
+ .command("lifecycle <state>")
405
+ .description("Declare this session's task lifecycle: active, blocked, or done")
406
+ .option("--reason <text>", "Explain the lifecycle declaration")
407
+ .option(
408
+ "--session-id <id>",
409
+ "Target the heartbeat with this session_id directly, bypassing the ppid walk.",
410
+ )
411
+ .action((state: string, opts: { reason?: string; sessionId?: string }) => {
412
+ runLifecycle(state, opts);
413
+ });
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
+
320
469
  cmd
321
470
  .command("release-claim <path>")
322
471
  .description(
@@ -428,18 +577,19 @@ export function registerAgentsCommand(
428
577
  .command("heal")
429
578
  .description(
430
579
  "Force a coord-layer recovery action on a specific agent. " +
431
- "Kinds: pidmap (force PIDMAP_HEAL), heartbeat (force HEARTBEAT_HEAL), " +
432
- "kill (rm the heartbeat file). Runs through the same heartbeat flock " +
433
- "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).",
434
582
  )
435
583
  .requiredOption("--owner <id>", "Target agent's instance_id")
436
- .requiredOption("--kind <kind>", "pidmap | heartbeat | kill")
584
+ .requiredOption("--kind <kind>", "pidmap | cache")
437
585
  .option(
438
586
  "--session-id <id>",
439
- "(--kind heartbeat) session_id to stamp on the heartbeat. " +
440
- "Default: inherit from existing heartbeat if one exists. " +
441
- "Required when no heartbeat exists yet (a heartbeat without " +
442
- "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.",
443
593
  )
444
594
  .option(
445
595
  "--pid <pid>",
@@ -450,13 +600,218 @@ export function registerAgentsCommand(
450
600
  )
451
601
  .option("--json", "JSON envelope output")
452
602
  .action(
453
- (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
+ }) => {
454
611
  runHeal(opts);
455
612
  },
456
613
  );
457
614
  registerIdentityCommands(cmd);
458
615
  }
459
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
+
460
815
  function registerIdentityCommands(parent: Command): void {
461
816
  const identity = parent
462
817
  .command("identity")
@@ -740,7 +1095,7 @@ export function registerCouncilCommands(parent: Command): void {
740
1095
  "archive it first (trash-can pattern). Without --yes this prints " +
741
1096
  "the paths that would be removed and exits 0 without touching " +
742
1097
  "anything. Does NOT touch target_doc, close_handoff_path, or " +
743
- "session-events.ndjson, which are owned by separate authors.",
1098
+ "the canonical V3 event ledger, which is owned independently.",
744
1099
  )
745
1100
  .option("-y, --yes", "Required to actually delete; without this, dry-run")
746
1101
  .option("--json", "JSON envelope output")
@@ -879,11 +1234,11 @@ function runWhoami(opts: { json?: boolean }): void {
879
1234
  process.exit(1);
880
1235
  }
881
1236
 
882
- const hb = readHeartbeat(myOwner);
1237
+ const hb = readLiveCoordinationRow(root, myOwner);
883
1238
  if (!hb) {
884
1239
  emit.error({
885
- code: "no_heartbeat",
886
- message: noHeartbeatMessage(myOwner),
1240
+ code: "no_live_generation",
1241
+ message: noLiveGenerationMessage(myOwner),
887
1242
  });
888
1243
  process.exit(1);
889
1244
  }
@@ -895,14 +1250,17 @@ function runWhoami(opts: { json?: boolean }): void {
895
1250
  session_id: hb.session_id,
896
1251
  kind: normalizeKind(hb.kind),
897
1252
  relation: "self",
898
- started_at: hb.started_at,
1253
+ started_at: hb.started_at ?? hb.last_heartbeat,
899
1254
  last_heartbeat: hb.last_heartbeat,
900
1255
  files_touched: hb.files_touched ?? [],
901
- last_tool: hb.last_tool ?? null,
902
- last_tool_target: hb.last_tool_target ?? null,
903
1256
  task: hb.task ?? null,
904
- turn_summary: hb.turn_summary ?? null,
905
- turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
1257
+ activity: activityOf(hb),
1258
+ activity_updated_at: hb.activity_updated_at ?? null,
1259
+ activity_source: hb.activity_source ?? null,
1260
+ task_state: taskStateOf(hb),
1261
+ task_state_scope: "current",
1262
+ task_state_updated_at: hb.task_state_updated_at ?? null,
1263
+ task_state_reason: hb.task_state_reason ?? null,
906
1264
  platform: hb.platform ?? "claude-code",
907
1265
  };
908
1266
 
@@ -926,38 +1284,19 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
926
1284
  process.exit(1);
927
1285
  }
928
1286
 
929
- const activeDir = resolve(root, ".harnery", "active");
930
- if (!existsSync(activeDir)) {
931
- emit.data({ rows: [], note: SUBAGENT_NOTE });
932
- return;
933
- }
934
-
935
1287
  // Resolve self for relation column; best-effort, missing → "unknown" on every row.
936
1288
  const myOwner = resolveOwner();
937
- const myHb = myOwner ? readHeartbeat(myOwner) : null;
1289
+ const myHb = myOwner ? readLiveCoordinationRow(root, myOwner) : null;
938
1290
  const mySession = myHb?.session_id ?? null;
939
1291
 
940
- // Read every heartbeat.
941
- const heartbeats: Heartbeat[] = [];
942
- for (const file of readdirSync(activeDir)) {
943
- if (!file.endsWith(".json")) continue;
944
- try {
945
- const raw = readFileSync(resolve(activeDir, file), "utf8");
946
- const parsed = JSON.parse(raw);
947
- if (parsed && typeof parsed.instance_id === "string") {
948
- heartbeats.push(parsed as Heartbeat);
949
- }
950
- } catch {
951
- // skip malformed
952
- }
953
- }
1292
+ const generations = readLiveCoordinationRows(root);
954
1293
 
955
1294
  // Apply staleness filter unless --stale.
956
1295
  const nowSec = Math.floor(Date.now() / 1000);
957
1296
  const cutoff = nowSec - freshnessCutoffSecs();
958
1297
  const live = opts.stale
959
- ? heartbeats
960
- : heartbeats.filter((h) => {
1298
+ ? generations
1299
+ : generations.filter((h) => {
961
1300
  const ts = Date.parse(h.last_heartbeat);
962
1301
  return Number.isFinite(ts) && ts / 1000 >= cutoff;
963
1302
  });
@@ -993,14 +1332,17 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
993
1332
  session_id: h.session_id,
994
1333
  kind: "transient",
995
1334
  relation: relationOf(h, myOwner ?? "", mySession),
996
- started_at: h.started_at,
1335
+ started_at: h.started_at ?? h.last_heartbeat,
997
1336
  last_heartbeat: h.last_heartbeat,
998
1337
  files_touched: [...(h.files_touched ?? [])].sort(),
999
- last_tool: h.last_tool ?? null,
1000
- last_tool_target: h.last_tool_target ?? null,
1001
1338
  task: h.task ?? null,
1002
- turn_summary: h.turn_summary ?? null,
1003
- turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1339
+ activity: activityOf(h),
1340
+ activity_updated_at: h.activity_updated_at ?? null,
1341
+ activity_source: h.activity_source ?? null,
1342
+ task_state: taskStateOf(h),
1343
+ task_state_scope: "current",
1344
+ task_state_updated_at: h.task_state_updated_at ?? null,
1345
+ task_state_reason: h.task_state_reason ?? null,
1004
1346
  platform: h.platform ?? "claude-code",
1005
1347
  });
1006
1348
  continue;
@@ -1018,26 +1360,28 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1018
1360
  session_id: h.session_id,
1019
1361
  kind: normalizeKind(h.kind),
1020
1362
  relation: relationOf(h, myOwner ?? "", mySession),
1021
- started_at: h.started_at,
1363
+ started_at: h.started_at ?? h.last_heartbeat,
1022
1364
  last_heartbeat: h.last_heartbeat,
1023
1365
  files_touched: files,
1024
- last_tool: h.last_tool ?? null,
1025
- last_tool_target: h.last_tool_target ?? null,
1026
1366
  task: h.task ?? null,
1027
- turn_summary: h.turn_summary ?? null,
1028
- turn_summary_updated_at: h.turn_summary_updated_at ?? null,
1367
+ activity: activityOf(h),
1368
+ activity_updated_at: h.activity_updated_at ?? null,
1369
+ activity_source: h.activity_source ?? null,
1370
+ task_state: taskStateOf(h),
1371
+ task_state_scope: "current",
1372
+ task_state_updated_at: h.task_state_updated_at ?? null,
1373
+ task_state_reason: h.task_state_reason ?? null,
1029
1374
  platform: h.platform ?? "claude-code",
1030
1375
  });
1031
1376
  }
1032
1377
 
1033
- // Guard missing started_at: a heartbeat seeded from a stray event can lack it
1034
- // (legacy zombies), and an unguarded .localeCompare throws, which is exactly
1035
- // 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.
1036
1380
  rows.sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
1037
1381
 
1038
1382
  // Cross-machine presence (ADR 0016): append sessions on OTHER machines from
1039
1383
  // the locally-fetched presence refs. Advisory rows (relation=remote) — they
1040
- // don't participate in local claim blocking in v1.
1384
+ // never participate in local claim blocking.
1041
1385
  for (const rm of readRemoteMachines(root)) {
1042
1386
  for (const a of rm.agents) {
1043
1387
  rows.push({
@@ -1049,11 +1393,14 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1049
1393
  started_at: a.started_at ?? "",
1050
1394
  last_heartbeat: a.last_heartbeat ?? "",
1051
1395
  files_touched: [...(a.files_touched ?? [])].sort(),
1052
- last_tool: a.last_tool ?? null,
1053
- last_tool_target: null,
1054
1396
  task: a.task ?? null,
1055
- turn_summary: a.turn_summary ?? null,
1056
- turn_summary_updated_at: null,
1397
+ activity: a.activity ?? "unknown",
1398
+ activity_updated_at: null,
1399
+ activity_source: null,
1400
+ task_state: a.task_state ?? "active",
1401
+ task_state_scope: "current",
1402
+ task_state_updated_at: null,
1403
+ task_state_reason: a.task_state_reason ?? null,
1057
1404
  platform: a.platform ?? "claude-code",
1058
1405
  machine: rm.machine,
1059
1406
  });
@@ -1068,7 +1415,7 @@ function runList(opts: { all?: boolean; stale?: boolean; json?: boolean }): void
1068
1415
  }
1069
1416
 
1070
1417
  function relationOf(
1071
- peer: Heartbeat,
1418
+ peer: Pick<LiveCoordinationRow, "instance_id" | "session_id">,
1072
1419
  myOwner: string,
1073
1420
  mySession: string | null,
1074
1421
  ): "self" | "group" | "blocks" | "unknown" {
@@ -1084,22 +1431,18 @@ function normalizeKind(kind: string | undefined | null): string {
1084
1431
  }
1085
1432
 
1086
1433
  /**
1087
- * The `no_heartbeat` diagnostic, quoting the owner id in FULL.
1434
+ * The `no_live_generation` diagnostic, quoting the owner id in full.
1088
1435
  *
1089
- * This id is actionable: the reader's next move is `agents heal --kind
1090
- * heartbeat --owner <id>`, and heartbeats are keyed by the whole
1091
- * `instance_id` (`.harnery/active/<instance_id>.json`). An abbreviated id
1092
- * here reads as complete, so it gets copy-pasted into `--owner` and mints a
1093
- * heartbeat at a filename no reader resolves — an orphan that leaves the
1094
- * session looking unhealable. Abbreviate ids for display elsewhere, never in
1095
- * 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.
1096
1439
  */
1097
- function noHeartbeatMessage(owner: string): string {
1098
- 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`;
1099
1442
  }
1100
1443
 
1101
1444
  /**
1102
- * 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.
1103
1446
  *
1104
1447
  * Used to recognize an abbreviated id handed back by a reader when the caller
1105
1448
  * supplied no canonical id of their own. Ambiguity is treated as "no answer":
@@ -1107,12 +1450,9 @@ function noHeartbeatMessage(owner: string): string {
1107
1450
  * and refusing with the wrong id in the message would be worse than the orphan.
1108
1451
  */
1109
1452
  function liveIdWithPrefix(root: string, prefix: string): string | null {
1110
- const activeDir = resolve(root, ".harnery", "active");
1111
- if (!existsSync(activeDir)) return null;
1112
1453
  const matches = new Set<string>();
1113
- for (const file of readdirSync(activeDir)) {
1114
- if (!file.endsWith(".json")) continue;
1115
- const id = file.slice(0, -".json".length);
1454
+ for (const row of readLiveCoordinationRows(root)) {
1455
+ const id = row.instance_id;
1116
1456
  if (id !== prefix && id.startsWith(prefix)) matches.add(id);
1117
1457
  }
1118
1458
  return matches.size === 1 ? (matches.values().next().value as string) : null;
@@ -1127,82 +1467,60 @@ async function runWatch(pollMs: number): Promise<void> {
1127
1467
  });
1128
1468
  process.exit(1);
1129
1469
  }
1130
- const activeDir = resolve(root, ".harnery", "active");
1131
- if (!existsSync(activeDir)) {
1132
- emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
1133
- process.exit(1);
1134
- }
1135
-
1136
- const fs = await import("node:fs");
1137
- const cache = new Map<string, Heartbeat>();
1470
+ const cache = new Map<string, LiveCoordinationRow>();
1138
1471
 
1139
1472
  // Seed cache + print an initial roster line per live peer.
1140
- const initial = listActiveHeartbeats(activeDir);
1141
- 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
1142
1475
  for (const h of initial) {
1143
1476
  cache.set(h.instance_id, h);
1144
1477
  emitWatchLine(
1145
- `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}"` : ""})`,
1146
1479
  );
1147
1480
  }
1148
1481
 
1149
- let scheduled: NodeJS.Timeout | null = null;
1150
1482
  const rescan = () => {
1151
- if (scheduled) return;
1152
- scheduled = setTimeout(() => {
1153
- scheduled = null;
1154
- const current = new Map<string, Heartbeat>();
1155
- for (const h of listActiveHeartbeats(activeDir)) current.set(h.instance_id, h);
1156
-
1157
- // Removed agents.
1158
- for (const [id, old] of cache) {
1159
- if (!current.has(id)) {
1160
- emitWatchLine(`agent-${old.name ?? "?"} ended`);
1161
- cache.delete(id);
1162
- }
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);
1163
1491
  }
1164
- // Added or changed agents.
1165
- for (const [id, h] of current) {
1166
- const prev = cache.get(id);
1167
- if (!prev) {
1168
- emitWatchLine(
1169
- `agent-${h.name ?? "?"} started (${formatAge(secondsSince(h.started_at))} old${h.task ? `, task: "${h.task}"` : ""})`,
1170
- );
1171
- cache.set(id, h);
1172
- continue;
1173
- }
1174
- // Diff fields we care about.
1175
- if ((prev.task ?? "") !== (h.task ?? "")) {
1176
- emitWatchLine(`agent-${h.name ?? "?"} task: ${h.task ? `"${h.task}"` : "(cleared)"}`);
1177
- }
1178
- if (
1179
- (prev.last_tool ?? "") !== (h.last_tool ?? "") ||
1180
- (prev.last_tool_target ?? "") !== (h.last_tool_target ?? "")
1181
- ) {
1182
- if (h.last_tool) {
1183
- const target = h.last_tool_target ? ` ${truncate(h.last_tool_target, 80)}` : "";
1184
- emitWatchLine(`agent-${h.name ?? "?"} ${h.last_tool}${target}`);
1185
- }
1186
- }
1187
- // File additions/removals.
1188
- const prevFiles = new Set(prev.files_touched ?? []);
1189
- const currFiles = new Set(h.files_touched ?? []);
1190
- for (const f of currFiles) {
1191
- if (!prevFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} +claim ${f}`);
1192
- }
1193
- for (const f of prevFiles) {
1194
- if (!currFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} -release ${f}`);
1195
- }
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
+ );
1196
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}`);
1512
+ }
1513
+ for (const f of prevFiles) {
1514
+ if (!currFiles.has(f)) emitWatchLine(`agent-${h.name ?? "?"} -release ${f}`);
1197
1515
  }
1198
- }, pollMs);
1516
+ cache.set(id, h);
1517
+ }
1199
1518
  };
1200
1519
 
1201
- const watcher = fs.watch(activeDir, { persistent: true }, () => rescan());
1202
-
1203
1520
  await new Promise<void>((resolveP) => {
1521
+ const timer = setInterval(rescan, Math.max(100, pollMs));
1204
1522
  const stop = () => {
1205
- watcher.close();
1523
+ clearInterval(timer);
1206
1524
  resolveP();
1207
1525
  };
1208
1526
  process.on("SIGINT", stop);
@@ -1210,23 +1528,13 @@ async function runWatch(pollMs: number): Promise<void> {
1210
1528
  });
1211
1529
  }
1212
1530
 
1213
- function listActiveHeartbeats(activeDir: string): Heartbeat[] {
1214
- const out: Heartbeat[] = [];
1531
+ function listLiveGenerations(root: string): LiveCoordinationRow[] {
1215
1532
  const nowSec = Math.floor(Date.now() / 1000);
1216
1533
  const cutoff = nowSec - freshnessCutoffSecs();
1217
- for (const file of readdirSync(activeDir)) {
1218
- if (!file.endsWith(".json")) continue;
1219
- try {
1220
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1221
- const parsed = JSON.parse(raw) as Heartbeat;
1222
- if (!parsed || typeof parsed.instance_id !== "string") continue;
1223
- const ts = Date.parse(parsed.last_heartbeat);
1224
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) out.push(parsed);
1225
- } catch {
1226
- // skip
1227
- }
1228
- }
1229
- 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
+ });
1230
1538
  }
1231
1539
 
1232
1540
  function secondsSince(iso: string): number {
@@ -1248,29 +1556,14 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1248
1556
  });
1249
1557
  process.exit(1);
1250
1558
  }
1251
- const activeDir = resolve(root, ".harnery", "active");
1252
- if (!existsSync(activeDir)) {
1253
- emit.error({ code: "no_active_dir", message: ".harnery/active/ missing" });
1254
- process.exit(1);
1255
- }
1256
-
1257
- // Read all heartbeats; match by name (case-insensitive). Apply freshness filter.
1258
- const matches: Heartbeat[] = [];
1559
+ // Match authority-safe V3 generations by resolved display name.
1259
1560
  const nowSec = Math.floor(Date.now() / 1000);
1260
1561
  const cutoff = nowSec - freshnessCutoffSecs();
1261
- for (const file of readdirSync(activeDir)) {
1262
- if (!file.endsWith(".json")) continue;
1263
- try {
1264
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1265
- const parsed = JSON.parse(raw) as Heartbeat;
1266
- if (!parsed || typeof parsed.instance_id !== "string") continue;
1267
- if ((parsed.name ?? "").toLowerCase() !== name.toLowerCase()) continue;
1268
- const ts = Date.parse(parsed.last_heartbeat);
1269
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) matches.push(parsed);
1270
- } catch {
1271
- // skip malformed
1272
- }
1273
- }
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
+ });
1274
1567
 
1275
1568
  if (matches.length === 0) {
1276
1569
  emit.error({
@@ -1302,7 +1595,7 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1302
1595
  const report = null as PeerReport | null;
1303
1596
  const bqError = null as string | null;
1304
1597
 
1305
- const startedAtMs = Date.parse(hb.started_at);
1598
+ const startedAtMs = Date.parse(hb.started_at ?? hb.last_heartbeat);
1306
1599
  const ageSecs = Number.isFinite(startedAtMs)
1307
1600
  ? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
1308
1601
  : 0;
@@ -1319,12 +1612,15 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1319
1612
  age_secs: ageSecs,
1320
1613
  last_heartbeat_secs_ago: heartbeatAgeSecs,
1321
1614
  task: hb.task ?? null,
1322
- turn_summary: hb.turn_summary ?? null,
1323
- turn_summary_updated_at: hb.turn_summary_updated_at ?? null,
1615
+ activity: activityOf(hb),
1616
+ activity_updated_at: hb.activity_updated_at ?? null,
1617
+ activity_source: hb.activity_source ?? null,
1618
+ task_state: taskStateOf(hb),
1619
+ task_state_scope: "current" as const,
1620
+ task_state_updated_at: hb.task_state_updated_at ?? null,
1621
+ task_state_reason: hb.task_state_reason ?? null,
1324
1622
  title: report?.title ?? null,
1325
1623
  files_held: hb.files_touched ?? [],
1326
- last_tool: hb.last_tool ?? null,
1327
- last_tool_target: hb.last_tool_target ?? null,
1328
1624
  recent_prompts: report?.recent_prompts ?? [],
1329
1625
  recent_tools: report?.recent_tools ?? [],
1330
1626
  tool_counts: report?.tool_counts ?? [],
@@ -1350,10 +1646,10 @@ async function runShow(name: string, opts: { json?: boolean }): Promise<void> {
1350
1646
  ` session ${formatAge(ageSecs)} old · kind=${data.kind} · session_id=${data.session_id.slice(0, 8)}…`,
1351
1647
  );
1352
1648
  lines.push(` last heartbeat: ${formatAge(heartbeatAgeSecs)} ago`);
1353
- if (data.last_tool) {
1354
- const target = data.last_tool_target ? ` ${truncate(data.last_tool_target, 80)}` : "";
1355
- lines.push(` last activity: ${data.last_tool}${target}`);
1356
- }
1649
+ lines.push(` activity: ${data.activity}`);
1650
+ lines.push(
1651
+ ` lifecycle: ${data.task_state}${data.task_state === "blocked" && data.task_state_reason ? `: ${data.task_state_reason}` : ""}`,
1652
+ );
1357
1653
  if (data.files_held.length > 0) {
1358
1654
  lines.push(` holds ${data.files_held.length} file(s):`);
1359
1655
  for (const f of data.files_held.slice(0, 10)) lines.push(` ${f}`);
@@ -1508,13 +1804,19 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1508
1804
  // Identity: prefer explicit --session-id (the ppid-walk-free escape hatch,
1509
1805
  // mirrors `status`), fall back to the ppid walk. Cursor shell tool calls
1510
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.
1511
1813
  if (!opts?.sessionId) ensureCursorSession(root);
1512
- const myOwner = opts?.sessionId ?? resolveOwner();
1814
+ const myOwner = opts?.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
1513
1815
  if (!myOwner) {
1514
1816
  emit.error({
1515
1817
  code: "no_pidmap_entry",
1516
1818
  message:
1517
- "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)",
1518
1820
  });
1519
1821
  process.exit(1);
1520
1822
  }
@@ -1525,7 +1827,12 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1525
1827
  // naming window, and subagent/workflow kinds are never named — see
1526
1828
  // heartbeat-writer.setTask. This call is the naming call exactly when the
1527
1829
  // stamp appears across the mutation.
1528
- const priorHb = readHeartbeat(myOwner);
1830
+ const priorHb = readCurrentCoordinationRow(myOwner);
1831
+ const normalizedTask = task.length > 0 ? task : undefined;
1832
+ const lifecycleWarning =
1833
+ priorHb && (priorHb.task_state ?? "active") !== "active" && priorHb.task !== normalizedTask
1834
+ ? `Task text changed while lifecycle is ${priorHb.task_state}; run \`${resolveBinName(root)} agents lifecycle active\` to reopen it explicitly.`
1835
+ : null;
1529
1836
 
1530
1837
  // Heartbeat mutation goes through agent-coord (atomic temp+rename).
1531
1838
  const helper = agentCoordOrExit(root);
@@ -1541,7 +1848,7 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1541
1848
  process.exit(1);
1542
1849
  }
1543
1850
 
1544
- const hb = readHeartbeat(myOwner);
1851
+ const hb = readCurrentCoordinationRow(myOwner);
1545
1852
 
1546
1853
  // The naming call: the mutation just stamped `suggested_session_name` for
1547
1854
  // the first time. `first_of_session: true` means exactly "this call produced
@@ -1553,19 +1860,6 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1553
1860
  : null;
1554
1861
  const firstOfSession = suggestedName !== null;
1555
1862
 
1556
- emitCanonical({
1557
- type: "state.task_set",
1558
- owner: myOwner,
1559
- session: hb?.session_id ?? myOwner,
1560
- adapter: normalizeAdapter(hb?.platform),
1561
- data: {
1562
- task,
1563
- cleared: !task || task.length === 0,
1564
- first_of_session: firstOfSession,
1565
- suggested_session_name: suggestedName,
1566
- },
1567
- });
1568
-
1569
1863
  emit.data({
1570
1864
  instance_id: myOwner,
1571
1865
  name: hb?.name ?? null,
@@ -1573,11 +1867,199 @@ function runSetTask(task: string, opts?: { sessionId?: string }): void {
1573
1867
  cleared: !task || task.length === 0,
1574
1868
  first_of_session: firstOfSession,
1575
1869
  suggested_session_name: suggestedName,
1870
+ ...(lifecycleWarning ? { warning: lifecycleWarning } : {}),
1576
1871
  // Right-time instruction: the UserPromptSubmit nudge fires before the name
1577
1872
  // exists; this result is the moment the model holds the string.
1578
1873
  ...(suggestedName
1579
1874
  ? {
1580
- note: "Reproduce suggested_session_name verbatim, by itself, in a fenced code block at the top of your reply — the operator copies it as the session/tab title.",
1875
+ note: SESSION_NAME_DISPLAY_NOTE,
1876
+ }
1877
+ : {}),
1878
+ });
1879
+ }
1880
+
1881
+ function runLifecycle(rawState: string, opts: { reason?: string; sessionId?: string }): void {
1882
+ const state = rawState.trim().toLowerCase();
1883
+ if (state !== "active" && state !== "blocked" && state !== "done") {
1884
+ emit.error({
1885
+ code: "invalid_lifecycle_state",
1886
+ message: "lifecycle state must be one of: active, blocked, done",
1887
+ });
1888
+ process.exitCode = 1;
1889
+ return;
1890
+ }
1891
+
1892
+ const root = monorepoRoot();
1893
+ if (!root) {
1894
+ emit.error({
1895
+ code: "not_in_repo",
1896
+ message: "not in an agent session; coord_root() returned null",
1897
+ });
1898
+ process.exitCode = 1;
1899
+ return;
1900
+ }
1901
+ if (!opts.sessionId) ensureCursorSession(root);
1902
+ const myOwner = opts.sessionId ?? resolveOwner() ?? sessionIdentityFromEnv();
1903
+ if (!myOwner) {
1904
+ emit.error({
1905
+ code: "no_pidmap_entry",
1906
+ message:
1907
+ "not in an agent session; ppid walk found no pid-map entry (pass --session-id to bypass)",
1908
+ });
1909
+ process.exitCode = 1;
1910
+ return;
1911
+ }
1912
+
1913
+ let hb = readCurrentCoordinationRow(myOwner);
1914
+ let reopenedGeneration: ReturnType<typeof reopenLiveCoordinationGenerationV3> | undefined;
1915
+ if (!hb) {
1916
+ if (state !== "active") {
1917
+ emit.error({
1918
+ code: "no_live_generation",
1919
+ message: `${noLiveGenerationMessage(myOwner)}; run \`${resolveBinName(root)} agents lifecycle active\` to open a fresh generation`,
1920
+ });
1921
+ process.exitCode = 1;
1922
+ return;
1923
+ }
1924
+ try {
1925
+ reopenedGeneration = reopenLiveCoordinationGenerationV3({
1926
+ coordRoot: root,
1927
+ owner: myOwner,
1928
+ nativeSessionId: nativeSessionIdentity(readHeartbeatCache(root, myOwner), myOwner),
1929
+ });
1930
+ } catch (error) {
1931
+ emit.error({
1932
+ code: "lifecycle_reopen_failed",
1933
+ message: error instanceof Error ? error.message : String(error),
1934
+ });
1935
+ process.exitCode = 1;
1936
+ return;
1937
+ }
1938
+ hb = readCurrentCoordinationRow(myOwner);
1939
+ if (!hb) {
1940
+ emit.error({
1941
+ code: "lifecycle_reopen_failed",
1942
+ message: "the fresh generation was recorded but did not project as live",
1943
+ });
1944
+ process.exitCode = 1;
1945
+ return;
1946
+ }
1947
+ }
1948
+ if (hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) {
1949
+ emit.error({
1950
+ code: "lifecycle_not_human_facing",
1951
+ message: "task lifecycle declarations are limited to human-facing sessions",
1952
+ });
1953
+ process.exitCode = 1;
1954
+ return;
1955
+ }
1956
+
1957
+ const reason = opts.reason?.trim() || undefined;
1958
+ if (state === "blocked" && !reason) {
1959
+ emit.error({
1960
+ code: "blocked_reason_required",
1961
+ message: "lifecycle blocked requires --reason <text>",
1962
+ });
1963
+ process.exitCode = 1;
1964
+ return;
1965
+ }
1966
+ if (state === "done" && !hb.task) {
1967
+ emit.error({
1968
+ code: "task_required_for_done",
1969
+ message: "declare a current task with agents set-task before marking it done",
1970
+ });
1971
+ process.exitCode = 1;
1972
+ return;
1973
+ }
1974
+
1975
+ const priorState: TaskState = hb.task_state ?? "active";
1976
+ const priorReason = hb.task_state_reason || undefined;
1977
+ if (reopenedGeneration) {
1978
+ emit.data({
1979
+ instance_id: myOwner,
1980
+ task_state: "active",
1981
+ prior_state: null,
1982
+ reason: null,
1983
+ changed: true,
1984
+ generation_reopened: true,
1985
+ prior_generation_id: reopenedGeneration.prior_generation_id,
1986
+ generation_id: reopenedGeneration.generation_id,
1987
+ name_reminted: false,
1988
+ suggested_session_name: null,
1989
+ git_finalization_checked: false,
1990
+ });
1991
+ return;
1992
+ }
1993
+ if (priorState === state && priorReason === reason) {
1994
+ emit.data({
1995
+ instance_id: myOwner,
1996
+ task_state: state,
1997
+ reason: reason ?? null,
1998
+ changed: false,
1999
+ name_reminted: false,
2000
+ git_finalization_checked: false,
2001
+ });
2002
+ return;
2003
+ }
2004
+
2005
+ let finalization: GitFinalizationResult | null = null;
2006
+ if (state === "done") {
2007
+ const history = readSessionWriteClaims(
2008
+ root,
2009
+ hb.instance_id,
2010
+ nativeSessionIdentity(hb, myOwner),
2011
+ );
2012
+ const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
2013
+ finalization = checkGitFinalization(root, touchedPaths, {
2014
+ claimHistoryComplete: history.complete,
2015
+ });
2016
+ if (!finalization.ok) {
2017
+ emit.error({
2018
+ code: "git_not_finalized",
2019
+ message: formatGitFinalizationFailure(finalization, resolveBinName(root)).replace(
2020
+ "the status box was not issued",
2021
+ "lifecycle was not changed",
2022
+ ),
2023
+ });
2024
+ process.exitCode = 1;
2025
+ return;
2026
+ }
2027
+ }
2028
+
2029
+ const suggestedName = buildLifecycleSuggestedName(hb.suggested_session_name, state);
2030
+ const nameReminted = suggestedName !== null && suggestedName !== hb.suggested_session_name;
2031
+ const emitted = emitEventV3({
2032
+ owner: myOwner,
2033
+ session: nativeSessionIdentity(hb, myOwner),
2034
+ adapter: normalizeAdapter(hb.platform),
2035
+ observation: {
2036
+ event_type: "coord.lifecycle_changed",
2037
+ new_state: state,
2038
+ ...(reason ? { reason } : {}),
2039
+ ...(nameReminted ? { suggested_session_name: suggestedName } : {}),
2040
+ },
2041
+ });
2042
+ if (!emitted) {
2043
+ emit.error({
2044
+ code: "lifecycle_event_failed",
2045
+ message: "the lifecycle event could not be recorded; heartbeat state was left unchanged",
2046
+ });
2047
+ process.exitCode = 1;
2048
+ return;
2049
+ }
2050
+
2051
+ emit.data({
2052
+ instance_id: myOwner,
2053
+ task_state: state,
2054
+ prior_state: priorState,
2055
+ reason: reason ?? null,
2056
+ changed: true,
2057
+ name_reminted: nameReminted,
2058
+ suggested_session_name: nameReminted ? suggestedName : null,
2059
+ git_finalization_checked: finalization !== null,
2060
+ ...(nameReminted
2061
+ ? {
2062
+ note: SESSION_NAME_DISPLAY_NOTE,
1581
2063
  }
1582
2064
  : {}),
1583
2065
  });
@@ -1609,7 +2091,7 @@ function runSuggestName(
1609
2091
  process.exit(1);
1610
2092
  }
1611
2093
 
1612
- const hb = readHeartbeat(myOwner);
2094
+ const hb = readCurrentCoordinationRow(myOwner);
1613
2095
  const agentName = hb?.name || "unknown";
1614
2096
  // Description resolution: an explicit arg wins; with no arg, fall back to the
1615
2097
  // agent's current declared task, so a bare `suggest-name` reprints the running
@@ -1647,7 +2129,12 @@ function runSuggestName(
1647
2129
  process.stdout.write(`${suggestedName}\n`); // lint-ok-emission: chat-paste path
1648
2130
  }
1649
2131
 
1650
- function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string }): void {
2132
+ function runStatus(opts: {
2133
+ endTurn?: boolean;
2134
+ endSession?: boolean;
2135
+ json?: boolean;
2136
+ sessionId?: string;
2137
+ }): void {
1651
2138
  const root = monorepoRoot();
1652
2139
  if (!root) {
1653
2140
  emit.error({
@@ -1656,6 +2143,14 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1656
2143
  });
1657
2144
  process.exit(1);
1658
2145
  }
2146
+ if (opts.endSession && !opts.endTurn) {
2147
+ emit.error({
2148
+ code: "end_session_requires_end_turn",
2149
+ message: "--end-session requires --end-turn so Git finalization is verified first",
2150
+ });
2151
+ process.exitCode = 1;
2152
+ return;
2153
+ }
1659
2154
 
1660
2155
  // Identity resolution: prefer explicit --session-id (hook-friendly), fall
1661
2156
  // back to ppid walk for interactive shell usage.
@@ -1670,18 +2165,30 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1670
2165
  process.exit(1);
1671
2166
  }
1672
2167
 
1673
- const hb = readHeartbeat(myOwner);
2168
+ const hb = readCurrentCoordinationRow(myOwner);
1674
2169
  if (!hb) {
1675
2170
  emit.error({
1676
- code: "no_heartbeat",
1677
- message: noHeartbeatMessage(myOwner),
2171
+ code: "no_live_generation",
2172
+ message: noLiveGenerationMessage(myOwner),
1678
2173
  });
1679
2174
  process.exit(1);
1680
2175
  }
1681
2176
 
2177
+ // Report-only run quality: cheap config/cursor due check first, then a
2178
+ // non-blocking lazy evaluation. Shadow mode deliberately exposes no severity.
2179
+ const qualityEvaluation = evaluateRunQualityIfDue(root, new Date(), myOwner);
2180
+ const quality = qualityForStatus(
2181
+ qualityEvaluation.config.requested_mode,
2182
+ qualityEvaluation.snapshot,
2183
+ );
2184
+
1682
2185
  let finalization: GitFinalizationResult | null = null;
1683
2186
  if (opts.endTurn) {
1684
- const history = readSessionWriteClaims(root, hb.instance_id, hb.session_id ?? myOwner);
2187
+ const history = readSessionWriteClaims(
2188
+ root,
2189
+ hb.instance_id,
2190
+ nativeSessionIdentity(hb, myOwner),
2191
+ );
1685
2192
  const touchedPaths = [...new Set([...(hb.files_touched ?? []), ...history.paths])];
1686
2193
  finalization = checkGitFinalization(root, touchedPaths, {
1687
2194
  claimHistoryComplete: history.complete,
@@ -1696,48 +2203,60 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1696
2203
  }
1697
2204
  }
1698
2205
 
1699
- emitCanonical({
1700
- type: "state.status_checked",
2206
+ emitEventV3({
1701
2207
  owner: myOwner,
1702
- session: hb.session_id ?? myOwner,
2208
+ session: nativeSessionIdentity(hb, myOwner),
1703
2209
  adapter: normalizeAdapter(hb.platform),
1704
- data: {
1705
- format: opts.json ? "json" : "box",
1706
- git_finalization_checked: opts.endTurn === true,
1707
- agent_count: 0, // computed below, not yet available here; Phase 5 verdict reads owner-scope only
1708
- included_self: true,
2210
+ observation: {
2211
+ event_type: "coord.status_observed",
2212
+ status: opts.endTurn ? "end_turn_checked" : opts.json ? "json_checked" : "box_checked",
1709
2213
  },
1710
2214
  });
1711
2215
 
1712
- const startedAtMs = Date.parse(hb.started_at);
2216
+ let sessionEnd:
2217
+ | { state: "queued" | "already_requested"; request_id: string }
2218
+ | { state: "recorded" | "already_ended"; terminal_event_id?: string }
2219
+ | undefined;
2220
+ if (opts.endSession) {
2221
+ const canonicalInstanceId = liveInstanceIdV3(hb.instance_id);
2222
+ const records = listHookProducerStateRecordsV3(root, { includeTerminal: true }).filter(
2223
+ ({ state }) => state.instance_id === canonicalInstanceId,
2224
+ );
2225
+ if (records.length !== 1 || !records[0]) {
2226
+ emit.error({
2227
+ code: "session_identity_ambiguous",
2228
+ message: `expected one V3 generation for ${canonicalInstanceId}; found ${records.length}`,
2229
+ });
2230
+ process.exitCode = 1;
2231
+ return;
2232
+ }
2233
+ const state = records[0].state;
2234
+ const requested = requestSessionEndExplicitV3({
2235
+ coordRoot: root,
2236
+ instance_id: state.instance_id,
2237
+ generation_id: state.generation_id,
2238
+ outcome: "succeeded",
2239
+ coordination_finalized: true,
2240
+ });
2241
+ if (requested.state === "queued" || requested.state === "already_requested") {
2242
+ sessionEnd = { state: requested.state, request_id: requested.request.request_id };
2243
+ } else if (requested.state === "recorded") {
2244
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event.event_id };
2245
+ } else if (requested.state === "already_ended") {
2246
+ sessionEnd = { state: requested.state, terminal_event_id: requested.event_id };
2247
+ } else {
2248
+ emit.error({ code: "session_end_failed", message: JSON.stringify(requested) });
2249
+ process.exitCode = 1;
2250
+ return;
2251
+ }
2252
+ }
2253
+
2254
+ const startedAtMs = Date.parse(hb.started_at ?? hb.last_heartbeat);
1713
2255
  const ageSecs = Number.isFinite(startedAtMs)
1714
2256
  ? Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))
1715
2257
  : 0;
1716
2258
 
1717
- const activeDir = resolve(root, ".harnery", "active");
1718
- const nowSec = Math.floor(Date.now() / 1000);
1719
- const cutoff = nowSec - freshnessCutoffSecs();
1720
- const livePeers: Heartbeat[] = [];
1721
- let peersStale = 0;
1722
- if (existsSync(activeDir)) {
1723
- for (const file of readdirSync(activeDir)) {
1724
- if (!file.endsWith(".json")) continue;
1725
- try {
1726
- const raw = readFileSync(resolve(activeDir, file), "utf8");
1727
- const peer = JSON.parse(raw) as Heartbeat;
1728
- if (!peer || typeof peer.instance_id !== "string") continue;
1729
- if (peer.instance_id === myOwner) continue;
1730
- const ts = Date.parse(peer.last_heartbeat);
1731
- if (Number.isFinite(ts) && ts / 1000 >= cutoff) {
1732
- livePeers.push(peer);
1733
- } else {
1734
- peersStale++;
1735
- }
1736
- } catch {
1737
- // skip malformed
1738
- }
1739
- }
1740
- }
2259
+ const { livePeers, stale: peersStale } = collectStatusPeerHealth(root, myOwner);
1741
2260
 
1742
2261
  // Sort: file-holders first (by file count desc), then idle peers by recency.
1743
2262
  livePeers.sort((a, b) => {
@@ -1757,7 +2276,7 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1757
2276
  const remoteMachines = readRemoteMachines(root);
1758
2277
  const peersStr = formatPeers(livePeers, 4, peersStale, remoteMachines);
1759
2278
 
1760
- const ctxUsage = readContextUsage(hb.session_id, hb.platform);
2279
+ const ctxUsage = readContextUsage(hb.native_session_id ?? hb.session_id, hb.platform);
1761
2280
  let ctxStr: string;
1762
2281
  if (!ctxUsage) {
1763
2282
  ctxStr = "unavailable";
@@ -1790,6 +2309,13 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1790
2309
  instance_id: hb.instance_id,
1791
2310
  kind: normalizeKind(hb.kind),
1792
2311
  session_age_secs: ageSecs,
2312
+ activity: activityOf(hb),
2313
+ activity_updated_at: hb.activity_updated_at ?? null,
2314
+ activity_source: hb.activity_source ?? null,
2315
+ task_state: taskStateOf(hb),
2316
+ task_state_scope: "current" as const,
2317
+ task_state_updated_at: hb.task_state_updated_at ?? null,
2318
+ task_state_reason: hb.task_state_reason ?? null,
1793
2319
  files_held: filesHeld,
1794
2320
  peers_live: livePeers.length,
1795
2321
  peers_stale: peersStale,
@@ -1803,6 +2329,9 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1803
2329
  agents: m.agents.map((a) => ({
1804
2330
  name: a.name || "unnamed",
1805
2331
  task: a.task ?? null,
2332
+ activity: a.activity ?? "unknown",
2333
+ task_state: a.task_state ?? "active",
2334
+ task_state_reason: a.task_state_reason ?? null,
1806
2335
  files: a.files_touched?.length ?? 0,
1807
2336
  })),
1808
2337
  })),
@@ -1811,7 +2340,9 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1811
2340
  context_window: ctxUsage?.window ?? null,
1812
2341
  timestamp_iso: new Date().toISOString(),
1813
2342
  timestamp_local: timeStr,
2343
+ ...(quality ? { quality } : {}),
1814
2344
  ...(finalization ? { finalization } : {}),
2345
+ ...(sessionEnd ? { session_end: sessionEnd } : {}),
1815
2346
  };
1816
2347
 
1817
2348
  if (opts.json) {
@@ -1822,15 +2353,14 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1822
2353
 
1823
2354
  const rows: Array<[string, string]> = [
1824
2355
  ["session", formatAge(ageSecs)],
2356
+ ["activity", activityOf(hb)],
2357
+ ["lifecycle", lifecycleLabel(hb)],
1825
2358
  ["context", ctxStr],
1826
2359
  ["files", filesStr],
1827
2360
  ["peers", peersStr],
1828
2361
  ["time", timeStr],
1829
2362
  ];
1830
- // task + turn_summary get full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
1831
- if (hb.turn_summary && hb.turn_summary.length > 0) {
1832
- rows.splice(1, 0, ["last turn", hb.turn_summary]);
1833
- }
2363
+ // Task gets full text; formatBox word-wraps to MAX_BOX_CONTENT_WIDTH.
1834
2364
  if (hb.task && hb.task.length > 0) {
1835
2365
  rows.splice(1, 0, ["task", hb.task]);
1836
2366
  }
@@ -1845,11 +2375,59 @@ function runStatus(opts: { endTurn?: boolean; json?: boolean; sessionId?: string
1845
2375
  : `${pendingCouncils.length} pending (${pendingCouncils[0]}, +${pendingCouncils.length - 1})`;
1846
2376
  rows.splice(idx, 0, ["council", summary]);
1847
2377
  }
2378
+ if (quality) {
2379
+ const idx = rows.findIndex((row) => row[0] === "time");
2380
+ rows.splice(idx, 0, ["quality", formatRunQuality(quality.status, quality.signal_ids)]);
2381
+ }
2382
+ if (sessionEnd) {
2383
+ const idx = rows.findIndex((row) => row[0] === "time");
2384
+ rows.splice(idx, 0, [
2385
+ "session end",
2386
+ sessionEnd.state === "queued" || sessionEnd.state === "already_requested"
2387
+ ? "queued after this turn"
2388
+ : "recorded",
2389
+ ]);
2390
+ }
1848
2391
  // Box rendering needs predictable stdout regardless of TTY/pipe detection:
1849
2392
  // agent runs this via Bash (no TTY) and pastes captured stdout into chat.
1850
2393
  process.stdout.write(`${formatBox(displayName, rows)}\n`); // lint-ok-emission: chat-paste path; emit.text() auto-suppresses non-TTY
1851
2394
  }
1852
2395
 
2396
+ function qualityForStatus(
2397
+ mode: "off" | "shadow" | "report",
2398
+ snapshot: RunQualitySnapshot | null,
2399
+ ): {
2400
+ status: RunQualityStatus;
2401
+ signal_ids: string[];
2402
+ evaluated_at: string | null;
2403
+ fresh: boolean;
2404
+ } | null {
2405
+ if (mode !== "report") return null;
2406
+ const fresh = !!snapshot && Date.parse(snapshot.expires_at) > Date.now();
2407
+ if (!snapshot || !fresh) {
2408
+ return {
2409
+ status: "unknown",
2410
+ signal_ids: [],
2411
+ evaluated_at: snapshot?.evaluated_at ?? null,
2412
+ fresh: false,
2413
+ };
2414
+ }
2415
+ return {
2416
+ status: snapshot.status,
2417
+ signal_ids: snapshot.signals
2418
+ .filter((signal) => signal.state === "active" && signal.severity !== "none")
2419
+ .map((signal) => signal.id)
2420
+ .sort()
2421
+ .slice(0, 3),
2422
+ evaluated_at: snapshot.evaluated_at,
2423
+ fresh: true,
2424
+ };
2425
+ }
2426
+
2427
+ function formatRunQuality(status: RunQualityStatus, signalIds: string[]): string {
2428
+ return signalIds.length > 0 ? `${status} (${signalIds.join(", ")})` : status;
2429
+ }
2430
+
1853
2431
  function formatList(items: string[], cap: number, emptyLabel: string): string {
1854
2432
  if (items.length === 0) return emptyLabel;
1855
2433
  if (items.length <= cap) return items.join(", ");
@@ -1858,7 +2436,7 @@ function formatList(items: string[], cap: number, emptyLabel: string): string {
1858
2436
  }
1859
2437
 
1860
2438
  function formatPeers(
1861
- peers: Heartbeat[],
2439
+ peers: StatusPeerHeartbeat[],
1862
2440
  cap: number,
1863
2441
  staleCount: number,
1864
2442
  remoteMachines: RemoteMachine[] = [],
@@ -1891,6 +2469,38 @@ function formatPeers(
1891
2469
  return main;
1892
2470
  }
1893
2471
 
2472
+ type StatusPeerHeartbeat = {
2473
+ instance_id: string;
2474
+ last_heartbeat: string;
2475
+ files_touched?: string[];
2476
+ name?: string;
2477
+ platform?: string;
2478
+ };
2479
+
2480
+ export function collectStatusPeerHealth(
2481
+ root: string,
2482
+ myOwner: string,
2483
+ nowMs = Date.now(),
2484
+ ): { livePeers: StatusPeerHeartbeat[]; stale: number } {
2485
+ let rows: StatusPeerHeartbeat[] = [];
2486
+ try {
2487
+ rows = readLiveCoordinationRows(root);
2488
+ } catch {
2489
+ // V3 authority failures must not resurrect disposable cache rows.
2490
+ }
2491
+
2492
+ const cutoffMs = nowMs - freshnessCutoffSecs() * 1000;
2493
+ const livePeers: StatusPeerHeartbeat[] = [];
2494
+ let stale = 0;
2495
+ for (const peer of rows) {
2496
+ if (peer.instance_id === myOwner) continue;
2497
+ const observedAtMs = Date.parse(peer.last_heartbeat);
2498
+ if (Number.isFinite(observedAtMs) && observedAtMs >= cutoffMs) livePeers.push(peer);
2499
+ else stale += 1;
2500
+ }
2501
+ return { livePeers, stale };
2502
+ }
2503
+
1894
2504
  function fmtTokens(n: number): string {
1895
2505
  if (n < 1000) return `${n}`;
1896
2506
  if (n < 1000000) return `${Math.round(n / 1000)}K`;
@@ -2140,46 +2750,69 @@ interface HealEvent {
2140
2750
  platform: string;
2141
2751
  }
2142
2752
 
2143
- /**
2144
- * One canonical event envelope from `.harnery/events.ndjson` (loose shape: we
2145
- * only read the fields the health/heal aggregators need).
2146
- */
2147
- interface CanonicalEvent {
2148
- event_type: string;
2753
+ /** Bounded diagnostic event shape projected from the canonical V3 ledger. */
2754
+ export interface CanonicalEvent {
2755
+ event_type: EventTypeV3;
2149
2756
  ts: string;
2150
2757
  instance_id?: string;
2151
2758
  adapter?: string;
2152
- data?: Record<string, unknown>;
2759
+ payload?: Record<string, unknown>;
2153
2760
  }
2154
2761
 
2155
- /**
2156
- * Read canonical events in a time window. The heal + council telemetry the
2157
- * health/heal commands report lives here. Full-file read + ts filter is fine
2158
- * for an on-demand diagnostic; events.ndjson is the canonical store.
2159
- */
2160
- function readCanonicalEventsInWindow(root: string, cutoffMs: number): CanonicalEvent[] {
2161
- const p = resolve(root, ".harnery", "events.ndjson");
2162
- if (!existsSync(p)) return [];
2163
- let raw: string;
2164
- try {
2165
- raw = readFileSync(p, "utf8");
2166
- } catch {
2167
- return [];
2168
- }
2169
- const out: CanonicalEvent[] = [];
2170
- for (const line of raw.split("\n")) {
2171
- if (!line) continue;
2172
- try {
2173
- const ev = JSON.parse(line) as CanonicalEvent;
2174
- if (!ev.event_type || !ev.ts) continue;
2175
- const tsMs = Date.parse(ev.ts);
2762
+ export interface AgentDiagnosticEventRead {
2763
+ source: "v3";
2764
+ authoritative: boolean;
2765
+ reason?: string;
2766
+ truncated: boolean;
2767
+ bytes: number;
2768
+ events: CanonicalEvent[];
2769
+ }
2770
+
2771
+ /** Read validated canonical V3 events. */
2772
+ export function readAgentDiagnosticEventsInWindow(
2773
+ root: string,
2774
+ cutoffMs: number,
2775
+ ): AgentDiagnosticEventRead {
2776
+ const control = readEventV3ControlState(root);
2777
+ if (control.state === "candidate" || control.state === "active") {
2778
+ const ledger = readLedgerV3(root);
2779
+ if (!ledger.complete) {
2780
+ return {
2781
+ source: "v3",
2782
+ authoritative: false,
2783
+ reason: `V3 ledger validation failed: ${ledger.diagnostics.map((item) => item.code).join(", ") || "incomplete"}`,
2784
+ truncated: false,
2785
+ bytes: ledger.bytes,
2786
+ events: [],
2787
+ };
2788
+ }
2789
+ const events: CanonicalEvent[] = [];
2790
+ for (const { event } of ledger.events) {
2791
+ const tsMs = Date.parse(event.time.recorded_at);
2176
2792
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2177
- out.push(ev);
2178
- } catch {
2179
- /* skip malformed */
2793
+ events.push({
2794
+ event_type: event.event_type,
2795
+ ts: event.time.recorded_at,
2796
+ instance_id: event.scope.instance_id,
2797
+ payload: event.payload as Record<string, unknown>,
2798
+ });
2180
2799
  }
2800
+ return {
2801
+ source: "v3",
2802
+ authoritative: true,
2803
+ truncated: false,
2804
+ bytes: ledger.bytes,
2805
+ events,
2806
+ };
2181
2807
  }
2182
- return out;
2808
+ return {
2809
+ source: "v3",
2810
+ authoritative: false,
2811
+ reason: `V3 control state is ${control.state}`,
2812
+ truncated: false,
2813
+ bytes: 0,
2814
+ events: [],
2815
+ };
2183
2816
  }
2184
2817
 
2185
2818
  /** Normalize adapter event data into the heartbeat platform value. */
@@ -2187,19 +2820,21 @@ function adapterToPlatform(adapter: string | undefined): string {
2187
2820
  if (adapter === "claude-code") return "claude-code";
2188
2821
  if (adapter === "cursor") return "cursor";
2189
2822
  if (adapter === "codex") return "codex";
2190
- return "claude-code";
2823
+ return "unknown";
2191
2824
  }
2192
2825
 
2193
2826
  /** Project a canonical health.* event into the HealEvent shape the aggregators
2194
2827
  * already consume. Returns null for non-heal events. instance_id → display name
2195
2828
  * via `nameById` (full-UUID keyed). */
2196
2829
  function canonicalToHealEvent(ev: CanonicalEvent, nameById: Map<string, string>): HealEvent | null {
2197
- if (ev.event_type !== "health.pidmap_heal" && ev.event_type !== "health.heartbeat_heal") {
2830
+ if (ev.event_type !== "health.observed") {
2198
2831
  return null;
2199
2832
  }
2200
- const kind = ev.event_type === "health.pidmap_heal" ? "pidmap" : "heartbeat";
2201
- const data = ev.data ?? {};
2202
- const reason: "missing" | "stale" = data.reason === "stale" ? "stale" : "missing";
2833
+ const data = ev.payload ?? {};
2834
+ const subsystem = typeof data.subsystem === "string" ? data.subsystem : "";
2835
+ if (subsystem !== "pidmap" && subsystem !== "heartbeat") return null;
2836
+ const kind = subsystem;
2837
+ const reason: "missing" | "stale" = data.condition === "stale" ? "stale" : "missing";
2203
2838
  const instanceId = ev.instance_id ?? "";
2204
2839
  const name = nameById.get(instanceId);
2205
2840
  const agent = name ? `agent-${name}` : `agent-${instanceId.slice(0, 8) || "unknown"}`;
@@ -2280,13 +2915,12 @@ function runHealEvents(opts: {
2280
2915
  process.exit(1);
2281
2916
  }
2282
2917
 
2283
- // Heal telemetry lives in the canonical .harnery/events.ndjson stream
2284
- // (health.pidmap_heal / health.heartbeat_heal), emitted by the writer on
2285
- // actual self-heal writes.
2918
+ // Heal telemetry is read from the canonical V3 ledger.
2286
2919
  const cutoffMs = Date.now() - sinceSecs * 1000;
2287
2920
  const nameById = buildNameById(root);
2288
2921
  const events: HealEvent[] = [];
2289
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
2922
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
2923
+ for (const ev of diagnosticEvents.events) {
2290
2924
  const heal = canonicalToHealEvent(ev, nameById);
2291
2925
  if (heal) events.push(heal);
2292
2926
  }
@@ -2323,6 +2957,11 @@ function runHealEvents(opts: {
2323
2957
 
2324
2958
  const data = {
2325
2959
  since: opts.since,
2960
+ telemetry: {
2961
+ source: diagnosticEvents.source,
2962
+ authoritative: diagnosticEvents.authoritative,
2963
+ reason: diagnosticEvents.reason,
2964
+ },
2326
2965
  total: events.length,
2327
2966
  by_reason: byReason,
2328
2967
  by_kind: byKind,
@@ -2334,7 +2973,17 @@ function runHealEvents(opts: {
2334
2973
 
2335
2974
  if (opts.csv) {
2336
2975
  emit.config({ format: "csv" });
2337
- emit.data(recent);
2976
+ emit.data(
2977
+ data.telemetry.authoritative
2978
+ ? recent
2979
+ : [
2980
+ {
2981
+ status: "unavailable",
2982
+ source: data.telemetry.source,
2983
+ reason: data.telemetry.reason,
2984
+ },
2985
+ ],
2986
+ );
2338
2987
  return;
2339
2988
  }
2340
2989
  if (opts.json) {
@@ -2349,6 +2998,13 @@ function runHealEvents(opts: {
2349
2998
  lines.push(
2350
2999
  `Heal events: ${events.length} total in last ${opts.since} (health.pidmap_heal + health.heartbeat_heal)`,
2351
3000
  );
3001
+ if (!data.telemetry.authoritative) {
3002
+ lines.push(
3003
+ ` unavailable: ${data.telemetry.reason ?? "diagnostic event source is incomplete"}`,
3004
+ );
3005
+ emit.text(`${lines.join("\n")}\n`);
3006
+ return;
3007
+ }
2352
3008
  lines.push("");
2353
3009
  if (events.length === 0) {
2354
3010
  lines.push(" (none; pid-map/heartbeat drift is not happening in this window)");
@@ -2400,7 +3056,13 @@ function runHealEvents(opts: {
2400
3056
  interface HealthReport {
2401
3057
  since: string;
2402
3058
  generated_at: string;
3059
+ event_telemetry: {
3060
+ source: "v3";
3061
+ authoritative: boolean;
3062
+ reason?: string;
3063
+ };
2403
3064
  active_agents: {
3065
+ source: "event-ledger-v3";
2404
3066
  total: number;
2405
3067
  by_platform: Record<string, number>;
2406
3068
  by_kind: Record<string, number>;
@@ -2430,11 +3092,19 @@ interface HealthReport {
2430
3092
  // bug (e.g. a stop-projection crash that caused ~200 errors/day until it was fixed).
2431
3093
  hook_errors: {
2432
3094
  total: number;
3095
+ last_1h: number;
3096
+ latest_at: string | null;
2433
3097
  by_phase: Record<string, number>;
3098
+ by_error: Record<string, number>;
2434
3099
  top: Array<{ phase: string; count: number; sample: string }>;
3100
+ top_errors: Array<{ error: string; count: number; phase: string }>;
3101
+ recent_top_errors: Array<{ error: string; count: number; phase: string }>;
2435
3102
  };
2436
3103
  // Canonical event stream growth + drain lag.
2437
3104
  stream: {
3105
+ source: "v3";
3106
+ authoritative: boolean;
3107
+ reason?: string;
2438
3108
  bytes: number;
2439
3109
  lines: number;
2440
3110
  cursor_backlog: number;
@@ -2446,31 +3116,269 @@ interface HealthReport {
2446
3116
  count: number;
2447
3117
  samples: string[];
2448
3118
  };
3119
+ // V3 event-ledger producer health: open tool spans, pending finalization
3120
+ // requests, intake/diagnostics spool depth, span-count pressure.
3121
+ event_ledger: EventLedgerHealthV3;
2449
3122
  anomalies: string[];
2450
3123
  }
2451
3124
 
3125
+ /** Open-span soft watermark per producer state. The producer-state reader
3126
+ * hard-caps at 256 spans (a state file beyond it fails to load), so surfacing
3127
+ * pressure at half that gives room to act before reads start failing. */
3128
+ const SPAN_PRESSURE_SOFT_WATERMARK = 128;
3129
+
3130
+ export type EventLedgerHealthV3 =
3131
+ | { state: "unavailable"; reason: string }
3132
+ | {
3133
+ state: "live";
3134
+ mode: "candidate" | "active";
3135
+ open_spans: {
3136
+ total: number;
3137
+ generations: Array<{
3138
+ instance_id: string;
3139
+ generation_id: string;
3140
+ adapter: string;
3141
+ span_count: number;
3142
+ /** False = spans are open with NO open turn: the orphan signature
3143
+ * (a turn ended without its tool spans being closed). */
3144
+ turn_open: boolean;
3145
+ }>;
3146
+ };
3147
+ pending_finalizations: Array<{
3148
+ request_id: string;
3149
+ trigger: string;
3150
+ generation_id: string;
3151
+ age_ms: number;
3152
+ allowed_open_span_count: number;
3153
+ }>;
3154
+ intake_spool: {
3155
+ total: number;
3156
+ groups: Array<{ adapter: string; session_hash: string; count: number }>;
3157
+ };
3158
+ diagnostics_spool: {
3159
+ total: number;
3160
+ last_24h: number;
3161
+ last_1h: number;
3162
+ latest_at: string | null;
3163
+ by_category: Record<string, { total: number; last_24h: number }>;
3164
+ recent_by_category: Record<string, { last_1h: number; latest_at: string | null }>;
3165
+ };
3166
+ span_pressure: Array<{ instance_id: string; generation_id: string; span_count: number }>;
3167
+ collection_errors: string[];
3168
+ };
3169
+
3170
+ function errorText(error: unknown): string {
3171
+ return error instanceof Error ? error.message : String(error);
3172
+ }
3173
+
3174
+ /**
3175
+ * Read-only health counters for the V3 event ledger's producer surfaces. Never
3176
+ * mutates ledger state (no control repair, no spool drain) and never throws:
3177
+ * a non-live route returns `{ state: "unavailable" }`, and each sub-surface
3178
+ * that fails to read lands in `collection_errors` instead of aborting the rest.
3179
+ */
3180
+ export function collectEventLedgerHealthV3(root: string, nowMs = Date.now()): EventLedgerHealthV3 {
3181
+ let control: ReturnType<typeof readEventV3ControlState>;
3182
+ try {
3183
+ control = readEventV3ControlState(root);
3184
+ } catch (error) {
3185
+ return { state: "unavailable", reason: `control read failed: ${errorText(error)}` };
3186
+ }
3187
+ if (control.state !== "candidate" && control.state !== "active") {
3188
+ return {
3189
+ state: "unavailable",
3190
+ reason: `${control.state}: ${control.reason}`,
3191
+ };
3192
+ }
3193
+
3194
+ const collectionErrors: string[] = [];
3195
+
3196
+ // 1) Open tool spans per live (non-terminal) generation + span-count pressure.
3197
+ const generations: Extract<EventLedgerHealthV3, { state: "live" }>["open_spans"]["generations"] =
3198
+ [];
3199
+ const spanPressure: Extract<EventLedgerHealthV3, { state: "live" }>["span_pressure"] = [];
3200
+ try {
3201
+ for (const { state } of listHookProducerStateRecordsV3(root)) {
3202
+ if (state.spans.length >= SPAN_PRESSURE_SOFT_WATERMARK) {
3203
+ spanPressure.push({
3204
+ instance_id: state.instance_id,
3205
+ generation_id: state.generation_id,
3206
+ span_count: state.spans.length,
3207
+ });
3208
+ }
3209
+ if (state.spans.length === 0) continue;
3210
+ generations.push({
3211
+ instance_id: state.instance_id,
3212
+ generation_id: state.generation_id,
3213
+ adapter: state.adapter,
3214
+ span_count: state.spans.length,
3215
+ turn_open: Boolean(state.current_turn_id),
3216
+ });
3217
+ }
3218
+ } catch (error) {
3219
+ collectionErrors.push(`producer states unreadable: ${errorText(error)}`);
3220
+ }
3221
+
3222
+ // 2) Pending finalization requests with age + trigger.
3223
+ const pending: Extract<EventLedgerHealthV3, { state: "live" }>["pending_finalizations"] = [];
3224
+ try {
3225
+ for (const request of listSessionFinalizationRequestsV3(root)) {
3226
+ if (request.status !== "pending") continue;
3227
+ const observedMs = Date.parse(request.observed_at);
3228
+ pending.push({
3229
+ request_id: request.request_id,
3230
+ trigger: request.trigger,
3231
+ generation_id: request.generation_id,
3232
+ age_ms: Number.isFinite(observedMs) ? Math.max(0, nowMs - observedMs) : -1,
3233
+ allowed_open_span_count: request.allowed_open_span_ids?.length ?? 0,
3234
+ });
3235
+ }
3236
+ } catch (error) {
3237
+ collectionErrors.push(`finalization requests unreadable: ${errorText(error)}`);
3238
+ }
3239
+
3240
+ // 3) Intake spool depth (queued hook signals awaiting a lease-holder drain).
3241
+ let intakeTotal = 0;
3242
+ const intakeGroups: Extract<EventLedgerHealthV3, { state: "live" }>["intake_spool"]["groups"] =
3243
+ [];
3244
+ try {
3245
+ for (const group of listHookIntakeGroupsV3(root)) {
3246
+ const count = listHookIntakeRecordsV3(group.directory).length;
3247
+ if (count === 0) continue;
3248
+ intakeTotal += count;
3249
+ intakeGroups.push({ adapter: group.adapter, session_hash: group.session_hash, count });
3250
+ }
3251
+ } catch (error) {
3252
+ collectionErrors.push(`intake spool unreadable: ${errorText(error)}`);
3253
+ }
3254
+
3255
+ // 4) Diagnostics spool counts by category. Filenames only (never the
3256
+ // contents): `<category>-<orderkey>.json` where the orderkey leads with a
3257
+ // zero-padded epoch-ms, which also gives the last-24h split for free.
3258
+ const byCategory: Record<string, { total: number; last_24h: number }> = {};
3259
+ const recentByCategory: Extract<
3260
+ EventLedgerHealthV3,
3261
+ { state: "live" }
3262
+ >["diagnostics_spool"]["recent_by_category"] = {};
3263
+ let diagnosticsTotal = 0;
3264
+ let diagnostics24h = 0;
3265
+ let diagnostics1h = 0;
3266
+ let latestDiagnosticMs = Number.NEGATIVE_INFINITY;
3267
+ try {
3268
+ const diagnosticsDir = join(resolve(root), EVENT_V3_LEDGER_RELATIVE_ROOT, "diagnostics");
3269
+ if (existsSync(diagnosticsDir)) {
3270
+ const dayAgoMs = nowMs - 24 * 60 * 60 * 1000;
3271
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
3272
+ for (const name of readdirSync(diagnosticsDir)) {
3273
+ const match = /^(.+)-(\d{15})-\d{20}-\d+-[0-9a-f-]+\.json$/.exec(name);
3274
+ const category = match?.[1];
3275
+ const epochMs = Number(match?.[2]);
3276
+ if (!category) continue;
3277
+ const entry = byCategory[category] ?? { total: 0, last_24h: 0 };
3278
+ const recent = recentByCategory[category] ?? { last_1h: 0, latest_at: null };
3279
+ byCategory[category] = entry;
3280
+ recentByCategory[category] = recent;
3281
+ entry.total += 1;
3282
+ diagnosticsTotal += 1;
3283
+ if (Number.isFinite(epochMs)) {
3284
+ const recordedAt = new Date(epochMs).toISOString();
3285
+ if (!recent.latest_at || epochMs > Date.parse(recent.latest_at)) {
3286
+ recent.latest_at = recordedAt;
3287
+ }
3288
+ latestDiagnosticMs = Math.max(latestDiagnosticMs, epochMs);
3289
+ if (epochMs >= dayAgoMs) {
3290
+ entry.last_24h += 1;
3291
+ diagnostics24h += 1;
3292
+ }
3293
+ if (epochMs >= hourAgoMs) {
3294
+ recent.last_1h += 1;
3295
+ diagnostics1h += 1;
3296
+ }
3297
+ }
3298
+ }
3299
+ }
3300
+ } catch (error) {
3301
+ collectionErrors.push(`diagnostics spool unreadable: ${errorText(error)}`);
3302
+ }
3303
+
3304
+ return {
3305
+ state: "live",
3306
+ mode: control.state,
3307
+ open_spans: {
3308
+ total: generations.reduce((sum, generation) => sum + generation.span_count, 0),
3309
+ generations,
3310
+ },
3311
+ pending_finalizations: pending,
3312
+ intake_spool: { total: intakeTotal, groups: intakeGroups },
3313
+ diagnostics_spool: {
3314
+ total: diagnosticsTotal,
3315
+ last_24h: diagnostics24h,
3316
+ last_1h: diagnostics1h,
3317
+ latest_at: Number.isFinite(latestDiagnosticMs)
3318
+ ? new Date(latestDiagnosticMs).toISOString()
3319
+ : null,
3320
+ by_category: byCategory,
3321
+ recent_by_category: recentByCategory,
3322
+ },
3323
+ span_pressure: spanPressure,
3324
+ collection_errors: collectionErrors,
3325
+ };
3326
+ }
3327
+
2452
3328
  /** Tally agent-hook failures (.harnery/debug/agent-hook.errors.ndjson) in the
2453
- * window, grouped by `phase`. Each line is {ts, error, phase, ...}. A dominant
2454
- * phase points straight at a systemic hook bug. */
2455
- function readHookErrors(
3329
+ * window by exact error and phase, while separating current-hour failures from
3330
+ * historical rows. Each line is {ts, error, phase, ...}. */
3331
+ export function readHookErrors(
2456
3332
  root: string,
2457
3333
  cutoffMs: number,
3334
+ nowMs = Date.now(),
2458
3335
  ): {
2459
3336
  total: number;
3337
+ last1h: number;
3338
+ latestAt: string | null;
2460
3339
  byPhase: Record<string, number>;
3340
+ byError: Record<string, number>;
2461
3341
  top: Array<{ phase: string; count: number; sample: string }>;
3342
+ topErrors: Array<{ error: string; count: number; phase: string }>;
3343
+ recentTopErrors: Array<{ error: string; count: number; phase: string }>;
2462
3344
  } {
2463
3345
  const p = resolve(root, ".harnery", "debug", "agent-hook.errors.ndjson");
2464
3346
  const byPhase: Record<string, number> = {};
3347
+ const byError: Record<string, number> = {};
2465
3348
  const sampleByPhase: Record<string, string> = {};
3349
+ const errorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
3350
+ const recentErrorPhaseCounts = new Map<string, { error: string; phase: string; count: number }>();
2466
3351
  let total = 0;
2467
- if (!existsSync(p)) return { total: 0, byPhase, top: [] };
3352
+ let last1h = 0;
3353
+ let latestMs = Number.NEGATIVE_INFINITY;
3354
+ if (!existsSync(p)) {
3355
+ return {
3356
+ total: 0,
3357
+ last1h: 0,
3358
+ latestAt: null,
3359
+ byPhase,
3360
+ byError,
3361
+ top: [],
3362
+ topErrors: [],
3363
+ recentTopErrors: [],
3364
+ };
3365
+ }
2468
3366
  let raw: string;
2469
3367
  try {
2470
3368
  raw = readFileSync(p, "utf8");
2471
3369
  } catch {
2472
- return { total: 0, byPhase, top: [] };
3370
+ return {
3371
+ total: 0,
3372
+ last1h: 0,
3373
+ latestAt: null,
3374
+ byPhase,
3375
+ byError,
3376
+ top: [],
3377
+ topErrors: [],
3378
+ recentTopErrors: [],
3379
+ };
2473
3380
  }
3381
+ const hourAgoMs = nowMs - 60 * 60 * 1000;
2474
3382
  for (const line of raw.split("\n")) {
2475
3383
  if (!line.trim()) continue;
2476
3384
  try {
@@ -2478,8 +3386,21 @@ function readHookErrors(
2478
3386
  const tsMs = e.ts ? Date.parse(e.ts) : Number.NaN;
2479
3387
  if (!Number.isFinite(tsMs) || tsMs < cutoffMs) continue;
2480
3388
  const phase = e.phase ?? "(unknown)";
3389
+ const error = e.error ?? "(unknown)";
2481
3390
  byPhase[phase] = (byPhase[phase] ?? 0) + 1;
3391
+ byError[error] = (byError[error] ?? 0) + 1;
2482
3392
  if (!sampleByPhase[phase] && e.error) sampleByPhase[phase] = e.error;
3393
+ const errorPhaseKey = `${error}\0${phase}`;
3394
+ const errorPhase = errorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3395
+ errorPhase.count += 1;
3396
+ errorPhaseCounts.set(errorPhaseKey, errorPhase);
3397
+ latestMs = Math.max(latestMs, tsMs);
3398
+ if (tsMs >= hourAgoMs) {
3399
+ last1h += 1;
3400
+ const recent = recentErrorPhaseCounts.get(errorPhaseKey) ?? { error, phase, count: 0 };
3401
+ recent.count += 1;
3402
+ recentErrorPhaseCounts.set(errorPhaseKey, recent);
3403
+ }
2483
3404
  total++;
2484
3405
  } catch {
2485
3406
  /* skip malformed */
@@ -2489,124 +3410,181 @@ function readHookErrors(
2489
3410
  .sort((a, b) => b[1] - a[1])
2490
3411
  .slice(0, 5)
2491
3412
  .map(([phase, count]) => ({ phase, count, sample: sampleByPhase[phase] ?? "" }));
2492
- return { total, byPhase, top };
3413
+ const topErrors = [...errorPhaseCounts.values()]
3414
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3415
+ .slice(0, 5);
3416
+ const recentTopErrors = [...recentErrorPhaseCounts.values()]
3417
+ .sort((left, right) => right.count - left.count || left.error.localeCompare(right.error))
3418
+ .slice(0, 5);
3419
+ return {
3420
+ total,
3421
+ last1h,
3422
+ latestAt: Number.isFinite(latestMs) ? new Date(latestMs).toISOString() : null,
3423
+ byPhase,
3424
+ byError,
3425
+ top,
3426
+ topErrors,
3427
+ recentTopErrors,
3428
+ };
2493
3429
  }
2494
3430
 
2495
3431
  /** Canonical event stream size + drain lag (events appended after the cursor). */
2496
- function readStreamStats(root: string): { bytes: number; lines: number; cursor_backlog: number } {
2497
- const streamPath = resolve(root, ".harnery", "events.ndjson");
2498
- if (!existsSync(streamPath)) return { bytes: 0, lines: 0, cursor_backlog: 0 };
2499
- let bytes = 0;
2500
- try {
2501
- bytes = statSync(streamPath).size;
2502
- } catch {
2503
- /* ignore */
2504
- }
2505
- let cursor: string | null = null;
2506
- const cursorPath = resolve(root, ".harnery", ".events-cursor");
2507
- if (existsSync(cursorPath)) {
2508
- try {
2509
- cursor = readFileSync(cursorPath, "utf8").trim() || null;
2510
- } catch {
2511
- /* ignore */
2512
- }
2513
- }
2514
- let lines = 0;
2515
- let backlog = 0;
2516
- let seenCursor = cursor === null; // no cursor → everything is "backlog"
2517
- try {
2518
- // Bounded tail read: the ledger grows without bound and a whole-file
2519
- // readFileSync throws past V8's ~512MB string limit. lines/backlog are then
2520
- // scoped to the scanned window; `bytes` (full size, via statSync above) still
2521
- // reflects the true stream size for the health rollup.
2522
- const { text } = readStreamTailBounded(streamPath, STREAM_SCAN_CAP_BYTES);
2523
- for (const line of text.split("\n")) {
2524
- if (!line.trim()) continue;
2525
- lines++;
2526
- if (seenCursor) {
2527
- backlog++;
2528
- } else if (cursor && line.includes(`"event_id":"${cursor}"`)) {
2529
- seenCursor = true;
2530
- }
2531
- }
2532
- } catch {
2533
- /* ignore */
2534
- }
2535
- return { bytes, lines, cursor_backlog: backlog };
3432
+ function readStreamStats(root: string): HealthReport["stream"] {
3433
+ const read = readAgentDiagnosticEventsInWindow(root, 0);
3434
+ return {
3435
+ source: "v3",
3436
+ authoritative: read.authoritative,
3437
+ ...(read.reason ? { reason: read.reason } : {}),
3438
+ bytes: read.bytes,
3439
+ lines: read.events.length,
3440
+ cursor_backlog: 0,
3441
+ };
2536
3442
  }
2537
3443
 
2538
3444
  /** One rendered line in a trace. */
2539
- interface TraceEntry {
3445
+ export interface TraceEntry {
2540
3446
  ts: string;
2541
3447
  event_type: string;
2542
3448
  detail: string;
2543
3449
  }
2544
3450
 
3451
+ export function traceInstanceIdsForEventSource(
3452
+ nativeInstanceIds: readonly string[],
3453
+ source: "v3",
3454
+ ): string[] {
3455
+ void source;
3456
+ return nativeInstanceIds.map((instanceId) => liveInstanceIdV3(instanceId));
3457
+ }
3458
+
2545
3459
  /** Map a canonical event to a concise trace line, or null to drop it. */
2546
- function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
2547
- const d = (ev.data ?? {}) as Record<string, unknown>;
3460
+ export function traceLine(ev: CanonicalEvent, allTools: boolean): TraceEntry | null {
3461
+ const d = (ev.payload ?? {}) as Record<string, unknown>;
2548
3462
  const s = (k: string): string => (typeof d[k] === "string" ? (d[k] as string) : "");
3463
+ const object = (value: unknown): Record<string, unknown> =>
3464
+ typeof value === "object" && value !== null && !Array.isArray(value)
3465
+ ? (value as Record<string, unknown>)
3466
+ : {};
2549
3467
  const clip = (v: string, n = 70): string => (v.length <= n ? v : `${v.slice(0, n - 1)}…`);
3468
+ const recovery =
3469
+ typeof d.recovery === "object" && d.recovery !== null
3470
+ ? (d.recovery as { reason?: unknown })
3471
+ : null;
3472
+ const recoveryReason = typeof recovery?.reason === "string" ? recovery.reason : "";
3473
+ const recoveryDetail = recoveryReason ? ` · RECOVERY reason=${recoveryReason}` : "";
2550
3474
  let detail = "";
2551
3475
  switch (ev.event_type) {
2552
- case "session.start":
2553
- detail = `${s("source") || "startup"}${s("model") ? ` · model=${s("model")}` : ""}${s("name") ? ` · ${s("name")}` : ""}`;
3476
+ case "session.started":
3477
+ detail = "generation started";
3478
+ break;
3479
+ case "session.resumed":
3480
+ detail = "generation resumed";
3481
+ break;
3482
+ case "session.ended":
3483
+ detail = `outcome=${s("outcome") || "unknown"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
3484
+ break;
3485
+ case "session.termination_observed":
3486
+ detail = `provisional ${s("observation") || "termination"}${s("reason") ? ` · reason=${s("reason")}` : ""}`;
3487
+ break;
3488
+ case "agent.delegated":
3489
+ detail = `delegated child=${s("child_generation_id") || "unknown"}`;
3490
+ break;
3491
+ case "agent.started":
3492
+ detail = `child started=${s("child_generation_id") || "unknown"}`;
3493
+ break;
3494
+ case "agent.completed":
3495
+ detail = `child completed=${s("child_generation_id") || "unknown"} · outcome=${s("outcome") || "unknown"}`;
3496
+ break;
3497
+ case "turn.started":
3498
+ detail = `intent=${s("intent_kind") || "unknown"}`;
3499
+ break;
3500
+ case "turn.completed":
3501
+ detail = `outcome=${s("outcome") || "unknown"}`;
3502
+ break;
3503
+ case "tool.requested": {
3504
+ const tool = object(d.tool);
3505
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} requested (content omitted)${recoveryDetail}`;
3506
+ break;
3507
+ }
3508
+ case "tool.completed": {
3509
+ const tool = object(d.tool);
3510
+ detail = `${typeof tool.name === "string" ? tool.name : "tool"} · outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3511
+ break;
3512
+ }
3513
+ case "command.started":
3514
+ if (!allTools) return null;
3515
+ detail = "command started (content omitted)";
3516
+ break;
3517
+ case "command.completed":
3518
+ if (!allTools && !recoveryReason) return null;
3519
+ detail = `outcome=${s("outcome") || "unknown"}${recoveryDetail}`;
3520
+ break;
3521
+ case "coord.task_changed":
3522
+ case "coord.lifecycle_changed":
3523
+ case "coord.presence_changed":
3524
+ detail = `${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}${s("reason") ? ` · ${clip(s("reason"), 55)}` : ""}`;
3525
+ break;
3526
+ case "coord.status_observed":
3527
+ detail = s("status") || "status observed";
2554
3528
  break;
2555
- case "session.end":
2556
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3529
+ case "coord.claim_changed": {
3530
+ const target = object(d.target);
3531
+ detail = `${s("operation") || "changed"}${typeof target.display === "string" ? ` · ${clip(target.display)}` : ""}`;
2557
3532
  break;
2558
- case "subagent.start":
2559
- detail = `${s("agent_type") || "subagent"}${s("name") ? ` · ${s("name")}` : ""}`;
3533
+ }
3534
+ case "coord.message_observed":
3535
+ detail = `${s("direction") || "observed"} · peer=${s("peer_instance_id") || "unknown"}`;
2560
3536
  break;
2561
- case "subagent.stop":
2562
- detail = `clean_exit=${d.clean_exit ?? "?"}`;
3537
+ case "coord.identity_attested":
3538
+ detail = `${s("identity_id") || "identity"} · method=${s("method") || "unknown"}`;
2563
3539
  break;
2564
- case "user_prompt.submit":
2565
- detail = clip(s("prompt_text") || s("prompt"));
3540
+ case "wait.started":
3541
+ detail = s("kind") || "wait started";
2566
3542
  break;
2567
- case "turn.stop":
2568
- detail = `status_box=${d.status_box_present ?? "?"}${s("turn_summary") ? ` · ${clip(s("turn_summary"), 50)}` : ""}`;
3543
+ case "wait.ended":
3544
+ detail = `outcome=${s("outcome") || "unknown"}`;
2569
3545
  break;
2570
- case "tool.pre_use":
2571
- detail = `${s("tool_name")}${s("tool_target") || s("intent") ? ` · ${clip(s("tool_target") || s("intent"), 60)}` : ""}`;
3546
+ case "artifact.observed": {
3547
+ const artifact = object(d.artifact);
3548
+ detail = `operation=${s("operation") || "observed"}${typeof artifact.kind === "string" ? ` · kind=${artifact.kind}` : ""}`;
2572
3549
  break;
2573
- case "state.task_set":
2574
- detail = d.cleared ? "(cleared)" : clip(s("task"));
3550
+ }
3551
+ case "progress.observed":
3552
+ detail = s("kind") || "progress";
2575
3553
  break;
2576
- case "state.status_checked":
2577
- detail = "status box rendered";
3554
+ case "health.observed":
3555
+ detail = `${s("subsystem") || "unknown"} · ${s("severity") || "unknown"} · ${s("condition") || "unknown"}`;
2578
3556
  break;
2579
- case "identity.assumed":
2580
- detail = `${s("previous_name") || "unknown"} → ${s("name") || "unknown"}${s("agent_id") ? ` · persona=${s("agent_id").slice(0, 8)}…` : ""}`;
3557
+ case "council.state_changed":
3558
+ detail = `${s("council_id") || "council"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2581
3559
  break;
2582
- case "claim.acquire":
2583
- case "claim.release":
2584
- case "claim.conflict":
2585
- detail = clip(s("path"));
3560
+ case "decision.state_changed":
3561
+ detail = `${s("decision_id") || "decision"} · ${s("prior_state") || "unknown"} → ${s("new_state") || "unknown"}`;
2586
3562
  break;
2587
- case "health.heartbeat_heal":
2588
- case "health.pidmap_heal":
2589
- detail = `reason=${s("reason")}`;
3563
+ case "lifecycle.recovered":
3564
+ detail = `recovered=${s("recovery_kind") || "unknown"}`;
2590
3565
  break;
2591
- case "health.heartbeat_swept":
2592
- detail = `reason=${s("reason")}${d.age_secs !== undefined ? ` · age=${d.age_secs}s` : ""}`;
3566
+ case "lifecycle.sweep_observed":
3567
+ detail = `${s("observation") || "sweep"}${typeof d.age_ms === "number" ? ` · age=${Math.round(d.age_ms / 1000)}s` : ""}`;
2593
3568
  break;
2594
3569
  default:
2595
- // Noise unless --all-tools: per-line command.* + tool.post_use.
2596
- if (!allTools) return null;
2597
- if (
2598
- ev.event_type === "tool.post_use" ||
2599
- ev.event_type === "tool.post_use_failure" ||
2600
- ev.event_type.startsWith("command.")
2601
- ) {
2602
- detail = s("tool_name") || "";
2603
- break;
2604
- }
2605
3570
  return null;
2606
3571
  }
2607
3572
  return { ts: ev.ts, event_type: ev.event_type, detail };
2608
3573
  }
2609
3574
 
3575
+ export function pendingFinalizationTraceEntries(
3576
+ requests: readonly SessionFinalizationRequestV3[],
3577
+ instanceId: string,
3578
+ ): TraceEntry[] {
3579
+ return requests
3580
+ .filter((request) => request.status === "pending" && request.instance_id === instanceId)
3581
+ .map((request) => ({
3582
+ ts: request.observed_at,
3583
+ event_type: "session.finalization_pending",
3584
+ detail: `trigger=${request.trigger} · request=${request.request_id}${request.allowed_open_span_ids?.length ? ` · allowed_open_spans=${request.allowed_open_span_ids.length}` : ""}`,
3585
+ }));
3586
+ }
3587
+
2610
3588
  function runTrace(
2611
3589
  name: string,
2612
3590
  opts: { since?: string; limit: string; allTools?: boolean; json?: boolean },
@@ -2635,7 +3613,7 @@ function runTrace(
2635
3613
  if (candidates.length === 1) targetId = candidates[0]![0];
2636
3614
  else if (candidates.length > 1)
2637
3615
  targetId = candidates.map(([id]) => id).join("\x00"); // sentinel; resolved below
2638
- else if (/^[0-9a-f-]{8,}$/i.test(wanted)) targetId = wanted; // looks like an id not in history
3616
+ else if (/^(?:inst_)?[0-9a-z._-]{8,}$/i.test(wanted)) targetId = wanted;
2639
3617
  }
2640
3618
  if (!targetId) {
2641
3619
  emit.error({
@@ -2647,37 +3625,16 @@ function runTrace(
2647
3625
 
2648
3626
  const sinceMs = opts.since ? Date.now() - (parseWindowSecs(opts.since) ?? 0) * 1000 : 0;
2649
3627
  const limit = Math.max(1, Number.parseInt(opts.limit, 10) || 200);
2650
- const candidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
2651
-
2652
- // Scan the stream tail once, bucket events by instance_id for the candidates.
2653
- // Bounded read: the ledger grows without bound and a whole-file readFileSync
2654
- // throws past V8's ~512MB string limit. A trace of an agent whose events all
2655
- // predate the window is reported as truncated rather than crashing.
2656
- const streamPath = resolve(root, ".harnery", "events.ndjson");
3628
+ const diagnosticRead = readAgentDiagnosticEventsInWindow(root, sinceMs);
3629
+ const nativeCandidateIds = targetId.includes("\x00") ? targetId.split("\x00") : [targetId];
3630
+ const candidateIds = traceInstanceIdsForEventSource(nativeCandidateIds, diagnosticRead.source);
2657
3631
  const byId = new Map<string, CanonicalEvent[]>();
2658
- const { text: streamText, truncated: streamTruncated } = readStreamTailBounded(
2659
- streamPath,
2660
- STREAM_SCAN_CAP_BYTES,
2661
- );
2662
- for (const line of streamText.split("\n")) {
2663
- if (!line) continue;
2664
- try {
2665
- const ev = JSON.parse(line) as CanonicalEvent;
2666
- if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
2667
- if (sinceMs && Date.parse(ev.ts) < sinceMs) continue;
2668
- const arr = byId.get(ev.instance_id) ?? [];
2669
- arr.push(ev);
2670
- byId.set(ev.instance_id, arr);
2671
- } catch {
2672
- /* skip */
2673
- }
2674
- }
2675
- if (streamTruncated) {
2676
- process.stderr.write(
2677
- `note: event ledger exceeds ${Math.round(STREAM_SCAN_CAP_BYTES / 1024 / 1024)}MB; traced only the most recent window (older events omitted)\n`,
2678
- );
3632
+ for (const ev of diagnosticRead.events) {
3633
+ if (!ev.instance_id || !candidateIds.includes(ev.instance_id)) continue;
3634
+ const arr = byId.get(ev.instance_id) ?? [];
3635
+ arr.push(ev);
3636
+ byId.set(ev.instance_id, arr);
2679
3637
  }
2680
-
2681
3638
  // If the name mapped to multiple instances, trace the one with the latest event.
2682
3639
  let resolvedId = candidateIds[0]!;
2683
3640
  if (candidateIds.length > 1) {
@@ -2693,20 +3650,86 @@ function runTrace(
2693
3650
  }
2694
3651
 
2695
3652
  const events = byId.get(resolvedId) ?? [];
2696
- const lines = events
2697
- .map((ev) => traceLine(ev, !!opts.allTools))
2698
- .filter((l): l is TraceEntry => l !== null)
3653
+ let state = foldSessionState(events, { instance_id: resolvedId });
3654
+ let sessionState: "live" | "ended" = events.some((event) => event.event_type === "session.ended")
3655
+ ? "ended"
3656
+ : "live";
3657
+ if (diagnosticRead.source === "v3" && diagnosticRead.authoritative) {
3658
+ try {
3659
+ const view = projectCoordinationViewV3(readLedgerV3(root));
3660
+ const liveGeneration = view.instances[resolvedId];
3661
+ const terminalGeneration = Object.values(view.terminal_generations).find(
3662
+ (candidate) => candidate.instance_id === resolvedId,
3663
+ );
3664
+ const generation = liveGeneration ?? terminalGeneration;
3665
+ if (generation) {
3666
+ sessionState = terminalGeneration && !liveGeneration ? "ended" : "live";
3667
+ const lifecycleState = generation.lifecycle_state;
3668
+ state = {
3669
+ activity: generation.activity === "terminal" ? "idle" : generation.activity,
3670
+ activity_updated_at: generation.last_observed_at,
3671
+ activity_source: "event-v3-coordination-view",
3672
+ task_state:
3673
+ lifecycleState === "active" || lifecycleState === "blocked" || lifecycleState === "done"
3674
+ ? lifecycleState
3675
+ : "active",
3676
+ task_state_updated_at: generation.lifecycle_state_updated_at,
3677
+ };
3678
+ }
3679
+ } catch {
3680
+ diagnosticRead.authoritative = false;
3681
+ diagnosticRead.reason = "V3 coordination projection is unavailable";
3682
+ }
3683
+ }
3684
+ let pendingFinalizations: SessionFinalizationRequestV3[] = [];
3685
+ if (diagnosticRead.source === "v3") {
3686
+ try {
3687
+ pendingFinalizations = listSessionFinalizationRequestsV3(root).filter(
3688
+ (request) => request.status === "pending" && request.instance_id === resolvedId,
3689
+ );
3690
+ } catch {
3691
+ diagnosticRead.authoritative = false;
3692
+ diagnosticRead.reason = "V3 finalization requests are unreadable";
3693
+ }
3694
+ }
3695
+ const lines = [
3696
+ ...events
3697
+ .map((ev) => traceLine(ev, !!opts.allTools))
3698
+ .filter((l): l is TraceEntry => l !== null),
3699
+ ...pendingFinalizationTraceEntries(pendingFinalizations, resolvedId),
3700
+ ]
2699
3701
  // Sort by timestamp, not file order: codex replays events (original ts,
2700
3702
  // appended later), so append-order ≠ chronological order.
2701
3703
  .sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
2702
3704
  const shown = lines.slice(-limit);
2703
- const displayName = nameById.get(resolvedId) ?? resolvedId.slice(0, 8);
3705
+ const nativeResolvedId =
3706
+ diagnosticRead.source === "v3"
3707
+ ? nativeCandidateIds[candidateIds.indexOf(resolvedId)]
3708
+ : resolvedId;
3709
+ const displayName =
3710
+ nameById.get(resolvedId) ??
3711
+ (nativeResolvedId ? nameById.get(nativeResolvedId) : undefined) ??
3712
+ resolvedId.slice(0, 8);
2704
3713
 
2705
3714
  const result = {
3715
+ event_source: {
3716
+ contract: diagnosticRead.source,
3717
+ authoritative: diagnosticRead.authoritative,
3718
+ reason: diagnosticRead.reason ?? null,
3719
+ },
2706
3720
  name: displayName,
2707
3721
  instance_id: resolvedId,
2708
3722
  other_instances: candidateIds.filter((id) => id !== resolvedId),
3723
+ pending_finalizations: pendingFinalizations,
2709
3724
  total_events: events.length,
3725
+ session_state: sessionState,
3726
+ activity: state.activity,
3727
+ activity_updated_at: state.activity_updated_at ?? null,
3728
+ activity_source: state.activity_source ?? null,
3729
+ task_state: state.task_state,
3730
+ task_state_scope: sessionState === "ended" ? "historical" : "current",
3731
+ task_state_updated_at: state.task_state_updated_at ?? null,
3732
+ task_state_reason: state.task_state_reason ?? null,
2710
3733
  shown: shown.length,
2711
3734
  entries: shown,
2712
3735
  };
@@ -2718,6 +3741,12 @@ function runTrace(
2718
3741
  emit.data(result);
2719
3742
  const header = `Trace: agent-${displayName} (${resolvedId.slice(0, 8)}…) ${events.length} events${result.other_instances.length ? ` · ${result.other_instances.length} older instance(s) of this name` : ""}`;
2720
3743
  process.stdout.write(`${header}\n`); // lint-ok-emission: human trace view
3744
+ process.stdout.write(
3745
+ ` activity=${state.activity} · session=${sessionState} · lifecycle=${sessionState === "ended" ? `historical(${state.task_state})` : state.task_state}${sessionState !== "ended" && state.task_state === "blocked" && state.task_state_reason ? `: ${state.task_state_reason}` : ""}\n`,
3746
+ ); // lint-ok-emission: human trace view
3747
+ if (!diagnosticRead.authoritative) {
3748
+ process.stdout.write(` unavailable: ${diagnosticRead.reason ?? "event source incomplete"}\n`); // lint-ok-emission: explicit no-fallback diagnostic
3749
+ }
2721
3750
  if (shown.length === 0) {
2722
3751
  process.stdout.write(" (no events)\n"); // lint-ok-emission: human trace view
2723
3752
  return;
@@ -2728,6 +3757,73 @@ function runTrace(
2728
3757
  }
2729
3758
  }
2730
3759
 
3760
+ export interface ActiveAgentHealthSummary {
3761
+ source: "event-ledger-v3";
3762
+ total: number;
3763
+ by_platform: Record<string, number>;
3764
+ by_kind: Record<string, number>;
3765
+ by_schema_version: Record<string, number>;
3766
+ stale: number;
3767
+ }
3768
+
3769
+ type ActiveHealthHeartbeat = {
3770
+ instance_id: string;
3771
+ platform?: string;
3772
+ kind?: string;
3773
+ schema_version?: number;
3774
+ last_heartbeat?: string;
3775
+ };
3776
+
3777
+ /**
3778
+ * V3 generations, not disposable heartbeat caches, are the active-agent authority.
3779
+ */
3780
+ export function collectActiveAgentHealth(
3781
+ root: string,
3782
+ nowMs = Date.now(),
3783
+ ): ActiveAgentHealthSummary {
3784
+ const source: ActiveAgentHealthSummary["source"] = "event-ledger-v3";
3785
+ let heartbeats: ActiveHealthHeartbeat[] = [];
3786
+ try {
3787
+ const control = readEventV3ControlState(root);
3788
+ if (control.state === "candidate" || control.state === "active") {
3789
+ heartbeats = readLiveCoordinationRows(root);
3790
+ }
3791
+ } catch {
3792
+ // The event-ledger section reports the authority read failure. Do not
3793
+ // substitute stale cache rows for a live V3 route.
3794
+ }
3795
+
3796
+ const byPlatform: Record<string, number> = {};
3797
+ const byKind: Record<string, number> = {};
3798
+ const bySchema: Record<string, number> = {};
3799
+ let stale = 0;
3800
+ for (const heartbeat of heartbeats) {
3801
+ const platform = formatPlatformLabel(heartbeat.platform);
3802
+ byPlatform[platform] = (byPlatform[platform] ?? 0) + 1;
3803
+ const kind = heartbeat.kind ?? "unknown";
3804
+ byKind[kind] = (byKind[kind] ?? 0) + 1;
3805
+ // These rows exist only after an authority-safe V3 projection. The
3806
+ // disposable heartbeat file has its own cache-format version, which must
3807
+ // not be reported as the ledger schema version.
3808
+ bySchema.v3 = (bySchema.v3 ?? 0) + 1;
3809
+ const lastObservedMs = heartbeat.last_heartbeat
3810
+ ? Date.parse(heartbeat.last_heartbeat)
3811
+ : Number.NaN;
3812
+ const ageMs = Number.isFinite(lastObservedMs)
3813
+ ? nowMs - lastObservedMs
3814
+ : Number.POSITIVE_INFINITY;
3815
+ if (ageMs > freshnessCutoffSecs() * 1000) stale += 1;
3816
+ }
3817
+ return {
3818
+ source,
3819
+ total: heartbeats.length,
3820
+ by_platform: byPlatform,
3821
+ by_kind: byKind,
3822
+ by_schema_version: bySchema,
3823
+ stale,
3824
+ };
3825
+ }
3826
+
2731
3827
  function runHealth(opts: { since: string; json?: boolean }): void {
2732
3828
  if (opts.json) emit.config({ format: "json" });
2733
3829
 
@@ -2749,12 +3845,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2749
3845
  process.exit(1);
2750
3846
  }
2751
3847
 
2752
- const cutoffMs = Date.now() - sinceSecs * 1000;
3848
+ const nowMs = Date.now();
3849
+ const cutoffMs = nowMs - sinceSecs * 1000;
2753
3850
  const activeDir = resolve(root, ".harnery/active");
2754
3851
  const councilsDir = resolve(root, ".harnery/councils");
2755
3852
 
2756
- // Coordination telemetry reads from the canonical events.ndjson stream.
2757
- // Heals come from health.*; council window-activity from council.*. The
3853
+ // Coordination telemetry reads from the canonical V3 ledger.
3854
+ // Heals come from health.observed; council activity from council.state_changed.
2758
3855
  const heal: HealEvent[] = [];
2759
3856
  let councilAdvanced = 0;
2760
3857
  let councilClosed = 0;
@@ -2763,33 +3860,33 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2763
3860
  const sweptByReason: Record<string, number> = {};
2764
3861
 
2765
3862
  const nameById = buildNameById(root);
2766
- for (const ev of readCanonicalEventsInWindow(root, cutoffMs)) {
3863
+ const diagnosticEvents = readAgentDiagnosticEventsInWindow(root, cutoffMs);
3864
+ for (const ev of diagnosticEvents.events) {
2767
3865
  const healEv = canonicalToHealEvent(ev, nameById);
2768
3866
  if (healEv) {
2769
3867
  heal.push(healEv);
2770
3868
  continue;
2771
3869
  }
3870
+ if (ev.event_type === "council.state_changed") {
3871
+ const state = String(ev.payload?.new_state ?? "");
3872
+ if (state === "round_open") councilAdvanced++;
3873
+ if (state === "closed") councilClosed++;
3874
+ if (state === "archived") councilArchived++;
3875
+ continue;
3876
+ }
2772
3877
  switch (ev.event_type) {
2773
- case "council.round_open":
2774
- councilAdvanced++;
2775
- break;
2776
- case "council.close":
2777
- councilClosed++;
2778
- break;
2779
- case "council.archive":
2780
- councilArchived++;
2781
- break;
2782
- case "health.heartbeat_swept": {
3878
+ case "lifecycle.sweep_observed": {
2783
3879
  sweptTotal++;
2784
- const reason = String((ev.data as { reason?: unknown })?.reason ?? "unknown");
3880
+ const reason = String(ev.payload?.observation ?? "unknown");
2785
3881
  sweptByReason[reason] = (sweptByReason[reason] ?? 0) + 1;
2786
3882
  break;
2787
3883
  }
2788
3884
  }
2789
3885
  }
2790
3886
 
2791
- const hookErrors = readHookErrors(root, cutoffMs);
3887
+ const hookErrors = readHookErrors(root, cutoffMs, nowMs);
2792
3888
  const stream = readStreamStats(root);
3889
+ const eventLedger = collectEventLedgerHealthV3(root);
2793
3890
 
2794
3891
  // Canonical health.* events carry the full instance_id, already resolved to
2795
3892
  // `agent-<name>` (or `agent-<hex8>` fallback) by canonicalToHealEvent via
@@ -2809,19 +3906,13 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2809
3906
  .slice(0, 5)
2810
3907
  .map(([agent, count]) => ({ agent, count }));
2811
3908
 
2812
- // Active heartbeats: scan ALL files in active/, classify fresh vs stale ourselves.
2813
- const activeByPlatform: Record<string, number> = {};
2814
- const activeByKind: Record<string, number> = {};
2815
- const activeBySchema: Record<string, number> = {};
2816
- let activeTotal = 0;
2817
- let staleHeartbeats = 0;
3909
+ const activeAgents = collectActiveAgentHealth(root, nowMs);
2818
3910
  // Zombies: files in active/ that are broken: unparseable, nameless, or an
2819
3911
  // absurd (epoch-ish) last_heartbeat. These show as `agent-unknown` ghosts and
2820
3912
  // mean dead files the sweep isn't reaping.
2821
3913
  let zombieCount = 0;
2822
3914
  const zombieSamples: string[] = [];
2823
3915
  const ABSURD_AGE_MS = 24 * 60 * 60 * 1000; // > 1 day = clearly not a live, self-healing agent
2824
- const nowMs = Date.now();
2825
3916
  if (existsSync(activeDir)) {
2826
3917
  for (const file of readdirSync(activeDir)) {
2827
3918
  if (!file.endsWith(".json")) continue;
@@ -2838,17 +3929,8 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2838
3929
  zombieSamples.push(`${idFromFile.slice(0, 12)} (unparseable/no-id)`);
2839
3930
  continue;
2840
3931
  }
2841
- activeTotal++;
2842
- const platform = formatPlatformLabel(hb.platform);
2843
- activeByPlatform[platform] = (activeByPlatform[platform] ?? 0) + 1;
2844
- const kind = hb.kind ?? "unknown";
2845
- activeByKind[kind] = (activeByKind[kind] ?? 0) + 1;
2846
- const sv = (hb as { schema_version?: number }).schema_version;
2847
- const schemaKey = sv === undefined ? "v0" : `v${sv}`;
2848
- activeBySchema[schemaKey] = (activeBySchema[schemaKey] ?? 0) + 1;
2849
3932
  const lastHbMs = hb.last_heartbeat ? Date.parse(hb.last_heartbeat) : Number.NaN;
2850
3933
  const ageMs = Number.isFinite(lastHbMs) ? nowMs - lastHbMs : Number.POSITIVE_INFINITY;
2851
- if (ageMs > freshnessCutoffSecs() * 1000) staleHeartbeats++;
2852
3934
  // Zombie heuristics on a parseable heartbeat: no name, or an age so large
2853
3935
  // it can only be a broken/epoch timestamp (a real agent would have healed).
2854
3936
  if (!hb.name || hb.name === "unknown" || ageMs > ABSURD_AGE_MS) {
@@ -2880,37 +3962,38 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2880
3962
  );
2881
3963
  }
2882
3964
  }
2883
- if (staleHeartbeats > 0) {
3965
+ if (activeAgents.stale > 0) {
3966
+ const noun = activeAgents.source === "event-ledger-v3" ? "V3 generation" : "heartbeat";
3967
+ const action =
3968
+ activeAgents.source === "event-ledger-v3"
3969
+ ? "lifecycle reconciliation may be delayed"
3970
+ : "heal mechanism may not be firing";
2884
3971
  anomalies.push(
2885
- `${staleHeartbeats} active heartbeat(s) older than ${Math.floor(freshnessCutoffSecs() / 60)}min; heal mechanism may not be firing`,
3972
+ `${activeAgents.stale} active ${noun}(s) without activity for ${Math.floor(freshnessCutoffSecs() / 60)}min; ${action}`,
2886
3973
  );
2887
3974
  }
2888
- const unexpectedSchemas = Object.keys(activeBySchema).filter((k) => k !== "v1");
3975
+ const expectedSchema = "v3";
3976
+ const unexpectedSchemas = Object.keys(activeAgents.by_schema_version).filter(
3977
+ (schema) => schema !== expectedSchema,
3978
+ );
2889
3979
  if (unexpectedSchemas.length > 0) {
2890
3980
  anomalies.push(
2891
- `Unexpected heartbeat schema versions in use: ${unexpectedSchemas.join(", ")} (expected v1)`,
3981
+ `Unexpected active-agent schema versions in use: ${unexpectedSchemas.join(", ")} (expected ${expectedSchema})`,
2892
3982
  );
2893
3983
  }
2894
- // agent-hook failures: a dominant phase is the fastest pointer to a systemic
2895
- // hook bug (this is the signal that would have surfaced the stop-projection
2896
- // crash immediately instead of after an hour of log-grepping).
2897
- if (hookErrors.total > 0) {
2898
- const top = hookErrors.top[0];
2899
- const detail = top
2900
- ? `: top phase '${top.phase}' x${top.count}${top.sample ? ` (${top.sample.slice(0, 80)})` : ""}`
2901
- : "";
2902
- anomalies.push(`agent-hook errored ${hookErrors.total}x in ${opts.since}${detail}`);
3984
+ // Only recent hook failures are active anomalies. Historical rows remain in
3985
+ // the report with exact error signatures and their latest timestamp.
3986
+ if (hookErrors.last1h > 0) {
3987
+ const top = hookErrors.recentTopErrors[0];
3988
+ const detail = top ? `: top '${top.error.slice(0, 80)}' x${top.count} (${top.phase})` : "";
3989
+ anomalies.push(`agent-hook errored ${hookErrors.last1h}x in the last hour${detail}`);
2903
3990
  }
2904
3991
  if (stream.cursor_backlog > 500) {
2905
3992
  anomalies.push(
2906
3993
  `projection cursor is ${stream.cursor_backlog} events behind; drain lagging (stop projection may be failing)`,
2907
3994
  );
2908
3995
  }
2909
- // NB: raw stream size is NOT an anomaly. events.ndjson is a deliberate
2910
- // append-only ledger (names + forensics for the life of the log), and
2911
- // consumeSince tail-reads it, so size no longer drives latency. The
2912
- // bytes/lines still surface in the summary line for visibility;
2913
- // cursor_backlog above is the real drain-lag signal.
3996
+ // Raw V3 stream size is not an anomaly; catalog rotation bounds readers.
2914
3997
  if ((sweptByReason.unparseable ?? 0) > 0) {
2915
3998
  anomalies.push(
2916
3999
  `${sweptByReason.unparseable} heartbeat(s) swept as unparseable in ${opts.since}; possible corruption or a non-atomic writer`,
@@ -2921,16 +4004,46 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2921
4004
  `${zombieCount} zombie heartbeat(s) in active/ (${zombieSamples.join(", ")}); broken files the sweep isn't reaping`,
2922
4005
  );
2923
4006
  }
4007
+ if (eventLedger.state === "live") {
4008
+ // Open spans whose generation has no open turn: the turn ended without its
4009
+ // tool spans closing, so an explicit end can only queue, never finalize.
4010
+ const orphanGenerations = eventLedger.open_spans.generations.filter(
4011
+ (generation) => !generation.turn_open,
4012
+ );
4013
+ if (orphanGenerations.length > 0) {
4014
+ const orphanSpans = orphanGenerations.reduce(
4015
+ (sum, generation) => sum + generation.span_count,
4016
+ 0,
4017
+ );
4018
+ anomalies.push(
4019
+ `${orphanSpans} open tool span(s) across ${orphanGenerations.length} generation(s) with no open turn; orphaned spans block clean session end`,
4020
+ );
4021
+ }
4022
+ for (const pressure of eventLedger.span_pressure) {
4023
+ anomalies.push(
4024
+ `${pressure.instance_id} (${pressure.generation_id}) holds ${pressure.span_count} open spans (soft watermark ${SPAN_PRESSURE_SOFT_WATERMARK}; the reader's 256-span cap makes the state unreadable)`,
4025
+ );
4026
+ }
4027
+ for (const collectionError of eventLedger.collection_errors) {
4028
+ anomalies.push(`event-ledger health: ${collectionError}`);
4029
+ }
4030
+ }
2924
4031
 
2925
4032
  const report: HealthReport = {
2926
4033
  since: opts.since,
2927
4034
  generated_at: new Date().toISOString(),
4035
+ event_telemetry: {
4036
+ source: diagnosticEvents.source,
4037
+ authoritative: diagnosticEvents.authoritative,
4038
+ ...(diagnosticEvents.reason ? { reason: diagnosticEvents.reason } : {}),
4039
+ },
2928
4040
  active_agents: {
2929
- total: activeTotal,
2930
- by_platform: activeByPlatform,
2931
- by_kind: activeByKind,
2932
- by_schema_version: activeBySchema,
2933
- stale: staleHeartbeats,
4041
+ source: activeAgents.source,
4042
+ total: activeAgents.total,
4043
+ by_platform: activeAgents.by_platform,
4044
+ by_kind: activeAgents.by_kind,
4045
+ by_schema_version: activeAgents.by_schema_version,
4046
+ stale: activeAgents.stale,
2934
4047
  },
2935
4048
  heal_events: {
2936
4049
  total: heal.length,
@@ -2946,9 +4059,19 @@ function runHealth(opts: { since: string; json?: boolean }): void {
2946
4059
  closed_in_window: councilClosed,
2947
4060
  },
2948
4061
  swept_events: { total: sweptTotal, by_reason: sweptByReason },
2949
- hook_errors: { total: hookErrors.total, by_phase: hookErrors.byPhase, top: hookErrors.top },
4062
+ hook_errors: {
4063
+ total: hookErrors.total,
4064
+ last_1h: hookErrors.last1h,
4065
+ latest_at: hookErrors.latestAt,
4066
+ by_phase: hookErrors.byPhase,
4067
+ by_error: hookErrors.byError,
4068
+ top: hookErrors.top,
4069
+ top_errors: hookErrors.topErrors,
4070
+ recent_top_errors: hookErrors.recentTopErrors,
4071
+ },
2950
4072
  stream,
2951
4073
  zombies: { count: zombieCount, samples: zombieSamples },
4074
+ event_ledger: eventLedger,
2952
4075
  anomalies,
2953
4076
  };
2954
4077
 
@@ -2990,7 +4113,9 @@ function renderHealthBox(report: HealthReport): void {
2990
4113
  if (report.councils.archived_in_window > 0)
2991
4114
  councilParts.push(`${report.councils.archived_in_window} archived`);
2992
4115
 
2993
- const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""}`;
4116
+ const activeSource =
4117
+ report.active_agents.source === "event-ledger-v3" ? "ledger" : "heartbeat cache";
4118
+ const activeStr = `${report.active_agents.total}${platforms ? ` (${platforms})` : ""}${schemas ? ` · ${schemas}` : ""}${report.active_agents.stale > 0 ? ` · ${report.active_agents.stale} stale` : ""} · ${activeSource}`;
2994
4119
 
2995
4120
  const sweptReasonStr = Object.entries(report.swept_events.by_reason)
2996
4121
  .map(([r, n]) => `${r} ${n}`)
@@ -2998,11 +4123,36 @@ function renderHealthBox(report: HealthReport): void {
2998
4123
  const hookErrStr =
2999
4124
  report.hook_errors.total === 0
3000
4125
  ? "0"
3001
- : `${report.hook_errors.total}${report.hook_errors.top[0] ? ` (${report.hook_errors.top[0].phase} x${report.hook_errors.top[0].count})` : ""}`;
4126
+ : `${report.hook_errors.total} in window · ${report.hook_errors.last_1h} in 1h`;
3002
4127
  const streamStr = `${(report.stream.bytes / 1048576).toFixed(1)}MB · ${report.stream.lines} lines · ${report.stream.cursor_backlog} behind`;
3003
4128
 
4129
+ const ledger = report.event_ledger;
4130
+ let ledgerStr: string;
4131
+ if (ledger.state !== "live") {
4132
+ ledgerStr = `unavailable (${ledger.reason})`;
4133
+ } else {
4134
+ const orphanGenerations = ledger.open_spans.generations.filter(
4135
+ (generation) => !generation.turn_open,
4136
+ ).length;
4137
+ const pendingAges = ledger.pending_finalizations
4138
+ .map((request) =>
4139
+ request.age_ms < 0 ? "age?" : formatAge(Math.floor(request.age_ms / 1000)),
4140
+ )
4141
+ .join(", ");
4142
+ ledgerStr = [
4143
+ `open spans ${ledger.open_spans.total}${orphanGenerations > 0 ? ` (${orphanGenerations} gen turn-closed)` : ""}`,
4144
+ `pending ends ${ledger.pending_finalizations.length}${pendingAges ? ` (${pendingAges})` : ""}`,
4145
+ `intake ${ledger.intake_spool.total}`,
4146
+ `diagnostics ${ledger.diagnostics_spool.total} (${ledger.diagnostics_spool.last_1h} in 1h / ${ledger.diagnostics_spool.last_24h} in 24h)`,
4147
+ ].join(" · ");
4148
+ }
4149
+
3004
4150
  const rows: Array<[string, string]> = [
3005
4151
  ["window", `last ${report.since}`],
4152
+ [
4153
+ "event source",
4154
+ `${report.event_telemetry.source.toUpperCase()} · ${report.event_telemetry.authoritative ? "validated" : `unavailable (${report.event_telemetry.reason ?? "incomplete"})`}`,
4155
+ ],
3006
4156
  ["active", activeStr],
3007
4157
  [
3008
4158
  "heals",
@@ -3012,6 +4162,7 @@ function renderHealthBox(report: HealthReport): void {
3012
4162
  ["swept", `${report.swept_events.total}${sweptReasonStr ? ` (${sweptReasonStr})` : ""}`],
3013
4163
  ["hook errors", hookErrStr],
3014
4164
  ["stream", streamStr],
4165
+ ["event ledger", ledgerStr],
3015
4166
  [
3016
4167
  "zombies",
3017
4168
  report.zombies.count === 0
@@ -3136,6 +4287,7 @@ function runAdapterProbe(
3136
4287
  replayExitCode = result.exitCode;
3137
4288
  data.samples = samples;
3138
4289
  data.samples_summary = result.summary;
4290
+ if (result.note) data.samples_note = result.note;
3139
4291
  }
3140
4292
 
3141
4293
  emit.data(data);
@@ -3155,7 +4307,9 @@ function runAdapterProbe(
3155
4307
  | undefined;
3156
4308
  lines.push("");
3157
4309
  if (samples.length === 0) {
3158
- lines.push(` Sample replay: no .json fixtures found under ${sampleDir}`);
4310
+ lines.push(
4311
+ ` Sample replay: ${String(data.samples_note ?? `no .json fixtures found under ${sampleDir}`)}`,
4312
+ );
3159
4313
  } else {
3160
4314
  lines.push(
3161
4315
  ` Sample replay (${samples.length} fixture${samples.length === 1 ? "" : "s"}):`,
@@ -3212,6 +4366,7 @@ function replayAdapterSamples(
3212
4366
  samples: SampleReplayResult[];
3213
4367
  exitCode: number;
3214
4368
  summary: { total: number; pass: number; fail: number; skipped: number };
4369
+ note?: string;
3215
4370
  } {
3216
4371
  const sampleDir = resolve(root, relativeSampleDir);
3217
4372
  if (!existsSync(sampleDir)) {
@@ -3219,6 +4374,7 @@ function replayAdapterSamples(
3219
4374
  samples: [],
3220
4375
  exitCode: 0,
3221
4376
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4377
+ note: `sample directory not found: ${relativeSampleDir} — replay verified nothing`,
3222
4378
  };
3223
4379
  }
3224
4380
 
@@ -3232,6 +4388,7 @@ function replayAdapterSamples(
3232
4388
  samples: [],
3233
4389
  exitCode: 0,
3234
4390
  summary: { total: 0, pass: 0, fail: 0, skipped: 0 },
4391
+ note: `no .json fixtures under ${relativeSampleDir} — replay verified nothing`,
3235
4392
  };
3236
4393
  }
3237
4394
 
@@ -3457,11 +4614,26 @@ function runPing(name: string, message: string, opts: { json?: boolean }): void
3457
4614
  });
3458
4615
  process.exit(1);
3459
4616
  }
3460
- const myHb = readHeartbeat(myOwner);
4617
+ const myHb = readCurrentCoordinationRow(myOwner);
3461
4618
  const fromName = myHb?.name ?? "anonymous";
3462
4619
  const body = `from agent-${fromName}: ${message.trim()}`;
3463
4620
  const doc = appendEntry(peerOwner, "handoff", body);
3464
4621
 
4622
+ // Canonical delivery record: sender is the envelope owner, recipient rides
4623
+ // in data, so read-only observers can render the communication without
4624
+ // joining journal files.
4625
+ emitEventV3({
4626
+ owner: myOwner,
4627
+ session: nativeSessionIdentity(myHb, myOwner),
4628
+ adapter: normalizeAdapter(myHb?.platform),
4629
+ observation: {
4630
+ event_type: "coord.message_observed",
4631
+ direction: "sent",
4632
+ subject: peerOwner,
4633
+ body: message.trim(),
4634
+ },
4635
+ });
4636
+
3465
4637
  const data = {
3466
4638
  peer: name,
3467
4639
  peer_instance_id: peerOwner,
@@ -3519,7 +4691,7 @@ async function runWait(
3519
4691
 
3520
4692
  let lastProgressMs = 0;
3521
4693
  while (true) {
3522
- const hb = readHeartbeat(peerOwner);
4694
+ const hb = readCurrentCoordinationRow(peerOwner);
3523
4695
  const now = Date.now();
3524
4696
  const elapsedMs = now - startMs;
3525
4697
 
@@ -3559,9 +4731,8 @@ async function runWait(
3559
4731
  const progressGapMs = Math.max(pollMs, 30_000);
3560
4732
  if (!opts.quiet && now - lastProgressMs >= progressGapMs) {
3561
4733
  lastProgressMs = now;
3562
- const lastTool = hb.last_tool ? `, last=${hb.last_tool}` : "";
3563
4734
  const elapsedStr = formatAge(Math.floor(elapsedMs / 1000));
3564
- const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking${lastTool}\n`;
4735
+ const progress = ` [${elapsedStr}] ${stillBlocking.length} file(s) blocking\n`;
3565
4736
  process.stderr.write(progress); // lint-ok-emission: per-poll progress heartbeat to stderr
3566
4737
  }
3567
4738
 
@@ -3602,6 +4773,7 @@ function runHeal(opts: {
3602
4773
  owner: string;
3603
4774
  kind: string;
3604
4775
  sessionId?: string;
4776
+ adapter?: string;
3605
4777
  pid?: string;
3606
4778
  json?: boolean;
3607
4779
  }): void {
@@ -3613,10 +4785,22 @@ function runHeal(opts: {
3613
4785
  emit.error({ code: "missing_owner", message: "--owner is required" });
3614
4786
  process.exit(1);
3615
4787
  }
3616
- if (kind !== "pidmap" && kind !== "heartbeat" && kind !== "kill") {
4788
+ if (kind !== "pidmap" && kind !== "cache") {
3617
4789
  emit.error({
3618
4790
  code: "bad_kind",
3619
- message: "--kind must be one of: pidmap, heartbeat, kill",
4791
+ message: "--kind must be one of: pidmap, cache",
4792
+ });
4793
+ process.exit(1);
4794
+ }
4795
+ if (
4796
+ opts.adapter &&
4797
+ opts.adapter !== "claude-code" &&
4798
+ opts.adapter !== "cursor" &&
4799
+ opts.adapter !== "codex"
4800
+ ) {
4801
+ emit.error({
4802
+ code: "bad_adapter",
4803
+ message: "--adapter must be one of: claude-code, cursor, codex",
3620
4804
  });
3621
4805
  process.exit(1);
3622
4806
  }
@@ -3630,10 +4814,9 @@ function runHeal(opts: {
3630
4814
  process.exit(1);
3631
4815
  }
3632
4816
 
3633
- const action =
3634
- kind === "pidmap" ? "heal-pidmap" : kind === "heartbeat" ? "heal-heartbeat" : "kill-heartbeat";
4817
+ const action = kind === "pidmap" ? "heal-pidmap" : "repair-coordination-cache";
3635
4818
 
3636
- // Refuse to mint a heartbeat at a TRUNCATED owner id.
4819
+ // Refuse to materialize a V3 cache at a truncated owner id.
3637
4820
  //
3638
4821
  // Heartbeats are keyed by the whole instance_id, so healing at an
3639
4822
  // abbreviated id writes `.harnery/active/<prefix>.json` while every reader
@@ -3653,19 +4836,18 @@ function runHeal(opts: {
3653
4836
  // whose instance_id this id is a prefix of settles it just as well, and is
3654
4837
  // present in precisely the case that matters (the session the reader was
3655
4838
  // trying to heal is registered, just not under the abbreviated name).
3656
- if (kind === "heartbeat" && !readHeartbeat(owner)) {
4839
+ if (kind === "cache" && !readHeartbeatCache(root, owner)) {
3657
4840
  const canonical =
3658
4841
  opts.sessionId && opts.sessionId.trim() !== owner && opts.sessionId.trim().startsWith(owner)
3659
4842
  ? opts.sessionId.trim()
3660
4843
  : liveIdWithPrefix(root, owner);
3661
4844
  if (canonical) {
3662
- const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live heartbeat";
4845
+ const source = opts.sessionId?.trim() === canonical ? "--session-id" : "a live V3 generation";
3663
4846
  emit.error({
3664
4847
  code: "truncated_owner",
3665
4848
  message:
3666
- `--owner ${owner} is a prefix of ${canonical} (${source}), and no heartbeat exists ` +
3667
- `at .harnery/active/${owner}.json. Healing here would create a heartbeat no reader ` +
3668
- `resolves. Re-run with --owner ${canonical}.`,
4849
+ `--owner ${owner} is a prefix of ${canonical} (${source}). Cache repair requires ` +
4850
+ `the canonical instance id; re-run with --owner ${canonical}.`,
3669
4851
  });
3670
4852
  process.exit(1);
3671
4853
  }
@@ -3673,14 +4855,13 @@ function runHeal(opts: {
3673
4855
 
3674
4856
  // Build positional args. agent-coord's arg layout:
3675
4857
  // heal-pidmap <instance_id> [<pid>]
3676
- // heal-heartbeat <instance_id> [<session_id>]
3677
- // kill-heartbeat <instance_id>
4858
+ // repair-coordination-cache <instance_id> [<session_id>]
3678
4859
  const helperArgs: string[] = [action, owner];
3679
4860
  if (kind === "pidmap" && opts.pid) helperArgs.push(opts.pid);
3680
- if (kind === "heartbeat" && opts.sessionId) helperArgs.push(opts.sessionId);
4861
+ if (kind === "cache" && opts.sessionId) helperArgs.push(opts.sessionId);
4862
+ if (kind === "cache" && opts.adapter) helperArgs.push(`--adapter=${opts.adapter}`);
3681
4863
 
3682
- // heal-pidmap / heal-heartbeat / kill-heartbeat are handled by the
3683
- // bundled agent-coord binary.
4864
+ // Both recovery actions are handled by the bundled agent-coord binary.
3684
4865
  const helper = agentCoordOrExit(root);
3685
4866
  const proc = spawnSync(helper, helperArgs, {
3686
4867
  encoding: "utf8",
@@ -3695,27 +4876,22 @@ function runHeal(opts: {
3695
4876
  process.exit(1);
3696
4877
  }
3697
4878
 
3698
- // Re-read the heartbeat to surface post-action state (or null if killed).
4879
+ // Read the derived cache to surface post-action state.
3699
4880
  let after: Heartbeat | null = null;
3700
4881
  try {
3701
- after = readHeartbeat(owner);
4882
+ after = readHeartbeatCache(root, owner);
3702
4883
  } catch {
3703
4884
  after = null;
3704
4885
  }
3705
4886
 
3706
- // Outcome semantics differ per kind. kill-heartbeat targets the heartbeat
3707
- // file directly; heal-heartbeat upserts it; heal-pidmap touches a pid-map
3708
- // row whose existence is independent of the heartbeat. Reporting on
3709
- // "heartbeat present after" for the pidmap path was misleading: it's
3710
- // unrelated to whether the heal succeeded.
3711
4887
  const outcome =
3712
4888
  kind === "pidmap"
3713
4889
  ? proc.status === 0
3714
4890
  ? "ok"
3715
4891
  : "failed"
3716
4892
  : after
3717
- ? "heartbeat_present"
3718
- : "heartbeat_absent";
4893
+ ? "cache_present"
4894
+ : "cache_absent";
3719
4895
 
3720
4896
  emit.data({
3721
4897
  rows: [
@@ -3738,36 +4914,40 @@ function runHeal(opts: {
3738
4914
  if (kind === "pidmap") {
3739
4915
  emit.text(`agent-coord ${action} ok\n`);
3740
4916
  } else {
3741
- emit.text(`agent-coord ${action} ok: heartbeat ${after ? "present" : "absent"} after\n`);
4917
+ emit.text(`agent-coord ${action} ok: cache ${after ? "present" : "absent"} after\n`);
3742
4918
  }
3743
4919
  }
3744
4920
 
3745
- // Canonical health.* emission is owned by the writer (heartbeat-writer.ts
3746
- // healPidmap/healHeartbeat), so it fires inside the agent-coord subprocess
4921
+ // Canonical health.* emission is owned by the cache writer, so it fires
4922
+ // inside the agent-coord subprocess
3747
4923
  // above on actual writes only: write-only telemetry, no double-emit, no
3748
4924
  // event when an already-correct heal no-ops. (Previously emitted here
3749
4925
  // unconditionally on every `harn agents heal`, which over-counted no-op heals.)
3750
4926
  }
3751
4927
 
3752
4928
  /**
3753
- * Shared emitter for council.* events. Looks up the running agent's
3754
- * heartbeat so each event carries a real instance_id / session_id; falls
3755
- * through silently if no session (CI / direct invocation).
4929
+ * Emit one V3 council audit transition keyed to the exact durable manifest.
4930
+ * Falls through silently when no live session can attest the observation.
3756
4931
  */
3757
4932
  function emitCouncilStateEvent(
3758
- type: string,
3759
4933
  manifest: CouncilManifest,
3760
- extraData: Record<string, unknown>,
4934
+ newState: string,
4935
+ priorState?: string,
3761
4936
  ): void {
3762
4937
  const myOwner = resolveOwner();
3763
4938
  if (!myOwner) return;
3764
- const hb = readHeartbeat(myOwner);
3765
- emitCanonical({
3766
- type,
4939
+ const hb = readCurrentCoordinationRow(myOwner);
4940
+ emitEventV3({
3767
4941
  owner: myOwner,
3768
- session: hb?.session_id ?? myOwner,
4942
+ session: nativeSessionIdentity(hb, myOwner),
3769
4943
  adapter: normalizeAdapter(hb?.platform),
3770
- data: { council_id: manifest.council_id, ...extraData },
4944
+ observation: {
4945
+ event_type: "council.state_changed",
4946
+ council_id: manifest.council_id,
4947
+ ...(priorState ? { prior_state: priorState } : {}),
4948
+ new_state: newState,
4949
+ record: manifest,
4950
+ },
3771
4951
  });
3772
4952
  }
3773
4953
 
@@ -3824,7 +5004,7 @@ function runCouncilCreate(
3824
5004
  if (opts.createdBy?.trim()) {
3825
5005
  createdBy = normalizeAgentName(opts.createdBy);
3826
5006
  } else if (myOwner) {
3827
- const myHb = readHeartbeat(myOwner);
5007
+ const myHb = readCurrentCoordinationRow(myOwner);
3828
5008
  if (myHb?.name) {
3829
5009
  createdBy = normalizeAgentName(myHb.name);
3830
5010
  }
@@ -3885,17 +5065,16 @@ function runCouncilCreate(
3885
5065
  writeManifest(manifest);
3886
5066
 
3887
5067
  if (myOwner) {
3888
- const myHbForEmit = readHeartbeat(myOwner);
3889
- emitCanonical({
3890
- type: "council.open",
5068
+ const myHbForEmit = readCurrentCoordinationRow(myOwner);
5069
+ emitEventV3({
3891
5070
  owner: myOwner,
3892
- session: myHbForEmit?.session_id ?? myOwner,
5071
+ session: nativeSessionIdentity(myHbForEmit, myOwner),
3893
5072
  adapter: normalizeAdapter(myHbForEmit?.platform),
3894
- data: {
3895
- council_id: councilId,
3896
- topic: trimmedObjective,
3897
- members,
3898
- target_doc: manifest.target_doc ?? undefined,
5073
+ observation: {
5074
+ event_type: "council.state_changed",
5075
+ council_id: manifest.council_id,
5076
+ new_state: "active",
5077
+ record: manifest,
3899
5078
  },
3900
5079
  });
3901
5080
  }
@@ -3908,7 +5087,7 @@ function runCouncilCreate(
3908
5087
  const skippedMembers: string[] = [];
3909
5088
  for (const memberName of members) {
3910
5089
  const bareName = memberName.replace(/^agent-/, "");
3911
- if (myOwner && readHeartbeat(myOwner)?.name === bareName) {
5090
+ if (myOwner && readCurrentCoordinationRow(myOwner)?.name === bareName) {
3912
5091
  // Convener is themselves a member; skip the self-ping
3913
5092
  continue;
3914
5093
  }
@@ -3973,7 +5152,7 @@ function runCouncilList(opts: { status?: string; mine?: boolean; json?: boolean
3973
5152
  if (opts.mine) {
3974
5153
  const myOwner = resolveOwner();
3975
5154
  if (myOwner) {
3976
- const myHb = readHeartbeat(myOwner);
5155
+ const myHb = readCurrentCoordinationRow(myOwner);
3977
5156
  if (myHb?.name) myName = normalizeAgentName(myHb.name);
3978
5157
  }
3979
5158
  if (!myName) {
@@ -4150,7 +5329,7 @@ function runCouncilClose(id: string, opts: { json?: boolean }): void {
4150
5329
  closed_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
4151
5330
  };
4152
5331
  writeManifest(next);
4153
- emitCouncilStateEvent("council.close", next, { closed_at: next.closed_at! });
5332
+ emitCouncilStateEvent(next, "closed", manifest.status);
4154
5333
 
4155
5334
  // Build the transcript: every round's contributions in order.
4156
5335
  const transcript = buildTranscript(next);
@@ -4195,7 +5374,7 @@ function runCouncilArchive(id: string, opts: { json?: boolean }): void {
4195
5374
  // updated status. moveToArchive then physically relocates the artifacts.
4196
5375
  writeManifest(next);
4197
5376
  moveToArchive(next.council_id);
4198
- emitCouncilStateEvent("council.archive", next, {});
5377
+ emitCouncilStateEvent(next, "archived", manifest.status);
4199
5378
 
4200
5379
  emit.data({
4201
5380
  rows: [
@@ -4254,7 +5433,7 @@ function runCouncilUnarchive(id: string, opts: { json?: boolean }): void {
4254
5433
  // (allows re-running for testing).
4255
5434
  moveFromArchive(next.council_id);
4256
5435
  writeManifest(next);
4257
- emitCouncilStateEvent("council.unarchive", next, { restored_status: restoredStatus });
5436
+ emitCouncilStateEvent(next, restoredStatus, "archived");
4258
5437
 
4259
5438
  emit.data({
4260
5439
  rows: [
@@ -4316,7 +5495,7 @@ function runCouncilDelete(id: string, opts: { yes?: boolean; json?: boolean }):
4316
5495
 
4317
5496
  const removed = deleteArchivedCouncil(manifest.council_id);
4318
5497
  if (removed) {
4319
- emitCouncilStateEvent("council.delete", manifest, {});
5498
+ emitCouncilStateEvent(manifest, "deleted", "archived");
4320
5499
  }
4321
5500
 
4322
5501
  emit.data({
@@ -4506,7 +5685,7 @@ function runCouncilContribute(
4506
5685
  try {
4507
5686
  const myOwner = resolveOwner();
4508
5687
  if (myOwner) {
4509
- const myHb = readHeartbeat(myOwner);
5688
+ const myHb = readCurrentCoordinationRow(myOwner);
4510
5689
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4511
5690
  }
4512
5691
  } catch {
@@ -4522,7 +5701,7 @@ function runCouncilContribute(
4522
5701
  });
4523
5702
  process.exit(1);
4524
5703
  }
4525
- const myHb = readHeartbeat(myOwner);
5704
+ const myHb = readCurrentCoordinationRow(myOwner);
4526
5705
  if (!myHb?.name) {
4527
5706
  emit.error({
4528
5707
  code: "no_self_name",
@@ -4575,11 +5754,7 @@ function runCouncilContribute(
4575
5754
  }
4576
5755
 
4577
5756
  const path = writeContribution(manifest.council_id, manifest.current_round, myName, body);
4578
- emitCouncilStateEvent("council.contribution", manifest, {
4579
- round_no: manifest.current_round,
4580
- member: myName,
4581
- body_summary: body.length > 1000 ? `${body.slice(0, 997)}...` : body,
4582
- });
5757
+ emitCouncilStateEvent(manifest, "contribution_recorded", manifest.round_status);
4583
5758
 
4584
5759
  // Update manifest: if all members have now contributed, flip round_status.
4585
5760
  const contributorsNow = contributorsInRound(manifest.council_id, manifest.current_round);
@@ -4589,14 +5764,10 @@ function runCouncilContribute(
4589
5764
  if (allIn) {
4590
5765
  nextManifest = { ...manifest, round_status: "collected" };
4591
5766
  writeManifest(nextManifest);
4592
- emitCouncilStateEvent("council.round_close", nextManifest, {
4593
- round_no: nextManifest.current_round,
4594
- });
5767
+ emitCouncilStateEvent(nextManifest, "round_closed", manifest.round_status);
4595
5768
  if (manifest.auto_advance) {
4596
5769
  nextManifest = advanceCouncil(nextManifest, /*force=*/ false);
4597
- emitCouncilStateEvent("council.round_open", nextManifest, {
4598
- round_no: nextManifest.current_round,
4599
- });
5770
+ emitCouncilStateEvent(nextManifest, "round_open", "round_closed");
4600
5771
  autoAdvanced = true;
4601
5772
  }
4602
5773
  }
@@ -4687,7 +5858,7 @@ function runCouncilPrompt(
4687
5858
  try {
4688
5859
  const myOwner = resolveOwner();
4689
5860
  if (myOwner) {
4690
- const myHb = readHeartbeat(myOwner);
5861
+ const myHb = readCurrentCoordinationRow(myOwner);
4691
5862
  if (myHb?.name) actualName = normalizeAgentName(myHb.name);
4692
5863
  }
4693
5864
  } catch {
@@ -4703,7 +5874,7 @@ function runCouncilPrompt(
4703
5874
  });
4704
5875
  process.exit(1);
4705
5876
  }
4706
- const myHb = readHeartbeat(myOwner);
5877
+ const myHb = readCurrentCoordinationRow(myOwner);
4707
5878
  if (!myHb?.name) {
4708
5879
  emit.error({
4709
5880
  code: "no_self_name",
@@ -4868,12 +6039,8 @@ function runCouncilAdvance(id: string, opts: { force?: boolean; json?: boolean }
4868
6039
  }
4869
6040
 
4870
6041
  const next = advanceCouncil(manifest, !!opts.force);
4871
- emitCouncilStateEvent("council.round_close", manifest, {
4872
- round_no: manifest.current_round,
4873
- });
4874
- emitCouncilStateEvent("council.round_open", next, {
4875
- round_no: next.current_round,
4876
- });
6042
+ emitCouncilStateEvent(manifest, "round_closed", manifest.round_status);
6043
+ emitCouncilStateEvent(next, "round_open", "round_closed");
4877
6044
 
4878
6045
  emit.data({
4879
6046
  rows: [
@@ -4919,7 +6086,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
4919
6086
  // (Convener already knows; we skip pinging them if they convened it from
4920
6087
  // their own session.)
4921
6088
  const myOwner = resolveOwner();
4922
- const myName = myOwner ? normalizeAgentName(readHeartbeat(myOwner)?.name ?? "") : "";
6089
+ const myName = myOwner ? normalizeAgentName(readCurrentCoordinationRow(myOwner)?.name ?? "") : "";
4923
6090
  for (const memberName of next.members) {
4924
6091
  if (memberName === myName) continue;
4925
6092
  const bareName = memberName.replace(/^agent-/, "");
@@ -4943,7 +6110,7 @@ function advanceCouncil(manifest: CouncilManifest, force: boolean): CouncilManif
4943
6110
  // Hard cap on box width. Values longer than the per-row budget word-wrap to
4944
6111
  // continuation lines (blank key column, value resumes indented). Picked to
4945
6112
  // stay readable in narrow terminals + chat clients while giving long
4946
- // turn_summary / task values room to breathe.
6113
+ // Task values need room to breathe.
4947
6114
  const MAX_BOX_CONTENT_WIDTH = 100;
4948
6115
 
4949
6116
  function formatBox(title: string, rows: Array<[string, string]>): string {