harnery 0.31.6 → 0.33.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 (264) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +5 -1
  4. package/dist/commands/agents.d.ts +0 -9
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +280 -94
  7. package/dist/commands/browse-session.d.ts +21 -0
  8. package/dist/commands/browse-session.d.ts.map +1 -0
  9. package/dist/commands/browse-session.js +157 -0
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +205 -37
  12. package/dist/commands/checkpoint.js +1 -1
  13. package/dist/commands/claude-desktop.d.ts +2 -2
  14. package/dist/commands/claude-desktop.d.ts.map +1 -1
  15. package/dist/commands/claude-desktop.js +74 -3
  16. package/dist/commands/deinit.d.ts.map +1 -1
  17. package/dist/commands/deinit.js +4 -0
  18. package/dist/commands/docs.d.ts.map +1 -1
  19. package/dist/commands/docs.js +40 -0
  20. package/dist/commands/doctor.d.ts +4 -2
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +141 -19
  23. package/dist/commands/init.d.ts +2 -2
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +26 -5
  26. package/dist/commands/instructions.d.ts +4 -0
  27. package/dist/commands/instructions.d.ts.map +1 -0
  28. package/dist/commands/instructions.js +100 -0
  29. package/dist/commands/presence.d.ts.map +1 -1
  30. package/dist/commands/presence.js +4 -1
  31. package/dist/commands/tunnel.d.ts.map +1 -1
  32. package/dist/commands/tunnel.js +2 -3
  33. package/dist/core/agents/canonical-emit.d.ts +2 -3
  34. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  35. package/dist/core/agents/canonical-emit.js +6 -5
  36. package/dist/core/agents/cli.js +128 -89
  37. package/dist/core/agents/coord-client.d.ts +18 -3
  38. package/dist/core/agents/coord-client.d.ts.map +1 -1
  39. package/dist/core/agents/coord-client.js +5 -4
  40. package/dist/core/agents/finalization.d.ts +68 -0
  41. package/dist/core/agents/finalization.d.ts.map +1 -0
  42. package/dist/core/agents/finalization.js +443 -0
  43. package/dist/core/agents/git-hook.d.ts +51 -0
  44. package/dist/core/agents/git-hook.d.ts.map +1 -0
  45. package/dist/core/agents/git-hook.js +118 -0
  46. package/dist/core/agents/index.d.ts +1 -0
  47. package/dist/core/agents/index.d.ts.map +1 -1
  48. package/dist/core/agents/index.js +1 -0
  49. package/dist/core/agents/render/prompt-context.d.ts +12 -5
  50. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/prompt-context.js +70 -16
  52. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  53. package/dist/core/agents/render/session-context.js +23 -4
  54. package/dist/core/agents/rules/claim-conflict.js +3 -3
  55. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  56. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  57. package/dist/core/agents/rules/commit-conflict.js +21 -5
  58. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  59. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  60. package/dist/core/agents/rules/stop-hook.js +59 -23
  61. package/dist/core/agents/session-events.d.ts +8 -16
  62. package/dist/core/agents/session-events.d.ts.map +1 -1
  63. package/dist/core/agents/session-events.js +12 -26
  64. package/dist/core/agents/state/heartbeat-projector.d.ts +14 -0
  65. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  66. package/dist/core/agents/state/heartbeat-projector.js +98 -1
  67. package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
  68. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  69. package/dist/core/agents/state/heartbeat-writer.js +86 -17
  70. package/dist/core/agents/state/names.d.ts +22 -1
  71. package/dist/core/agents/state/names.d.ts.map +1 -1
  72. package/dist/core/agents/state/names.js +40 -2
  73. package/dist/core/agents/state/session-state.d.ts +44 -0
  74. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  75. package/dist/core/agents/state/session-state.js +81 -0
  76. package/dist/core/config.d.ts +30 -2
  77. package/dist/core/config.d.ts.map +1 -1
  78. package/dist/core/config.js +74 -11
  79. package/dist/core/governor/planning.d.ts.map +1 -1
  80. package/dist/core/governor/planning.js +12 -13
  81. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  82. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  83. package/dist/core/hooks/adapter/detect.js +4 -14
  84. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  85. package/dist/core/hooks/adapter/events.js +1 -0
  86. package/dist/core/hooks/adapter/output.d.ts +1 -1
  87. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  88. package/dist/core/hooks/adapter/output.js +5 -4
  89. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  90. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  91. package/dist/core/hooks/adapter/parse.js +2 -0
  92. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  93. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/wiring.js +32 -0
  95. package/dist/core/hooks/cli.d.ts +1 -2
  96. package/dist/core/hooks/cli.d.ts.map +1 -1
  97. package/dist/core/hooks/cli.js +258 -32
  98. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  99. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  100. package/dist/core/hooks/codex-authorization.js +199 -0
  101. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  102. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  103. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  104. package/dist/core/hooks/effects/index.d.ts +1 -1
  105. package/dist/core/hooks/effects/index.js +3 -3
  106. package/dist/core/hooks/events/schema.d.ts +57 -1
  107. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  108. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  109. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  110. package/dist/core/hooks/resolve/owner.js +15 -0
  111. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  112. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  113. package/dist/core/hooks/resolve/transcript.js +176 -1
  114. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  115. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  116. package/dist/core/hooks/session-name-presence.js +43 -0
  117. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  118. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  119. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  120. package/dist/core/presence/blob.d.ts +4 -0
  121. package/dist/core/presence/blob.d.ts.map +1 -1
  122. package/dist/core/presence/blob.js +12 -0
  123. package/dist/core/presence/index.js +15 -1
  124. package/dist/core/work/state.d.ts +4 -5
  125. package/dist/core/work/state.d.ts.map +1 -1
  126. package/dist/core/work/state.js +5 -8
  127. package/dist/core/workflow/index.d.ts +1 -1
  128. package/dist/core/workflow/index.d.ts.map +1 -1
  129. package/dist/core/workflow/proof.d.ts +2 -2
  130. package/dist/core/workflow/proof.d.ts.map +1 -1
  131. package/dist/core/workflow/proof.js +2 -2
  132. package/dist/core/workflow/run-state.d.ts +2 -2
  133. package/dist/core/workflow/run-state.d.ts.map +1 -1
  134. package/dist/core/workflow/run-state.js +2 -2
  135. package/dist/core/workflow/types.d.ts +3 -3
  136. package/dist/core/workflow/types.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  138. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  139. package/dist/core/workflow/workspaces/execution.js +5 -0
  140. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  141. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  142. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  143. package/dist/core/workflow/workspaces/local-git.js +1 -2
  144. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  145. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  146. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  147. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  148. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  149. package/dist/core/workflow/workspaces/validate.js +0 -2
  150. package/dist/lib/browser/client.d.ts +118 -1
  151. package/dist/lib/browser/client.d.ts.map +1 -1
  152. package/dist/lib/browser/client.js +435 -6
  153. package/dist/lib/browser/geometry.d.ts.map +1 -1
  154. package/dist/lib/browser/geometry.js +180 -37
  155. package/dist/lib/browser/index.d.ts +5 -1
  156. package/dist/lib/browser/index.d.ts.map +1 -1
  157. package/dist/lib/browser/index.js +5 -1
  158. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  159. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  160. package/dist/lib/browser/netscape-cookies.js +31 -0
  161. package/dist/lib/browser/proxy.d.ts +24 -0
  162. package/dist/lib/browser/proxy.d.ts.map +1 -0
  163. package/dist/lib/browser/proxy.js +84 -0
  164. package/dist/lib/browser/runts.d.ts +6 -0
  165. package/dist/lib/browser/runts.d.ts.map +1 -1
  166. package/dist/lib/browser/runts.js +21 -4
  167. package/dist/lib/browser/session-control.d.ts +112 -0
  168. package/dist/lib/browser/session-control.d.ts.map +1 -0
  169. package/dist/lib/browser/session-control.js +670 -0
  170. package/dist/lib/claude-desktop.d.ts +47 -0
  171. package/dist/lib/claude-desktop.d.ts.map +1 -1
  172. package/dist/lib/claude-desktop.js +169 -1
  173. package/dist/lib/docs-links.d.ts +108 -0
  174. package/dist/lib/docs-links.d.ts.map +1 -0
  175. package/dist/lib/docs-links.js +555 -0
  176. package/dist/lib/exec.d.ts +1 -1
  177. package/dist/lib/exec.js +1 -1
  178. package/dist/lib/identities/assume.d.ts +4 -2
  179. package/dist/lib/identities/assume.d.ts.map +1 -1
  180. package/dist/lib/identities/assume.js +15 -2
  181. package/dist/lib/instructions/bundle.d.ts +33 -0
  182. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  183. package/dist/lib/instructions/bundle.js +149 -0
  184. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  185. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  186. package/dist/lib/instructions/git-hooks.js +238 -0
  187. package/dist/lib/instructions/splice.d.ts +10 -4
  188. package/dist/lib/instructions/splice.d.ts.map +1 -1
  189. package/dist/lib/instructions/splice.js +19 -11
  190. package/package.json +2 -2
  191. package/schemas/config.schema.json +32 -0
  192. package/src/commander.ts +5 -1
  193. package/src/commands/agents.ts +341 -113
  194. package/src/commands/browse-session.ts +245 -0
  195. package/src/commands/browse.ts +287 -44
  196. package/src/commands/checkpoint.ts +1 -1
  197. package/src/commands/claude-desktop.ts +106 -5
  198. package/src/commands/deinit.ts +7 -1
  199. package/src/commands/docs.ts +56 -0
  200. package/src/commands/doctor.ts +155 -21
  201. package/src/commands/init.ts +28 -9
  202. package/src/commands/instructions.ts +129 -0
  203. package/src/commands/presence.ts +7 -1
  204. package/src/commands/tunnel.ts +2 -5
  205. package/src/core/agents/canonical-emit.ts +7 -6
  206. package/src/core/agents/cli.ts +143 -89
  207. package/src/core/agents/coord-client.ts +21 -5
  208. package/src/core/agents/finalization.ts +595 -0
  209. package/src/core/agents/git-hook.ts +126 -0
  210. package/src/core/agents/index.ts +1 -0
  211. package/src/core/agents/render/prompt-context.ts +96 -16
  212. package/src/core/agents/render/session-context.ts +29 -4
  213. package/src/core/agents/rules/claim-conflict.ts +3 -3
  214. package/src/core/agents/rules/commit-conflict.ts +35 -8
  215. package/src/core/agents/rules/stop-hook.ts +71 -23
  216. package/src/core/agents/session-events.ts +14 -41
  217. package/src/core/agents/state/heartbeat-projector.ts +108 -1
  218. package/src/core/agents/state/heartbeat-writer.ts +129 -16
  219. package/src/core/agents/state/names.ts +54 -2
  220. package/src/core/agents/state/session-state.ts +131 -0
  221. package/src/core/config.ts +92 -12
  222. package/src/core/governor/planning.ts +17 -12
  223. package/src/core/hooks/adapter/detect.ts +4 -13
  224. package/src/core/hooks/adapter/events.ts +1 -0
  225. package/src/core/hooks/adapter/output.ts +9 -4
  226. package/src/core/hooks/adapter/parse.ts +3 -0
  227. package/src/core/hooks/adapter/wiring.ts +43 -0
  228. package/src/core/hooks/cli.ts +296 -27
  229. package/src/core/hooks/codex-authorization.ts +253 -0
  230. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  231. package/src/core/hooks/effects/index.ts +5 -5
  232. package/src/core/hooks/events/schema.ts +71 -0
  233. package/src/core/hooks/resolve/owner.ts +22 -1
  234. package/src/core/hooks/resolve/transcript.ts +164 -1
  235. package/src/core/hooks/session-name-presence.ts +52 -0
  236. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  237. package/src/core/presence/blob.ts +18 -0
  238. package/src/core/presence/index.ts +18 -1
  239. package/src/core/work/state.ts +8 -12
  240. package/src/core/workflow/engine.ts +2 -2
  241. package/src/core/workflow/index.ts +0 -1
  242. package/src/core/workflow/proof.ts +4 -4
  243. package/src/core/workflow/run-state.ts +5 -5
  244. package/src/core/workflow/types.ts +3 -3
  245. package/src/core/workflow/workspaces/execution.ts +11 -4
  246. package/src/core/workflow/workspaces/index.ts +0 -3
  247. package/src/core/workflow/workspaces/local-git.ts +1 -2
  248. package/src/core/workflow/workspaces/paths.ts +2 -2
  249. package/src/core/workflow/workspaces/types.ts +0 -9
  250. package/src/core/workflow/workspaces/validate.ts +1 -5
  251. package/src/lib/browser/client.ts +528 -6
  252. package/src/lib/browser/geometry.ts +197 -37
  253. package/src/lib/browser/index.ts +39 -0
  254. package/src/lib/browser/netscape-cookies.ts +39 -0
  255. package/src/lib/browser/proxy.ts +105 -0
  256. package/src/lib/browser/runts.ts +27 -3
  257. package/src/lib/browser/session-control.ts +892 -0
  258. package/src/lib/claude-desktop.ts +232 -1
  259. package/src/lib/docs-links.ts +674 -0
  260. package/src/lib/exec.ts +1 -1
  261. package/src/lib/identities/assume.ts +22 -1
  262. package/src/lib/instructions/bundle.ts +199 -0
  263. package/src/lib/instructions/git-hooks.ts +259 -0
  264. package/src/lib/instructions/splice.ts +41 -11
