harnery 0.34.1 → 0.35.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 (212) 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 +15 -4
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +89 -175
  6. package/dist/commands/browse.d.ts.map +1 -1
  7. package/dist/commands/browse.js +26 -2
  8. package/dist/commands/decision.d.ts.map +1 -1
  9. package/dist/commands/decision.js +2 -2
  10. package/dist/commands/events.d.ts.map +1 -1
  11. package/dist/commands/events.js +18 -1
  12. package/dist/commands/presence.d.ts.map +1 -1
  13. package/dist/commands/presence.js +2 -2
  14. package/dist/commands/semantic.d.ts +4 -0
  15. package/dist/commands/semantic.d.ts.map +1 -0
  16. package/dist/commands/semantic.js +147 -0
  17. package/dist/commands/web.d.ts.map +1 -1
  18. package/dist/commands/web.js +17 -0
  19. package/dist/core/agents/canonical-emit.d.ts +3 -0
  20. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  21. package/dist/core/agents/canonical-emit.js +12 -0
  22. package/dist/core/agents/claim-path.d.ts +11 -0
  23. package/dist/core/agents/claim-path.d.ts.map +1 -0
  24. package/dist/core/agents/claim-path.js +22 -0
  25. package/dist/core/agents/coord-client.d.ts +29 -3
  26. package/dist/core/agents/coord-client.d.ts.map +1 -1
  27. package/dist/core/agents/coord-client.js +88 -9
  28. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  29. package/dist/core/agents/live-authority-v3.js +1 -3
  30. package/dist/core/agents/live-lifecycle-v3.js +3 -3
  31. package/dist/core/agents/live-observation-v3.d.ts.map +1 -1
  32. package/dist/core/agents/live-observation-v3.js +2 -2
  33. package/dist/core/agents/render/prompt-context.js +12 -10
  34. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  35. package/dist/core/agents/rules/stop-hook.js +23 -11
  36. package/dist/core/agents/session-name-display.d.ts +1 -0
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +8 -1
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +46 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -0
  41. package/dist/core/agents/state/heartbeat-reader.js +25 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts +3 -48
  43. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  44. package/dist/core/agents/state/heartbeat-writer.js +16 -34
  45. package/dist/core/agents/state/live-coordination-view.d.ts +1 -1
  46. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  47. package/dist/core/agents/state/live-coordination-view.js +25 -1
  48. package/dist/core/agents/state/names.d.ts +9 -1
  49. package/dist/core/agents/state/names.d.ts.map +1 -1
  50. package/dist/core/agents/state/names.js +190 -182
  51. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  52. package/dist/core/agents/state/stale-sweep.js +23 -5
  53. package/dist/core/artifacts/constants.d.ts +5 -0
  54. package/dist/core/artifacts/constants.d.ts.map +1 -0
  55. package/dist/core/artifacts/constants.js +4 -0
  56. package/dist/core/artifacts/index.d.ts +3 -3
  57. package/dist/core/artifacts/index.d.ts.map +1 -1
  58. package/dist/core/artifacts/index.js +50 -7
  59. package/dist/core/events/v3/base-contract.d.ts +795 -0
  60. package/dist/core/events/v3/base-contract.d.ts.map +1 -1
  61. package/dist/core/events/v3/base-contract.js +33 -0
  62. package/dist/core/events/v3/capabilities.d.ts +11 -0
  63. package/dist/core/events/v3/capabilities.d.ts.map +1 -1
  64. package/dist/core/events/v3/capabilities.js +23 -0
  65. package/dist/core/events/v3/generated.d.ts +1 -1
  66. package/dist/core/events/v3/generated.js +1 -1
  67. package/dist/core/events/v3/index.d.ts +4 -3
  68. package/dist/core/events/v3/index.d.ts.map +1 -1
  69. package/dist/core/events/v3/index.js +4 -3
  70. package/dist/core/events/v3/latency.d.ts +31 -1
  71. package/dist/core/events/v3/latency.d.ts.map +1 -1
  72. package/dist/core/events/v3/latency.js +127 -3
  73. package/dist/core/events/v3/live-routing.d.ts +30 -0
  74. package/dist/core/events/v3/live-routing.d.ts.map +1 -1
  75. package/dist/core/events/v3/live-routing.js +109 -1
  76. package/dist/core/events/v3/producers/command-recorder.d.ts.map +1 -1
  77. package/dist/core/events/v3/producers/command-recorder.js +10 -1
  78. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  79. package/dist/core/events/v3/producers/coordination-recorder.js +14 -3
  80. package/dist/core/events/v3/producers/hook-base.d.ts +17 -1
  81. package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
  82. package/dist/core/events/v3/producers/hook-base.js +82 -1
  83. package/dist/core/events/v3/producers/intake.d.ts +6 -0
  84. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  85. package/dist/core/events/v3/producers/intake.js +11 -0
  86. package/dist/core/events/v3/producers/recorder.d.ts +62 -0
  87. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  88. package/dist/core/events/v3/producers/recorder.js +654 -18
  89. package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts +51 -0
  90. package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts.map +1 -0
  91. package/dist/core/events/v3/runtime-telemetry-capabilities.js +54 -0
  92. package/dist/core/events/v3/turn-telemetry.d.ts +9 -0
  93. package/dist/core/events/v3/turn-telemetry.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/parse.d.ts +3 -0
  95. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  96. package/dist/core/hooks/adapter/parse.js +14 -0
  97. package/dist/core/hooks/adapter/runtime-telemetry.d.ts +119 -0
  98. package/dist/core/hooks/adapter/runtime-telemetry.d.ts.map +1 -0
  99. package/dist/core/hooks/adapter/runtime-telemetry.js +568 -0
  100. package/dist/core/hooks/cli.js +111 -36
  101. package/dist/core/hooks/remediation-cap.d.ts +34 -0
  102. package/dist/core/hooks/remediation-cap.d.ts.map +1 -0
  103. package/dist/core/hooks/remediation-cap.js +71 -0
  104. package/dist/core/hooks/resolve/transcript.d.ts +25 -9
  105. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  106. package/dist/core/hooks/resolve/transcript.js +22 -10
  107. package/dist/core/semantic/adapters.d.ts +34 -0
  108. package/dist/core/semantic/adapters.d.ts.map +1 -0
  109. package/dist/core/semantic/adapters.js +222 -0
  110. package/dist/core/semantic/contract.d.ts +588 -0
  111. package/dist/core/semantic/contract.d.ts.map +1 -0
  112. package/dist/core/semantic/contract.js +230 -0
  113. package/dist/core/semantic/evidence.d.ts +11 -0
  114. package/dist/core/semantic/evidence.d.ts.map +1 -0
  115. package/dist/core/semantic/evidence.js +262 -0
  116. package/dist/core/semantic/index.d.ts +10 -0
  117. package/dist/core/semantic/index.d.ts.map +1 -0
  118. package/dist/core/semantic/index.js +9 -0
  119. package/dist/core/semantic/once.d.ts +33 -0
  120. package/dist/core/semantic/once.d.ts.map +1 -0
  121. package/dist/core/semantic/once.js +293 -0
  122. package/dist/core/semantic/prompt.d.ts +12 -0
  123. package/dist/core/semantic/prompt.d.ts.map +1 -0
  124. package/dist/core/semantic/prompt.js +70 -0
  125. package/dist/core/semantic/scheduler.d.ts +16 -0
  126. package/dist/core/semantic/scheduler.d.ts.map +1 -0
  127. package/dist/core/semantic/scheduler.js +67 -0
  128. package/dist/core/semantic/service-status.d.ts +31 -0
  129. package/dist/core/semantic/service-status.d.ts.map +1 -0
  130. package/dist/core/semantic/service-status.js +84 -0
  131. package/dist/core/semantic/service.d.ts +51 -0
  132. package/dist/core/semantic/service.d.ts.map +1 -0
  133. package/dist/core/semantic/service.js +470 -0
  134. package/dist/core/semantic/storage.d.ts +71 -0
  135. package/dist/core/semantic/storage.d.ts.map +1 -0
  136. package/dist/core/semantic/storage.js +179 -0
  137. package/dist/core/semantic/validate.d.ts +16 -0
  138. package/dist/core/semantic/validate.d.ts.map +1 -0
  139. package/dist/core/semantic/validate.js +174 -0
  140. package/dist/lib/browser/client.d.ts +11 -0
  141. package/dist/lib/browser/client.d.ts.map +1 -1
  142. package/dist/lib/browser/client.js +13 -0
  143. package/dist/lib/browser/index.d.ts +1 -0
  144. package/dist/lib/browser/index.d.ts.map +1 -1
  145. package/dist/lib/browser/standalone-html.d.ts +25 -0
  146. package/dist/lib/browser/standalone-html.d.ts.map +1 -0
  147. package/dist/lib/browser/standalone-html.js +284 -0
  148. package/dist/lib/instructions/apply.d.ts.map +1 -1
  149. package/dist/lib/instructions/apply.js +1 -0
  150. package/dist/lib/instructions/templates.d.ts +2 -0
  151. package/dist/lib/instructions/templates.d.ts.map +1 -1
  152. package/dist/lib/instructions/templates.js +144 -4
  153. package/package.json +6 -1
  154. package/schemas/event-v3.schema.json +2758 -478
  155. package/src/commander.ts +2 -0
  156. package/src/commands/agents.ts +102 -167
  157. package/src/commands/browse.ts +25 -2
  158. package/src/commands/decision.ts +8 -2
  159. package/src/commands/events.ts +21 -1
  160. package/src/commands/presence.ts +7 -2
  161. package/src/commands/semantic.ts +179 -0
  162. package/src/commands/web.ts +15 -0
  163. package/src/core/agents/canonical-emit.ts +15 -0
  164. package/src/core/agents/claim-path.ts +23 -0
  165. package/src/core/agents/coord-client.ts +92 -8
  166. package/src/core/agents/live-authority-v3.ts +1 -4
  167. package/src/core/agents/live-lifecycle-v3.ts +8 -3
  168. package/src/core/agents/live-observation-v3.ts +7 -2
  169. package/src/core/agents/render/prompt-context.ts +13 -10
  170. package/src/core/agents/rules/stop-hook.ts +24 -11
  171. package/src/core/agents/session-name-display.ts +9 -1
  172. package/src/core/agents/state/heartbeat-reader.ts +69 -0
  173. package/src/core/agents/state/heartbeat-writer.ts +20 -84
  174. package/src/core/agents/state/live-coordination-view.ts +26 -1
  175. package/src/core/agents/state/names.ts +190 -182
  176. package/src/core/agents/state/stale-sweep.ts +25 -5
  177. package/src/core/artifacts/constants.ts +4 -0
  178. package/src/core/artifacts/index.ts +49 -7
  179. package/src/core/events/v3/base-contract.ts +39 -0
  180. package/src/core/events/v3/capabilities.ts +34 -0
  181. package/src/core/events/v3/generated.ts +1 -1
  182. package/src/core/events/v3/index.ts +21 -0
  183. package/src/core/events/v3/latency.ts +213 -2
  184. package/src/core/events/v3/live-routing.ts +120 -1
  185. package/src/core/events/v3/producers/command-recorder.ts +10 -1
  186. package/src/core/events/v3/producers/coordination-recorder.ts +17 -5
  187. package/src/core/events/v3/producers/hook-base.ts +115 -1
  188. package/src/core/events/v3/producers/intake.ts +18 -0
  189. package/src/core/events/v3/producers/recorder.ts +856 -21
  190. package/src/core/events/v3/runtime-telemetry-capabilities.ts +137 -0
  191. package/src/core/events/v3/turn-telemetry.ts +10 -0
  192. package/src/core/hooks/adapter/parse.ts +16 -0
  193. package/src/core/hooks/adapter/runtime-telemetry.ts +805 -0
  194. package/src/core/hooks/cli.ts +123 -39
  195. package/src/core/hooks/remediation-cap.ts +77 -0
  196. package/src/core/hooks/resolve/transcript.ts +37 -11
  197. package/src/core/semantic/adapters.ts +293 -0
  198. package/src/core/semantic/contract.ts +298 -0
  199. package/src/core/semantic/evidence.ts +336 -0
  200. package/src/core/semantic/index.ts +127 -0
  201. package/src/core/semantic/once.ts +410 -0
  202. package/src/core/semantic/prompt.ts +84 -0
  203. package/src/core/semantic/scheduler.ts +107 -0
  204. package/src/core/semantic/service-status.ts +118 -0
  205. package/src/core/semantic/service.ts +580 -0
  206. package/src/core/semantic/storage.ts +286 -0
  207. package/src/core/semantic/validate.ts +212 -0
  208. package/src/lib/browser/client.ts +21 -0
  209. package/src/lib/browser/index.ts +4 -0
  210. package/src/lib/browser/standalone-html.ts +312 -0
  211. package/src/lib/instructions/apply.ts +1 -0
  212. package/src/lib/instructions/templates.ts +149 -4
