harnery 0.31.5 → 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 (222) 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 +11 -4
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +27 -9
  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/parse.d.ts +1 -1
  72. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/parse.js +22 -5
  74. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +32 -0
  77. package/dist/core/hooks/cli.d.ts +1 -2
  78. package/dist/core/hooks/cli.d.ts.map +1 -1
  79. package/dist/core/hooks/cli.js +169 -27
  80. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  81. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  82. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  83. package/dist/core/hooks/effects/index.d.ts +1 -1
  84. package/dist/core/hooks/effects/index.js +3 -3
  85. package/dist/core/hooks/events/schema.d.ts +27 -0
  86. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  87. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  88. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/owner.js +15 -0
  90. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  91. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  92. package/dist/core/hooks/resolve/transcript.js +176 -1
  93. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  94. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  95. package/dist/core/hooks/session-name-presence.js +43 -0
  96. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  97. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  98. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  99. package/dist/core/work/state.d.ts +4 -5
  100. package/dist/core/work/state.d.ts.map +1 -1
  101. package/dist/core/work/state.js +5 -8
  102. package/dist/core/workflow/index.d.ts +1 -1
  103. package/dist/core/workflow/index.d.ts.map +1 -1
  104. package/dist/core/workflow/proof.d.ts +2 -2
  105. package/dist/core/workflow/proof.d.ts.map +1 -1
  106. package/dist/core/workflow/proof.js +2 -2
  107. package/dist/core/workflow/run-state.d.ts +2 -2
  108. package/dist/core/workflow/run-state.d.ts.map +1 -1
  109. package/dist/core/workflow/run-state.js +2 -2
  110. package/dist/core/workflow/types.d.ts +3 -3
  111. package/dist/core/workflow/types.d.ts.map +1 -1
  112. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  113. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/execution.js +5 -0
  115. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  116. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  117. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  118. package/dist/core/workflow/workspaces/local-git.js +1 -2
  119. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  120. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  121. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  122. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  123. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  124. package/dist/core/workflow/workspaces/validate.js +0 -2
  125. package/dist/lib/browser/client.d.ts +118 -1
  126. package/dist/lib/browser/client.d.ts.map +1 -1
  127. package/dist/lib/browser/client.js +435 -6
  128. package/dist/lib/browser/geometry.d.ts.map +1 -1
  129. package/dist/lib/browser/geometry.js +180 -37
  130. package/dist/lib/browser/index.d.ts +5 -1
  131. package/dist/lib/browser/index.d.ts.map +1 -1
  132. package/dist/lib/browser/index.js +5 -1
  133. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  134. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  135. package/dist/lib/browser/netscape-cookies.js +31 -0
  136. package/dist/lib/browser/proxy.d.ts +24 -0
  137. package/dist/lib/browser/proxy.d.ts.map +1 -0
  138. package/dist/lib/browser/proxy.js +84 -0
  139. package/dist/lib/browser/runts.d.ts +6 -0
  140. package/dist/lib/browser/runts.d.ts.map +1 -1
  141. package/dist/lib/browser/runts.js +21 -4
  142. package/dist/lib/browser/session-control.d.ts +112 -0
  143. package/dist/lib/browser/session-control.d.ts.map +1 -0
  144. package/dist/lib/browser/session-control.js +670 -0
  145. package/dist/lib/docs-links.d.ts +108 -0
  146. package/dist/lib/docs-links.d.ts.map +1 -0
  147. package/dist/lib/docs-links.js +555 -0
  148. package/dist/lib/exec.d.ts +1 -1
  149. package/dist/lib/exec.js +1 -1
  150. package/dist/lib/identities/assume.d.ts +4 -2
  151. package/dist/lib/identities/assume.d.ts.map +1 -1
  152. package/dist/lib/identities/assume.js +15 -2
  153. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  154. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  155. package/dist/lib/instructions/git-hooks.js +238 -0
  156. package/dist/lib/instructions/splice.d.ts +10 -4
  157. package/dist/lib/instructions/splice.d.ts.map +1 -1
  158. package/dist/lib/instructions/splice.js +19 -11
  159. package/package.json +1 -1
  160. package/schemas/config.schema.json +32 -0
  161. package/src/commander.ts +2 -0
  162. package/src/commands/agents.ts +96 -115
  163. package/src/commands/browse-session.ts +245 -0
  164. package/src/commands/browse.ts +287 -44
  165. package/src/commands/checkpoint.ts +1 -1
  166. package/src/commands/deinit.ts +7 -1
  167. package/src/commands/docs.ts +56 -0
  168. package/src/commands/doctor.ts +100 -18
  169. package/src/commands/init.ts +17 -9
  170. package/src/commands/tunnel.ts +2 -5
  171. package/src/core/agents/canonical-emit.ts +1 -2
  172. package/src/core/agents/cli.ts +137 -88
  173. package/src/core/agents/coord-client.ts +38 -9
  174. package/src/core/agents/finalization.ts +595 -0
  175. package/src/core/agents/git-hook.ts +126 -0
  176. package/src/core/agents/render/prompt-context.ts +28 -13
  177. package/src/core/agents/render/session-context.ts +15 -2
  178. package/src/core/agents/rules/claim-conflict.ts +3 -3
  179. package/src/core/agents/rules/commit-conflict.ts +35 -8
  180. package/src/core/agents/rules/stop-hook.ts +71 -23
  181. package/src/core/agents/session-events.ts +14 -41
  182. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  183. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  184. package/src/core/agents/state/names.ts +54 -2
  185. package/src/core/config.ts +92 -12
  186. package/src/core/governor/planning.ts +17 -12
  187. package/src/core/hooks/adapter/detect.ts +4 -13
  188. package/src/core/hooks/adapter/output.ts +9 -4
  189. package/src/core/hooks/adapter/parse.ts +24 -5
  190. package/src/core/hooks/adapter/wiring.ts +43 -0
  191. package/src/core/hooks/cli.ts +201 -23
  192. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  193. package/src/core/hooks/effects/index.ts +5 -5
  194. package/src/core/hooks/events/schema.ts +27 -0
  195. package/src/core/hooks/resolve/owner.ts +22 -1
  196. package/src/core/hooks/resolve/transcript.ts +164 -1
  197. package/src/core/hooks/session-name-presence.ts +52 -0
  198. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  199. package/src/core/work/state.ts +8 -12
  200. package/src/core/workflow/engine.ts +2 -2
  201. package/src/core/workflow/index.ts +0 -1
  202. package/src/core/workflow/proof.ts +4 -4
  203. package/src/core/workflow/run-state.ts +5 -5
  204. package/src/core/workflow/types.ts +3 -3
  205. package/src/core/workflow/workspaces/execution.ts +11 -4
  206. package/src/core/workflow/workspaces/index.ts +0 -3
  207. package/src/core/workflow/workspaces/local-git.ts +1 -2
  208. package/src/core/workflow/workspaces/paths.ts +2 -2
  209. package/src/core/workflow/workspaces/types.ts +0 -9
  210. package/src/core/workflow/workspaces/validate.ts +1 -5
  211. package/src/lib/browser/client.ts +528 -6
  212. package/src/lib/browser/geometry.ts +197 -37
  213. package/src/lib/browser/index.ts +39 -0
  214. package/src/lib/browser/netscape-cookies.ts +39 -0
  215. package/src/lib/browser/proxy.ts +105 -0
  216. package/src/lib/browser/runts.ts +27 -3
  217. package/src/lib/browser/session-control.ts +892 -0
  218. package/src/lib/docs-links.ts +674 -0
  219. package/src/lib/exec.ts +1 -1
  220. package/src/lib/identities/assume.ts +22 -1
  221. package/src/lib/instructions/git-hooks.ts +259 -0
  222. package/src/lib/instructions/splice.ts +41 -11