@@ -11,6 +11,9 @@
11
11
  * stop currently firing carries that field via the in-flight event
12
12
  * agent-hook emits before the stop hook runs).
13
13
  * Rule 3/3: `state.task_set` event with matching turn boundary exists.
14
+ * Session-naming rule (at most once per session, CC semantics only): the turn
15
+ * whose set-task produced `suggested_session_name` must carry an
16
+ * in-window `turn.stop` with `session_name_present: true`.
14
17
  *
15
18
  * Pure-prose-turn exemption: when zero `tool.pre_use` events fire in
16
19
  * the current turn, rules 1/3 and 3/3 do not apply. Rule 2/3 still applies
@@ -25,7 +28,11 @@
25
28
 
26
29
  import { existsSync, readFileSync } from "node:fs";
27
30
  import { join } from "node:path";
28
- import { resolveBinName } from "../../config.ts";
31
+ import {
32
+ agentsRequireGitFinalization,
33
+ endOfTurnStatusCommand,
34
+ resolveBinName,
35
+ } from "../../config.ts";
29
36
 
30
37
  export type { VerdictResult } from "./verdict.ts";
31
38
 
@@ -106,18 +113,6 @@ const RECENT_EVENT_WINDOW_LINES = 5_000;
106
113
  */
107
114
  export const STOP_REMEDIATION_MARKER = "[harnery:stop-remediation";
