harnery 0.31.6 → 0.32.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 (218) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +2 -0
  3. package/dist/commands/agents.d.ts +0 -9
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +70 -95
  6. package/dist/commands/browse-session.d.ts +21 -0
  7. package/dist/commands/browse-session.d.ts.map +1 -0
  8. package/dist/commands/browse-session.js +157 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +205 -37
  11. package/dist/commands/checkpoint.js +1 -1
  12. package/dist/commands/deinit.d.ts.map +1 -1
  13. package/dist/commands/deinit.js +4 -0
  14. package/dist/commands/docs.d.ts.map +1 -1
  15. package/dist/commands/docs.js +40 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +100 -17
  18. package/dist/commands/init.d.ts +1 -2
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +16 -5
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +2 -3
  23. package/dist/core/agents/canonical-emit.d.ts +1 -2
  24. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  25. package/dist/core/agents/canonical-emit.js +1 -2
  26. package/dist/core/agents/cli.js +123 -88
  27. package/dist/core/agents/coord-client.d.ts +7 -3
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +5 -4
  30. package/dist/core/agents/finalization.d.ts +68 -0
  31. package/dist/core/agents/finalization.d.ts.map +1 -0
  32. package/dist/core/agents/finalization.js +443 -0
  33. package/dist/core/agents/git-hook.d.ts +51 -0
  34. package/dist/core/agents/git-hook.d.ts.map +1 -0
  35. package/dist/core/agents/git-hook.js +118 -0
  36. package/dist/core/agents/render/prompt-context.d.ts +6 -5
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +26 -13
  39. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/session-context.js +15 -2
  41. package/dist/core/agents/rules/claim-conflict.js +3 -3
  42. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +21 -5
  45. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  46. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  47. package/dist/core/agents/rules/stop-hook.js +59 -23
  48. package/dist/core/agents/session-events.d.ts +8 -16
  49. package/dist/core/agents/session-events.d.ts.map +1 -1
  50. package/dist/core/agents/session-events.js +12 -26
  51. package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
  52. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  53. package/dist/core/agents/state/heartbeat-projector.js +44 -1
  54. package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
  55. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/heartbeat-writer.js +54 -17
  57. package/dist/core/agents/state/names.d.ts +22 -1
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +40 -2
  60. package/dist/core/config.d.ts +30 -2
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +74 -11
  63. package/dist/core/governor/planning.d.ts.map +1 -1
  64. package/dist/core/governor/planning.js +12 -13
  65. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  66. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  67. package/dist/core/hooks/adapter/detect.js +4 -14
  68. package/dist/core/hooks/adapter/output.d.ts +1 -1
  69. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  70. package/dist/core/hooks/adapter/output.js +5 -4
  71. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +32 -0
  74. package/dist/core/hooks/cli.d.ts +1 -2
  75. package/dist/core/hooks/cli.d.ts.map +1 -1
  76. package/dist/core/hooks/cli.js +169 -27
  77. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  78. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  79. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  80. package/dist/core/hooks/effects/index.d.ts +1 -1
  81. package/dist/core/hooks/effects/index.js +3 -3
  82. package/dist/core/hooks/events/schema.d.ts +27 -0
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  85. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  86. package/dist/core/hooks/resolve/owner.js +15 -0
  87. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  88. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/transcript.js +176 -1
  90. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  91. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  92. package/dist/core/hooks/session-name-presence.js +43 -0
  93. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  94. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  95. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  96. package/dist/core/work/state.d.ts +4 -5
  97. package/dist/core/work/state.d.ts.map +1 -1
  98. package/dist/core/work/state.js +5 -8
  99. package/dist/core/workflow/index.d.ts +1 -1
  100. package/dist/core/workflow/index.d.ts.map +1 -1
  101. package/dist/core/workflow/proof.d.ts +2 -2
  102. package/dist/core/workflow/proof.d.ts.map +1 -1
  103. package/dist/core/workflow/proof.js +2 -2
  104. package/dist/core/workflow/run-state.d.ts +2 -2
  105. package/dist/core/workflow/run-state.d.ts.map +1 -1
  106. package/dist/core/workflow/run-state.js +2 -2
  107. package/dist/core/workflow/types.d.ts +3 -3
  108. package/dist/core/workflow/types.d.ts.map +1 -1
  109. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  110. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  111. package/dist/core/workflow/workspaces/execution.js +5 -0
  112. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  113. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  115. package/dist/core/workflow/workspaces/local-git.js +1 -2
  116. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  117. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  118. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  119. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  120. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  121. package/dist/core/workflow/workspaces/validate.js +0 -2
  122. package/dist/lib/browser/client.d.ts +118 -1
  123. package/dist/lib/browser/client.d.ts.map +1 -1
  124. package/dist/lib/browser/client.js +435 -6
  125. package/dist/lib/browser/geometry.d.ts.map +1 -1
  126. package/dist/lib/browser/geometry.js +180 -37
  127. package/dist/lib/browser/index.d.ts +5 -1
  128. package/dist/lib/browser/index.d.ts.map +1 -1
  129. package/dist/lib/browser/index.js +5 -1
  130. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  131. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  132. package/dist/lib/browser/netscape-cookies.js +31 -0
  133. package/dist/lib/browser/proxy.d.ts +24 -0
  134. package/dist/lib/browser/proxy.d.ts.map +1 -0
  135. package/dist/lib/browser/proxy.js +84 -0
  136. package/dist/lib/browser/runts.d.ts +6 -0
  137. package/dist/lib/browser/runts.d.ts.map +1 -1
  138. package/dist/lib/browser/runts.js +21 -4
  139. package/dist/lib/browser/session-control.d.ts +112 -0
  140. package/dist/lib/browser/session-control.d.ts.map +1 -0
  141. package/dist/lib/browser/session-control.js +670 -0
  142. package/dist/lib/docs-links.d.ts +108 -0
  143. package/dist/lib/docs-links.d.ts.map +1 -0
  144. package/dist/lib/docs-links.js +555 -0
  145. package/dist/lib/exec.d.ts +1 -1
  146. package/dist/lib/exec.js +1 -1
  147. package/dist/lib/identities/assume.d.ts +4 -2
  148. package/dist/lib/identities/assume.d.ts.map +1 -1
  149. package/dist/lib/identities/assume.js +15 -2
  150. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  151. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  152. package/dist/lib/instructions/git-hooks.js +238 -0
  153. package/dist/lib/instructions/splice.d.ts +10 -4
  154. package/dist/lib/instructions/splice.d.ts.map +1 -1
  155. package/dist/lib/instructions/splice.js +19 -11
  156. package/package.json +1 -1
  157. package/schemas/config.schema.json +32 -0
  158. package/src/commander.ts +2 -0
  159. package/src/commands/agents.ts +96 -115
  160. package/src/commands/browse-session.ts +245 -0
  161. package/src/commands/browse.ts +287 -44
  162. package/src/commands/checkpoint.ts +1 -1
  163. package/src/commands/deinit.ts +7 -1
  164. package/src/commands/docs.ts +56 -0
  165. package/src/commands/doctor.ts +100 -18
  166. package/src/commands/init.ts +17 -9
  167. package/src/commands/tunnel.ts +2 -5
  168. package/src/core/agents/canonical-emit.ts +1 -2
  169. package/src/core/agents/cli.ts +137 -88
  170. package/src/core/agents/coord-client.ts +10 -5
  171. package/src/core/agents/finalization.ts +595 -0
  172. package/src/core/agents/git-hook.ts +126 -0
  173. package/src/core/agents/render/prompt-context.ts +28 -13
  174. package/src/core/agents/render/session-context.ts +15 -2
  175. package/src/core/agents/rules/claim-conflict.ts +3 -3
  176. package/src/core/agents/rules/commit-conflict.ts +35 -8
  177. package/src/core/agents/rules/stop-hook.ts +71 -23
  178. package/src/core/agents/session-events.ts +14 -41
  179. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  180. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  181. package/src/core/agents/state/names.ts +54 -2
  182. package/src/core/config.ts +92 -12
  183. package/src/core/governor/planning.ts +17 -12
  184. package/src/core/hooks/adapter/detect.ts +4 -13
  185. package/src/core/hooks/adapter/output.ts +9 -4
  186. package/src/core/hooks/adapter/wiring.ts +43 -0
  187. package/src/core/hooks/cli.ts +201 -23
  188. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  189. package/src/core/hooks/effects/index.ts +5 -5
  190. package/src/core/hooks/events/schema.ts +27 -0
  191. package/src/core/hooks/resolve/owner.ts +22 -1
  192. package/src/core/hooks/resolve/transcript.ts +164 -1
  193. package/src/core/hooks/session-name-presence.ts +52 -0
  194. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  195. package/src/core/work/state.ts +8 -12
  196. package/src/core/workflow/engine.ts +2 -2
  197. package/src/core/workflow/index.ts +0 -1
  198. package/src/core/workflow/proof.ts +4 -4
  199. package/src/core/workflow/run-state.ts +5 -5
  200. package/src/core/workflow/types.ts +3 -3
  201. package/src/core/workflow/workspaces/execution.ts +11 -4
  202. package/src/core/workflow/workspaces/index.ts +0 -3
  203. package/src/core/workflow/workspaces/local-git.ts +1 -2
  204. package/src/core/workflow/workspaces/paths.ts +2 -2
  205. package/src/core/workflow/workspaces/types.ts +0 -9
  206. package/src/core/workflow/workspaces/validate.ts +1 -5
  207. package/src/lib/browser/client.ts +528 -6
  208. package/src/lib/browser/geometry.ts +197 -37
  209. package/src/lib/browser/index.ts +39 -0
  210. package/src/lib/browser/netscape-cookies.ts +39 -0
  211. package/src/lib/browser/proxy.ts +105 -0
  212. package/src/lib/browser/runts.ts +27 -3
  213. package/src/lib/browser/session-control.ts +892 -0
  214. package/src/lib/docs-links.ts +674 -0
  215. package/src/lib/exec.ts +1 -1
  216. package/src/lib/identities/assume.ts +22 -1
  217. package/src/lib/instructions/git-hooks.ts +259 -0
  218. package/src/lib/instructions/splice.ts +41 -11