@@ -0,0 +1,179 @@
1
+ import { resolve } from "node:path";
2
+ import type { Command } from "commander";
3
+ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
4
+ import {
5
+ createSemanticAdapters,
6
+ discoverSemanticReaders,
7
+ inspectSemanticDocument,
8
+ readSemanticManifest,
9
+ readSemanticServiceStatus,
10
+ requestSemanticServiceStop,
11
+ runSemanticOnce,
12
+ runSemanticServiceDaemon,
13
+ semanticPaths,
14
+ spawnSemanticService,
15
+ } from "../core/semantic/index.ts";
16
+
17
+ export function registerSemanticCommand(
18
+ program: Command,
19
+ emit: EmitContext,
20
+ context?: HarneryProgramContext,
21
+ ): void {
22
+ const semantic = program
23
+ .command("semantic")
24
+ .description("Build and inspect evidence-cited semantic readings of active V3 generations");
25
+
26
+ semantic
27
+ .command("once")
28
+ .description("Run one bounded semantic pass using each generation's source harness")
29
+ .option("--root <path>", "Explicit coordination root")
30
+ .option("--calls-per-hour <count>", "Configured call ceiling below the hard limit", integer)
31
+ .action(async (options: { root?: string; callsPerHour?: number }) => {
32
+ try {
33
+ emit.data(
34
+ await runSemanticOnce({
35
+ coordRoot: resolve(options.root ?? coordRoot(context)),
36
+ callsPerHour: options.callsPerHour,
37
+ }),
38
+ );
39
+ } catch (error) {
40
+ emitFailure(emit, "semantic_once_failed", error);
41
+ }
42
+ });
43
+
44
+ const service = semantic
45
+ .command("service")
46
+ .description("Run semantic reading as a singleton service");
47
+
48
+ service
49
+ .command("start")
50
+ .description("Start or resume the detached per-root semantic reader service")
51
+ .option("--root <path>", "Explicit coordination root")
52
+ .option("--calls-per-hour <count>", "Configured call ceiling below the hard limit", integer)
53
+ .action(async (options: { root?: string; callsPerHour?: number }) => {
54
+ try {
55
+ emit.data(
56
+ await spawnSemanticService(resolve(options.root ?? coordRoot(context)), {
57
+ callsPerHour: options.callsPerHour,
58
+ }),
59
+ );
60
+ } catch (error) {
61
+ emitFailure(emit, "semantic_service_start_failed", error);
62
+ }
63
+ });
64
+
65
+ service
66
+ .command("status")
67
+ .description("Show singleton liveness, pass metrics, and pending work")
68
+ .option("--root <path>", "Explicit coordination root")
69
+ .action((options: { root?: string }) => {
70
+ try {
71
+ emit.data(readSemanticServiceStatus(resolve(options.root ?? coordRoot(context))));
72
+ } catch (error) {
73
+ emitFailure(emit, "semantic_service_status_failed", error);
74
+ }
75
+ });
76
+
77
+ service
78
+ .command("stop")
79
+ .description("Request a graceful stop after the current model call")
80
+ .option("--root <path>", "Explicit coordination root")
81
+ .action(async (options: { root?: string }) => {
82
+ try {
83
+ const root = resolve(options.root ?? coordRoot(context));
84
+ let status = requestSemanticServiceStop(root);
85
+ const deadline = Date.now() + 5_000;
86
+ while (status.running && Date.now() < deadline) {
87
+ await new Promise((done) => setTimeout(done, 50));
88
+ status = readSemanticServiceStatus(root);
89
+ }
90
+ emit.data(status);
91
+ } catch (error) {
92
+ emitFailure(emit, "semantic_service_stop_failed", error);
93
+ }
94
+ });
95
+
96
+ service
97
+ .command("daemon", { hidden: true })
98
+ .description("Internal detached semantic service entrypoint")
99
+ .option("--root <path>", "Explicit coordination root")
100
+ .option("--calls-per-hour <count>", "Configured call ceiling below the hard limit", integer)
101
+ .action(async (options: { root?: string; callsPerHour?: number }) => {
102
+ try {
103
+ emit.data(
104
+ await runSemanticServiceDaemon({
105
+ coordRoot: resolve(options.root ?? coordRoot(context)),
106
+ callsPerHour: options.callsPerHour,
107
+ }),
108
+ );
109
+ } catch (error) {
110
+ emitFailure(emit, "semantic_service_daemon_failed", error);
111
+ }
112
+ });
113
+
114
+ semantic
115
+ .command("inspect")
116
+ .description("Inspect one generation or instance semantic document")
117
+ .argument("<instance-or-generation>")
118
+ .option("--root <path>", "Explicit coordination root")
119
+ .action((instanceOrGeneration: string, options: { root?: string }) => {
120
+ try {
121
+ const document = inspectSemanticDocument(
122
+ resolve(options.root ?? coordRoot(context)),
123
+ instanceOrGeneration,
124
+ );
125
+ if (!document) throw new Error(`no semantic document for ${instanceOrGeneration}`);
126
+ emit.data(document);
127
+ } catch (error) {
128
+ emitFailure(emit, "semantic_inspect_failed", error);
129
+ }
130
+ });
131
+
132
+ semantic
133
+ .command("doctor")
134
+ .description("Inspect semantic reader availability without making a model request")
135
+ .option("--root <path>", "Explicit coordination root")
136
+ .action((options: { root?: string }) => {
137
+ try {
138
+ const root = resolve(options.root ?? coordRoot(context));
139
+ emit.data({
140
+ schema_version: 1,
141
+ root,
142
+ storage: semanticPaths(root).root,
143
+ readers: discoverSemanticReaders(createSemanticAdapters()),
144
+ manifest: readManifestSafe(root),
145
+ });
146
+ } catch (error) {
147
+ emitFailure(emit, "semantic_doctor_failed", error);
148
+ }
149
+ });
150
+ }
151
+
152
+ function coordRoot(context: HarneryProgramContext | undefined): string {
153
+ return resolve(
154
+ context?.resolveCoordRoot?.() ??
155
+ context?.repoRoot ??
156
+ process.env.HARNERY_COORD_ROOT ??
157
+ process.cwd(),
158
+ );
159
+ }
160
+
161
+ function integer(value: string): number {
162
+ const parsed = Number(value);
163
+ if (!Number.isSafeInteger(parsed) || parsed < 1)
164
+ throw new Error("count must be a positive integer");
165
+ return parsed;
166
+ }
167
+
168
+ function readManifestSafe(root: string) {
169
+ try {
170
+ return readSemanticManifest(root);
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ function emitFailure(emit: EmitContext, code: string, error: unknown): never {
177
+ emit.error({ code, message: error instanceof Error ? error.message : String(error) });
178
+ throw error;
179
+ }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import type { Command } from "commander";
7
7
  import type { EmitContext } from "../commander.ts";
8
8
  import { DEFAULT_WEB_PORT, resolveWebPort } from "../core/config.ts";
9
+ import { ensureSemanticServiceRunning } from "../core/semantic/service.ts";
9
10
  import { lazyFetchWebRoot, webRunner } from "./web-fetch.ts";
10
11
 
11
12
  /**
@@ -69,6 +70,17 @@ export function nodeOptionsWithHeapCap(mb: number): string | undefined {
69
70
  return `${inherited} --max-old-space-size=${mb}`.trim();
70
71
  }
71
72
 
73
+ async function startSemanticServiceWithWeb(coordRoot: string, emit: EmitContext): Promise<void> {
74
+ const result = await ensureSemanticServiceRunning(coordRoot);
75
+ if (result.state === "started") emit.log("semantic reader · started automatically", "info");
76
+ if (result.state === "running") emit.log("semantic reader · already running", "info");
77
+ if (result.state === "paused") emit.log("semantic reader · paused by operator", "info");
78
+ if (result.state === "inactive") emit.log("semantic reader · waiting for active V3", "info");
79
+ if (result.state === "unavailable") {
80
+ emit.log(`semantic reader · unavailable (${result.error ?? "startup failed"})`, "warn");
81
+ }
82
+ }
83
+
72
84
  /**
73
85
  * Resolve the web app root. A local `web/` (source checkout / submodule) wins.
74
86
  * Otherwise, on an npm install, lazy-fetch it (clone + install) unless the
@@ -193,6 +205,8 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
193
205
 
194
206
  if (!(await requireAvailablePort(emit, port))) return;
195
207
 
208
+ await startSemanticServiceWithWeb(coordRoot, emit);
209
+
196
210
  const heapMb = resolveMaxOldSpaceMb(opts.maxOldSpace);
197
211
  emit.log(`harn web · http://localhost:${port} (${mode})`, "info");
198
212
  emit.log(`files origin · http://harnery-files.localhost:${port}`, "info");
@@ -281,6 +295,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
281
295
  return;
282
296
  }
283
297
  if (!(await requireAvailablePort(emit, port))) return;
298
+ await startSemanticServiceWithWeb(coordRoot, emit);
284
299
  const heapMb = resolveMaxOldSpaceMb(opts.maxOldSpace);
285
300
  emit.log(`harn web · http://localhost:${port} (start)`, "info");
286
301
  emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
@@ -14,6 +14,7 @@ import { spawnSync } from "node:child_process";
14
14
  import type { LiveCoordinationObservationV3 } from "../events/v3/live-observation.ts";
15
15
  import { coordBinPath } from "./coord-bin.ts";
16
16
  import { resolveCoordRoot } from "./coord-client.ts";
17
+ import type { Heartbeat } from "./state/heartbeat-reader.ts";
17
18
 
18
19
  export type EventV3EmitObservation =
19
20
  | LiveCoordinationObservationV3
@@ -96,3 +97,17 @@ export function normalizeAdapter(platform: string | undefined): "claude-code" |
96
97
  if (platform === "codex") return "codex";
97
98
  return "claude-code";
98
99
  }
100
+
101
+ /** Producer joins use the private native session ID, never its V3 fingerprint. */
102
+ export function nativeSessionIdentity(
103
+ row: Pick<Heartbeat, "native_session_id" | "session_id"> | null | undefined,
104
+ fallback: string,
105
+ ): string {
106
+ if (row?.native_session_id) return row.native_session_id;
107
+ // A projection-only row carries the privacy-safe canonical session
108
+ // fingerprint, not the adapter's native session ID needed to join the hook
109
+ // producer. Until a disposable cache exists, the resolved owner is the only
110
+ // native identity available to command surfaces.
111
+ if (row?.session_id && !/^sid_[a-f0-9]{64}$/.test(row.session_id)) return row.session_id;
112
+ return fallback;
113
+ }
@@ -0,0 +1,23 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ /**
4
+ * Canonical identity for an authority-bearing write claim.
5
+ *
6
+ * Workspace paths stay portable and repository-relative. Approved paths
7
+ * outside the coordination root become resolved absolute paths so a later
8
+ * release matches whether the caller supplies the stored dot-dot form or the
9
+ * absolute target. This is lexical identity only; finalization policy owns the
10
+ * physical containment and symlink checks before a claim is acquired.
11
+ */
12
+ export function canonicalClaimPath(coordRoot: string, value: string): string {
13
+ const root = resolve(coordRoot);
14
+ const absolute = resolve(root, value);
15
+ const fromRoot = relative(root, absolute);
16
+ const contained = fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
17
+ if (contained) return portable(fromRoot || ".");
18
+ return portable(absolute);
19
+ }
20
+
21
+ function portable(value: string): string {
22
+ return value.split(sep).join("/");
23
+ }
@@ -347,9 +347,10 @@ function readPidmapRow(pidmapDir: string, pid: number): string | null {
347
347
  * Prefer a row whose platform matches `HARNERY_AGENT_COORD_PLATFORM` (default
348
348
  * `claude-code`); otherwise return the first owner seen on the walk.
349
349
  *
350
- * Subagents intentionally do not write pid-map entries; a Bash-tool ppid-walk
351
- * from inside a subagent therefore resolves to the *parent's* pid-map entry.
352
- * v1 ships this behavior; a real subagent-aware bridge is out of scope.
350
+ * Subagents intentionally do not write pid-map entries; a bare Bash-tool
351
+ * ppid-walk from inside an unbridged child therefore resolves to the parent's
352
+ * pid-map entry. Native hook bridges supersede this fallback through the
353
+ * child session environment.
353
354
  */
354
355
  export function resolveOwner(): string | null {
355
356
  return resolveOwnerWithSource().owner;
@@ -381,9 +382,36 @@ export function resolveOwnerWithSource(): {
381
382
  // Every supported adapter exports its native session id into subprocesses.
382
383
  // Match it against the live V3 producer state, never a disposable cache.
383
384
  if (shouldPreferSessionEnv()) {
384
- const bySession = resolveOwnerBySessionEnv(root);
385
+ const bySession = resolveOwnerBySessionEnvDetailed(root);
385
386
  if (bySession) {
386
- return { owner: bySession, source: "session_env" };
387
+ // Env vars cross process boundaries that session identity does not: a
388
+ // nested different-adapter child inherits its spawner's exported id (a
389
+ // Codex session's CODEX_THREAD_ID survives into a `claude -p` child's
390
+ // shells untouched), and that stale statement joins the spawner's live
391
+ // producer. The nearest token-verified pid-map anchor names the session
392
+ // whose tool environment this process actually runs in, so on a
393
+ // CROSS-adapter conflict the anchor wins — the anchoring adapter never
394
+ // stamps another adapter's variable, so a foreign-adapter id here is
395
+ // inherited context, while the anchor row is re-healed on every tool
396
+ // call. A SAME-adapter conflict keeps the env: the adapter stamps its
397
+ // own variable fresh per tool call, and the row may lag one heal (e.g.
398
+ // a session id change inside one process). Only per-session-process
399
+ // adapters may anchor (claude-code, codex) — Cursor sessions share one
400
+ // terminal process, so a verified Cursor row can belong to a sibling
401
+ // session. Unverified rows keep losing to the env too: without a start
402
+ // token the row is the guess the env ordering was introduced to beat.
403
+ // Never for bridge-marked children: a connector crosses a process-tree
404
+ // boundary, so an anchor in THIS tree is someone else's by definition.
405
+ const anchor = bridge ? null : nearestVerifiedSessionAnchor(root);
406
+ if (
407
+ anchor &&
408
+ anchor.owner !== bySession.owner &&
409
+ !bySession.adapters.includes(anchor.platform) &&
410
+ (anchor.platform === "claude-code" || anchor.platform === "codex")
411
+ ) {
412
+ return { owner: anchor.owner, source: "pidmap" };
413
+ }
414
+ return { owner: bySession.owner, source: "session_env" };
387
415
  }
388
416
  }
389
417
 
@@ -410,6 +438,42 @@ export function resolveOwnerWithSource(): {
410
438
  return { owner: null, source: "none" };
411
439
  }
412
440
 
441
+ /**
442
+ * The nearest pid-map anchor on our own ppid chain whose start token PROVES the
443
+ * row still describes the process holding that pid, or null when the nearest
444
+ * row is unverified, recycled, or absent.
445
+ *
446
+ * Nearest matters: in a nested-agent tree (session A spawns a headless session
447
+ * B whose shell runs this code), both A's and B's adapter processes are
448
+ * ancestors, and only the innermost one owns this process's tool environment.
449
+ * Exported for unit testing with an injectable root.
450
+ */
451
+ export function nearestVerifiedSessionAnchor(
452
+ root: string,
453
+ ): { owner: string; platform: string } | null {
454
+ const pidmapDir = resolve(root, ".harnery", "pid-map");
455
+ if (!existsSync(pidmapDir)) return null;
456
+ let pid: number | null = process.pid;
457
+ for (let hop = 0; hop < 20; hop++) {
458
+ if (pid === null) break;
459
+ const row = readPidmapRow(pidmapDir, pid);
460
+ if (row) {
461
+ const owner = parsePidmapRowOwner(row);
462
+ if (owner && !pidWasRecycled(pid, row)) {
463
+ // A recycled row is about a dead process's tenure of this pid — walk
464
+ // past it like the owner walk does. A live row without a provable
465
+ // token match is the nearest anchor but not a VERIFIED one: stop and
466
+ // report none, so the session env keeps outranking guesses.
467
+ const token = parsePidmapRowStartToken(row);
468
+ if (!token || !pidStartToken(pid)) return null;
469
+ return { owner, platform: parsePidmapRowPlatform(row) };
470
+ }
471
+ }
472
+ pid = readPpid(pid);
473
+ }
474
+ return null;
475
+ }
476
+
413
477
  /**
414
478
  * Walk our own ppid chain for a pid-map row in ONE given root.
415
479
  *
@@ -524,17 +588,37 @@ function shouldPreferSessionEnv(): boolean {
524
588
  * Exported for unit testing with an injectable root.
525
589
  */
526
590
  export function resolveOwnerBySessionEnv(root: string): string | null {
591
+ return resolveOwnerBySessionEnvDetailed(root)?.owner ?? null;
592
+ }
593
+
594
+ /**
595
+ * `resolveOwnerBySessionEnv` plus WHICH adapter's producer state the session id
596
+ * joined. The caller uses the adapter to tell a same-adapter conflict (the env
597
+ * var is stamped fresh per tool call and outranks a possibly-lagging pid-map
598
+ * row) from a cross-adapter one (the var is inherited context from an outer
599
+ * session and the verified row wins).
600
+ */
601
+ export function resolveOwnerBySessionEnvDetailed(
602
+ root: string,
603
+ ): { owner: string; adapters: string[] } | null {
527
604
  const sessionIds = sessionIdsFromEnv();
528
605
  if (sessionIds.length === 0) return null;
529
606
  const adapters = adapterCandidatesFromEnv();
530
- const matches = new Set<string>();
607
+ const matches = new Map<string, Set<string>>();
531
608
  for (const sessionId of sessionIds) {
532
609
  for (const adapter of adapters) {
533
610
  const state = readHookProducerStateV3(root, adapter, sessionId);
534
- if (state && !state.terminal) matches.add(nativeOwnerForV3Instance(root, state.instance_id));
611
+ if (state && !state.terminal) {
612
+ const owner = nativeOwnerForV3Instance(root, state.instance_id);
613
+ const set = matches.get(owner) ?? new Set<string>();
614
+ set.add(adapter);
615
+ matches.set(owner, set);
616
+ }
535
617
  }
536
618
  }
537
- return matches.size === 1 ? [...matches][0]! : null;
619
+ if (matches.size !== 1) return null;
620
+ const [owner, adapterSet] = [...matches.entries()][0]!;
621
+ return { owner, adapters: [...adapterSet] };
538
622
  }
539
623
 
540
624
  /**
@@ -25,6 +25,7 @@ import {
25
25
  readTerminalHookProducerStateV3,
26
26
  recordHookSignalV3,
27
27
  } from "../events/v3/producers/recorder.ts";
28
+ import { canonicalClaimPath } from "./claim-path.ts";
28
29
  import {
29
30
  acquireClaim,
30
31
  type Heartbeat,
@@ -369,7 +370,3 @@ function authorityStateDigest(heartbeat: Heartbeat): `sha256:${string}` {
369
370
  }),
370
371
  );
371
372
  }
372
-
373
- function canonicalClaimPath(coordRoot: string, path: string): string {
374
- return path.startsWith(`${coordRoot}/`) ? path.slice(coordRoot.length + 1) : path;
375
- }
@@ -10,7 +10,7 @@ import {
10
10
  livePlatformV3,
11
11
  resolveLiveEventLedgerRouteV3,
12
12
  } from "../events/v3/live-routing.ts";
13
- import { readHookProducerStateV3 } from "../events/v3/producers/recorder.ts";
13
+ import { readJoinableHookProducerStateV3 } from "../events/v3/producers/recorder.ts";
14
14
  import { writeEventV3 } from "../events/v3/writer.ts";
15
15
  import { LiveCoordinationAuthorityV3Error } from "./live-authority-v3.ts";
16
16
  import { liveCoordinationAdapterV3 } from "./state/live-coordination-view.ts";
@@ -132,8 +132,13 @@ function withAttestedAdapter<T extends LiveLifecycleObservationBaseV3>(input: T)
132
132
  }
133
133
 
134
134
  function requireHookState(input: LiveLifecycleObservationBaseV3) {
135
- const hook = readHookProducerStateV3(input.coordRoot, input.adapter, input.nativeSessionId);
136
- if (!hook || hook.instance_id !== liveInstanceIdV3(input.owner)) {
135
+ const hook = readJoinableHookProducerStateV3(
136
+ input.coordRoot,
137
+ input.adapter,
138
+ input.nativeSessionId,
139
+ liveInstanceIdV3(input.owner),
140
+ );
141
+ if (!hook) {
137
142
  throw new LiveCoordinationAuthorityV3Error("hook_generation_not_joinable");
138
143
  }
139
144
  return hook;
@@ -17,7 +17,7 @@ import {
17
17
  livePlatformV3,
18
18
  resolveLiveEventLedgerRouteV3,
19
19
  } from "../events/v3/live-routing.ts";
20
- import { readHookProducerStateV3 } from "../events/v3/producers/recorder.ts";
20
+ import { readJoinableHookProducerStateV3 } from "../events/v3/producers/recorder.ts";
21
21
  import { writeEventV3 } from "../events/v3/writer.ts";
22
22
  import { LiveCoordinationAuthorityV3Error } from "./live-authority-v3.ts";
23
23
  import { liveCoordinationAdapterV3 } from "./state/live-coordination-view.ts";
@@ -54,7 +54,12 @@ export function recordLiveCoordinationObservationV3(
54
54
  throw new LiveCoordinationAuthorityV3Error("control_state_changed");
55
55
  }
56
56
  const adapter = liveCoordinationAdapterV3(input.coordRoot, input.owner) ?? input.adapter;
57
- const hook = readHookProducerStateV3(input.coordRoot, adapter, input.nativeSessionId);
57
+ const hook = readJoinableHookProducerStateV3(
58
+ input.coordRoot,
59
+ adapter,
60
+ input.nativeSessionId,
61
+ liveInstanceIdV3(input.owner),
62
+ );
58
63
  if (
59
64
  !hook ||
60
65
  hook.terminal ||
@@ -224,7 +224,7 @@ function computeFocusNudgeIfChanged(
224
224
  // Keyed on "a name was ever produced", not task_updated_at: a bare clear
225
225
  // as the first declaration must not end the naming window.
226
226
  needsNudge = true;
227
- nudgeKind = "session-name";
227
+ nudgeKind = pendingSessionName ? "session-name-pending" : "session-name-unminted";
228
228
  message = pendingSessionName
229
229
  ? `This session name is still pending display. Before any prose or another tool call, send this exact block as your next assistant text:\n\n${sessionNameDisplayBlock(pendingSessionName)}`
230
230
  : `This session has no name yet: run \`${bin} agents set-task "<2-5 word session topic>"\` as your first tool call. ` +
@@ -254,19 +254,22 @@ function computeFocusNudgeIfChanged(
254
254
  return "";
255
255
  }
256
256
 
257
- // The session-name reminder is deliberately NOT deduped: it re-emits on
258
- // every prompt until a name is produced, because the "still unnamed" state
259
- // is the failure state — deduping it erases the only reminder after one
260
- // ignored prompt (the miss mode operators reported). Bounded in practice:
261
- // Stop rule 3/3 forces a set-task on the first tool-using turn.
262
- if (nudgeKind === "session-name") {
257
+ // An unminted session still gets a fresh reminder on every prompt until its
258
+ // first focus declaration. Once a name exists, the exact pending-name prompt
259
+ // is delivered once per minted value. Repeating it on every prompt creates
260
+ // an unbounded loop on adapters that cannot verify assistant reply text.
261
+ if (nudgeKind === "session-name-unminted") {
263
262
  clearHashFile(hashFile);
264
263
  return message;
265
264
  }
266
265
 
267
- // Dedup the task-unset/stale reminders on kind + task state so a repeat
268
- // prompt in the same state stays quiet.
269
- const state = `${nudgeKind}|task=${taskValue}|updated=${hb.task_updated_at ?? ""}|threshold=${threshold}`;
266
+ // A pending reminder is keyed only to the minted name: changing the task
267
+ // must not reconstruct or re-request that older title. Task reminders retain
268
+ // their own task-state key.
269
+ const state =
270
+ nudgeKind === "session-name-pending"
271
+ ? `${nudgeKind}|name=${pendingSessionName ?? ""}`
272
+ : `${nudgeKind}|task=${taskValue}|updated=${hb.task_updated_at ?? ""}|threshold=${threshold}`;
270
273
  const newHash = sha256Hex16(state);
271
274
  const oldHash = safeRead(hashFile);
272
275
  if (oldHash && oldHash === newHash) return "";
@@ -138,16 +138,18 @@ export function evaluateStopHookV3Events(
138
138
  event.event_type === "turn.completed",
139
139
  );
140
140
  const latestTerminal = turnTerminals.at(-1);
141
- const latestBox = observedBoolean(latestTerminal?.payload.ritual?.status_box_present);
141
+ const latestBox = observedBoolean(latestTerminal?.payload.ritual?.status_box_present_strict);
142
142
  if (req.adapter !== "cursor" && latestBox === undefined) {
143
143
  return evidenceUnavailable(
144
- "turn.completed has no observed V3 status-box result; Stop remains fail-open",
144
+ "turn.completed has no observed V3 assistant-text status-box result; Stop remains fail-open",
145
145
  );
146
146
  }
147
147
 
148
148
  const ackPresent = req.adapter === "cursor" ? requiredStatusChecked : latestBox === true;
149
149
  const ackBlock =
150
- req.adapter === "cursor" ? () => rule13Block(coordRoot) : () => rule23Block(coordRoot);
150
+ req.adapter === "cursor"
151
+ ? () => rule13Block(coordRoot, req.session_id)
152
+ : () => rule23Block(coordRoot, req.session_id);
151
153
 
152
154
  // Pure prose turns keep the human-visible acknowledgement but do not require
153
155
  // task or final-status mutations. This preserves the pre-V3 policy exactly.
@@ -161,9 +163,9 @@ export function evaluateStopHookV3Events(
161
163
  };
162
164
  }
163
165
 
164
- if (!requiredStatusChecked) return rule13Block(coordRoot);
166
+ if (!requiredStatusChecked) return rule13Block(coordRoot, req.session_id);
165
167
  if (!ackPresent) return ackBlock();
166
- if (!taskSet) return rule33Block(coordRoot);
168
+ if (!taskSet) return rule33Block(coordRoot, req.session_id);
167
169
 
168
170
  // Claude Code can prove session-name presentation from assistant-only text.
169
171
  // Cursor cannot expose that text and Codex returned observe-only above.
@@ -271,7 +273,18 @@ function evidenceUnavailable(reason: string): VerdictResult {
271
273
  };
272
274
  }
273
275
 
274
- function rule13Block(coordRoot?: string): VerdictResult {
276
+ /**
277
+ * Remediation commands carry `--session-id` when the session id is known: the
278
+ * remediating tool call may not descend from a pid-map-registered anchor
279
+ * (headless children, Cursor shells), and a ppid-walk that resolves to a
280
+ * DIFFERENT owner records evidence this verdict will never see — the block
281
+ * then repeats forever while the agent complies every time.
282
+ */
283
+ function sessionIdSuffix(sessionId?: string): string {
284
+ return sessionId ? ` --session-id ${sessionId}` : "";
285
+ }
286
+
287
+ function rule13Block(coordRoot?: string, sessionId?: string): VerdictResult {
275
288
  const missingEvidence = agentsRequireGitFinalization(coordRoot)
276
289
  ? "no end_turn_checked coord.status_observed event found in this turn"
277
290
  : "no coord.status_observed event found in this turn";
@@ -279,16 +292,16 @@ function rule13Block(coordRoot?: string): VerdictResult {
279
292
  allow: false,
280
293
  exit_code: 2,
281
294
  rule: "stop-hook.rule_1_3",
282
- reason: `End-of-turn rule (1/3): ${missingEvidence}; run \`${endOfTurnStatusCommand(coordRoot)}\` as your last tool call.`,
295
+ reason: `End-of-turn rule (1/3): ${missingEvidence}; run \`${endOfTurnStatusCommand(coordRoot)}${sessionIdSuffix(sessionId)}\` as your last tool call.`,
283
296
  };
284
297
  }
285
298
 
286
- function rule23Block(coordRoot?: string): VerdictResult {
299
+ function rule23Block(coordRoot?: string, sessionId?: string): VerdictResult {
287
300
  return {
288
301
  allow: false,
289
302
  exit_code: 2,
290
303
  rule: "stop-hook.rule_2_3",
291
- reason: `End-of-turn rule (2/3): turn.completed did not observe the agent-status box in your reply text. Paste the \`${endOfTurnStatusCommand(coordRoot)}\` output verbatim as a fenced code block (the \`┌─ agent-\` prefix is the detection signal).`,
304
+ reason: `End-of-turn rule (2/3): turn.completed did not observe the agent-status box in your reply text. Paste the \`${endOfTurnStatusCommand(coordRoot)}${sessionIdSuffix(sessionId)}\` output verbatim as a fenced code block (the \`┌─ agent-\` prefix is the detection signal).`,
292
305
  };
293
306
  }
294
307
 
@@ -335,11 +348,11 @@ function sessionNameBlock(coordRoot: string, instanceId: string): VerdictResult
335
348
  };
336
349
  }
337
350
 
338
- function rule33Block(coordRoot?: string): VerdictResult {
351
+ function rule33Block(coordRoot?: string, sessionId?: string): VerdictResult {
339
352
  return {
340
353
  allow: false,
341
354
  exit_code: 2,
342
355
  rule: "stop-hook.rule_3_3",
343
- reason: `End-of-turn rule (3/3): no coord.task_changed event found in this turn; run \`${resolveBinName(coordRoot)} agents set-task "<short focus>"\` to declare what you're working on. Pass an empty string if the turn was purely conversational.`,
356
+ reason: `End-of-turn rule (3/3): no coord.task_changed event found in this turn; run \`${resolveBinName(coordRoot)} agents set-task${sessionIdSuffix(sessionId)} "<short focus>"\` to declare what you're working on. Pass an empty string if the turn was purely conversational.`,
344
357
  };
345
358
  }
@@ -36,6 +36,14 @@ export function sessionNameDisplayInstruction(name: string): string {
36
36
  ].join("\n");
37
37
  }
38
38
 
39
+ export function sessionNameDisplayRecoveryInstruction(name: string, binName: string): string {
40
+ return [
41
+ sessionNameDisplayInstruction(name),
42
+ "",
43
+ `If that exact block was already shown but the gate still rejects tools, run \`${binName} agents suggest-name --json\` as the only tool call. That read-only command is exempt and creates a fresh verification boundary.`,
44
+ ].join("\n");
45
+ }
46
+
39
47
  /**
40
48
  * A PostToolUse result should announce the display protocol only when that
41
49
  * exact tool call minted or retried the current suggestion. The coordination row keeps
@@ -143,7 +151,7 @@ export function isSessionNameRemediationCommand(
143
151
  if (!body || /[\n;&|<>`$]/.test(body)) return false;
144
152
  const escapedBin = binName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
153
  const pattern = new RegExp(
146
- `^(?:codex-wsl\\s+--\\s+)?${escapedBin}\\s+agents\\s+(?:set-task|status)(?:\\s+.*)?$`,
154
+ `^(?:codex-wsl\\s+--\\s+)?${escapedBin}\\s+agents\\s+(?:set-task|status|suggest-name)(?:\\s+.*)?$`,
147
155
  );
148
156
  return pattern.test(body);
149
157
  }