harnery 0.8.0 → 0.10.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 (234) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +8 -0
  4. package/dist/commands/agents.d.ts +24 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +244 -13
  7. package/dist/commands/backup.d.ts +5 -4
  8. package/dist/commands/backup.d.ts.map +1 -1
  9. package/dist/commands/backup.js +15 -14
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +27 -1
  12. package/dist/commands/claude-desktop.d.ts +19 -0
  13. package/dist/commands/claude-desktop.d.ts.map +1 -0
  14. package/dist/commands/claude-desktop.js +168 -0
  15. package/dist/commands/context.d.ts.map +1 -1
  16. package/dist/commands/context.js +149 -1
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +37 -0
  19. package/dist/commands/eml.d.ts +32 -0
  20. package/dist/commands/eml.d.ts.map +1 -1
  21. package/dist/commands/eml.js +16 -3
  22. package/dist/commands/grep.d.ts +35 -2
  23. package/dist/commands/grep.d.ts.map +1 -1
  24. package/dist/commands/grep.js +427 -139
  25. package/dist/commands/harness.d.ts +11 -0
  26. package/dist/commands/harness.d.ts.map +1 -0
  27. package/dist/commands/harness.js +118 -0
  28. package/dist/commands/init.d.ts +15 -5
  29. package/dist/commands/init.d.ts.map +1 -1
  30. package/dist/commands/init.js +125 -14
  31. package/dist/commands/presence.d.ts +9 -4
  32. package/dist/commands/presence.d.ts.map +1 -1
  33. package/dist/commands/presence.js +88 -5
  34. package/dist/commands/relay.d.ts +9 -0
  35. package/dist/commands/relay.d.ts.map +1 -0
  36. package/dist/commands/relay.js +143 -0
  37. package/dist/commands/sync.d.ts.map +1 -1
  38. package/dist/commands/sync.js +5 -0
  39. package/dist/commands/workflow.d.ts +4 -0
  40. package/dist/commands/workflow.d.ts.map +1 -0
  41. package/dist/commands/workflow.js +82 -0
  42. package/dist/core/agents/canonical-emit.d.ts +15 -0
  43. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  44. package/dist/core/agents/canonical-emit.js +41 -3
  45. package/dist/core/agents/cli.js +14 -5
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +44 -3
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +26 -1
  50. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  51. package/dist/core/agents/rules/claim-conflict.js +26 -1
  52. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  53. package/dist/core/agents/rules/commit-conflict.js +4 -37
  54. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  55. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  56. package/dist/core/agents/rules/stop-hook.js +8 -0
  57. package/dist/core/agents/session-events.d.ts.map +1 -1
  58. package/dist/core/agents/session-events.js +16 -43
  59. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  60. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  61. package/dist/core/agents/state/heartbeat-projector.js +24 -4
  62. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  63. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  64. package/dist/core/agents/state/heartbeat-writer.js +24 -7
  65. package/dist/core/agents/state/names.d.ts +38 -3
  66. package/dist/core/agents/state/names.d.ts.map +1 -1
  67. package/dist/core/agents/state/names.js +46 -7
  68. package/dist/core/agents/state/pidmap.d.ts +5 -1
  69. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  70. package/dist/core/agents/state/pidmap.js +58 -2
  71. package/dist/core/agents/state/stale-sweep.d.ts +3 -2
  72. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  73. package/dist/core/agents/state/stale-sweep.js +6 -6
  74. package/dist/core/config.d.ts +77 -11
  75. package/dist/core/config.d.ts.map +1 -1
  76. package/dist/core/config.js +210 -25
  77. package/dist/core/context/index.d.ts +144 -0
  78. package/dist/core/context/index.d.ts.map +1 -0
  79. package/dist/core/context/index.js +380 -0
  80. package/dist/core/harnesses/bench.d.ts +29 -0
  81. package/dist/core/harnesses/bench.d.ts.map +1 -0
  82. package/dist/core/harnesses/bench.js +151 -0
  83. package/dist/core/harnesses/index.d.ts +9 -0
  84. package/dist/core/harnesses/index.d.ts.map +1 -0
  85. package/dist/core/harnesses/index.js +4 -0
  86. package/dist/core/harnesses/profiles.d.ts +62 -0
  87. package/dist/core/harnesses/profiles.d.ts.map +1 -0
  88. package/dist/core/harnesses/profiles.js +115 -0
  89. package/dist/core/harnesses/registry.d.ts +15 -0
  90. package/dist/core/harnesses/registry.d.ts.map +1 -0
  91. package/dist/core/harnesses/registry.js +113 -0
  92. package/dist/core/harnesses/types.d.ts +64 -0
  93. package/dist/core/harnesses/types.d.ts.map +1 -0
  94. package/dist/core/harnesses/types.js +20 -0
  95. package/dist/core/hooks/cli.js +309 -66
  96. package/dist/core/hooks/events/schema.d.ts +42 -1
  97. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  98. package/dist/core/hooks/harness/events.d.ts +7 -0
  99. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  100. package/dist/core/hooks/harness/events.js +4 -0
  101. package/dist/core/hooks/harness/parse.d.ts +1 -1
  102. package/dist/core/hooks/harness/parse.d.ts.map +1 -1
  103. package/dist/core/hooks/harness/parse.js +4 -0
  104. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  105. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/coord-root.js +28 -6
  107. package/dist/core/presence/blob.d.ts +40 -0
  108. package/dist/core/presence/blob.d.ts.map +1 -0
  109. package/dist/core/presence/blob.js +91 -0
  110. package/dist/core/presence/git.d.ts +68 -0
  111. package/dist/core/presence/git.d.ts.map +1 -0
  112. package/dist/core/presence/git.js +173 -0
  113. package/dist/core/presence/index.d.ts +74 -0
  114. package/dist/core/presence/index.d.ts.map +1 -0
  115. package/dist/core/presence/index.js +224 -0
  116. package/dist/core/presence/relay-client.d.ts +39 -0
  117. package/dist/core/presence/relay-client.d.ts.map +1 -0
  118. package/dist/core/presence/relay-client.js +295 -0
  119. package/dist/core/presence/relay-protocol.d.ts +94 -0
  120. package/dist/core/presence/relay-protocol.d.ts.map +1 -0
  121. package/dist/core/presence/relay-protocol.js +169 -0
  122. package/dist/core/workflow/billing.d.ts +48 -0
  123. package/dist/core/workflow/billing.d.ts.map +1 -0
  124. package/dist/core/workflow/billing.js +108 -0
  125. package/dist/core/workflow/child-env.d.ts +30 -0
  126. package/dist/core/workflow/child-env.d.ts.map +1 -0
  127. package/dist/core/workflow/child-env.js +43 -0
  128. package/dist/core/workflow/engine.d.ts +21 -0
  129. package/dist/core/workflow/engine.d.ts.map +1 -0
  130. package/dist/core/workflow/engine.js +349 -0
  131. package/dist/core/workflow/harnesses.d.ts +17 -0
  132. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  133. package/dist/core/workflow/harnesses.js +21 -0
  134. package/dist/core/workflow/spawn-claude.d.ts +25 -0
  135. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  136. package/dist/core/workflow/spawn-claude.js +103 -0
  137. package/dist/core/workflow/spawn-codex.d.ts +22 -0
  138. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  139. package/dist/core/workflow/spawn-codex.js +88 -0
  140. package/dist/core/workflow/spawn-cursor.d.ts +29 -0
  141. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  142. package/dist/core/workflow/spawn-cursor.js +91 -0
  143. package/dist/core/workflow/types.d.ts +152 -0
  144. package/dist/core/workflow/types.d.ts.map +1 -0
  145. package/dist/core/workflow/types.js +9 -0
  146. package/dist/core/workflow/validate.d.ts +15 -0
  147. package/dist/core/workflow/validate.d.ts.map +1 -0
  148. package/dist/core/workflow/validate.js +70 -0
  149. package/dist/lib/browser/client.d.ts +16 -0
  150. package/dist/lib/browser/client.d.ts.map +1 -1
  151. package/dist/lib/browser/client.js +22 -0
  152. package/dist/lib/browser/index.d.ts +1 -0
  153. package/dist/lib/browser/index.d.ts.map +1 -1
  154. package/dist/lib/browser/index.js +1 -0
  155. package/dist/lib/browser/launch-args.d.ts +21 -0
  156. package/dist/lib/browser/launch-args.d.ts.map +1 -0
  157. package/dist/lib/browser/launch-args.js +32 -0
  158. package/dist/lib/claude-desktop.d.ts +105 -0
  159. package/dist/lib/claude-desktop.d.ts.map +1 -0
  160. package/dist/lib/claude-desktop.js +217 -0
  161. package/dist/lib/docs-lint.d.ts.map +1 -1
  162. package/dist/lib/docs-lint.js +5 -2
  163. package/dist/lib/identities/assume.d.ts +51 -0
  164. package/dist/lib/identities/assume.d.ts.map +1 -0
  165. package/dist/lib/identities/assume.js +274 -0
  166. package/dist/lib/identities/index.d.ts +7 -7
  167. package/dist/lib/identities/index.d.ts.map +1 -1
  168. package/dist/lib/identities/index.js +24 -24
  169. package/dist/lib/instructions/templates.d.ts.map +1 -1
  170. package/dist/lib/instructions/templates.js +15 -2
  171. package/package.json +12 -1
  172. package/schemas/config.schema.json +88 -19
  173. package/src/commander.ts +8 -0
  174. package/src/commands/agents.ts +280 -14
  175. package/src/commands/backup.ts +17 -16
  176. package/src/commands/browse.ts +32 -0
  177. package/src/commands/claude-desktop.ts +215 -0
  178. package/src/commands/context.ts +174 -1
  179. package/src/commands/doctor.ts +44 -0
  180. package/src/commands/eml.ts +22 -4
  181. package/src/commands/grep.ts +535 -142
  182. package/src/commands/harness.ts +147 -0
  183. package/src/commands/init.ts +138 -17
  184. package/src/commands/presence.ts +111 -5
  185. package/src/commands/relay.ts +160 -0
  186. package/src/commands/sync.ts +4 -0
  187. package/src/commands/workflow.ts +123 -0
  188. package/src/core/agents/canonical-emit.ts +41 -3
  189. package/src/core/agents/cli.ts +15 -6
  190. package/src/core/agents/render/prompt-context.ts +48 -3
  191. package/src/core/agents/render/session-context.ts +27 -1
  192. package/src/core/agents/rules/claim-conflict.ts +26 -1
  193. package/src/core/agents/rules/commit-conflict.ts +4 -33
  194. package/src/core/agents/rules/stop-hook.ts +17 -0
  195. package/src/core/agents/session-events.ts +15 -39
  196. package/src/core/agents/state/heartbeat-projector.ts +23 -2
  197. package/src/core/agents/state/heartbeat-writer.ts +33 -7
  198. package/src/core/agents/state/names.ts +68 -9
  199. package/src/core/agents/state/pidmap.ts +59 -2
  200. package/src/core/agents/state/stale-sweep.ts +6 -11
  201. package/src/core/config.ts +271 -24
  202. package/src/core/context/index.ts +575 -0
  203. package/src/core/harnesses/bench.ts +214 -0
  204. package/src/core/harnesses/index.ts +31 -0
  205. package/src/core/harnesses/profiles.ts +141 -0
  206. package/src/core/harnesses/registry.ts +140 -0
  207. package/src/core/harnesses/types.ts +90 -0
  208. package/src/core/hooks/cli.ts +361 -65
  209. package/src/core/hooks/events/schema.ts +72 -0
  210. package/src/core/hooks/harness/events.ts +11 -0
  211. package/src/core/hooks/harness/parse.ts +7 -1
  212. package/src/core/hooks/resolve/coord-root.ts +28 -6
  213. package/src/core/presence/blob.ts +125 -0
  214. package/src/core/presence/git.ts +191 -0
  215. package/src/core/presence/index.ts +274 -0
  216. package/src/core/presence/relay-client.ts +322 -0
  217. package/src/core/presence/relay-protocol.ts +246 -0
  218. package/src/core/workflow/billing.ts +152 -0
  219. package/src/core/workflow/child-env.ts +47 -0
  220. package/src/core/workflow/engine.ts +403 -0
  221. package/src/core/workflow/harnesses.ts +33 -0
  222. package/src/core/workflow/spawn-claude.ts +118 -0
  223. package/src/core/workflow/spawn-codex.ts +93 -0
  224. package/src/core/workflow/spawn-cursor.ts +108 -0
  225. package/src/core/workflow/types.ts +160 -0
  226. package/src/core/workflow/validate.ts +75 -0
  227. package/src/lib/browser/client.ts +37 -0
  228. package/src/lib/browser/index.ts +1 -0
  229. package/src/lib/browser/launch-args.ts +33 -0
  230. package/src/lib/claude-desktop.ts +293 -0
  231. package/src/lib/docs-lint.ts +5 -2
  232. package/src/lib/identities/assume.ts +363 -0
  233. package/src/lib/identities/index.ts +28 -24
  234. package/src/lib/instructions/templates.ts +16 -2