@@ -75,11 +75,15 @@ export interface Heartbeat {
75
75
  task?: string;
76
76
  /** UTC ISO-8601 timestamp when task was last set/cleared. Used by adapters without Stop enforcement to compute staleness. 2026-05-24. */
77
77
  task_updated_at?: string | null;
78
+ /** Session name built on the first non-empty set-task; its presence means "this session has been named". 2026-08-09. */
79
+ suggested_session_name?: string;
80
+ /** Stamped by turn.stop once the suggested name is seen in assistant reply text. 2026-08-09. */
81
+ session_name_seen_at?: string;
78
82
  /** Auto-generated per-turn summary written by the Stop hook via Haiku. 2026-05-23. */
79
83
  turn_summary?: string | null;
80
84
  /** UTC ISO-8601 timestamp when turn_summary was last refreshed. */
81
85
  turn_summary_updated_at?: string | null;
82
- /** Hook client: `claude_code` (default) or `cursor`. Cursor Phase 1. */
86
+ /** Hook client: `claude-code` (default) or `cursor`. Cursor Phase 1. */
83
87
  platform?: string;
84
88
  }
85
89
 
@@ -260,10 +264,10 @@ export function parsePidmapRowOwner(row: string): string {
260
264
  return tab >= 0 ? trimmed.slice(0, tab) : trimmed;
261
265
  }
262
266
 
263
- /** Parse platform from a pid-map row; legacy rows default to `claude_code`. */
267
+ /** Parse platform from a pid-map row; legacy rows default to `claude-code`. */
264
268
  export function parsePidmapRowPlatform(row: string): string {
265
269
  const platform = row.trim().split("\t")[1]?.trim();
266
- return platform || "claude_code";
270
+ return platform || "claude-code";
267
271
  }
268
272
 
269
273
  /** Parse the start token from a pid-map row; rows written before it carry none. */
@@ -368,7 +372,7 @@ function readPidmapRow(pidmapDir: string, pid: number): string | null {
368
372
  *
369
373
  * Pid-map files are `instance_id` or `instance_id\tplatform` (Cursor Phase 1).
370
374
  * Prefer a row whose platform matches `HARNERY_AGENT_COORD_PLATFORM` (default
371
- * `claude_code`); otherwise return the first owner seen on the walk.
375
+ * `claude-code`); otherwise return the first owner seen on the walk.
372
376
  *