@@ -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);
@@ -34,6 +34,9 @@ export interface V2Heartbeat {
34
34
  last_tool_at?: string;
35
35
  task?: string;
36
36
  task_updated_at?: string;
37
+ suggested_session_name?: string;
38
+ session_name_seen_at?: string;
39
+ session_name_seen_for?: string;
37
40
  last_status_at?: string;
38
41
  presence?: "mobile" | "office";
39
42
  last_intent?: string;
@@ -245,6 +248,18 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
245
248
  case "turn.stop":
246
249
  hb.last_turn_stop_at = ev.ts;
247
250
  hb.last_turn_status_box_present = pickBool(d, "status_box_present");
251
+ // Rebuild fidelity for the naming ritual: the live stamp is written by
252
+ // the Stop hook (stampSessionNameSeen); reproduce it from the event so a
253
+ // full projector rebuild doesn't re-open a satisfied naming window.
254
+ if (d.session_name_present === true) {
255
+ hb.session_name_seen_at ??= ev.ts;
256
+ // Attribute the sighting to the name the scan actually covered. Reading
257
+ // the name current at this point in the replay instead re-attributed an
258
+ // old sighting to a re-minted name, which faked "already seen" for a
259
+ // name no reply had shown. An event without the field predates it, so
260
+ // leave the attribution unset and let the next stop re-scan.
261
+ hb.session_name_seen_for = pickStr(d, "session_name_present_for");
262
+ }
248
263
  {
249
264
  const summary = pickStr(d, "turn_summary");
250
265
  if (summary) {
@@ -301,6 +316,11 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
301
316
  hb.task = task;
302
317
  }
303
318
  hb.task_updated_at = ev.ts;
319
+ // Rebuild fidelity: the naming call carries the name it produced.
320
+ const suggested = pickStr(d, "suggested_session_name");
321
+ if (suggested && !hb.suggested_session_name) {
322
+ hb.suggested_session_name = suggested;
323
+ }
304
324
  break;
305
325
  }
306
326
 
@@ -322,6 +342,19 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
322
342
  break;
323
343
  }