@@ -25,6 +25,7 @@ import os from "node:os";
25
25
  import path from "node:path";
26
26
  import type { Command } from "commander";
27
27
  import type { EmitContext } from "../commander.ts";
28
+ import { syncJsoncConfig } from "../core/config.ts";
28
29
 
29
30
  // What we sync. Anything matching one of these (relative to .harnery/) is
30
31
  // considered part of the sync set. Everything else stays local-only.
@@ -40,9 +41,12 @@ function configPath(): string {
40
41
  }
41
42
 
42
43
  function loadConfig(): SyncConfig | null {
44
+ // Precedence: env → .harnery/config.jsonc `sync` → ~/.config/harnery/sync.json.
43
45
  const envRemote = process.env.HARNERY_SYNC_REMOTE;
44
46
  const envPrefix = process.env.HARNERY_SYNC_PREFIX ?? "harnery";
45
47
  if (envRemote) return { remote: envRemote, prefix: envPrefix };
48
+ const jsonc = syncJsoncConfig();
49
+ if (jsonc) return jsonc;
46
50
  const p = configPath();
47
51
  if (!existsSync(p)) return null;
48
52
  try {
@@ -0,0 +1,123 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import { workflowSubscriptionOnly } from "../core/config.ts";
4
+ import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
5
+ import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
6
+
7
+ /**
8
+ * `workflow run <script>`: execute a workflow script — bounded, schema-gated,
9
+ * conditionally-routed stages fanning out to headless harness-CLI subagents.
10
+ * The script (deterministic code), not a model, decides routing between
11
+ * stages, and the run terminates when the script returns.
12
+ *
13
+ * Phase 2 surface: `run` only, claude-code spawn adapter only. Design +
14
+ * phasing: decision 0015.
15
+ */
16
+
17
+ interface WorkflowRunOpts {
18
+ maxAgents?: string;
19
+ concurrency?: string;
20
+ cwd?: string;
21
+ harness?: string;
22
+ resumeFrom?: string;
23
+ subscriptionOnly?: boolean;
24
+ allowApiBilling?: boolean;
25
+ json?: boolean;
26
+ }
27
+
28
+ export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
29
+ const registry = createBuiltinHarnessRegistry();
30
+ const harnesses = registry.ids();
31
+ const workflow = program
32
+ .command("workflow")
33
+ .description("Run bounded, schema-gated multi-subagent workflow scripts.");
34
+
35
+ workflow
36
+ .command("run <script>")
37
+ .description(
38
+ "Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
39
+ "Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
40
+ )
41
+ .option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
42
+ .option("--concurrency <n>", "Concurrent-subagent cap (default 4)")
43
+ .option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
44
+ .option(
45
+ "--harness <name>",
46
+ `Default harness for agent() calls: ${harnesses.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
47
+ )
48
+ .option(
49
+ "--resume-from <run-id>",
50
+ "Reuse completed agent results from a prior run's journal; only changed/failed calls re-run",
51
+ )
52
+ .option(
53
+ "--subscription-only",
54
+ "Guarantee subscription billing: scrub API-key vars from child envs; fail loud when a " +
55
+ "harness has no stored login (repo default via config.jsonc workflow.subscriptionOnly)",
56
+ )
57
+ .option(
58
+ "--allow-api-billing",
59
+ "Permit an exported API key to override a stored subscription login (per-token billing); " +
60
+ "without this the engine refuses that silent-override state",
61
+ )
62
+ .option("--json", "Emit the full RunReport as JSON")
63
+ .action(async (script: string, opts: WorkflowRunOpts) => {
64
+ const coordRoot = findCoordRoot();
65
+ if (!coordRoot) {
66
+ emit.error({
67
+ code: "no_coord_root",
68
+ message: "no .harnery/ coordination root found; run `init` first",
69
+ });
70
+ process.exit(1);
71
+ }
72
+ if (opts.harness && !registry.get(opts.harness)) {
73
+ emit.error({
74
+ code: "bad_harness",
75
+ message: `unknown harness "${opts.harness}" (expected: ${harnesses.join(" | ")})`,
76
+ });
77
+ process.exit(1);
78
+ }
79
+ // Flag beats config; the two flags contradict each other by design.
80
+ const subscriptionOnly = opts.subscriptionOnly || workflowSubscriptionOnly(coordRoot);
81
+ if (subscriptionOnly && opts.allowApiBilling) {
82
+ emit.error({
83
+ code: "billing_flags_conflict",
84
+ message:
85
+ "--allow-api-billing contradicts subscription-only mode " +
86
+ "(flag or config.jsonc workflow.subscriptionOnly); pick one",
87
+ });
88
+ process.exit(1);
89
+ }
90
+ try {
91
+ const { runWorkflow } = await import("../core/workflow/engine.ts");
92
+ const report = await runWorkflow(script, {
93
+ coordRoot,
94
+ spawners: registry.spawners(),
95
+ defaultHarness: opts.harness,
96
+ resumeFrom: opts.resumeFrom,
97
+ subscriptionOnly,
98
+ allowApiBilling: opts.allowApiBilling,
99
+ maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
100
+ concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
101
+ cwd: opts.cwd,
102
+ });
103
+ if (opts.json) {
104
+ emit.config({ format: "json" });
105
+ emit.data(report);
106
+ return;
107
+ }
108
+ const cachedPart = report.agentsCached > 0 ? ` (+${report.agentsCached} cached)` : "";
109
+ const billingPart = report.billing.length
110
+ ? `billing: ${report.billing.map((b) => `${b.harness}=${b.mode}`).join(", ")}\n`
111
+ : "";
112
+ emit.text(
113
+ `run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
114
+ `$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
115
+ `journal: ${report.journalPath}\n` +
116
+ `result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
117
+ );
118
+ } catch (err) {
119
+ emit.error({ code: "workflow_failed", message: (err as Error).message });
120
+ process.exit(1);
121
+ }
122
+ });
123
+ }
@@ -12,6 +12,7 @@
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
+ import { monorepoRoot } from "./coord-client.ts";
15
16
 