373
377
  * Subagents intentionally do not write pid-map entries; a Bash-tool ppid-walk
374
378
  * from inside a subagent therefore resolves to the *parent's* pid-map entry.
@@ -443,7 +447,7 @@ export function resolveOwnerByPidmap(root: string): {
443
447
  const pidmapDir = resolve(root, ".harnery", "pid-map");
444
448
  if (!existsSync(pidmapDir)) return { owner: null, source: "none" };
445
449
 
446
- const prefer = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim() || "claude_code";
450
+ const prefer = process.env.HARNERY_AGENT_COORD_PLATFORM?.trim() || "claude-code";
447
451
  let fallbackOwner: string | null = null;
448
452
  let pid: number | null = process.pid;
449
453
 
@@ -482,6 +486,7 @@ const SESSION_ID_ENV_VARS = [
482
486
  "CURSOR_SESSION_ID",
483
487
  "CURSOR_CONVERSATION_ID",
484
488
  "CODEX_SESSION_ID",
489
+ "CODEX_THREAD_ID",
485
490
  ] as const;
486
491
 
487
492
  /** Read normalized candidates from the first non-empty adapter session-id env var. */
@@ -528,7 +533,10 @@ function shouldPreferSessionEnv(): boolean {
528
533
  * `instance_id`, or null if there's no session-id env var or no live heartbeat
529
534
  * carries it. "Live" reuses the same 10-minute freshness window the singleton
530
535
  * fallback applies, so a stale heartbeat from a prior session of the same id
531
- * doesn't resolve.
536
+ * doesn't resolve. When legacy Cursor Glass state contains both the canonical
537
+ * bare id and its raw `bc-` alias, candidate order wins first; ties prefer a
538
+ * named heartbeat, then the newest heartbeat, with instance id as a stable
539
+ * final tie-breaker. Filesystem enumeration order never decides identity.
532
540
  *
533
541
  * Exported for unit testing with an injectable root.
534
542
  */
@@ -546,19 +554,40 @@ export function resolveOwnerBySessionEnv(root: string): string | null {
546
554
  } catch {
547
555
  return null;
548
556
  }
557
+ const matches: Array<{
558
+ instanceId: string;
559
+ sessionPreference: number;
560
+ hasName: boolean;
561
+ lastHeartbeatMs: number;
562
+ }> = [];
549
563
  for (const file of files) {
550
564
  if (!file.endsWith(".json")) continue;
551
565
  try {
552
566
  const parsed = JSON.parse(readFileSync(resolve(activeDir, file), "utf8"));
553
- if (!parsed || !sessionIds.includes(parsed.session_id)) continue;
567
+ if (!parsed) continue;
568
+ const sessionPreference = sessionIds.indexOf(parsed.session_id);
569
+ if (sessionPreference === -1) continue;
554
570
  if (typeof parsed.instance_id !== "string") continue;
555
571
  const ts = Date.parse(parsed.last_heartbeat);
556
- if (Number.isFinite(ts) && ts >= cutoffMs) return parsed.instance_id;
572
+ if (!Number.isFinite(ts) || ts < cutoffMs) continue;
573
+ matches.push({
574
+ instanceId: parsed.instance_id,
575
+ sessionPreference,
576
+ hasName: typeof parsed.name === "string" && parsed.name.trim().length > 0,
577
+ lastHeartbeatMs: ts,
578
+ });
557
579
  } catch {
558
580
  // skip malformed
559
581
  }
560
582
  }
561
- return null;
583
+ matches.sort(
584
+ (a, b) =>
585
+ a.sessionPreference - b.sessionPreference ||
586
+ Number(b.hasName) - Number(a.hasName) ||
587
+ b.lastHeartbeatMs - a.lastHeartbeatMs ||
588
+ a.instanceId.localeCompare(b.instanceId),
589
+ );
590
+ return matches[0]?.instanceId ?? null;
562
591
  }
563
592
 
564
593
  /**
@@ -0,0 +1,595 @@
1
+ /**
2
+ * Ownership-aware Git completion checks for the end-of-turn status ritual.
3
+ *
4
+ * The heartbeat's durable `files_touched` ledger is the ownership boundary. We
5
+ * inspect only those paths, plus any enclosing submodule gitlinks, so a peer's
6
+ * active edits elsewhere in a shared checkout do not block this session.
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, realpathSync, statSync } from "node:fs";
11
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
+ import { type AgentFinalizationDisposition, agentsFinalizationRoots } from "../config.ts";
13
+ import { readStreamTailBounded } from "./events/consume.ts";
14
+
15
+ const CLAIM_HISTORY_CAP_BYTES = 128 * 1024 * 1024;
16
+
17
+ const GIT_DISCOVERY_VARS = [
18
+ "GIT_DIR",
19
+ "GIT_WORK_TREE",
20
+ "GIT_COMMON_DIR",
21
+ "GIT_INDEX_FILE",
22
+ "GIT_OBJECT_DIRECTORY",
23
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
24
+ "GIT_PREFIX",
25
+ "GIT_NAMESPACE",
26
+ ] as const;
27
+
28
+ interface GitResult {
29
+ status: number;
30
+ stdout: string;
31
+ stderr: string;
32
+ }
33
+
34
+ interface RepoWork {
35
+ root: string;
36
+ paths: Set<string>;
37
+ gitlinks: Set<string>;
38
+ }
39
+
40
+ export interface GitFinalizationResult {
41
+ ok: boolean;
42
+ claim_history_complete: boolean;
43
+ dirty_paths: string[];
44
+ unpushed_repos: string[];
45
+ host_output_paths: string[];
46
+ unsupported_paths: UnsupportedFinalizationPath[];
47
+ unverifiable_paths: string[];
48
+ unverifiable_repos: string[];
49
+ repos_checked: string[];
50
+ }
51
+
52
+ export type UnsupportedFinalizationReason =
53
+ | "outside_finalization_roots"
54
+ | "invalid_finalization_root"
55
+ | "ambiguous_finalization_roots"
56
+ | "path_escapes_finalization_root"
57
+ | "git_repository_unavailable"
58
+ | "output_path_is_git";
59
+
60
+ export interface UnsupportedFinalizationPath {
61
+ path: string;
62
+ reason: UnsupportedFinalizationReason;
63
+ }
64
+
65
+ export interface ClaimFinalizationDescriptor {
66
+ disposition: AgentFinalizationDisposition;
67
+ root: string;
68
+ }
69
+
70
+ export type ClaimFinalizationDecision =
71
+ | {
72
+ allow: true;
73
+ path: string;
74
+ descriptor: ClaimFinalizationDescriptor;
75
+ authorityRoot: string;
76
+ repo?: { root: string; path: string };
77
+ }
78
+ | {
79
+ allow: false;
80
+ path: string;
81
+ reason: UnsupportedFinalizationReason;
82
+ };
83
+
84
+ export interface SessionWriteClaims {
85
+ paths: string[];
86
+ complete: boolean;
87
+ }
88
+
89
+ /**
90
+ * Read every write claim made by this session, including claims released after
91
+ * a commit. Current heartbeat claims describe dirty ownership; this history is
92
+ * what keeps the touched repository in scope until its commits are pushed.
93
+ */
94
+ export function readSessionWriteClaims(
95
+ coordRoot: string,
96
+ instanceId: string,
97
+ sessionId: string,
98
+ ): SessionWriteClaims {
99
+ const streamPath = join(coordRoot, ".harnery", "events.ndjson");
100
+ const { text, truncated } = readStreamTailBounded(streamPath, CLAIM_HISTORY_CAP_BYTES);
101
+ const paths = new Set<string>();
102
+ let sawSessionStart = false;
103
+
104
+ for (const line of text.split("\n")) {
105
+ if (line.trim().length === 0) continue;
106
+ try {
107
+ const event = JSON.parse(line) as {
108
+ event_type?: string;
109
+ instance_id?: string;
110
+ session_id?: string;
111
+ data?: { path?: unknown; mode?: unknown };
112
+ };
113
+ if (event.instance_id !== instanceId || event.session_id !== sessionId) continue;
114
+ if (event.event_type === "session.start") sawSessionStart = true;
115
+ if (
116
+ event.event_type === "claim.acquire" &&
117
+ event.data?.mode === "write" &&
118
+ typeof event.data.path === "string"
119
+ ) {
120
+ paths.add(event.data.path);
121
+ }
122
+ } catch {
123
+ // Ignore malformed and crash-truncated event lines, matching the
124
+ // canonical stream consumer's recovery behavior.
125
+ }
126
+ }
127
+
128
+ return { paths: [...paths], complete: !truncated || sawSessionStart };
129
+ }
130
+
131
+ function git(args: string[], cwd: string): GitResult {
132
+ const env = { ...process.env };
133
+ for (const name of GIT_DISCOVERY_VARS) delete env[name];
134
+ const result = spawnSync("git", args, { cwd, env, encoding: "utf8", timeout: 5_000 });
135
+ return {
136
+ status: result.status ?? 1,
137
+ stdout: result.stdout ?? "",
138
+ stderr: result.stderr ?? result.error?.message ?? "",
139
+ };
140
+ }
141
+
142
+ function repoLabel(coordRoot: string, repoRoot: string): string {
143
+ const rel = relative(coordRoot, repoRoot).replaceAll("\\", "/");
144
+ return rel.length === 0 ? "." : rel;
145
+ }
146
+
147
+ function pathInside(root: string, candidate: string): boolean {
148
+ const rel = relative(root, candidate);
149
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
150
+ }
151
+
152
+ function existingProbeDir(authorityRoot: string, absolutePath: string): string | null {
153
+ let current = absolutePath;
154
+ if (existsSync(current) && !statSync(current).isDirectory()) current = dirname(current);
155
+ while (!existsSync(current)) {
156
+ const parent = dirname(current);
157
+ if (parent === current || !pathInside(authorityRoot, parent)) return null;
158
+ current = parent;
159
+ }
160
+ return pathInside(authorityRoot, current) ? current : null;
161
+ }
162
+
163
+ /** Resolve existing ancestors through symlinks while preserving a missing tail. */
164
+ function physicalPath(absolutePath: string): string | null {
165
+ let existing = absolutePath;
166
+ while (!existsSync(existing)) {
167
+ const parent = dirname(existing);
168
+ if (parent === existing) return null;
169
+ existing = parent;
170
+ }
171
+ try {
172
+ const physical = realpathSync.native(existing);
173
+ const tail = relative(existing, absolutePath);
174
+ return resolve(physical, tail);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Translate a Windows-native WSL UNC claim back to the Linux path seen by the
182
+ * coordination process. Codex can report `\\\\wsl.localhost\\<distro>\\...`
183
+ * while Harnery runs inside that distro with a `/...` coord root.
184
+ */
185
+ function resolveHeldPath(coordRoot: string, heldPath: string): string {
186
+ const wslUnc = /^\\\\(?:wsl\.localhost|wsl\$)\\[^\\]+\\(.+)$/i.exec(heldPath);
187
+ if (wslUnc) {
188
+ const linuxPath = `/${wslUnc[1]!.replaceAll("\\", "/")}`;
189
+ const slashRoot = coordRoot.replaceAll("\\", "/");
190
+ if (slashRoot.startsWith("/") || pathInside(coordRoot, linuxPath)) return linuxPath;
191
+ }
192
+ return resolve(coordRoot, heldPath);
193
+ }
194
+
195
+ function discoverRepo(
196
+ authorityRoot: string,
197
+ absolutePath: string,
198
+ ): { root: string; path: string } | null {
199
+ const probeDir = existingProbeDir(authorityRoot, absolutePath);
200
+ if (!probeDir) return null;
201
+ const rootResult = git(["rev-parse", "--show-toplevel"], probeDir);
202
+ if (rootResult.status !== 0) return null;
203
+ const repoRoot = physicalPath(resolve(rootResult.stdout.trim()));
204
+ if (!repoRoot || !pathInside(authorityRoot, repoRoot) || !pathInside(repoRoot, absolutePath)) {
205
+ return null;
206
+ }
207
+ const repoPath = relative(repoRoot, absolutePath).replaceAll("\\", "/") || ".";
208
+ return { root: repoRoot, path: repoPath };
209
+ }
210
+
211
+ interface FinalizationRoot {
212
+ configuredPath: string;
213
+ disposition: AgentFinalizationDisposition;
214
+ lexicalRoot: string;
215
+ physicalRoot: string | null;
216
+ invalid: boolean;
217
+ }
218
+
219
+ function rootIsGitRepository(root: string): boolean {
220
+ if (!existsSync(root) || !statSync(root).isDirectory()) return false;
221
+ const result = git(["rev-parse", "--show-toplevel"], root);
222
+ if (result.status !== 0) return false;
223
+ const discovered = physicalPath(resolve(result.stdout.trim()));
224
+ const physicalRoot = physicalPath(root);
225
+ return discovered !== null && physicalRoot !== null && discovered === physicalRoot;
226
+ }
227
+
228
+ function outputRootIsInsideGit(root: string): boolean {
229
+ let probe = root;
230
+ while (!existsSync(probe)) {
231
+ const parent = dirname(probe);
232
+ if (parent === probe) return false;
233
+ probe = parent;
234
+ }
235
+ if (!statSync(probe).isDirectory()) probe = dirname(probe);
236
+ return git(["rev-parse", "--show-toplevel"], probe).status === 0;
237
+ }
238
+
239
+ function finalizationRoots(coordRoot: string): FinalizationRoot[] {
240
+ const root = resolve(coordRoot);
241
+ const configured = agentsFinalizationRoots(root).map((entry) => {
242
+ const lexicalRoot = resolveHeldPath(root, entry.path);
243
+ const physicalRoot = physicalPath(lexicalRoot);
244
+ const invalid =
245
+ physicalRoot === null ||
246
+ (entry.disposition === "git"
247
+ ? !rootIsGitRepository(physicalRoot)
248
+ : outputRootIsInsideGit(physicalRoot));
249
+ return {
250
+ configuredPath: entry.path,
251
+ disposition: entry.disposition,
252
+ lexicalRoot,
253
+ physicalRoot,
254
+ invalid,
255
+ } satisfies FinalizationRoot;
256
+ });
257
+
258
+ const rootPhysical = physicalPath(root);
259
+ if (rootPhysical && rootIsGitRepository(rootPhysical)) {
260
+ configured.push({
261
+ configuredPath: ".",
262
+ disposition: "git",
263
+ lexicalRoot: root,
264
+ physicalRoot: rootPhysical,
265
+ invalid: false,
266
+ });
267
+ }
268
+
269
+ for (const current of configured) {
270
+ if (!current.physicalRoot) continue;
271
+ for (const peer of configured) {
272
+ if (current === peer || !peer.physicalRoot || current.disposition === peer.disposition) {
273
+ continue;
274
+ }
275
+ if (
276
+ pathInside(current.physicalRoot, peer.physicalRoot) ||
277
+ pathInside(peer.physicalRoot, current.physicalRoot)
278
+ ) {
279
+ current.invalid = true;
280
+ peer.invalid = true;
281
+ }
282
+ }
283
+ }
284
+
285
+ return configured;
286
+ }
287
+
288
+ /**
289
+ * Resolve one guarded write to the host's single supported end-turn
290
+ * disposition. Claim event data never grants authority: every call starts from
291
+ * the project-owned finalization roots and only then inspects the selected
292
+ * repository or output tree.
293
+ */
294
+ export function classifyWriteClaimFinalization(
295
+ coordRoot: string,
296
+ heldPath: string,
297
+ ): ClaimFinalizationDecision {
298
+ const root = resolve(coordRoot);
299
+ const absolutePath = resolveHeldPath(root, heldPath);
300
+ const roots = finalizationRoots(root);
301
+ const lexicalMatches = roots.filter((entry) => pathInside(entry.lexicalRoot, absolutePath));
302
+ if (lexicalMatches.length === 0) {
303
+ return { allow: false, path: heldPath, reason: "outside_finalization_roots" };
304
+ }
305
+ if (lexicalMatches.some((entry) => entry.invalid)) {
306
+ return { allow: false, path: heldPath, reason: "invalid_finalization_root" };
307
+ }
308
+
309
+ const physical = physicalPath(absolutePath);
310
+ if (!physical) {
311
+ return { allow: false, path: heldPath, reason: "path_escapes_finalization_root" };
312
+ }
313
+ const matches = lexicalMatches.filter(
314
+ (entry) => entry.physicalRoot && pathInside(entry.physicalRoot, physical),
315
+ );
316
+ if (matches.length === 0) {
317
+ return { allow: false, path: heldPath, reason: "path_escapes_finalization_root" };
318
+ }
319
+ const dispositions = new Set(matches.map((entry) => entry.disposition));
320
+ if (dispositions.size !== 1) {
321
+ return { allow: false, path: heldPath, reason: "ambiguous_finalization_roots" };
322
+ }
323
+ const selected = [...matches].sort(
324
+ (a, b) => (b.physicalRoot?.length ?? 0) - (a.physicalRoot?.length ?? 0),
325
+ )[0]!;
326
+ const normalizedPath = relative(root, absolutePath).replaceAll("\\", "/") || ".";
327
+ const descriptor: ClaimFinalizationDescriptor = {
328
+ disposition: selected.disposition,
329
+ root: selected.configuredPath,
330
+ };
331
+
332
+ if (selected.disposition === "output") {
333
+ const probe = existingProbeDir(selected.physicalRoot!, physical);
334
+ if (probe && git(["rev-parse", "--show-toplevel"], probe).status === 0) {
335
+ return { allow: false, path: heldPath, reason: "output_path_is_git" };
336
+ }
337
+ return {
338
+ allow: true,
339
+ path: normalizedPath,
340
+ descriptor,
341
+ authorityRoot: selected.physicalRoot!,
342
+ };
343
+ }
344
+
345
+ const repo = discoverRepo(selected.physicalRoot!, physical);
346
+ if (!repo) {
347
+ return { allow: false, path: heldPath, reason: "git_repository_unavailable" };
348
+ }
349
+ return {
350
+ allow: true,
351
+ path: normalizedPath,
352
+ descriptor,
353
+ authorityRoot: selected.physicalRoot!,
354
+ repo,
355
+ };
356
+ }
357
+
358
+ function addRepoPath(
359
+ repos: Map<string, RepoWork>,
360
+ root: string,
361
+ path: string,
362
+ gitlink = false,
363
+ ): void {
364
+ const work = repos.get(root) ?? {
365
+ root,
366
+ paths: new Set<string>(),
367
+ gitlinks: new Set<string>(),
368
+ };
369
+ work.paths.add(path);
370
+ if (gitlink) work.gitlinks.add(path);
371
+ repos.set(root, work);
372
+ }
373
+
374
+ function addSuperprojects(
375
+ authorityRoot: string,
376
+ repos: Map<string, RepoWork>,
377
+ leafRoot: string,
378
+ ): void {
379
+ let childRoot = leafRoot;
380
+ for (;;) {
381
+ const result = git(["rev-parse", "--show-superproject-working-tree"], childRoot);
382
+ if (result.status !== 0 || result.stdout.trim().length === 0) return;
383
+ const parentRoot = physicalPath(resolve(result.stdout.trim()));
384
+ if (!parentRoot || !pathInside(authorityRoot, parentRoot) || parentRoot === childRoot) return;
385
+ const gitlink = relative(parentRoot, childRoot).replaceAll("\\", "/");
386
+ addRepoPath(repos, parentRoot, gitlink, true);
387
+ childRoot = parentRoot;
388
+ }
389
+ }
390
+
391
+ function repoHasRemote(repoRoot: string): boolean {
392
+ const result = git(["remote"], repoRoot);
393
+ return result.status === 0 && result.stdout.trim().length > 0;
394
+ }
395
+
396
+ function repoSyncState(repoRoot: string): "synced" | "unpushed" | "unverifiable" {
397
+ if (!repoHasRemote(repoRoot)) return "synced";
398
+ if (git(["rev-parse", "--verify", "HEAD"], repoRoot).status !== 0) return "unverifiable";
399
+
400
+ const upstream = git(
401
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
402
+ repoRoot,
403
+ );
404
+ if (upstream.status === 0) {
405
+ const ahead = git(["rev-list", "--count", "@{upstream}..HEAD"], repoRoot);
406
+ if (ahead.status !== 0) return "unverifiable";
407
+ return Number.parseInt(ahead.stdout.trim(), 10) > 0 ? "unpushed" : "synced";
408
+ }
409
+
410
+ // Detached submodules commonly have no upstream. A remote ref containing
411
+ // HEAD proves that commit is available elsewhere without requiring a branch.
412
+ const containing = git(
413
+ ["branch", "--remotes", "--contains", "HEAD", "--format=%(refname:short)"],
414
+ repoRoot,
415
+ );
416
+ if (containing.status !== 0) return "unverifiable";
417
+ return containing.stdout.trim().length > 0 ? "synced" : "unpushed";
418
+ }
419
+
420
+ /** Compare gitlink commits without treating dirty contents inside the child as a pointer change. */
421
+ function gitlinkIsDirty(parentRoot: string, gitlink: string): boolean {
422
+ const head = git(["rev-parse", `HEAD:${gitlink}`], parentRoot);
423
+ const index = git(["ls-files", "--stage", "--", gitlink], parentRoot);
424
+ const child = git(["rev-parse", "HEAD"], join(parentRoot, gitlink));
425
+ if (head.status !== 0 || index.status !== 0 || child.status !== 0) return true;
426
+ const indexMatch = /^160000 ([0-9a-f]{40}) 0\t/.exec(index.stdout.trim());
427
+ if (!indexMatch) return true;
428
+ return head.stdout.trim() !== indexMatch[1] || child.stdout.trim() !== indexMatch[1];
429
+ }
430
+
431
+ /** Check that this session's held paths are committed and their repositories are pushed. */
432
+ export function checkGitFinalization(
433
+ coordRoot: string,
434
+ heldPaths: readonly string[],
435
+ options: { claimHistoryComplete?: boolean } = {},
436
+ ): GitFinalizationResult {
437
+ const root = resolve(coordRoot);
438
+ const repos = new Map<string, RepoWork>();
439
+ const hostOutputPaths = new Set<string>();
440
+ const unsupportedPaths: UnsupportedFinalizationPath[] = [];
441
+ const unverifiablePaths: string[] = [];
442
+
443
+ for (const heldPath of heldPaths) {
444
+ const decision = classifyWriteClaimFinalization(root, heldPath);
445
+ if (!decision.allow) {
446
+ unsupportedPaths.push({ path: heldPath, reason: decision.reason });
447
+ continue;
448
+ }
449
+ if (decision.descriptor.disposition === "output") {
450
+ hostOutputPaths.add(decision.path);
451
+ continue;
452
+ }
453
+ if (!decision.repo) {
454
+ unsupportedPaths.push({ path: heldPath, reason: "git_repository_unavailable" });
455
+ continue;
456
+ }
457
+ addRepoPath(repos, decision.repo.root, decision.repo.path);
458
+ addSuperprojects(decision.authorityRoot, repos, decision.repo.root);
459
+ }
460
+
461
+ const dirtyPaths = new Set<string>();
462
+ const unpushedRepos: string[] = [];
463
+ const unverifiableRepos: string[] = [];
464
+
465
+ for (const work of repos.values()) {
466
+ for (const repoPath of work.paths) {
467
+ if (work.gitlinks.has(repoPath)) {
468
+ if (gitlinkIsDirty(work.root, repoPath)) {
469
+ const label = repoLabel(root, work.root);
470
+ dirtyPaths.add(label === "." ? repoPath : `${label}/${repoPath}`);
471
+ }
472
+ continue;
473
+ }
474
+ const status = git(
475
+ ["status", "--porcelain=v1", "--untracked-files=all", "--", repoPath],
476
+ work.root,
477
+ );
478
+ if (status.status !== 0) {
479
+ unverifiablePaths.push(join(repoLabel(root, work.root), repoPath).replaceAll("\\", "/"));
480
+ } else if (status.stdout.length > 0) {
481
+ const label = repoLabel(root, work.root);
482
+ dirtyPaths.add(label === "." ? repoPath : `${label}/${repoPath}`);
483
+ }
484
+ }
485
+
486
+ const syncState = repoSyncState(work.root);
487
+ const label = repoLabel(root, work.root);
488
+ if (syncState === "unpushed") unpushedRepos.push(label);
489
+ if (syncState === "unverifiable") unverifiableRepos.push(label);
490
+ }
491
+
492
+ const result: GitFinalizationResult = {
493
+ ok: false,
494
+ claim_history_complete: options.claimHistoryComplete ?? true,
495
+ dirty_paths: [...dirtyPaths].sort(),
496
+ unpushed_repos: [...new Set(unpushedRepos)].sort(),
497
+ host_output_paths: [...hostOutputPaths].sort(),
498
+ unsupported_paths: unsupportedPaths.sort(
499
+ (a, b) => a.path.localeCompare(b.path) || a.reason.localeCompare(b.reason),
500
+ ),
501
+ unverifiable_paths: [...new Set(unverifiablePaths)].sort(),
502
+ unverifiable_repos: [...new Set(unverifiableRepos)].sort(),
503
+ repos_checked: [...repos.keys()].map((repo) => repoLabel(root, repo)).sort(),
504
+ };
505
+ result.ok =
506
+ result.claim_history_complete &&
507
+ result.dirty_paths.length === 0 &&
508
+ result.unpushed_repos.length === 0 &&
509
+ result.unsupported_paths.length === 0 &&
510
+ result.unverifiable_paths.length === 0 &&
511
+ result.unverifiable_repos.length === 0;
512
+ return result;
513
+ }
514
+
515
+ export function formatGitFinalizationFailure(result: GitFinalizationResult, bin: string): string {
516
+ const lines = ["Owned work is not finalized; the status box was not issued."];
517
+ if (!result.claim_history_complete) {
518
+ lines.push("This session's write-claim history is older than the bounded event window.");
519
+ }
520
+ if (result.dirty_paths.length > 0) {
521
+ lines.push("Dirty owned paths:", ...result.dirty_paths.map((path) => ` - ${path}`));
522
+ }
523
+ if (result.unpushed_repos.length > 0) {
524
+ lines.push(
525
+ "Repositories with commits not on their remote:",
526
+ ...result.unpushed_repos.map((repo) => ` - ${repo}`),
527
+ );
528
+ }
529
+ if (result.unsupported_paths.length > 0) {
530
+ lines.push(
531
+ "Paths without an authorized end-turn disposition:",
532
+ ...result.unsupported_paths.map(
533
+ ({ path, reason }) => ` - ${path}: ${unsupportedReasonMessage(reason)}`,
534
+ ),
535
+ );
536
+ }
537
+ if (result.unverifiable_paths.length > 0) {
538
+ lines.push(
539
+ "Paths whose Git state could not be verified:",
540
+ ...result.unverifiable_paths.map((path) => ` - ${path}`),
541
+ );
542
+ }
543
+ if (result.unverifiable_repos.length > 0) {
544
+ lines.push(
545
+ "Repositories whose remote state could not be verified:",
546
+ ...result.unverifiable_repos.map((repo) => ` - ${repo}`),
547
+ );
548
+ }
549
+ if (
550
+ result.dirty_paths.length > 0 ||
551
+ result.unpushed_repos.length > 0 ||
552
+ result.unverifiable_paths.length > 0 ||
553
+ result.unverifiable_repos.length > 0
554
+ ) {
555
+ lines.push(
556
+ `Commit and push the owned Git work, then rerun \`${bin} agents status --end-turn\`.`,
557
+ );
558
+ }
559
+ if (result.unsupported_paths.length > 0) {
560
+ lines.push(
561
+ "For a legacy claim, add the containing Git repository or intentional non-Git output root to the project's `agents.finalizationRoots`, then rerun the guarded status. For new work, start the task from the target Git project or configure that root before editing.",
562
+ );
563
+ }
564
+ return lines.join("\n");
565
+ }
566
+
567
+ function unsupportedReasonMessage(reason: UnsupportedFinalizationReason): string {
568
+ switch (reason) {
569
+ case "outside_finalization_roots":
570
+ return "outside the coordination repository and project-configured finalization roots";
571
+ case "invalid_finalization_root":
572
+ return "the matching project-configured root is invalid or overlaps another disposition";
573
+ case "ambiguous_finalization_roots":
574
+ return "more than one finalization disposition matches this path";
575
+ case "path_escapes_finalization_root":
576
+ return "the resolved path escapes its configured root";
577
+ case "git_repository_unavailable":
578
+ return "the configured Git root does not contain a verifiable repository for this path";
579
+ case "output_path_is_git":
580
+ return "the configured non-Git output path is inside a Git repository";
581
+ }
582
+ }
583
+
584
+ export function formatWriteClaimFinalizationDenial(
585
+ decision: Extract<ClaimFinalizationDecision, { allow: false }>,
586
+ bin: string,
587
+ ): string {
588
+ return (
589
+ `Harnery denied the write to ${decision.path} before mutation: ` +
590
+ `${unsupportedReasonMessage(decision.reason)}. ` +
591
+ "Start the task from the target Git project, or add its Git repository root " +
592
+ "(or an intentional non-Git output root) to the project's `agents.finalizationRoots` " +
593
+ `before editing. Then retry the tool and finish with \`${bin} agents status --end-turn\`.`
594
+ );
595
+ }