324
344
 
345
+ case "claim.acquire": {
346
+ const path = pickStr(d, "path");
347
+ const mode = pickStr(d, "mode");
348
+ if (path && mode === "write") {
349
+ const canonical = path.startsWith(`${coordRoot}/`)
350
+ ? path.slice(coordRoot.length + 1)
351
+ : path;
352
+ if (!hb.files_touched) hb.files_touched = [];
353
+ if (!hb.files_touched.includes(canonical)) hb.files_touched.push(canonical);
354
+ }
355
+ break;
356
+ }
357
+
325
358
  case "claim.release": {
326
359
  const path = pickStr(d, "path");
327
360
  if (path && hb.files_touched) {
@@ -341,7 +374,7 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
341
374
  }
342
375
 
343
376
  function adapterToPlatform(adapter: string): string {
344
- if (adapter === "claude-code") return "claude_code";
377
+ if (adapter === "claude-code") return "claude-code";
345
378
  if (adapter === "cursor") return "cursor";
346
379
  if (adapter === "codex") return "codex";
347
380
  return adapter;
@@ -493,6 +526,17 @@ function writeHeartbeat(coordRoot: string, instanceId: string, hb: V2Heartbeat):
493
526
  setIfDefined(merged, "last_tool_at", hb.last_tool_at);
494
527
  setIfDefined(merged, "task", hb.task);
495
528
  setIfDefined(merged, "task_updated_at", hb.task_updated_at);
529
+ setIfDefined(merged, "suggested_session_name", hb.suggested_session_name);
530
+ setIfDefined(merged, "session_name_seen_at", hb.session_name_seen_at);
531
+ if (hb.session_name_seen_for) {
532
+ merged.session_name_seen_for = hb.session_name_seen_for;
533
+ } else {
534
+ // Unlike most projected fields, an absent attribution is meaningful: a
535
+ // legacy sighting cannot prove which suggested name it covered. Remove a
536
+ // stale value inherited through the additive merge so the next stop
537
+ // scans the current name instead of treating it as already satisfied.
538
+ delete merged.session_name_seen_for;
539
+ }
496
540
  setIfDefined(merged, "last_status_at", hb.last_status_at);
497
541
  setIfDefined(merged, "turn_summary", hb.turn_summary);
498
542
  setIfDefined(merged, "turn_summary_updated_at", hb.turn_summary_updated_at);
@@ -33,14 +33,13 @@ function adapterOf(platform: string | undefined): "claude-code" | "cursor" | "co
33
33
  return "claude-code";
34
34
  }
35
35
 
36
- /** Inverse of adapterOf: maps the canonical adapter ("claude-code"/"cursor"/
37
- * "codex") to the legacy underscore platform label stored on the heartbeat.
38
- * Mirrors heartbeat-projector.adapterToPlatform so a healed heartbeat carries
39
- * the same platform value projection would have written. */
36
+ /** Preserve the canonical adapter id in heartbeat state. Mirrors
37
+ * heartbeat-projector.adapterToPlatform so a healed heartbeat carries the same
38
+ * platform value projection would have written. */
40
39
  function adapterToPlatform(adapter: string | undefined): string {
41
40
  if (adapter === "cursor") return "cursor";
42
41
  if (adapter === "codex") return "codex";
43
- return "claude_code";
42
+ return "claude-code";
44
43
  }
45
44
 
46
45
  /**
@@ -87,6 +86,17 @@ export interface Heartbeat {
87
86
  files_touched: string[];
88
87
  task?: string;
89
88
  task_updated_at?: string | null;
89
+ /** Session name built on the first non-empty set-task (never rebuilt). Its
90
+ * presence is the "this session has been named" signal the prompt-context
91
+ * nudge and the Stop-hook naming rule key on. */
92
+ suggested_session_name?: string;
93
+ /** Stamped by turn.stop once the suggested name is seen in assistant reply
94
+ * text, ending the per-turn transcript scan. */
95
+ session_name_seen_at?: string;
96
+ /** WHICH name that sighting was for. The scan is skipped only while this
97
+ * matches the current suggested name, so a re-minted name is detectable
98
+ * again rather than being suppressed by the earlier sighting. */
99
+ session_name_seen_for?: string;
90
100
  last_status_at?: string;
91
101
  turn_summary?: string | null;
92
102
  turn_summary_updated_at?: string | null;
@@ -95,6 +105,7 @@ export interface Heartbeat {
95
105
  last_tool_at?: string;
96
106
  current_turn_id?: string;
97
107
  parent_instance_id?: string;
108
+ workflow_run_id?: string;
98
109
  [extra: string]: unknown;
99
110
  }
100
111
 
@@ -139,18 +150,53 @@ function mutate(
139
150
  export function setTask(coordRoot: string, instanceId: string, task: string): Heartbeat | null {
140
151
  return mutate(coordRoot, instanceId, (hb) => {
141
152
  const cleared = !task || task.length === 0;
153
+ // Name the session on its first NON-EMPTY declaration. Keyed on the
154
+ // suggested_session_name stamp, not task_updated_at, so a bare clear as
155
+ // the first call never burns the naming window. Subagents and workflow
156
+ // children have no human-owned tab to rename, so they are never named.
157
+ const humanFacing = hb.kind !== "subagent" && hb.kind !== "transient" && !hb.workflow_run_id;
158
+ const built =
159
+ !cleared && !hb.suggested_session_name && humanFacing
160
+ ? buildSuggestedName(hb.name ?? "unknown", [task])
161
+ : null;
142
162
  return {
143
163
  ...hb,
144
164
  task: cleared ? undefined : task,
145
165
  task_updated_at: nowIsoSeconds(),
166
+ ...(built ? { suggested_session_name: built.suggestedName } : {}),
146
167
  };
147
168
  });
148
169
  }
149
170
 
150
- export function stampStatusCheck(coordRoot: string, instanceId: string): Heartbeat | null {
171
+ /**
172
+ * Build the copy-pasteable session name from the coord identity + the agent's
173
+ * description parts. Pure (no coord-state reads) so it's unit-testable; collapses
174
+ * internal whitespace and trims. Returns null when the description is empty.
175
+ */
176
+ export function buildSuggestedName(
177
+ agentName: string,
178
+ descriptionParts: string[],
179
+ ): { suggestedName: string; description: string } | null {
180
+ const description = descriptionParts.join(" ").replace(/\s+/g, " ").trim();
181
+ if (!description) return null;
182
+ const name = agentName?.trim() || "unknown";
183
+ return { suggestedName: `Agent ${name} - ${description}`, description };
184
+ }
185
+
186
+ /** Stamp the sighting once the suggested name has been observed in assistant
187
+ * reply text; later turns skip the transcript scan. Records WHICH name was seen,
188
+ * because the skip is only valid for that one: if a later set-task mints a
189
+ * different suggested name, a bare "already seen" stamp would suppress the scan
190
+ * forever and leave the Stop-hook naming rule permanently unsatisfiable. */
191
+ export function stampSessionNameSeen(
192
+ coordRoot: string,
193
+ instanceId: string,
194
+ name?: string,
195
+ ): Heartbeat | null {
151
196
  return mutate(coordRoot, instanceId, (hb) => ({
152
197
  ...hb,
153
- last_status_at: nowIsoSeconds(),
198
+ session_name_seen_at: hb.session_name_seen_at ?? nowIsoSeconds(),
199
+ ...(name ? { session_name_seen_for: name } : {}),
154
200
  }));
155
201
  }
156
202
 
@@ -200,9 +246,8 @@ export interface GroupUnclaimHit {
200
246
  * durable `claim.release` events — a file-only prune is silently reverted by
201
247
  * the next projector replay.
202
248
  *
203
- * files_touched can hold either absolute-under-coordRoot or canonical
204
- * repo-relative entries (legacy projections stored the raw tool_input path),
205
- * so both sides are normalized before comparing — an exact-string match
249
+ * Tool payloads and release calls can supply either absolute-under-coordRoot or
250
+ * canonical repo-relative entries, so both sides are normalized before comparing. An exact-string match
206
251
  * silently no-ops on the mixed-form case and the claim never releases.
207
252
  *
208
253
  * This is the Option B fix for post-commit's pid-map attribution hole: a
@@ -264,7 +309,7 @@ export function healPidmap(coordRoot: string, instanceId: string, pid: number):
264
309
  const dir = join(coordRoot, ".harnery", "pid-map");
265
310
  mkdirSync(dir, { recursive: true });
266
311
  const hb = readHeartbeat(coordRoot, instanceId);
267
- const platform = hb?.platform ?? "claude_code";
312
+ const platform = hb?.platform ?? "claude-code";
268
313
  const pmPath = join(dir, String(pid));
269
314
  // Drift guard: only write + emit telemetry when the entry is missing or
270
315
  // points at a different owner. Without this, a per-tool-call heal would
@@ -291,6 +336,7 @@ export function healHeartbeat(
291
336
  sessionId?: string,
292
337
  model?: string,
293
338
  adapter?: string,
339
+ opts?: { forkedFrom?: string },
294
340
  ): Heartbeat | null {
295
341
  const path = heartbeatPath(coordRoot, instanceId);
296
342
  if (existsSync(path)) {
@@ -306,14 +352,28 @@ export function healHeartbeat(
306
352
  let agentId = "";
307
353
  try {
308
354
  // eslint-disable-next-line @typescript-eslint/no-require-imports
309
- const { resolveName } = require("./names.ts") as typeof import("./names.ts");
310
- const resolved = resolveName(coordRoot, instanceId, sessionId);
355
+ const names = require("./names.ts") as typeof import("./names.ts");
356
+ const resolved = names.resolveName(coordRoot, instanceId, sessionId);
311
357
  if (resolved) {
312
358
  name = resolved.name;
313
359
  kind = resolved.kind;
314
360
  // An explicitly assumed session carries its durable persona UUID in
315
361
  // name-history. Native subagents continue to use instance_id.
316
362
  agentId = resolved.agent_id ?? (resolved.kind === "subagent" ? instanceId : "");
363
+ } else if (!sessionId || sessionId === instanceId) {
364
+ // A main session with NO history at all never fired session.start —
365
+ // the CC fork flow (SessionStart fires under the parent's id before
366
+ // the fork id is minted), or a partially-wired adapter. Mint its pool
367
+ // name here instead of leaving a nameless heartbeat, and stamp the
368
+ // detected fork lineage while we're at it. A sessionId that differs
369
+ // from instanceId is left alone: that shape is a subagent whose parent
370
+ // is also unknown, and guessing "session" would be wrong.
371
+ name = names.assignName(
372
+ coordRoot,
373
+ instanceId,
374
+ "session",
375
+ opts?.forkedFrom ? { forkedFrom: opts.forkedFrom } : undefined,
376
+ );
317
377
  }
318
378
  } catch {
319
379
  /* names module unavailable, fall back to empty */
@@ -330,19 +390,21 @@ export function healHeartbeat(
330
390
  started_at: now,
331
391
  last_heartbeat: now,
332
392
  files_touched: [],
333
- // Default to claude_code only when the caller can't tell us the adapter
393
+ // Default to claude-code only when the caller can't tell us the adapter
334
394
  // (e.g. manual `harn agents heal`). The live tool.pre_use heal threads the
335
395
  // detected adapter so a pruned Cursor/Codex heartbeat is recreated with
336
- // the correct platform instead of being mislabeled claude_code.
396
+ // the correct platform instead of being mislabeled claude-code.
337
397
  platform: adapterToPlatform(adapter),
338
398
  };
339
399
  atomicWrite(path, JSON.stringify(hb, null, 2));
340
400
  // Write-only telemetry: only the actual-recreate branch reaches here (the
341
401
  // already-alive case returned above), so this records exactly the heals that
342
- // happened.
402
+ // happened. Recorded fork lineage rides the event too, so derived readers
403
+ // rebuilding from the ledger converge with .name-history.
343
404
  emitHealthHeal(coordRoot, "health.heartbeat_heal", instanceId, hb, {
344
405
  reason: "missing",
345
406
  kind: "heartbeat",
407
+ ...(opts?.forkedFrom ? { forked_from: opts.forkedFrom } : {}),
346
408
  });
347
409
  return hb;
348
410
  }
@@ -312,6 +312,10 @@ export interface NameHistoryRow {
312
312
  /** Audit marker distinguishing an explicit role adoption from pool assignment. */
313
313
  source?: "pool" | "identity.assume";
314
314
  previous_name?: string;
315
+ /** Instance this session was forked/branched from (recorded fork lineage).
316
+ * Stamped only on the row that first assigns this instance, when the adapter
317
+ * layer detected or supplied a parent conversation. */
318
+ forked_from?: string;
315
319
  }
316
320
 
317
321
  function atomicWrite(path: string, content: string): void {
@@ -433,8 +437,15 @@ export function recordNameAssumption(
433
437
  * Assign a name to <instanceId> with the given <kind>. Counter-consuming when
434
438
  * the owner is new. Idempotent: returns existing name on resume.
435
439
  */
436
- export function assignName(coordRoot: string, instanceId: string, kind: NameKind): string {
437
- // Check 1: existing history row → original name.
440
+ export function assignName(
441
+ coordRoot: string,
442
+ instanceId: string,
443
+ kind: NameKind,
444
+ opts?: { forkedFrom?: string },
445
+ ): string {
446
+ // Check 1: existing history row → original name. A resume re-enters here,
447
+ // which also makes fork stamping naturally idempotent: lineage lands only on
448
+ // the row that first assigns the instance.
438
449
  const existing = resolveName(coordRoot, instanceId);
439
450
  if (existing) return existing.name;
440
451
 
@@ -447,12 +458,53 @@ export function assignName(coordRoot: string, instanceId: string, kind: NameKind
447
458
  }
448
459
  const name = COORD_NAMES[counter % 260]!;
449
460
  atomicWrite(cPath, String(counter + 1));
461
+ const forkedFrom = opts?.forkedFrom;
450
462
  appendHistory(coordRoot, {
451
463
  instance_id: instanceId,
452
464
  name,
453
465
  kind,
454
466
  source: "pool",
467
+ ...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
455
468
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
456
469
  });
457
470
  return name;
458
471
  }
472
+
473
+ /** One step of recorded fork lineage: the latest row for <instanceId> that
474
+ * carries `forked_from` (latest-row-wins, matching resolveName). */
475
+ export function readForkParent(
476
+ coordRoot: string,
477
+ instanceId: string,
478
+ ): { instance_id: string; name: string | null } | null {
479
+ const history = readHistory(coordRoot);
480
+ for (let i = history.length - 1; i >= 0; i--) {
481
+ const row = history[i]!;
482
+ if (row.instance_id !== instanceId) continue;
483
+ if (!row.forked_from) return null;
484
+ const parent = resolveName(coordRoot, row.forked_from);
485
+ return { instance_id: row.forked_from, name: parent?.name ?? null };
486
+ }
487
+ return null;
488
+ }
489
+
490
+ /**
491
+ * Full recorded fork ancestry for <instanceId>, nearest ancestor first, each
492
+ * with its latest resolved name. Depth-capped and cycle-guarded: lineage is
493
+ * append-only operational data, not something to trust unboundedly.
494
+ */
495
+ export function resolveForkAncestry(
496
+ coordRoot: string,
497
+ instanceId: string,
498
+ ): Array<{ instance_id: string; name: string | null }> {
499
+ const out: Array<{ instance_id: string; name: string | null }> = [];
500
+ const seen = new Set<string>([instanceId]);
501
+ let cursor = instanceId;
502
+ for (let depth = 0; depth < 20; depth++) {
503
+ const parent = readForkParent(coordRoot, cursor);
504
+ if (!parent || seen.has(parent.instance_id)) break;
505
+ out.push(parent);
506
+ seen.add(parent.instance_id);
507
+ cursor = parent.instance_id;
508
+ }
509
+ return out;
510
+ }
@@ -7,7 +7,7 @@
7
7
  * 2. `<project-root>/.harnery/config.jsonc` — project override (authoritative)
8
8
  *
9
9
  * Fields owned here: `binName` (host CLI name for agent-facing strings),
10
- * `hooksSetupHint`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
10
+ * `hooksSetupHint`, `agents`, `tools`, `workflow`, `skills`, `presence`, plus the tunable
11
11
  * `coord` (heartbeat freshness), `artifacts` (working-file retention),
12
12
  * `backup` (restic repo/password/prune policy), and `sync` (rclone
13
13
  * remote/prefix) sections. The `files` deny/override section
@@ -30,6 +30,13 @@ export const DEFAULT_BIN_NAME = "harn";
30
30
  /** Heartbeat-freshness default (seconds): the sweep window when nothing overrides it. */
31
31
  export const DEFAULT_FRESHNESS_SECS = 600;
32
32
 
33
+ export type AgentFinalizationDisposition = "git" | "output";
34
+
35
+ export interface AgentFinalizationRoot {
36
+ path: string;
37
+ disposition: AgentFinalizationDisposition;
38
+ }
39
+
33
40
  interface HarneryConfig {
34
41
  /** Host CLI bin name, stamped by `harn init` for a consumer (e.g. "acme"). */
35
42
  binName?: string;
@@ -41,6 +48,15 @@ interface HarneryConfig {
41
48
  * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
42
49
  */
43
50
  hooksSetupHint?: string;
51
+ /**
52
+ * Agent-ritual policy owned by the host project. Git finalization is opt-in:
53
+ * standalone Harnery and embedding hosts keep the ordinary status ritual
54
+ * unless the project deliberately requires the guarded check.
55
+ */
56
+ agents?: {
57
+ requireGitFinalization?: boolean;
58
+ finalizationRoots?: AgentFinalizationRoot[];
59
+ };
44
60
  /**
45
61
  * Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }`
46
62
  * lets `grep` download the pinned, checksum-verified ripgrep into the
@@ -157,6 +173,16 @@ function statMtime(p: string): number {
157
173
  }
158
174
  }
159
175
 
176
+ /** Cache signature, or null when the file can't be stat'd (missing). */
177
+ function statSignature(p: string): string | null {
178
+ try {
179
+ const stat = statSync(p);
180
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
160
186
  /** Parse one JSONC config file to an object; missing/unparseable → `{}`. */
161
187
  function parseConfigFile(p: string): HarneryConfig {
162
188
  try {
@@ -186,8 +212,13 @@ function mergeConfig(base: HarneryConfig, override: HarneryConfig): HarneryConfi
186
212
  return out as HarneryConfig;
187
213
  }
188
214
 
189
- // mtime-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
190
- let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryConfig } | null = null;
215
+ // Stat-signature-keyed per-process cache (both layers): a stat is cheap, a parse on every render isn't.
216
+ let cache: {
217
+ root: string;
218
+ projSignature: string | null;
219
+ userSignature: string | null;
220
+ cfg: HarneryConfig;
221
+ } | null = null;
191
222
 
192
223
  /**
193
224
  * The effective config for `root`: user-global (`~/.config/harnery/config.jsonc`)
@@ -197,20 +228,20 @@ let cache: { root: string; projMtime: number; userMtime: number; cfg: HarneryCon
197
228
  function readConfig(root: string): HarneryConfig {
198
229
  const projPath = join(root, ".harnery", "config.jsonc");
199
230
  const userPath = userConfigPath();
200
- const projMtime = statMtime(projPath);
201
- const userMtime = statMtime(userPath);
231
+ const projSignature = statSignature(projPath);
232
+ const userSignature = statSignature(userPath);
202
233
  if (
203
234
  cache &&
204
235
  cache.root === root &&
205
- cache.projMtime === projMtime &&
206
- cache.userMtime === userMtime
236
+ cache.projSignature === projSignature &&
237
+ cache.userSignature === userSignature
207
238
  ) {
208
239
  return cache.cfg;
209
240
  }
210
- const user = userMtime === -1 ? {} : parseConfigFile(userPath);
211
- const project = projMtime === -1 ? {} : parseConfigFile(projPath);
241
+ const user = userSignature === null ? {} : parseConfigFile(userPath);
242
+ const project = projSignature === null ? {} : parseConfigFile(projPath);
212
243
  const cfg = mergeConfig(user, project);
213
- cache = { root, projMtime, userMtime, cfg };
244
+ cache = { root, projSignature, userSignature, cfg };
214
245
  return cfg;
215
246
  }
216
247
 
@@ -270,6 +301,55 @@ export function resolveHooksSetupHint(coordRoot?: string | null): string | null
270
301
  return typeof hint === "string" && hint.trim() ? hint.trim() : null;
271
302
  }
272
303
 
304
+ /**
305
+ * Whether the host requires the guarded Git check at the end of tool-using
306
+ * turns. Default false: Harnery exposes `agents status --end-turn` as a capability
307
+ * but does not impose a commit-and-push policy on embedding projects.
308
+ *
309
+ * `.harnery/config.jsonc`:
310
+ * `{ "agents": { "requireGitFinalization": true } }`
311
+ *
312
+ * `HARNERY_AGENTS_REQUIRE_GIT_FINALIZATION=1|0` overrides per process.
313
+ */
314
+ export function agentsRequireGitFinalization(coordRoot?: string | null): boolean {
315
+ const env = coordEnv("AGENTS_REQUIRE_GIT_FINALIZATION");
316
+ if (env === "1") return true;
317
+ if (env === "0") return false;
318
+ const root = coordRoot ?? findCoordRoot();
319
+ if (!root) return false;
320
+ return readConfig(root).agents?.requireGitFinalization === true;
321
+ }
322
+
323
+ /**
324
+ * Extra roots whose guarded writes have an explicit end-turn disposition.
325
+ *
326
+ * This trust boundary comes only from the project config. A user-global config
327
+ * may tune ordinary behavior, but it cannot grant one project filesystem
328
+ * authority outside its coordination root. Paths may be absolute or relative
329
+ * to the coordination root. Invalid entries are ignored here and fail closed
330
+ * when the finalization policy validates them.
331
+ */
332
+ export function agentsFinalizationRoots(coordRoot?: string | null): AgentFinalizationRoot[] {
333
+ const root = coordRoot ?? findCoordRoot();
334
+ if (!root) return [];
335
+ const entries = readProjectConfig(root).agents?.finalizationRoots;
336
+ if (!Array.isArray(entries)) return [];
337
+ return entries.flatMap((entry) => {
338
+ if (!entry || typeof entry !== "object") return [];
339
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
340
+ const disposition = entry.disposition;
341
+ if (!path || (disposition !== "git" && disposition !== "output")) return [];
342
+ return [{ path, disposition }];
343
+ });
344
+ }
345
+
346
+ /** The status command automatic prompts and Stop remediation should request. */
347
+ export function endOfTurnStatusCommand(coordRoot?: string | null): string {
348
+ const root = coordRoot ?? findCoordRoot();
349
+ const suffix = agentsRequireGitFinalization(root) ? " --end-turn" : "";
350
+ return `${resolveBinName(root)} agents status${suffix}`;
351
+ }
352
+
273
353
  /**
274
354
  * Whether the host project consented to automatic ripgrep provisioning:
275
355
  * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
@@ -349,13 +429,13 @@ function posIntOr(v: unknown, fallback: number): number {
349
429
  * The heartbeat-freshness window (seconds): the age above which the sweeper
350
430
  * prunes an agent, and the cutoff the `agents` surface uses to fold stale peers.
351
431
  * Precedence:
352
- * 1. `HARNERY_AGENT_COORD_FRESHNESS` env (canonical), or `HARNERY_AGENT_FRESHNESS` (legacy alias)
432
+ * 1. `HARNERY_AGENT_COORD_FRESHNESS` env
353
433
  * 2. `.harnery/config.jsonc` `coord.freshness_seconds`
354
434
  * 3. `600` (10 minutes)
355
435
  * `coordRoot` is resolved via `findCoordRoot()` when not passed.
356
436
  */
357
437
  export function coordFreshnessSeconds(coordRoot?: string | null): number {
358
- const env = coordEnv("AGENT_COORD_FRESHNESS") ?? coordEnv("AGENT_FRESHNESS");
438
+ const env = coordEnv("AGENT_COORD_FRESHNESS");
359
439
  if (env !== undefined) {
360
440
  const n = Number.parseInt(env, 10);
361
441
  if (Number.isFinite(n) && n > 0) return n;