16
17
  export interface CanonicalEmitInput {
17
18
  type: string;
@@ -24,8 +25,37 @@ export interface CanonicalEmitInput {
24
25
  parentTurnId?: string;
25
26
  }
26
27
 
28
+ /**
29
+ * Resolve the coord root for a canonical emit.
30
+ *
31
+ * Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
32
+ * `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
33
+ * hosts). The git-aware step is load-bearing: a shell cd'd into a nested
34
+ * directory that carries its own `.harnery/` (e.g. an embedded harnery
35
+ * checkout) made the old cwd-walk resolve the NESTED root, from which
36
+ * `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
37
+ * `set-task` emits silently vanished and the Stop-hook's rule 1/3
38
+ * (`state.status_checked` in-turn) blocked turns that had done the ritual.
39
+ * Same bug class as the coordHelperOpts root-pin fix; this closes the
40
+ * emitCanonical instance of it.
41
+ */
42
+ export function resolveEmitRoot(start: string = process.cwd()): string | null {
43
+ const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
44
+ if (override) return override;
45
+ // Memoized per start-dir: the session-tee middleware resolves once per
46
+ // emitted event, and monorepoRoot() spawns git — cache so a streaming
47
+ // command doesn't pay 1-3 subprocess spawns per output line.
48
+ if (cachedRoot && cachedRoot.start === start) return cachedRoot.root;
49
+ const gitRoot = monorepoRoot();
50
+ const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
51
+ cachedRoot = { start, root };
52
+ return root;
53
+ }
54
+
55
+ let cachedRoot: { start: string; root: string | null } | undefined;
56
+
27
57
  export function emitCanonical(input: CanonicalEmitInput): void {
28
- const root = findRepoRoot();
58
+ const root = resolveEmitRoot();
29
59
  if (!root) return;
30
60
  const binary = resolve(root, "harnery", "bin", "agent-coord");
31
61
  if (!existsSync(binary)) return;
@@ -46,11 +76,19 @@ export function emitCanonical(input: CanonicalEmitInput): void {
46
76
  if (input.turnId) args.push("--turn-id", input.turnId);
47
77
  if (input.parentSessionId) args.push("--parent-session-id", input.parentSessionId);
48
78
  if (input.parentTurnId) args.push("--parent-turn-id", input.parentTurnId);
49
- spawnSync(binary, args, {
79
+ // Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
80
+ // stream we resolved, regardless of where the caller's shell is cd'd.
81
+ const result = spawnSync(binary, args, {
50
82
  encoding: "utf8",
51
83
  stdio: "ignore",
52
84
  timeout: 3000,
85
+ cwd: root,
86
+ env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root },
53
87
  });
88
+ if (result.error || result.status !== 0) {
89
+ const why = result.error ? result.error.message : `exit ${result.status}`;
90
+ process.stderr.write(`emitCanonical: ${input.type} emit failed (${why})\n`); // lint-ok-emission: soft-fail diagnostic promised by the module doc; silent drops cost blocked turns
91
+ }
54
92
  } catch {
55
93
  /* never break the caller */
56
94
  }
@@ -66,7 +104,7 @@ export function normalizeHarness(platform: string | undefined): "claude-code" |
66
104
  return "claude-code";
67
105
  }
68
106
 
69
- function findRepoRoot(start: string = process.cwd()): string | null {
107
+ function findRepoRoot(start: string): string | null {
70
108
  let dir = resolve(start);
71
109
  while (true) {
72
110
  if (existsSync(join(dir, ".harnery"))) return dir;
@@ -176,7 +176,7 @@ async function emitClaimRelease(
176
176
  owner: string,
177
177
  hb: { session_id?: string; platform?: string },
178
178
  path: string,
179
- reason: "explicit" | "heal",
179
+ reason: "explicit" | "heal" | "commit" | "checkout",
180
180
  ): Promise<void> {
181
181
  try {
182
182
  const { emit } = await import("./events/emit.ts");
@@ -210,8 +210,10 @@ async function handleStateAction(root: string, action: string, rest: string[]):
210
210
  const task = args.join(" ");
211
211
  const hb = writer.setTask(root, owner, task);
212
212
  if (!hb) {
213
+ // Name the RESOLVED root: when a nested .harnery/ shadows the real
214
+ // coordination home, the full path is what makes that diagnosable.
213
215
  process.stderr.write(
214
- `agent-coord set-task: no heartbeat at .harnery/active/${owner}.json\n`,
216
+ `agent-coord set-task: no heartbeat at ${root}/.harnery/active/${owner}.json\n`,
215
217
  );
216
218
  return 1;
217
219
  }
@@ -475,11 +477,16 @@ async function handlePostCommit(root: string): Promise<number> {
475
477
  const { groupUnclaim } = await import("./state/heartbeat-writer.ts");
476
478
 
477
479
  // Session-group-wide unclaim. `owner` is the parent's session_id which is
478
- // also the group key (parent + subagents share session_id).
480
+ // also the group key (parent + subagents share session_id). Each actual
481
+ // removal also emits a durable claim.release event — the projector rebuilds
482
+ // files_touched from the permanent Edit/Write events, so a file-only prune
483
+ // would resurrect on the next replay.
479
484
  if (req.owner && Array.isArray(req.prune)) {
480
485
  for (const path of req.prune) {
481
486
  try {
482
- groupUnclaim(root, req.owner, path);
487
+ for (const hit of groupUnclaim(root, req.owner, path)) {
488
+ await emitClaimRelease(root, hit.instance_id, hit, path, "commit");
489
+ }
483
490
  } catch {
484
491
  /* best-effort */
485
492
  }
@@ -500,7 +507,9 @@ async function handlePostCheckout(root: string, _rest: string[]): Promise<number
500
507
  if (req.owner && Array.isArray(req.removed)) {
501
508
  for (const path of req.removed) {
502
509
  try {
503
- groupUnclaim(root, req.owner, path);
510
+ for (const hit of groupUnclaim(root, req.owner, path)) {
511
+ await emitClaimRelease(root, hit.instance_id, hit, path, "checkout");
512
+ }
504
513
  } catch {
505
514
  /* best-effort */
506
515
  }
@@ -709,7 +718,7 @@ async function handleResolveName(root: string, rest: string[]): Promise<number>
709
718
  return 0;
710
719
  }
711
720
  process.stdout.write(
712
- `${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind })}\n`,
721
+ `${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind, agent_id: resolved.agent_id ?? null })}\n`,
713
722
  );
714
723
  return 0;
715
724
  }
@@ -36,6 +36,7 @@ import {
36
36
  import { join } from "node:path";
37
37
 
38
38
  import { coordEnv } from "../../../lib/env.ts";
39
+ import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
39
40
  import { formatPendingCouncils } from "./session-context.ts";
40
41
 
41
42
  interface HeartbeatRow {
@@ -170,7 +171,10 @@ function computePeerTableIfChanged(
170
171
  if (!hb?.instance_id || hb.instance_id === selfInstanceId) continue;
171
172
  peers.push(hb);
172
173
  }
173
- if (peers.length === 0) return "";
174
+ // Cross-machine presence (ADR 0016): sessions on other machines, from the
175
+ // locally-fetched presence refs. Advisory (no claim blocking in v1).
176
+ const remote = readRemoteMachinesSafe(coordRoot);
177
+ if (peers.length === 0 && remote.length === 0) return "";
174
178
 
175
179
  // Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
176
180
  const basis = peers
@@ -184,14 +188,27 @@ function computePeerTableIfChanged(
184
188
  platform: p.platform ?? null,
185
189
  }))
186
190
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
187
- const newHash = sha256Hex16(JSON.stringify(basis));
191
+ // Remote basis: machine + per-agent identity/task/files (published_at is
192
+ // excluded — a keepalive re-publish alone shouldn't re-emit the table).
193
+ const remoteBasis = remote.map((m) => ({
194
+ machine: m.machine,
195
+ agents: m.agents.map((a) => ({
196
+ instance_id: a.instance_id,
197
+ name: a.name ?? null,
198
+ task: a.task ?? null,
199
+ files_touched: Array.from(a.files_touched ?? []).sort(),
200
+ })),
201
+ }));
202
+ const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
188
203
 
189
204
  const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
190
205
  const oldHash = safeRead(hashFile);
191
206
  if (oldHash && oldHash === newHash) return "";
192
207
 
193
208
  // Render peer table via the same formatter used at SessionStart.
194
- const table = formatPeerTable(peers, mySessionId);
209
+ const local = formatPeerTable(peers, mySessionId);
210
+ const remoteTable = formatRemoteTable(remote);
211
+ const table = [local, remoteTable].filter(Boolean).join("\n\n");
195
212
  if (!table) return "";
196
213
 
197
214
  // Persist hash atomically (temp + rename, same convention as other coord writes).
@@ -199,6 +216,34 @@ function computePeerTableIfChanged(
199
216
  return table;
200
217
  }
201
218
 
219
+ /** readRemoteMachines with a local failure guard (rendering must never throw). */
220
+ function readRemoteMachinesSafe(coordRoot: string): RemoteMachine[] {
221
+ try {
222
+ return readRemoteMachines(coordRoot);
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ /** Render sessions on other machines (presence transport) as a subtable. */
229
+ function formatRemoteTable(remote: RemoteMachine[]): string {
230
+ if (remote.length === 0) return "";
231
+ const nowSec = Math.floor(Date.now() / 1000);
232
+ const lines: string[] = [];
233
+ for (const m of remote) {
234
+ for (const a of m.agents.slice(0, 10)) {
235
+ lines.push(
236
+ formatRow(
237
+ { ...a, display_files: Array.from(a.files_touched ?? []).sort() },
238
+ nowSec,
239
+ ).replace(/^ {2}- (agent-[^ ]+)/, ` - $1 @${m.machine}`),
240
+ );
241
+ }
242
+ }
243
+ if (lines.length === 0) return "";
244
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
245
+ }
246
+
202
247
  function computeCouncilPendingIfChanged(
203
248
  coordRoot: string,
204
249
  selfInstanceId: string,
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
14
  import { harneryVersion, loadHarnessWiring } from "../../hooks/harness/wiring.ts";
15
+ import { readRemoteMachines } from "../../presence/index.ts";
15
16
 
16
17
  interface HeartbeatRow {
17
18
  instance_id?: string;
@@ -48,7 +49,9 @@ export function renderSessionContext(opts: RenderOpts): string {
48
49
 
49
50
  // 1. Self-name line + peer table (folded if peers present)
50
51
  const peers = readActivePeers(coordRoot, instanceId);
51
- const peerTable = formatPeerTable(peers, sessionId);
52
+ const localTable = formatPeerTable(peers, sessionId);
53
+ // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
54
+ const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
52
55
  if (agentName) {
53
56
  const suffix = platformLabel ? ` (${platformLabel})` : "";
54
57
  const selfLine = `You are agent-${agentName}${suffix}.`;
@@ -107,6 +110,29 @@ export function renderSessionContext(opts: RenderOpts): string {
107
110
  return messages.join("\n\n");
108
111
  }
109
112
 
113
+ /** One-line-per-agent view of sessions on other machines (presence refs). */
114
+ function formatRemoteMachines(coordRoot: string): string {
115
+ try {
116
+ const remote = readRemoteMachines(coordRoot);
117
+ if (remote.length === 0) return "";
118
+ const lines: string[] = [];
119
+ for (const m of remote) {
120
+ for (const a of m.agents.slice(0, 10)) {
121
+ const task = a.task ? ` "${a.task.slice(0, 60)}"` : "";
122
+ const files = a.files_touched?.length
123
+ ? `holds: ${a.files_touched.slice(0, 3).join(", ")}${a.files_touched.length > 3 ? `, +${a.files_touched.length - 3} more` : ""}`
124
+ : "nothing held";
125
+ lines.push(
126
+ ` - agent-${a.name ?? a.instance_id.slice(0, 8)} @${m.machine}${task} (${files})`,
127
+ );
128
+ }
129
+ }
130
+ return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+
110
136
  /** Read all peer heartbeats from .harnery/active/, excluding self. */
111
137
  function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRow[] {
112
138
  const out: HeartbeatRow[] = [];
@@ -17,6 +17,7 @@
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { emit } from "../events/emit.ts";
20
21
 
21
22
  const FRESHNESS_SECS = 600;
22
23
 
@@ -306,12 +307,36 @@ function pruneClaimFromPeer(coordRoot: string, instanceId: string, relPath: stri
306
307
  try {
307
308
  const body = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
308
309
  const files = (body.files_touched as string[] | undefined) ?? [];
309
- const next = files.filter((p) => p !== relPath);
310
+ // Normalize both sides: files_touched can hold absolute-under-coordRoot
311
+ // entries (legacy projections) as well as canonical relative ones — an
312
+ // exact-string filter silently no-ops on the mixed-form case and the
313
+ // stale claim never heals.
314
+ const norm = (p: string): string =>
315
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
316
+ const target = norm(relPath);
317
+ const next = files.filter((p) => norm(p) !== target);
310
318
  if (next.length === files.length) return;
311
319
  body.files_touched = next;
312
320
  const tmp = `${path}.tmp.${process.pid}`;
313
321
  writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
314
322
  renameSync(tmp, path);
323
+ // Durable subtraction: the projector rebuilds files_touched from the
324
+ // permanent Edit/Write events, so a file-only prune resurrects on the
325
+ // next replay. Soft-fail — the heartbeat write already landed.
326
+ try {
327
+ const platform = body.platform as string | undefined;
328
+ emit(coordRoot, {
329
+ event_type: "claim.release",
330
+ instance_id: instanceId,
331
+ session_id: (body.session_id as string | undefined) ?? instanceId,
332
+ harness: platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code",
333
+ source: "agent-coord",
334
+ data: { path: target, reason: "heal" },
335
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
336
+ } as Parameters<typeof emit>[1]);
337
+ } catch {
338
+ /* soft-fail */
339
+ }
315
340
  } catch {
316
341
  /* silent */
317
342
  }
@@ -23,9 +23,8 @@
23
23
  import { spawnSync } from "node:child_process";
24
24
  import { existsSync, readdirSync, readFileSync } from "node:fs";
25
25
  import { join } from "node:path";
26
- import { coordEnv } from "../../../lib/env.ts";
27
-
28
- const DEFAULT_FRESHNESS_SECS = 600;
26
+ import { coordFreshnessSeconds } from "../../config.ts";
27
+ import { instanceHasLivePid } from "../state/pidmap.ts";
29
28
 
30
29
  interface PeerHeartbeat {
31
30
  instance_id: string;
@@ -86,7 +85,7 @@ export function evaluateCommit(coordRoot: string, req: CommitVerdictRequest): Co
86
85
  }
87
86
 
88
87
  const peers = readActivePeers(coordRoot);
89
- const cutoffMs = Date.now() - freshnessSecs() * 1000;
88
+ const cutoffMs = Date.now() - coordFreshnessSeconds(coordRoot) * 1000;
90
89
  const stagedSet = new Set(req.staged_paths);
91
90
  const gitlinkSet = new Set(req.staged_gitlinks ?? []);
92
91
 
@@ -233,28 +232,7 @@ function isHolderSelfAttributed(
233
232
  }
234
233
 
235
234
  function holderHasLiveForeignPid(coordRoot: string, holderId: string): boolean {
236
- const dir = join(coordRoot, ".harnery", "pid-map");
237
- if (!existsSync(dir)) return false;
238
- for (const f of readdirSync(dir)) {
239
- let row = "";
240
- try {
241
- row = readFileSync(join(dir, f), "utf8").trim();
242
- } catch {
243
- continue;
244
- }
245
- const owner = row.split("\t")[0]?.trim() ?? "";
246
- if (owner !== holderId) continue;
247
- // Process still alive?
248
- const pid = Number.parseInt(f, 10);
249
- if (!Number.isFinite(pid)) continue;
250
- try {
251
- process.kill(pid, 0); // signal 0 = liveness probe
252
- return true; // live foreign pid found
253
- } catch {
254
- // ESRCH (no such process): pid-map entry is stale, skip
255
- }
256
- }
257
- return false;
235
+ return instanceHasLivePid(coordRoot, holderId);
258
236
  }
259
237
 
260
238
  function isPathCleanInHead(coordRoot: string, relPath: string): boolean {
@@ -290,13 +268,6 @@ function readActivePeers(coordRoot: string): PeerHeartbeat[] {
290
268
  return out;
291
269
  }
292
270
 
293
- function freshnessSecs(): number {
294
- const raw = coordEnv("AGENT_FRESHNESS");
295
- if (!raw) return DEFAULT_FRESHNESS_SECS;
296
- const n = Number.parseInt(raw, 10);
297
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_FRESHNESS_SECS;
298
- }
299
-
300
271
  function shortName(peer: PeerHeartbeat): string {
301
272
  if (peer.name && peer.name.length > 0) return `agent-${peer.name}`;
302
273
  return `agent-${peer.instance_id.slice(0, 8)}`;
@@ -49,6 +49,14 @@ export interface StopHookRequest {
49
49
  turn_window?: { start_ms: number; end_ms: number };
50
50
  /** Bypass switch: operator escape hatch identical to HARNERY_AGENT_COORD_BYPASS_STOP. */
51
51
  bypass?: boolean;
52
+ /** Headless child spawned by `harn workflow` (HARNERY_WORKFLOW_CHILD=1).
53
+ * The end-of-turn ritual exists to surface status to a HUMAN reader; a
54
+ * workflow child reports to the engine's journal instead, so the ritual is
55
+ * meaningless there — worse, blocking burns the child's turn budget on
56
+ * re-prompts (observed as error_max_turns in the Phase 1 spike). Exempting
57
+ * here, rather than disabling the child's hooks wholesale, keeps heartbeat +
58
+ * event capture on: the child stays visible to peers and the coord layer. */
59
+ workflow_child?: boolean;
52
60
  }
53
61
 
54
62
  /**
@@ -95,6 +103,15 @@ export function evaluateStopHook(coordRoot: string, req: StopHookRequest): Verdi
95
103
  };
96
104
  }
97
105
 
106
+ if (req.workflow_child) {
107
+ return {
108
+ allow: true,
109
+ exit_code: 0,
110
+ rule: "stop-hook.workflow_child",
111
+ reason: "HARNERY_WORKFLOW_CHILD=1: headless workflow child; ritual not applicable",
112
+ };
113
+ }
114
+
98
115
  let events: CanonicalEvent[];
99
116
  try {
100
117
  events = readRecentEvents(coordRoot, RECENT_EVENT_WINDOW_LINES);
@@ -9,10 +9,10 @@
9
9
  */
10
10
 
11
11
  import { randomBytes } from "node:crypto";
12
- import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { existsSync, readFileSync } from "node:fs";
13
13
  import { dirname, resolve } from "node:path";
14
14
  // Kept dependency-light: vendored verbatim into a downstream consumer, so no coordEnv import.
15
- import { normalizeHarness } from "./canonical-emit.ts";
15
+ import { normalizeHarness, resolveEmitRoot } from "./canonical-emit.ts";
16
16
  import { emit } from "./events/emit.ts";
17
17
 
18
18
  /** Event types accepted by `writeSessionEvent`. Only the command stream +
@@ -38,25 +38,14 @@ export function sessionEventsPath(): string {
38
38
  // Explicit override (tests + non-monorepo invocations).
39
39
  const explicit = process.env.HARNERY_OUTPUT_SESSION_EVENTS;
40
40
  if (explicit) return explicit;
41
- // Walk up looking for an EXISTING .harnery/ directory; only match dirs
42
- // (submodule .git is a file/gitlink, and CLAUDE.md / AGENTS.md can
43
- // proliferate per-subdirectory in many monorepos, so neither is a safe
44
- // anchor). Don't reintroduce CLAUDE.md / .git as anchors, which creates
41
+ // Superproject-aware root resolution (git first, cwd walk fallback) via
42
+ // resolveEmitRoot. A plain cwd walk here mis-anchored to a NESTED
43
+ // `.harnery/` when the shell sat inside a submodule carrying its own
44
+ // (e.g. an embedded harnery checkout), silently splitting the event
45
+ // stream. Don't reintroduce CLAUDE.md / .git as anchors, which creates
45
46
  // stray .harnery/ dirs under submodule subtrees.
46
- let dir = process.cwd();
47
- for (let i = 0; i < 10; i++) {
48
- const candidate = resolve(dir, ".harnery");
49
- try {
50
- if (existsSync(candidate) && statSync(candidate).isDirectory()) {
51
- return resolve(candidate, "session-events.ndjson");
52
- }
53
- } catch {
54
- /* keep walking */
55
- }
56
- const parent = dirname(dir);
57
- if (parent === dir) break;
58
- dir = parent;
59
- }
47
+ const root = resolveEmitRoot();
48
+ if (root) return resolve(root, ".harnery", "session-events.ndjson");
60
49
  return resolve(process.env.HOME || "/tmp", ".harnery", "session-events.ndjson");
61
50
  }
62
51
 
@@ -75,25 +64,12 @@ export function newCmdId(): string {
75
64
  */
76
65
  export function readLastIntent(instanceId?: string): string | null {
77
66
  if (!instanceId) return null;
78
- // Walk up from cwd looking for .harnery/, same resolution as
79
- // sessionEventsPath() (only match existing directories).
80
- let dir = process.cwd();
81
- let agentsDir: string | null = null;
82
- for (let i = 0; i < 10; i++) {
83
- const candidate = resolve(dir, ".harnery");
84
- try {
85
- if (existsSync(candidate) && statSync(candidate).isDirectory()) {
86
- agentsDir = candidate;
87
- break;
88
- }
89
- } catch {
90
- /* keep walking */
91
- }
92
- const parent = dirname(dir);
93
- if (parent === dir) break;
94
- dir = parent;
95
- }
96
- if (!agentsDir) return null;
67
+ // Same superproject-aware root resolution as sessionEventsPath(): the
68
+ // intent stamp is written by the PreToolUse hook into the SUPERPROJECT's
69
+ // .harnery/, so a nested-`.harnery/` cwd must not redirect the read.
70
+ const root = resolveEmitRoot();
71
+ if (!root) return null;
72
+ const agentsDir = resolve(root, ".harnery");
97
73
  const intentPath = resolve(agentsDir, `.last-intent.${instanceId}`);
98
74
  if (!existsSync(intentPath)) return null;
99
75
  try {