108
115
 
109
- /**
110
- * The pre-marker message shape, still recognized on purpose.
111
- *
112
- * A followup already sitting in the stream was written by whichever build was
113
- * running when it fired. An agent whose session spans an upgrade would otherwise
114
- * keep alternating until the first new-format followup appeared, which is the
115
- * exact failure this change removes. Recognizing the older trailing form closes
116
- * that rollout window at no cost: it only ever appears in a `prompt_text`
117
- * because Harnery put it there.
118
- */
119
- const STOP_REMEDIATION_LEGACY_MARKER = "[agent-hook stop]: rule=";
120
-
121
116
  /** How far back the remediation walk may travel. Adapters cap their own
122
117
  * followup chains (Cursor's `loop_limit`), and each prompt can appear once per
123
118
  * observing adapter, so this only guards a pathological stream. */
@@ -126,7 +121,7 @@ const MAX_REMEDIATION_WALK_BACK = 16;
126
121
  function isStopRemediationPrompt(event: CanonicalEvent): boolean {
127
122
  const text = event.data.prompt_text;
128
123
  if (typeof text !== "string") return false;
129
- return text.includes(STOP_REMEDIATION_MARKER) || text.includes(STOP_REMEDIATION_LEGACY_MARKER);
124
+ return text.includes(STOP_REMEDIATION_MARKER);
130
125
  }
