harnery 0.34.0 → 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 +91 -162
  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 +2 -1
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +10 -3
  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 +114 -38
  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 +35 -17
  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 +104 -152
  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 +11 -3
  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 +126 -41
  195. package/src/core/hooks/remediation-cap.ts +77 -0
  196. package/src/core/hooks/resolve/transcript.ts +49 -17
  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,118 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { resolve } from "node:path";
4
+
5
+ import { readSemanticManifest, semanticPaths } from "./storage.ts";
6
+
7
+ export const SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION = 1 as const;
8
+ const FOREIGN_STATUS_STALE_MS = 2 * 60_000;
9
+ const MAX_FILE_BYTES = 512 * 1024;
10
+
11
+ export type SemanticServiceState = "starting" | "running" | "stopping" | "stopped";
12
+ export type SemanticServiceErrorCode = "ledger_unavailable" | "semantic_pass_failed";
13
+
14
+ export interface SemanticServiceStatusRecord {
15
+ schema_version: typeof SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION;
16
+ pid: number;
17
+ host: string;
18
+ nonce: string;
19
+ state: SemanticServiceState;
20
+ started_at: string;
21
+ heartbeat_at: string;
22
+ calls_per_hour?: number;
23
+ sweep_count: number;
24
+ pass_count: number;
25
+ model_calls: number;
26
+ cache_hits: number;
27
+ last_sweep_at?: string;
28
+ last_pass_at?: string;
29
+ last_error_code?: SemanticServiceErrorCode;
30
+ stopped_at?: string;
31
+ }
32
+
33
+ export interface SemanticServiceStatus {
34
+ running: boolean;
35
+ stale: boolean;
36
+ record?: SemanticServiceStatusRecord;
37
+ newest_successful_pass?: string;
38
+ pending_count: number;
39
+ }
40
+
41
+ /** Read-only service health. This module cannot spawn, signal, or write. */
42
+ export function readSemanticServiceStatus(coordRootRaw: string): SemanticServiceStatus {
43
+ const coordRoot = resolve(coordRootRaw);
44
+ const record = readStatusRecord(coordRoot);
45
+ const manifest = safeManifest(coordRoot);
46
+ if (!record) {
47
+ return {
48
+ running: false,
49
+ stale: false,
50
+ newest_successful_pass: manifest?.newest_successful_pass,
51
+ pending_count: manifest?.pending.length ?? 0,
52
+ };
53
+ }
54
+ const running = statusOwnerIsLive(record);
55
+ return {
56
+ running,
57
+ stale: !running && record.state !== "stopped",
58
+ record,
59
+ newest_successful_pass: manifest?.newest_successful_pass,
60
+ pending_count: manifest?.pending.length ?? 0,
61
+ };
62
+ }
63
+
64
+ function safeManifest(coordRoot: string) {
65
+ try {
66
+ return readSemanticManifest(coordRoot);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ function readStatusRecord(coordRoot: string): SemanticServiceStatusRecord | undefined {
73
+ const path = semanticPaths(coordRoot).service;
74
+ if (!existsSync(path)) return undefined;
75
+ try {
76
+ const value = readBoundedJson<SemanticServiceStatusRecord>(path, "semantic service status");
77
+ if (
78
+ value.schema_version !== SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION ||
79
+ !Number.isSafeInteger(value.pid) ||
80
+ value.pid < 1 ||
81
+ typeof value.host !== "string" ||
82
+ typeof value.nonce !== "string" ||
83
+ !validTimestamp(value.started_at) ||
84
+ !validTimestamp(value.heartbeat_at)
85
+ ) {
86
+ return undefined;
87
+ }
88
+ return value;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ function statusOwnerIsLive(record: SemanticServiceStatusRecord): boolean {
95
+ if (record.state === "stopped") return false;
96
+ if (record.host === hostname()) return pidAlive(record.pid);
97
+ const age = Date.now() - Date.parse(record.heartbeat_at);
98
+ return Number.isFinite(age) && age < FOREIGN_STATUS_STALE_MS;
99
+ }
100
+
101
+ function pidAlive(pid: number): boolean {
102
+ try {
103
+ process.kill(pid, 0);
104
+ return true;
105
+ } catch (error) {
106
+ return (error as NodeJS.ErrnoException).code === "EPERM";
107
+ }
108
+ }
109
+
110
+ function readBoundedJson<T>(path: string, label: string): T {
111
+ const size = statSync(path).size;
112
+ if (size <= 0 || size > MAX_FILE_BYTES) throw new Error(`${label} has invalid size ${size}`);
113
+ return JSON.parse(readFileSync(path, "utf8")) as T;
114
+ }
115
+
116
+ function validTimestamp(value: unknown): value is string {
117
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
118
+ }
@@ -0,0 +1,580 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import {
4
+ appendFileSync,
5
+ chmodSync,
6
+ closeSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ rmSync,
13
+ statSync,
14
+ unlinkSync,
15
+ watch,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { hostname } from "node:os";
19
+ import { dirname, resolve } from "node:path";
20
+ import {
21
+ type ReadLedgerV3SinceResult,
22
+ readEventV3ControlState,
23
+ readLedgerV3Since,
24
+ } from "../events/v3/index.ts";
25
+ import { eventV3ActiveWatchPath } from "../events/v3/reader.ts";
26
+ import type { SemanticHarness } from "./contract.ts";
27
+ import { runSemanticOnce, type SemanticOnceReport } from "./once.ts";
28
+ import {
29
+ readSemanticServiceStatus,
30
+ SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION,
31
+ type SemanticServiceErrorCode,
32
+ type SemanticServiceStatus,
33
+ type SemanticServiceStatusRecord,
34
+ } from "./service-status.ts";
35
+ import {
36
+ listSemanticAgentDocuments,
37
+ readSemanticManifest,
38
+ semanticPaths,
39
+ writeSemanticManifest,
40
+ } from "./storage.ts";
41
+
42
+ export {
43
+ readSemanticServiceStatus,
44
+ SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION,
45
+ type SemanticServiceErrorCode,
46
+ type SemanticServiceState,
47
+ type SemanticServiceStatus,
48
+ type SemanticServiceStatusRecord,
49
+ } from "./service-status.ts";
50
+
51
+ export const SEMANTIC_SERVICE_DEFAULT_DEBOUNCE_MS = 5_000;
52
+ export const SEMANTIC_SERVICE_DEFAULT_WAKE_MS = 1_000;
53
+ export const SEMANTIC_SERVICE_DEFAULT_HEARTBEAT_MS = 5_000;
54
+ const FOREIGN_STATUS_STALE_MS = 2 * 60_000;
55
+ const MAX_FILE_BYTES = 512 * 1024;
56
+ const MAX_LOG_BYTES = 512 * 1024;
57
+ const REPEATED_ERROR_LOG_INTERVAL_MS = 60_000;
58
+
59
+ interface SemanticServiceLease {
60
+ pid: number;
61
+ host: string;
62
+ nonce: string;
63
+ created_at: string;
64
+ }
65
+
66
+ export interface RunSemanticServiceDaemonInput {
67
+ coordRoot: string;
68
+ callsPerHour?: number;
69
+ debounceMs?: number;
70
+ wakeIntervalMs?: number;
71
+ heartbeatIntervalMs?: number;
72
+ maxSweeps?: number;
73
+ now?: () => Date;
74
+ readSince?: typeof readLedgerV3Since;
75
+ runOnce?: (input: {
76
+ coordRoot: string;
77
+ callsPerHour?: number;
78
+ debounceMs: number;
79
+ shouldStop: () => boolean;
80
+ }) => Promise<SemanticOnceReport>;
81
+ waitForWake?: (milliseconds: number) => Promise<void>;
82
+ }
83
+
84
+ export type EnsureSemanticServiceState =
85
+ | "running"
86
+ | "started"
87
+ | "paused"
88
+ | "inactive"
89
+ | "unavailable";
90
+
91
+ export interface EnsureSemanticServiceResult {
92
+ state: EnsureSemanticServiceState;
93
+ status?: SemanticServiceStatus;
94
+ error?: string;
95
+ }
96
+
97
+ export interface EnsureSemanticServiceDependencies {
98
+ readStatus: (coordRoot: string) => SemanticServiceStatus;
99
+ start: (coordRoot: string) => Promise<SemanticServiceStatus>;
100
+ isPaused: (coordRoot: string) => boolean;
101
+ isActive: (coordRoot: string) => boolean;
102
+ }
103
+
104
+ export async function spawnSemanticService(
105
+ coordRootRaw: string,
106
+ options: { callsPerHour?: number } = {},
107
+ ): Promise<SemanticServiceStatus> {
108
+ const coordRoot = resolve(coordRootRaw);
109
+ const current = readSemanticServiceStatus(coordRoot);
110
+ if (current.running) {
111
+ throw new Error(`semantic service is already running under pid ${current.record?.pid}`);
112
+ }
113
+ const paths = semanticPaths(coordRoot);
114
+ mkdirSync(paths.root, { recursive: true, mode: 0o700 });
115
+ const logFd = openSync(paths.log, "a", 0o600);
116
+ chmodSync(paths.log, 0o600);
117
+ const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
118
+ if (!existsSync(harnBin)) {
119
+ closeSync(logFd);
120
+ throw new Error(`cannot find harn executable at ${harnBin}`);
121
+ }
122
+ const args = ["semantic", "service", "daemon", "--root", coordRoot];
123
+ if (options.callsPerHour !== undefined) {
124
+ args.push("--calls-per-hour", String(options.callsPerHour));
125
+ }
126
+ let spawnError: Error | undefined;
127
+ const child = spawn(harnBin, args, {
128
+ cwd: coordRoot,
129
+ detached: true,
130
+ stdio: ["ignore", logFd, logFd],
131
+ env: {
132
+ ...process.env,
133
+ HARNERY_COORD_ROOT_OVERRIDE: coordRoot,
134
+ HARNERY_OUTPUT_SESSION_TEE: "0",
135
+ },
136
+ });
137
+ child.once("error", (error) => {
138
+ spawnError = error;
139
+ });
140
+ closeSync(logFd);
141
+ child.unref();
142
+ const deadline = Date.now() + 5_000;
143
+ while (Date.now() < deadline) {
144
+ await delay(50);
145
+ if (spawnError) throw spawnError;
146
+ const status = readSemanticServiceStatus(coordRoot);
147
+ if (status.running && status.record?.pid === child.pid) return status;
148
+ if (child.exitCode !== null) break;
149
+ }
150
+ throw new Error(`semantic service failed to start; inspect ${paths.log}`);
151
+ }
152
+
153
+ /**
154
+ * Start semantic reading when V3 is active, unless an operator paused it.
155
+ *
156
+ * Dashboard hosts call this before launching their web process. A failed
157
+ * semantic launch is reported as data instead of throwing so the read-only
158
+ * dashboard remains available.
159
+ */
160
+ export async function ensureSemanticServiceRunning(
161
+ coordRootRaw: string,
162
+ overrides: Partial<EnsureSemanticServiceDependencies> = {},
163
+ ): Promise<EnsureSemanticServiceResult> {
164
+ const coordRoot = resolve(coordRootRaw);
165
+ const dependencies: EnsureSemanticServiceDependencies = {
166
+ readStatus: readSemanticServiceStatus,
167
+ start: spawnSemanticService,
168
+ isPaused: (root) => existsSync(semanticPaths(root).stop),
169
+ isActive: (root) => readEventV3ControlState(root).state === "active",
170
+ ...overrides,
171
+ };
172
+ const status = dependencies.readStatus(coordRoot);
173
+ if (status.running) return { state: "running", status };
174
+ if (dependencies.isPaused(coordRoot)) return { state: "paused", status };
175
+ if (!dependencies.isActive(coordRoot)) return { state: "inactive", status };
176
+ try {
177
+ return { state: "started", status: await dependencies.start(coordRoot) };
178
+ } catch (error) {
179
+ return {
180
+ state: "unavailable",
181
+ status: dependencies.readStatus(coordRoot),
182
+ error: error instanceof Error ? error.message : String(error),
183
+ };
184
+ }
185
+ }
186
+
187
+ export function requestSemanticServiceStop(coordRootRaw: string): SemanticServiceStatus {
188
+ const coordRoot = resolve(coordRootRaw);
189
+ const status = readSemanticServiceStatus(coordRoot);
190
+ mkdirSync(semanticPaths(coordRoot).root, { recursive: true, mode: 0o700 });
191
+ writePrivateJsonAtomic(semanticPaths(coordRoot).stop, {
192
+ requested_at: new Date().toISOString(),
193
+ requested_by_pid: process.pid,
194
+ });
195
+ if (status.running && status.record?.host === hostname()) {
196
+ try {
197
+ process.kill(status.record.pid, "SIGTERM");
198
+ } catch {
199
+ // The durable stop request remains for a racing or restarted daemon.
200
+ }
201
+ }
202
+ return readSemanticServiceStatus(coordRoot);
203
+ }
204
+
205
+ export async function runSemanticServiceDaemon(
206
+ input: RunSemanticServiceDaemonInput,
207
+ ): Promise<SemanticServiceStatusRecord> {
208
+ const coordRoot = resolve(input.coordRoot);
209
+ const paths = semanticPaths(coordRoot);
210
+ const release = acquireSemanticServiceLease(coordRoot);
211
+ rmSync(paths.stop, { force: true });
212
+ const now = input.now ?? (() => new Date());
213
+ const debounceMs = Math.max(0, input.debounceMs ?? SEMANTIC_SERVICE_DEFAULT_DEBOUNCE_MS);
214
+ const wakeIntervalMs = positiveInterval(
215
+ input.wakeIntervalMs ?? SEMANTIC_SERVICE_DEFAULT_WAKE_MS,
216
+ "wake interval",
217
+ );
218
+ const heartbeatIntervalMs = positiveInterval(
219
+ input.heartbeatIntervalMs ?? SEMANTIC_SERVICE_DEFAULT_HEARTBEAT_MS,
220
+ "heartbeat interval",
221
+ );
222
+ const readSince = input.readSince ?? readLedgerV3Since;
223
+ const runOnce =
224
+ input.runOnce ??
225
+ (async (options) =>
226
+ await runSemanticOnce({
227
+ coordRoot: options.coordRoot,
228
+ callsPerHour: options.callsPerHour,
229
+ debounceMs: options.debounceMs,
230
+ shouldStop: options.shouldStop,
231
+ }));
232
+ const startedAt = now().toISOString();
233
+ const status: SemanticServiceStatusRecord = {
234
+ schema_version: SEMANTIC_SERVICE_STATUS_SCHEMA_VERSION,
235
+ pid: process.pid,
236
+ host: hostname(),
237
+ nonce: randomUUID(),
238
+ state: "starting",
239
+ started_at: startedAt,
240
+ heartbeat_at: startedAt,
241
+ ...(input.callsPerHour !== undefined ? { calls_per_hour: input.callsPerHour } : {}),
242
+ sweep_count: 0,
243
+ pass_count: 0,
244
+ model_calls: 0,
245
+ cache_hits: 0,
246
+ };
247
+ let stopRequested = false;
248
+ let dirtySince: number | undefined;
249
+ let lastLoggedErrorCode: string | undefined;
250
+ let lastLoggedErrorAt = Number.NEGATIVE_INFINITY;
251
+ const writeStatus = (): void => {
252
+ status.heartbeat_at = now().toISOString();
253
+ writePrivateJsonAtomic(paths.service, status);
254
+ };
255
+ const requestStop = (): void => {
256
+ stopRequested = true;
257
+ status.state = "stopping";
258
+ writeStatus();
259
+ };
260
+ process.on("SIGINT", requestStop);
261
+ process.on("SIGTERM", requestStop);
262
+ status.state = "running";
263
+ writeStatus();
264
+ appendSemanticServiceLog(coordRoot, { event: "service_started" });
265
+ const heartbeat = setInterval(() => {
266
+ try {
267
+ writeStatus();
268
+ } catch {
269
+ // A later sweep or status read will expose the stopped or stale process.
270
+ }
271
+ }, heartbeatIntervalMs);
272
+ try {
273
+ while (!stopRequested && !existsSync(paths.stop)) {
274
+ const sweepAt = now();
275
+ status.sweep_count += 1;
276
+ try {
277
+ const before = safeManifest(coordRoot);
278
+ let read = readSince(coordRoot, before?.cursor, { authority: "active" });
279
+ if (read.reset_required) {
280
+ read = readSince(coordRoot, undefined, { authority: "active" });
281
+ }
282
+ requireCompleteLedger(read);
283
+ if (read.events.length > 0 || !before?.cursor) {
284
+ dirtySince ??= sweepAt.getTime();
285
+ }
286
+ const hasPending = (before?.pending.length ?? 0) > 0;
287
+ const hasEligibleDeferred = listSemanticAgentDocuments(coordRoot).some(
288
+ (document) =>
289
+ document.reader_outcome === "deferred" &&
290
+ Date.parse(document.receipt.eligible_after) <= sweepAt.getTime(),
291
+ );
292
+ if (
293
+ hasPending ||
294
+ hasEligibleDeferred ||
295
+ (dirtySince !== undefined && sweepAt.getTime() - dirtySince >= debounceMs)
296
+ ) {
297
+ const report = await runOnce({
298
+ coordRoot,
299
+ ...(input.callsPerHour !== undefined ? { callsPerHour: input.callsPerHour } : {}),
300
+ debounceMs,
301
+ shouldStop: () => stopRequested || existsSync(paths.stop),
302
+ });
303
+ status.pass_count += 1;
304
+ status.model_calls += report.model_calls;
305
+ status.cache_hits += report.cache_hits;
306
+ status.last_pass_at = report.completed_at;
307
+ status.last_error_code = undefined;
308
+ lastLoggedErrorCode = undefined;
309
+ const after = safeManifest(coordRoot);
310
+ if (after && read.cursor) {
311
+ after.cursor = read.cursor;
312
+ writeSemanticManifest(coordRoot, after);
313
+ }
314
+ dirtySince = undefined;
315
+ const logEntry = {
316
+ event: "pass",
317
+ evidence_count: report.evidence_count,
318
+ evidence_by_harness: report.evidence_by_harness,
319
+ model_calls: report.model_calls,
320
+ cache_hits: report.cache_hits,
321
+ accepted: report.outcomes.filter((outcome) => outcome.action === "accepted").length,
322
+ unavailable: report.outcomes.filter((outcome) => outcome.action === "unavailable")
323
+ .length,
324
+ invalid: report.outcomes.filter((outcome) => outcome.action === "invalid").length,
325
+ deferred: report.outcomes.filter((outcome) => outcome.action === "deferred").length,
326
+ harness_metrics: semanticHarnessMetrics(report),
327
+ };
328
+ if (report.model_calls > 0 || report.cache_hits > 0 || logEntry.unavailable > 0) {
329
+ appendSemanticServiceLog(coordRoot, logEntry);
330
+ }
331
+ }
332
+ } catch (error) {
333
+ status.last_error_code = serviceErrorCode(error);
334
+ if (
335
+ status.last_error_code !== lastLoggedErrorCode ||
336
+ sweepAt.getTime() - lastLoggedErrorAt >= REPEATED_ERROR_LOG_INTERVAL_MS
337
+ ) {
338
+ appendSemanticServiceLog(coordRoot, {
339
+ event: "sweep_error",
340
+ reason_code: status.last_error_code,
341
+ });
342
+ lastLoggedErrorCode = status.last_error_code;
343
+ lastLoggedErrorAt = sweepAt.getTime();
344
+ }
345
+ }
346
+ status.last_sweep_at = now().toISOString();
347
+ writeStatus();
348
+ if (input.maxSweeps !== undefined && status.sweep_count >= input.maxSweeps) break;
349
+ if (!stopRequested && !existsSync(paths.stop)) {
350
+ if (input.waitForWake) await input.waitForWake(wakeIntervalMs);
351
+ else await waitForLedgerWake(eventV3ActiveWatchPath(coordRoot), wakeIntervalMs);
352
+ }
353
+ }
354
+ } finally {
355
+ clearInterval(heartbeat);
356
+ process.off("SIGINT", requestStop);
357
+ process.off("SIGTERM", requestStop);
358
+ status.state = "stopped";
359
+ status.stopped_at = now().toISOString();
360
+ writeStatus();
361
+ appendSemanticServiceLog(coordRoot, {
362
+ event: "service_stopped",
363
+ sweeps: status.sweep_count,
364
+ passes: status.pass_count,
365
+ model_calls: status.model_calls,
366
+ });
367
+ release();
368
+ }
369
+ return status;
370
+ }
371
+
372
+ export function acquireSemanticServiceLease(coordRootRaw: string): () => void {
373
+ const path = semanticPaths(coordRootRaw).lease;
374
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
375
+ const owner: SemanticServiceLease = {
376
+ pid: process.pid,
377
+ host: hostname(),
378
+ nonce: randomUUID(),
379
+ created_at: new Date().toISOString(),
380
+ };
381
+ const acquire = (): boolean => {
382
+ try {
383
+ const fd = openSync(path, "wx", 0o600);
384
+ try {
385
+ writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
386
+ } finally {
387
+ closeSync(fd);
388
+ }
389
+ return true;
390
+ } catch (error) {
391
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
392
+ return false;
393
+ }
394
+ };
395
+ if (!acquire()) {
396
+ const existing = readLease(path);
397
+ if (existing && leaseOwnerIsLive(existing)) {
398
+ throw new Error(`semantic service is already running under pid ${existing.pid}`);
399
+ }
400
+ unlinkSync(path);
401
+ if (!acquire()) throw new Error("semantic service lease raced with another process");
402
+ }
403
+ return () => {
404
+ try {
405
+ const existing = readLease(path);
406
+ if (existing?.nonce === owner.nonce) unlinkSync(path);
407
+ } catch {
408
+ // A later explicit start can recover a stale private lease.
409
+ }
410
+ };
411
+ }
412
+
413
+ interface SemanticHarnessPassMetrics {
414
+ evidence_count: number;
415
+ model_calls: number;
416
+ cache_hits: number;
417
+ accepted: number;
418
+ unavailable: number;
419
+ invalid: number;
420
+ deferred: number;
421
+ duration_ms: number[];
422
+ input_bytes: number[];
423
+ output_bytes: number[];
424
+ }
425
+
426
+ function semanticHarnessMetrics(
427
+ report: SemanticOnceReport,
428
+ ): Record<SemanticHarness, SemanticHarnessPassMetrics> {
429
+ const metrics: Record<SemanticHarness, SemanticHarnessPassMetrics> = {
430
+ "claude-code": emptyHarnessMetrics(report.evidence_by_harness["claude-code"]),
431
+ codex: emptyHarnessMetrics(report.evidence_by_harness.codex),
432
+ cursor: emptyHarnessMetrics(report.evidence_by_harness.cursor),
433
+ };
434
+ for (const outcome of report.outcomes) {
435
+ const harness = metrics[outcome.source_harness];
436
+ if (outcome.model_call) harness.model_calls += 1;
437
+ if (outcome.action === "cached") harness.cache_hits += 1;
438
+ if (outcome.action === "accepted") harness.accepted += 1;
439
+ if (outcome.action === "unavailable") harness.unavailable += 1;
440
+ if (outcome.action === "invalid") harness.invalid += 1;
441
+ if (outcome.action === "deferred") harness.deferred += 1;
442
+ if (outcome.duration_ms !== undefined) harness.duration_ms.push(outcome.duration_ms);
443
+ if (outcome.input_bytes !== undefined) harness.input_bytes.push(outcome.input_bytes);
444
+ if (outcome.output_bytes !== undefined) harness.output_bytes.push(outcome.output_bytes);
445
+ }
446
+ return metrics;
447
+ }
448
+
449
+ function emptyHarnessMetrics(evidenceCount: number): SemanticHarnessPassMetrics {
450
+ return {
451
+ evidence_count: evidenceCount,
452
+ model_calls: 0,
453
+ cache_hits: 0,
454
+ accepted: 0,
455
+ unavailable: 0,
456
+ invalid: 0,
457
+ deferred: 0,
458
+ duration_ms: [],
459
+ input_bytes: [],
460
+ output_bytes: [],
461
+ };
462
+ }
463
+
464
+ function requireCompleteLedger(read: ReadLedgerV3SinceResult): void {
465
+ if (!read.complete || read.diagnostics.length > 0) throw new Error("ledger_unavailable");
466
+ }
467
+
468
+ function serviceErrorCode(error: unknown): SemanticServiceErrorCode {
469
+ return error instanceof Error && error.message === "ledger_unavailable"
470
+ ? "ledger_unavailable"
471
+ : "semantic_pass_failed";
472
+ }
473
+
474
+ function safeManifest(coordRoot: string) {
475
+ try {
476
+ return readSemanticManifest(coordRoot);
477
+ } catch {
478
+ return undefined;
479
+ }
480
+ }
481
+
482
+ function readLease(path: string): SemanticServiceLease | undefined {
483
+ try {
484
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<SemanticServiceLease>;
485
+ if (
486
+ !Number.isSafeInteger(value.pid) ||
487
+ (value.pid ?? 0) < 1 ||
488
+ typeof value.host !== "string" ||
489
+ typeof value.nonce !== "string" ||
490
+ !validTimestamp(value.created_at)
491
+ ) {
492
+ return undefined;
493
+ }
494
+ return value as SemanticServiceLease;
495
+ } catch {
496
+ return undefined;
497
+ }
498
+ }
499
+
500
+ function leaseOwnerIsLive(lease: SemanticServiceLease): boolean {
501
+ if (lease.host !== hostname()) {
502
+ const age = Date.now() - Date.parse(lease.created_at);
503
+ return Number.isFinite(age) && age < FOREIGN_STATUS_STALE_MS;
504
+ }
505
+ return pidAlive(lease.pid);
506
+ }
507
+
508
+ function pidAlive(pid: number): boolean {
509
+ try {
510
+ process.kill(pid, 0);
511
+ return true;
512
+ } catch (error) {
513
+ return (error as NodeJS.ErrnoException).code === "EPERM";
514
+ }
515
+ }
516
+
517
+ async function waitForLedgerWake(path: string, milliseconds: number): Promise<void> {
518
+ await new Promise<void>((done) => {
519
+ let settled = false;
520
+ let watcher: ReturnType<typeof watch> | undefined;
521
+ const finish = (): void => {
522
+ if (settled) return;
523
+ settled = true;
524
+ clearTimeout(timer);
525
+ watcher?.close();
526
+ done();
527
+ };
528
+ const timer = setTimeout(finish, milliseconds);
529
+ try {
530
+ watcher = watch(path, { persistent: false }, finish);
531
+ } catch {
532
+ // The timer is the polling fallback when the active file does not exist yet.
533
+ }
534
+ });
535
+ }
536
+
537
+ function appendSemanticServiceLog(coordRoot: string, entry: Record<string, unknown>): void {
538
+ const path = semanticPaths(coordRoot).log;
539
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
540
+ appendFileSync(
541
+ path,
542
+ `${JSON.stringify({ schema_version: 1, ts: new Date().toISOString(), ...entry })}\n`,
543
+ { encoding: "utf8", mode: 0o600 },
544
+ );
545
+ chmodSync(path, 0o600);
546
+ if (statSync(path).size <= MAX_LOG_BYTES) return;
547
+ const buffer = readFileSync(path);
548
+ const tail = buffer.subarray(Math.max(0, buffer.length - Math.floor(MAX_LOG_BYTES / 2)));
549
+ const newline = tail.indexOf(10);
550
+ const body = newline >= 0 ? tail.subarray(newline + 1) : tail;
551
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
552
+ writeFileSync(temporary, body, { flag: "wx", mode: 0o600 });
553
+ renameSync(temporary, path);
554
+ }
555
+
556
+ function writePrivateJsonAtomic(path: string, value: unknown): void {
557
+ const body = `${JSON.stringify(value, null, 2)}\n`;
558
+ if (Buffer.byteLength(body) > MAX_FILE_BYTES) {
559
+ throw new Error(`semantic service file exceeds ${MAX_FILE_BYTES} bytes`);
560
+ }
561
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
562
+ chmodSync(dirname(path), 0o700);
563
+ const temporary = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
564
+ writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
565
+ renameSync(temporary, path);
566
+ chmodSync(path, 0o600);
567
+ }
568
+
569
+ function validTimestamp(value: unknown): value is string {
570
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
571
+ }
572
+
573
+ function positiveInterval(value: number, label: string): number {
574
+ if (!Number.isFinite(value) || value < 1) throw new Error(`${label} must be positive`);
575
+ return Math.floor(value);
576
+ }
577
+
578
+ function delay(milliseconds: number): Promise<void> {
579
+ return new Promise((done) => setTimeout(done, milliseconds));
580
+ }