131
126
 
132
127
  /**
@@ -233,7 +228,7 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
233
228
  allow: true,
234
229
  exit_code: 0,
235
230
  rule: "stop-hook.no_history",
236
- reason: "no canonical events for this owner; defer to legacy or skip",
231
+ reason: "no canonical events for this owner; nothing to evaluate",
237
232
  };
238
233
  }
239
234
 
@@ -255,7 +250,11 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
255
250
  });
256
251
 
257
252
  const toolPreUseInTurn = inTurn.some((e) => e.event_type === "tool.pre_use");
258
- const statusChecked = inTurn.some((e) => e.event_type === "state.status_checked");
253
+ const statusEvents = inTurn.filter((e) => e.event_type === "state.status_checked");
254
+ const statusChecked = statusEvents.length > 0;
255
+ const requiredStatusChecked = agentsRequireGitFinalization(coordRoot)
256
+ ? statusEvents.some((e) => e.data.git_finalization_checked === true)
257
+ : statusChecked;
259
258
  const taskSet = inTurn.some((e) => e.event_type === "state.task_set");
260
259
 
261
260
  // Rule 2/3: status_box_present on the most recent turn.stop for this owner.
@@ -270,8 +269,14 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
270
269
  // on Claude Code / Codex it's the transcript-scanned `status_box_present`.
271
270
  // The matching block helper carries the right "how to fix" message.
272
271
  const ackSignal = ackSignalFor(req.adapter);
273
- const ackPresent = ackSignal === "status_checked" ? statusChecked : boxPresent;
274
- const ackBlock = ackSignal === "status_checked" ? rule13Block : rule23Block;
272
+ const ackPresent =
273
+ ackSignal === "status_checked"
274
+ ? toolPreUseInTurn
275
+ ? requiredStatusChecked
276
+ : statusChecked
277
+ : boxPresent;
278
+ const ackBlock =
279
+ ackSignal === "status_checked" ? () => rule13Block(coordRoot) : () => rule23Block(coordRoot);
275
280
 
276
281
  // Pure-prose-turn exemption: only the ack signal applies. Parity
277
282
  // across adapters: CC requires the box; Cursor requires status_checked.
@@ -287,12 +292,43 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
287
292
  };
288
293
  }
289
294
 
290
- if (!statusChecked) return rule13Block();
295
+ if (!requiredStatusChecked) return rule13Block(coordRoot);
291
296
  // On Cursor `ackPresent === statusChecked` (already true here), so this is a
292
297
  // no-op and rule 2/3 is not enforced; on CC/Codex it's the box-paste check.
293
298
  if (!ackPresent) return ackBlock();
294
299
  if (!taskSet) return rule33Block();
295
300
 
301
+ // Session-naming rule (at most once per session, evaluated last so
302
+ // remediation stays monotonic in the established order): the turn whose
303
+ // set-task produced the suggested session name must show that name in the
304
+ // reply. Claude Code semantics only — Cursor's Stop payload carries neither
305
+ // a transcript nor the assistant message, so the fence is undetectable there
306
+ // (the same reason rule 2/3 collapses into 1/3 on Cursor), and Codex
307
+ // returned observe-only above. Detection rides `turn.stop`'s
308
+ // `session_name_present` (assistant-text-only transcript scan), mirroring
309
+ // how rule 2/3 rides `status_box_present`.
310
+ if (ackSignal === "status_box_present") {
311
+ const namingEvent = inTurn.find(
312
+ (e) =>
313
+ ((e.event_type === "state.task_set" && e.data.first_of_session === true) ||
314
+ (e.event_type === "state.task_state" && e.data.name_reminted === true)) &&
315
+ typeof e.data.suggested_session_name === "string" &&
316
+ e.data.suggested_session_name.length > 0,
317
+ );
318
+ if (namingEvent) {
319
+ // ANY in-window turn.stop with the flag satisfies the rule, not just the
320
+ // latest: once seen, `session_name_seen_at` is stamped and later
321
+ // turn.stops OMIT the field, so a later block on a different rule (whose
322
+ // repair fires a fresh turn.stop) must not un-satisfy this one.
323
+ const namePresent = inTurn.some(
324
+ (e) => e.event_type === "turn.stop" && e.data.session_name_present === true,
325
+ );
326
+ if (!namePresent) {
327
+ return sessionNameBlock(String(namingEvent.data.suggested_session_name));
328
+ }
329
+ }
330
+ }
331
+
296
332
  return {
297
333
  allow: true,
298
334
  exit_code: 0,
@@ -300,21 +336,33 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
300
336
  };
301
337
  }
302
338
 
303
- function rule13Block(): VerdictResult {
339
+ function rule13Block(coordRoot?: string): VerdictResult {
340
+ const missingEvidence = agentsRequireGitFinalization(coordRoot)
341
+ ? "no Git-finalized state.status_checked event found in this turn"
342
+ : "no state.status_checked event found in this turn";
304
343
  return {
305
344
  allow: false,
306
345
  exit_code: 2,
307
346
  rule: "stop-hook.rule_1_3",
308
- reason: `End-of-turn rule (1/3): no state.status_checked event found in this turn; run \`${resolveBinName()} agents status\` as your last tool call.`,
347
+ reason: `End-of-turn rule (1/3): ${missingEvidence}; run \`${endOfTurnStatusCommand(coordRoot)}\` as your last tool call.`,
309
348
  };
310
349
  }
311
350
 
312
- function rule23Block(): VerdictResult {
351
+ function rule23Block(coordRoot?: string): VerdictResult {
313
352
  return {
314
353
  allow: false,
315
354
  exit_code: 2,
316
355
  rule: "stop-hook.rule_2_3",
317
- reason: `End-of-turn rule (2/3): turn.stop did not see the agent-status box in your reply text. Paste the \`${resolveBinName()} agents status\` output verbatim as a fenced code block (the \`┌─ agent-\` prefix is the detection signal).`,
356
+ reason: `End-of-turn rule (2/3): turn.stop did not see the agent-status box in your reply text. Paste the \`${endOfTurnStatusCommand(coordRoot)}\` output verbatim as a fenced code block (the \`┌─ agent-\` prefix is the detection signal).`,
357
+ };
358
+ }
359
+
360
+ function sessionNameBlock(name: string): VerdictResult {
361
+ return {
362
+ allow: false,
363
+ exit_code: 2,
364
+ rule: "stop-hook.session_name",
365
+ reason: `Session-naming rule: this turn's set-task produced the session name, but your reply doesn't show it. Reproduce it by itself in a fenced code block so the operator can one-click-copy it as the session/tab title: ${name}`,
318
366
  };
319
367
  }
320
368
 
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Command/narration event emitter for the coordination layer.
3
3
  *
4
- * `writeSessionEvent` emits command + narration events straight to the
4
+ * `writeSessionEvent` emits command and narration events straight to the
5
5
  * **canonical** `.harnery/events.ndjson` (alongside the hook events), which the
6
- * `/live` web viewer reads. The exported surface (`writeSessionEvent`,
7
- * `newCmdId`, `clampField`, `readLastIntent`) is stable so the session-tee
8
- * middleware callers need no edits.
6
+ * `/live` web viewer reads.
9
7
  */
10
8
 
11
9
  import { randomBytes } from "node:crypto";
@@ -15,28 +13,16 @@ import { dirname, resolve } from "node:path";
15
13
  import { normalizeAdapter, resolveEmitRoot } from "./canonical-emit.ts";
16
14
  import { emit } from "./events/emit.ts";
17
15
 
18
- /** Event types accepted by `writeSessionEvent`. Only the command stream +
19
- * narration are emitted canonically; the coord/state types are accepted for
20
- * call-site compatibility but are no-ops (the agents CLI emits those itself). */
21
- export type SessionEventType =
22
- | "command_start"
23
- | "output"
24
- | "command_end"
25
- | "end_of_turn"
26
- | "hook_event"
27
- | "set_task"
28
- | "file_claim"
29
- | "file_release"
30
- | "peer_change"
31
- | "narration";
16
+ /** Event types accepted by `writeSessionEvent`. */
17
+ export type SessionEventType = "command_start" | "output" | "command_end" | "narration";
32
18
 
33
19
  /**
34
20
  * Resolved path of the ndjson sidecar file. Lives inside `.harnery/` so a
35
21
  * containerized reader can pick it up through a single bind mount.
36
22
  */
37
- export function sessionEventsPath(): string {
23
+ export function canonicalEventsPath(): string {
38
24
  // Explicit override (tests + non-monorepo invocations).
39
- const explicit = process.env.HARNERY_OUTPUT_SESSION_EVENTS;
25
+ const explicit = process.env.HARNERY_EVENTS_PATH;
40
26
  if (explicit) return explicit;
41
27
  // Superproject-aware root resolution (git first, cwd walk fallback) via
42
28
  // resolveEmitRoot. A plain cwd walk here mis-anchored to a NESTED
@@ -64,7 +50,7 @@ export function newCmdId(): string {
64
50
  */
65
51
  export function readLastIntent(instanceId?: string): string | null {
66
52
  if (!instanceId) return null;
67
- // Same superproject-aware root resolution as sessionEventsPath(): the
53
+ // Same superproject-aware root resolution as canonicalEventsPath(): the
68
54
  // intent stamp is written by the PreToolUse hook into the SUPERPROJECT's
69
55
  // .harnery/, so a nested-`.harnery/` cwd must not redirect the read.
70
56
  const root = resolveEmitRoot();
@@ -81,15 +67,7 @@ export function readLastIntent(instanceId?: string): string | null {
81
67
  }
82
68
  }
83
69
 
84
- /**
85
- * Dual-write: mirror command/narration session-events
86
- * into the canonical `.harnery/events.ndjson` stream so the legacy
87
- * session-events.ndjson writer + its web consumers can be retired.
88
- * Only the command-stream + narration types migrate; the coord/state types
89
- * (`set_task`, `file_claim`, `peer_change`, …) are already emitted canonically
90
- * by the agents CLI, so re-emitting them here would double-count.
91
- */
92
- const CANONICAL_TYPE: Partial<Record<SessionEventType, string>> = {
70
+ const CANONICAL_TYPE: Record<SessionEventType, string> = {
93
71
  command_start: "command.start",
94
72
  output: "command.output",
95
73
  command_end: "command.end",
@@ -131,7 +109,7 @@ function enrichFromHeartbeat(coordRoot: string, instanceId: string): HeartbeatEn
131
109
  }
132
110
  }
133
111
 
134
- /** Project the flat legacy `fields` into the canonical event's `data` shape.
112
+ /** Project the flat middleware `fields` into the canonical event's `data` shape.
135
113
  * Unknown types never reach here, guarded by CANONICAL_TYPE. */
136
114
  function canonicalData(
137
115
  type: SessionEventType,
@@ -158,15 +136,15 @@ function canonicalData(
158
136
 
159
137
  /** Emit a command/narration event to the canonical stream. Swallows every
160
138
  * error and skips when identity can't be resolved: telemetry must never break
161
- * (or slow down) a command. Non-command types return early. */
139
+ * or slow down a command. */
162
140
  function emitCanonicalCommand(type: SessionEventType, fields: Record<string, unknown>): void {
163
141
  const eventType = CANONICAL_TYPE[type];
164
142
  if (!eventType) return;
165
143
  const instanceId = typeof fields.instance_id === "string" ? fields.instance_id : undefined;
166
144
  if (!instanceId) return;
167
145
  try {
168
- // coordRoot = the dir containing `.harnery/`; sessionEventsPath() anchors it.
169
- const coordRoot = dirname(dirname(sessionEventsPath()));
146
+ // coordRoot = the dir containing `.harnery/`; canonicalEventsPath() anchors it.
147
+ const coordRoot = dirname(dirname(canonicalEventsPath()));
170
148
  const enrich = enrichFromHeartbeat(coordRoot, instanceId);
171
149
  if (!enrich) return;
172
150
  emit(coordRoot, {
@@ -182,16 +160,11 @@ function emitCanonicalCommand(type: SessionEventType, fields: Record<string, unk
182
160
  }
183
161
 
184
162
  /**
185
- * Emit a session event. Command + narration events
186
- * are written to the canonical `.harnery/events.ndjson`; the coord/state types
187
- * are accepted for call-site compatibility but are no-ops here (the agents CLI
188
- * emits those itself). Best-effort, never throws into the caller; a command
189
- * must never break or slow on telemetry. The `agentName` arg is retained for
190
- * the stable call signature (canonical events key on instance_id, not name).
163
+ * Emit a command or narration event to `.harnery/events.ndjson`. Best-effort,
164
+ * never throws into the caller; telemetry must not break or slow a command.
191
165
  */
192
166
  export function writeSessionEvent(
193
167
  type: SessionEventType,
194
- _agentName: string,
195
168
  fields: Record<string, unknown> = {},
196
169
  ): void {
197
170
  emitCanonicalCommand(type, fields);
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import type { CanonicalEvent } from "../events/consume.ts";
15
+ import { type AgentActivity, applySessionStateEvent, type TaskState } from "./session-state.ts";
15
16
 
16
17
  export interface V2Heartbeat {
17
18
  instance_id: string;
@@ -21,6 +22,10 @@ export interface V2Heartbeat {
21
22
  name?: string;
22
23
  kind?: "session" | "subagent" | "transient";
23
24
  model?: string;
25
+ instruction_bundle_id?: string;
26
+ instruction_source_id?: string;
27
+ instruction_profile_root?: string;
28
+ instruction_component_count?: number;
24
29
  platform?: string;
25
30
  subagent_call_id?: string;
26
31
  parent_session_id?: string;
@@ -34,6 +39,15 @@ export interface V2Heartbeat {
34
39
  last_tool_at?: string;
35
40
  task?: string;
36
41
  task_updated_at?: string;
42
+ activity?: AgentActivity;
43
+ activity_updated_at?: string;
44
+ activity_source?: string;
45
+ task_state?: TaskState;
46
+ task_state_updated_at?: string;
47
+ task_state_reason?: string;
48
+ suggested_session_name?: string;
49
+ session_name_seen_at?: string;
50
+ session_name_seen_for?: string;
37
51
  last_status_at?: string;
38
52
  presence?: "mobile" | "office";
39
53
  last_intent?: string;
@@ -177,6 +191,14 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
177
191
  hb.v2_meta.last_projected = new Date().toISOString();
178
192
  if (ev.turn_id) hb.last_turn_id = ev.turn_id;
179
193
 
194
+ const sessionState = applySessionStateEvent(hb, ev);
195
+ hb.activity = sessionState.activity;
196
+ hb.activity_updated_at = sessionState.activity_updated_at;
197
+ hb.activity_source = sessionState.activity_source;
198
+ hb.task_state = sessionState.task_state;
199
+ hb.task_state_updated_at = sessionState.task_state_updated_at;
200
+ hb.task_state_reason = sessionState.task_state_reason;
201
+
180
202
  const d = ev.data;
181
203
  switch (ev.event_type) {
182
204
  case "session.start":
@@ -185,6 +207,16 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
185
207
  {
186
208
  const model = pickStr(d, "model");
187
209
  if (model) hb.model = model;
210
+ const instructionBundleId = pickStr(d, "instruction_bundle_id");
211
+ if (instructionBundleId) hb.instruction_bundle_id = instructionBundleId;
212
+ const instructionSourceId = pickStr(d, "instruction_source_id");
213
+ if (instructionSourceId) hb.instruction_source_id = instructionSourceId;
214
+ const instructionProfileRoot = pickStr(d, "instruction_profile_root");
215
+ if (instructionProfileRoot) hb.instruction_profile_root = instructionProfileRoot;
216
+ const instructionComponentCount = pickNum(d, "instruction_component_count");
217
+ if (instructionComponentCount !== undefined) {
218
+ hb.instruction_component_count = instructionComponentCount;
219
+ }
188
220
  const platform = pickStr(d, "platform") ?? adapterToPlatform(ev.adapter);
189
221
  hb.platform = platform;
190
222
  const name = pickStr(d, "name");
@@ -245,6 +277,18 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
245
277
  case "turn.stop":
246
278
  hb.last_turn_stop_at = ev.ts;
247
279
  hb.last_turn_status_box_present = pickBool(d, "status_box_present");
280
+ // Rebuild fidelity for the naming ritual: the live stamp is written by
281
+ // the Stop hook (stampSessionNameSeen); reproduce it from the event so a
282
+ // full projector rebuild doesn't re-open a satisfied naming window.
283
+ if (d.session_name_present === true) {
284
+ hb.session_name_seen_at ??= ev.ts;
285
+ // Attribute the sighting to the name the scan actually covered. Reading
286
+ // the name current at this point in the replay instead re-attributed an
287
+ // old sighting to a re-minted name, which faked "already seen" for a
288
+ // name no reply had shown. An event without the field predates it, so
289
+ // leave the attribution unset and let the next stop re-scan.
290
+ hb.session_name_seen_for = pickStr(d, "session_name_present_for");
291
+ }
248
292
  {
249
293
  const summary = pickStr(d, "turn_summary");
250
294
  if (summary) {
@@ -301,6 +345,26 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
301
345
  hb.task = task;
302
346
  }
303
347
  hb.task_updated_at = ev.ts;
348
+ // Rebuild fidelity: the naming call carries the name it produced.
349
+ const suggested = pickStr(d, "suggested_session_name");
350
+ if (suggested && !hb.suggested_session_name) {
351
+ hb.suggested_session_name = suggested;
352
+ }
353
+ break;
354
+ }
355
+
356
+ case "state.task_state": {
357
+ const suggested = pickStr(d, "suggested_session_name");
358
+ if (suggested && suggested !== hb.suggested_session_name) {
359
+ // Older heartbeats predate session_name_seen_for. A lifecycle re-mint
360
+ // still needs a mismatch the prompt and Stop paths can enforce, so
361
+ // attribute the prior title before replacing it. Ordinary legacy
362
+ // heartbeats remain quiet until a real lifecycle event changes them.
363
+ if (!hb.session_name_seen_for && hb.suggested_session_name) {
364
+ hb.session_name_seen_for = hb.suggested_session_name;
365
+ }
366
+ hb.suggested_session_name = suggested;
367
+ }
304
368
  break;
305
369
  }
306
370
 
@@ -322,6 +386,19 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
322
386
  break;
323
387
  }
324
388
 
389
+ case "claim.acquire": {
390
+ const path = pickStr(d, "path");
391
+ const mode = pickStr(d, "mode");
392
+ if (path && mode === "write") {
393
+ const canonical = path.startsWith(`${coordRoot}/`)
394
+ ? path.slice(coordRoot.length + 1)
395
+ : path;
396
+ if (!hb.files_touched) hb.files_touched = [];
397
+ if (!hb.files_touched.includes(canonical)) hb.files_touched.push(canonical);
398
+ }
399
+ break;
400
+ }
401
+
325
402
  case "claim.release": {
326
403
  const path = pickStr(d, "path");
327
404
  if (path && hb.files_touched) {
@@ -341,7 +418,7 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
341
418
  }
342
419
 
343
420
  function adapterToPlatform(adapter: string): string {
344
- if (adapter === "claude-code") return "claude_code";
421
+ if (adapter === "claude-code") return "claude-code";
345
422
  if (adapter === "cursor") return "cursor";
346
423
  if (adapter === "codex") return "codex";
347
424
  return adapter;
@@ -480,6 +557,10 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
480
557
  setIfDefined(merged, "workflow_run_id", hb.workflow_run_id);
481
558
  setIfDefined(merged, "workflow_agent_id", hb.workflow_agent_id);
482
559
  setIfDefined(merged, "model", hb.model);
560
+ setIfDefined(merged, "instruction_bundle_id", hb.instruction_bundle_id);
561
+ setIfDefined(merged, "instruction_source_id", hb.instruction_source_id);
562
+ setIfDefined(merged, "instruction_profile_root", hb.instruction_profile_root);
563
+ setIfDefined(merged, "instruction_component_count", hb.instruction_component_count);
483
564
  setIfDefined(merged, "platform", hb.platform);
484
565
  setIfDefined(merged, "started_at", hb.started_at);
485
566
  // files_touched is a required-array invariant for every reader
@@ -493,6 +574,27 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
493
574
  setIfDefined(merged, "last_tool_at", hb.last_tool_at);
494
575
  setIfDefined(merged, "task", hb.task);
495
576
  setIfDefined(merged, "task_updated_at", hb.task_updated_at);
577
+ setIfDefined(merged, "activity", hb.activity);
578
+ setIfDefined(merged, "activity_updated_at", hb.activity_updated_at);
579
+ setIfDefined(merged, "activity_source", hb.activity_source);
580
+ setIfDefined(merged, "task_state", hb.task_state);
581
+ setIfDefined(merged, "task_state_updated_at", hb.task_state_updated_at);
582
+ if (hb.task_state_reason) {
583
+ merged.task_state_reason = hb.task_state_reason;
584
+ } else if (hb.task_state) {
585
+ delete merged.task_state_reason;
586
+ }
587
+ setIfDefined(merged, "suggested_session_name", hb.suggested_session_name);
588
+ setIfDefined(merged, "session_name_seen_at", hb.session_name_seen_at);
589
+ if (hb.session_name_seen_for) {
590
+ merged.session_name_seen_for = hb.session_name_seen_for;
591
+ } else {
592
+ // Unlike most projected fields, an absent attribution is meaningful: a
593
+ // legacy sighting cannot prove which suggested name it covered. Remove a
594
+ // stale value inherited through the additive merge so the next stop
595
+ // scans the current name instead of treating it as already satisfied.
596
+ delete merged.session_name_seen_for;
597
+ }
496
598
  setIfDefined(merged, "last_status_at", hb.last_status_at);
497
599
  setIfDefined(merged, "turn_summary", hb.turn_summary);
498
600
  setIfDefined(merged, "turn_summary_updated_at", hb.turn_summary_updated_at);
@@ -536,3 +638,8 @@ function pickBool(o: Record<string, unknown>, k: string): boolean | undefined {
536
638
  const v = o[k];
537
639
  return typeof v === "boolean" ? v : undefined;
538
640
  }
641
+
642
+ function pickNum(o: Record<string, unknown>, k: string): number | undefined {
643
+ const v = o[k];
644
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
645
+ }