harnery 0.43.0 → 0.44.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 (278) hide show
  1. package/README.md +3 -3
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +41 -48
  4. package/dist/commands/browse.d.ts.map +1 -1
  5. package/dist/commands/browse.js +38 -11
  6. package/dist/commands/diagnostics.d.ts.map +1 -1
  7. package/dist/commands/diagnostics.js +10 -0
  8. package/dist/commands/doctor.d.ts +12 -0
  9. package/dist/commands/doctor.d.ts.map +1 -1
  10. package/dist/commands/doctor.js +22 -7
  11. package/dist/commands/governor.js +22 -10
  12. package/dist/commands/inbox.d.ts +1 -1
  13. package/dist/commands/inbox.d.ts.map +1 -1
  14. package/dist/commands/inbox.js +55 -2
  15. package/dist/commands/rm.d.ts.map +1 -1
  16. package/dist/commands/rm.js +28 -4
  17. package/dist/commands/servers.d.ts.map +1 -1
  18. package/dist/commands/servers.js +16 -1
  19. package/dist/commands/supervisor.d.ts.map +1 -1
  20. package/dist/commands/supervisor.js +7 -2
  21. package/dist/commands/toc.d.ts +14 -0
  22. package/dist/commands/toc.d.ts.map +1 -1
  23. package/dist/commands/toc.js +7 -4
  24. package/dist/commands/workflow.js +24 -30
  25. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  26. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  27. package/dist/core/agents/live-authority-v3.js +11 -0
  28. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  29. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  30. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  31. package/dist/core/agents/mailbox.d.ts +9 -8
  32. package/dist/core/agents/mailbox.d.ts.map +1 -1
  33. package/dist/core/agents/mailbox.js +24 -17
  34. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  35. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  36. package/dist/core/agents/render/prompt-context.js +8 -12
  37. package/dist/core/agents/render/session-context.d.ts +2 -8
  38. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  39. package/dist/core/agents/render/session-context.js +7 -30
  40. package/dist/core/agents/rules/stop-hook.js +3 -3
  41. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  42. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  43. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  44. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  45. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  46. package/dist/core/agents/state/pidmap.js +5 -0
  47. package/dist/core/agents/state/proc-start.d.ts +19 -0
  48. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  49. package/dist/core/agents/state/proc-start.js +41 -0
  50. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  51. package/dist/core/agents/state/stale-sweep.js +180 -70
  52. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  53. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  54. package/dist/core/agents/state/unchanged-file.js +390 -0
  55. package/dist/core/context/index.d.ts +22 -10
  56. package/dist/core/context/index.d.ts.map +1 -1
  57. package/dist/core/context/index.js +381 -119
  58. package/dist/core/context/observation.d.ts +26 -0
  59. package/dist/core/context/observation.d.ts.map +1 -0
  60. package/dist/core/context/observation.js +11 -0
  61. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  62. package/dist/core/diagnostics/bundle.js +25 -14
  63. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  64. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  65. package/dist/core/diagnostics/path-identity.js +12 -0
  66. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  67. package/dist/core/diagnostics/sanitize.js +4 -3
  68. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  69. package/dist/core/events/v3/authority-outbox.js +29 -0
  70. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  71. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  72. package/dist/core/events/v3/generation-custody.js +50 -0
  73. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  74. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  75. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  76. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  77. package/dist/core/events/v3/producers/recorder.js +77 -64
  78. package/dist/core/governor/planning.d.ts.map +1 -1
  79. package/dist/core/governor/planning.js +14 -1
  80. package/dist/core/governor/service-read.d.ts +10 -2
  81. package/dist/core/governor/service-read.d.ts.map +1 -1
  82. package/dist/core/governor/service-read.js +105 -22
  83. package/dist/core/governor/service.d.ts +20 -2
  84. package/dist/core/governor/service.d.ts.map +1 -1
  85. package/dist/core/governor/service.js +72 -47
  86. package/dist/core/hooks/cli.js +33 -5
  87. package/dist/core/inbox/contract.d.ts +2 -1
  88. package/dist/core/inbox/contract.d.ts.map +1 -1
  89. package/dist/core/inbox/index.d.ts +2 -0
  90. package/dist/core/inbox/index.d.ts.map +1 -1
  91. package/dist/core/inbox/index.js +2 -0
  92. package/dist/core/inbox/peer-messages.d.ts +37 -0
  93. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  94. package/dist/core/inbox/peer-messages.js +72 -0
  95. package/dist/core/inbox/service.d.ts +8 -1
  96. package/dist/core/inbox/service.d.ts.map +1 -1
  97. package/dist/core/inbox/service.js +67 -21
  98. package/dist/core/inbox/targets.d.ts +22 -0
  99. package/dist/core/inbox/targets.d.ts.map +1 -0
  100. package/dist/core/inbox/targets.js +108 -0
  101. package/dist/core/inbox/watcher.js +7 -3
  102. package/dist/core/journal/index.d.ts.map +1 -1
  103. package/dist/core/journal/index.js +17 -2
  104. package/dist/core/resources/contract.d.ts +3 -0
  105. package/dist/core/resources/contract.d.ts.map +1 -1
  106. package/dist/core/resources/service-status.d.ts +14 -1
  107. package/dist/core/resources/service-status.d.ts.map +1 -1
  108. package/dist/core/resources/service-status.js +23 -13
  109. package/dist/core/resources/service.d.ts +9 -1
  110. package/dist/core/resources/service.d.ts.map +1 -1
  111. package/dist/core/resources/service.js +47 -27
  112. package/dist/core/resources/status.d.ts +2 -5
  113. package/dist/core/resources/status.d.ts.map +1 -1
  114. package/dist/core/resources/status.js +36 -7
  115. package/dist/core/servers/index.d.ts +24 -13
  116. package/dist/core/servers/index.d.ts.map +1 -1
  117. package/dist/core/servers/index.js +132 -92
  118. package/dist/core/servers/tunnels.d.ts +3 -5
  119. package/dist/core/servers/tunnels.d.ts.map +1 -1
  120. package/dist/core/servers/tunnels.js +3 -9
  121. package/dist/core/supervisor/contract.d.ts +12 -0
  122. package/dist/core/supervisor/contract.d.ts.map +1 -1
  123. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  124. package/dist/core/supervisor/explanations.js +2 -1
  125. package/dist/core/supervisor/findings.d.ts.map +1 -1
  126. package/dist/core/supervisor/findings.js +54 -9
  127. package/dist/core/supervisor/service.d.ts +9 -1
  128. package/dist/core/supervisor/service.d.ts.map +1 -1
  129. package/dist/core/supervisor/service.js +59 -29
  130. package/dist/core/supervisor/services.d.ts.map +1 -1
  131. package/dist/core/supervisor/services.js +22 -1
  132. package/dist/core/supervisor/status.d.ts +17 -2
  133. package/dist/core/supervisor/status.d.ts.map +1 -1
  134. package/dist/core/supervisor/status.js +70 -12
  135. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  136. package/dist/core/supervisor/timeline.js +2 -1
  137. package/dist/core/work/state.js +7 -7
  138. package/dist/core/workflow/engine.d.ts.map +1 -1
  139. package/dist/core/workflow/engine.js +105 -32
  140. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  141. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  142. package/dist/core/workflow/failure-evidence.js +114 -0
  143. package/dist/core/workflow/index.d.ts +1 -1
  144. package/dist/core/workflow/index.d.ts.map +1 -1
  145. package/dist/core/workflow/index.js +1 -1
  146. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  147. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  148. package/dist/core/workflow/live-session-v3.js +126 -5
  149. package/dist/core/workflow/proof.d.ts +5 -5
  150. package/dist/core/workflow/proof.d.ts.map +1 -1
  151. package/dist/core/workflow/proof.js +115 -20
  152. package/dist/core/workflow/run-state.d.ts +3 -1
  153. package/dist/core/workflow/run-state.d.ts.map +1 -1
  154. package/dist/core/workflow/run-state.js +31 -27
  155. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  156. package/dist/core/workflow/spawn-claude.js +19 -0
  157. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  158. package/dist/core/workflow/spawn-codex.js +13 -0
  159. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  160. package/dist/core/workflow/spawn-cursor.js +19 -0
  161. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  162. package/dist/core/workflow/spawn-opencode.js +14 -0
  163. package/dist/core/workflow/types.d.ts +64 -17
  164. package/dist/core/workflow/types.d.ts.map +1 -1
  165. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  166. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  167. package/dist/core/workflow/workspaces/index.js +0 -1
  168. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  169. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  170. package/dist/core/workflow/workspaces/leases.js +30 -16
  171. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  172. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  173. package/dist/core/workflow/workspaces/local-git.js +270 -6
  174. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  175. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  176. package/dist/core/workflow/workspaces/ownership.js +194 -0
  177. package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
  178. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  179. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  180. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  181. package/dist/core/workflow/workspaces/recovery.js +21 -13
  182. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  183. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  184. package/dist/core/workflow/workspaces/state.js +21 -1
  185. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  186. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  187. package/dist/lib/browser/windows-chrome.js +262 -73
  188. package/dist/lib/docs-links.d.ts.map +1 -1
  189. package/dist/lib/docs-links.js +45 -2
  190. package/dist/lib/guarded-remove.d.ts +7 -0
  191. package/dist/lib/guarded-remove.d.ts.map +1 -1
  192. package/dist/lib/guarded-remove.js +106 -12
  193. package/dist/lib/identities/assume.d.ts.map +1 -1
  194. package/dist/lib/identities/assume.js +4 -0
  195. package/dist/lib/zip/index.d.ts +41 -0
  196. package/dist/lib/zip/index.d.ts.map +1 -0
  197. package/dist/lib/zip/index.js +398 -0
  198. package/package.json +7 -1
  199. package/src/commands/agents.ts +54 -63
  200. package/src/commands/browse.ts +50 -14
  201. package/src/commands/diagnostics.ts +10 -0
  202. package/src/commands/doctor.ts +26 -7
  203. package/src/commands/governor.ts +23 -10
  204. package/src/commands/inbox.ts +52 -2
  205. package/src/commands/rm.ts +43 -4
  206. package/src/commands/servers.ts +16 -4
  207. package/src/commands/supervisor.ts +7 -2
  208. package/src/commands/toc.ts +14 -6
  209. package/src/commands/workflow.ts +30 -30
  210. package/src/core/agents/live-authority-v3.ts +17 -0
  211. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  212. package/src/core/agents/mailbox.ts +26 -17
  213. package/src/core/agents/render/prompt-context.ts +10 -12
  214. package/src/core/agents/render/session-context.ts +9 -32
  215. package/src/core/agents/rules/stop-hook.ts +3 -3
  216. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  217. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  218. package/src/core/agents/state/pidmap.ts +13 -0
  219. package/src/core/agents/state/proc-start.ts +57 -0
  220. package/src/core/agents/state/stale-sweep.ts +247 -82
  221. package/src/core/agents/state/unchanged-file.ts +456 -0
  222. package/src/core/context/index.ts +446 -136
  223. package/src/core/context/observation.ts +29 -0
  224. package/src/core/diagnostics/bundle.ts +23 -18
  225. package/src/core/diagnostics/path-identity.ts +15 -0
  226. package/src/core/diagnostics/sanitize.ts +4 -3
  227. package/src/core/events/v3/authority-outbox.ts +56 -0
  228. package/src/core/events/v3/generation-custody.ts +59 -0
  229. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  230. package/src/core/events/v3/producers/recorder.ts +94 -68
  231. package/src/core/governor/planning.ts +20 -8
  232. package/src/core/governor/service-read.ts +128 -20
  233. package/src/core/governor/service.ts +119 -42
  234. package/src/core/hooks/cli.ts +33 -5
  235. package/src/core/inbox/contract.ts +2 -1
  236. package/src/core/inbox/index.ts +2 -0
  237. package/src/core/inbox/peer-messages.ts +94 -0
  238. package/src/core/inbox/service.ts +88 -22
  239. package/src/core/inbox/targets.ts +148 -0
  240. package/src/core/inbox/watcher.ts +8 -9
  241. package/src/core/journal/index.ts +19 -2
  242. package/src/core/resources/contract.ts +4 -0
  243. package/src/core/resources/service-status.ts +35 -12
  244. package/src/core/resources/service.ts +71 -24
  245. package/src/core/resources/status.ts +57 -8
  246. package/src/core/servers/index.ts +177 -91
  247. package/src/core/servers/tunnels.ts +3 -11
  248. package/src/core/supervisor/contract.ts +13 -0
  249. package/src/core/supervisor/explanations.ts +4 -1
  250. package/src/core/supervisor/findings.ts +68 -10
  251. package/src/core/supervisor/service.ts +88 -26
  252. package/src/core/supervisor/services.ts +26 -8
  253. package/src/core/supervisor/status.ts +89 -12
  254. package/src/core/supervisor/timeline.ts +2 -1
  255. package/src/core/work/state.ts +7 -7
  256. package/src/core/workflow/engine.ts +131 -49
  257. package/src/core/workflow/failure-evidence.ts +141 -0
  258. package/src/core/workflow/index.ts +0 -1
  259. package/src/core/workflow/live-session-v3.ts +146 -5
  260. package/src/core/workflow/proof.ts +134 -26
  261. package/src/core/workflow/run-state.ts +31 -29
  262. package/src/core/workflow/spawn-claude.ts +18 -0
  263. package/src/core/workflow/spawn-codex.ts +13 -0
  264. package/src/core/workflow/spawn-cursor.ts +18 -0
  265. package/src/core/workflow/spawn-opencode.ts +14 -0
  266. package/src/core/workflow/types.ts +51 -17
  267. package/src/core/workflow/workspaces/index.ts +0 -1
  268. package/src/core/workflow/workspaces/leases.ts +48 -17
  269. package/src/core/workflow/workspaces/local-git.ts +315 -5
  270. package/src/core/workflow/workspaces/ownership.ts +232 -0
  271. package/src/core/workflow/workspaces/reclaim.ts +58 -0
  272. package/src/core/workflow/workspaces/recovery.ts +25 -13
  273. package/src/core/workflow/workspaces/state.ts +20 -0
  274. package/src/lib/browser/windows-chrome.ts +346 -86
  275. package/src/lib/docs-links.ts +49 -2
  276. package/src/lib/guarded-remove.ts +132 -14
  277. package/src/lib/identities/assume.ts +4 -0
  278. package/src/lib/zip/index.ts +483 -0
@@ -8,15 +8,27 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, isAbsolute, join } from "node:path";
12
12
  import type { Adapter } from "../adapter.ts";
13
13
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
14
+ import { resolveBinName } from "../config.ts";
15
+ import type { HarneryInboxMessageRecordV1 } from "../inbox/contract.ts";
16
+ import { peerInbox } from "../inbox/peer-messages.ts";
17
+ import { project } from "../inbox/service.ts";
14
18
  import { stateFileMode } from "../storage/modes.ts";
19
+ import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
20
+ import type { RepoObservation, RepoSnapshot } from "./observation.ts";
21
+ import { observationsComparable } from "./observation.ts";
22
+
23
+ export type { RepoObservation, RepoSnapshot } from "./observation.ts";
24
+ export { observationsComparable } from "./observation.ts";
15
25
 
16
26
  export const CONTEXT_SCHEMA_VERSION = 1 as const;
17
27
  export const MAX_CAPSULE_BYTES = 32 * 1024;
18
28
  const MAX_SNAPSHOT_PATHS = 15;
19
29
  const MAX_SNAPSHOT_PATH_CHARS = 500;
30
+ const MAX_OBSERVATION_DETAIL_CHARS = 500;
31
+ const MAX_RECOVERY_INPUT_BODY_BYTES = 160;
20
32
 
21
33
  export type ContextSampleSource = "hook" | "native_event" | "result" | "transcript" | "estimate";
22
34
  export type ContextSampleConfidence = "exact" | "reported" | "estimated";
@@ -49,15 +61,6 @@ export interface ContextContinuityState {
49
61
  degraded_reason?: string;
50
62
  }
51
63
 
52
- export interface RepoSnapshot {
53
- cwd: string;
54
- root?: string;
55
- branch?: string;
56
- head?: string;
57
- dirty_paths: string[];
58
- dirty_paths_truncated?: boolean;
59
- }
60
-
61
64
  export interface ContinuityCapsule {
62
65
  schema_version: typeof CONTEXT_SCHEMA_VERSION;
63
66
  capsule_id: string;
@@ -113,7 +116,20 @@ export interface RecoverContextResult {
113
116
  recovered: boolean;
114
117
  }
115
118
 
119
+ /**
120
+ * Authority for accepting exactly the capsule supplied during preparation.
121
+ * It is returned by prepareContextRecovery, not reconstructed from latest state.
122
+ */
123
+ export interface PreparedContextCheckpoint {
124
+ session_id: string;
125
+ instance_id: string;
126
+ generation: number;
127
+ capsule_id: string;
128
+ capsule_sha256: string;
129
+ }
130
+
116
131
  export interface PreparedContextRecovery {
132
+ checkpoint: Readonly<PreparedContextCheckpoint>;
117
133
  state: ContextContinuityState;
118
134
  capsule: ContinuityCapsule;
119
135
  briefing: string;
@@ -190,19 +206,26 @@ export function recordContextSample(
190
206
  instanceId: string,
191
207
  sample: ContextSample,
192
208
  ): { state: ContextContinuityState; changed: boolean } {
193
- const current = readContextState(coordRoot, sample.session_id);
194
- const state =
195
- current ?? newState({ sessionId: sample.session_id, instanceId, now: sample.observed_at });
196
- const changed = !sameMeasurement(state.latest_context, sample);
197
- if (!changed) return { state, changed: false };
198
- const next: ContextContinuityState = {
199
- ...state,
200
- instance_id: instanceId,
201
- latest_context: sample,
202
- updated_at: sample.observed_at,
203
- };
204
- writeState(coordRoot, next);
205
- return { state: next, changed: true };
209
+ return withContextMutation(coordRoot, sample.session_id, () => {
210
+ const current = readContextState(coordRoot, sample.session_id);
211
+ const state =
212
+ current?.instance_id === instanceId
213
+ ? current
214
+ : {
215
+ ...newState({ sessionId: sample.session_id, instanceId, now: sample.observed_at }),
216
+ generation: current?.generation ?? 0,
217
+ };
218
+ const changed = !sameMeasurement(state.latest_context, sample);
219
+ if (!changed) return { state, changed: false };
220
+ const next: ContextContinuityState = {
221
+ ...state,
222
+ instance_id: instanceId,
223
+ latest_context: sample,
224
+ updated_at: sample.observed_at,
225
+ };
226
+ writeState(coordRoot, next);
227
+ return { state: next, changed: true };
228
+ });
206
229
  }
207
230
 
208
231
  /**
@@ -214,57 +237,64 @@ export function checkpointContext(
214
237
  coordRoot: string,
215
238
  input: CheckpointContextInput,
216
239
  ): CheckpointContextResult {
217
- const now = new Date().toISOString();
218
- const current =
219
- readContextState(coordRoot, input.sessionId) ??
220
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
221
- if (input.reason === "pre_compact" && current.phase === "checkpointed") {
222
- const existing = readLatestCapsule(coordRoot, input.sessionId);
223
- if (existing && current.latest_capsule) {
224
- return {
225
- capsule: existing,
226
- path: join(coordRoot, ".harnery", current.latest_capsule),
227
- state: current,
228
- reused: true,
229
- };
240
+ return withContextMutation(coordRoot, input.sessionId, () => {
241
+ const now = new Date().toISOString();
242
+ const previous = readContextState(coordRoot, input.sessionId);
243
+ const current =
244
+ previous?.instance_id === input.instanceId
245
+ ? previous
246
+ : {
247
+ ...newState({ sessionId: input.sessionId, instanceId: input.instanceId, now }),
248
+ generation: previous?.generation ?? 0,
249
+ };
250
+ if (input.reason === "pre_compact" && current.phase === "checkpointed") {
251
+ const existing = readLatestCapsule(coordRoot, input.sessionId);
252
+ if (existing && capsuleMatchesState(current, existing, input) && current.latest_capsule) {
253
+ return {
254
+ capsule: existing,
255
+ path: join(coordRoot, ".harnery", current.latest_capsule),
256
+ state: current,
257
+ reused: true,
258
+ };
259
+ }
230
260
  }
231
- }
232
261
 
233
- const heartbeat = readHeartbeat(coordRoot, input.instanceId);
234
- const generation = current.generation + 1;
235
- const capsule: ContinuityCapsule = {
236
- schema_version: CONTEXT_SCHEMA_VERSION,
237
- capsule_id: randomUUID(),
238
- generation,
239
- created_at: now,
240
- reason: input.reason,
241
- session: {
242
- session_id: input.sessionId,
243
- instance_id: input.instanceId,
244
- adapter: input.adapter,
245
- model: input.model ?? current.latest_context?.model ?? heartbeat?.model,
246
- },
247
- context: current.latest_context,
248
- work: buildWorkSnapshot(heartbeat, input.continuationNote),
249
- repo: snapshotRepo(input.cwd),
250
- };
262
+ const heartbeat = readHeartbeat(coordRoot, input.instanceId);
263
+ const generation = current.generation + 1;
264
+ const capsule: ContinuityCapsule = {
265
+ schema_version: CONTEXT_SCHEMA_VERSION,
266
+ capsule_id: randomUUID(),
267
+ generation,
268
+ created_at: now,
269
+ reason: input.reason,
270
+ session: {
271
+ session_id: input.sessionId,
272
+ instance_id: input.instanceId,
273
+ adapter: input.adapter,
274
+ model: input.model ?? current.latest_context?.model ?? heartbeat?.model,
275
+ },
276
+ context: current.latest_context,
277
+ work: buildWorkSnapshot(heartbeat, input.continuationNote),
278
+ repo: snapshotRepo(input.cwd),
279
+ };
251
280
 
252
- const relativePath = capsuleRelativePath(input.sessionId, generation);
253
- const absolutePath = join(coordRoot, ".harnery", relativePath);
254
- writeBoundedJson(absolutePath, capsule, MAX_CAPSULE_BYTES);
255
- const next: ContextContinuityState = {
256
- ...current,
257
- instance_id: input.instanceId,
258
- phase: "checkpointed",
259
- generation,
260
- latest_capsule: relativePath,
261
- updated_at: now,
262
- recovered_at: undefined,
263
- compaction_completed_at: undefined,
264
- degraded_reason: undefined,
265
- };
266
- writeState(coordRoot, next);
267
- return { capsule, path: absolutePath, state: next, reused: false };
281
+ const relativePath = capsuleRelativePath(input.sessionId, generation);
282
+ const absolutePath = join(coordRoot, ".harnery", relativePath);
283
+ writeBoundedJson(absolutePath, capsule, MAX_CAPSULE_BYTES);
284
+ const next: ContextContinuityState = {
285
+ ...current,
286
+ instance_id: input.instanceId,
287
+ phase: "checkpointed",
288
+ generation,
289
+ latest_capsule: relativePath,
290
+ updated_at: now,
291
+ recovered_at: undefined,
292
+ compaction_completed_at: undefined,
293
+ degraded_reason: undefined,
294
+ };
295
+ writeState(coordRoot, next);
296
+ return { capsule, path: absolutePath, state: next, reused: false };
297
+ });
268
298
  }
269
299
 
270
300
  /** Prepare a briefing without claiming that a adapter has accepted it yet. */
@@ -272,47 +302,156 @@ export function prepareContextRecovery(
272
302
  coordRoot: string,
273
303
  input: { sessionId: string; instanceId: string; cwd: string },
274
304
  ): PreparedContextRecovery | null {
275
- const now = new Date().toISOString();
276
- const current =
277
- readContextState(coordRoot, input.sessionId) ??
278
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
279
- const capsule = readLatestCapsule(coordRoot, input.sessionId);
280
- if (!capsule) {
281
- const degraded: ContextContinuityState = {
282
- ...current,
283
- instance_id: input.instanceId,
284
- phase: "degraded",
285
- updated_at: now,
286
- degraded_reason: "post-compaction signal arrived without a continuity checkpoint",
305
+ return withContextMutation(coordRoot, input.sessionId, () => {
306
+ const now = new Date().toISOString();
307
+ const current =
308
+ readContextState(coordRoot, input.sessionId) ??
309
+ newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
310
+ // A reused provider ID does not transfer the prior task's context.
311
+ if (current.instance_id !== input.instanceId) return null;
312
+ const capsule = readLatestCapsule(coordRoot, input.sessionId);
313
+ if (!capsule || !capsuleMatchesState(current, capsule, input)) {
314
+ const degraded: ContextContinuityState = {
315
+ ...current,
316
+ instance_id: input.instanceId,
317
+ phase: "degraded",
318
+ updated_at: now,
319
+ degraded_reason: capsule
320
+ ? "continuity checkpoint does not match the requesting instance and state"
321
+ : "post-compaction signal arrived without a continuity checkpoint",
322
+ };
323
+ writeState(coordRoot, degraded);
324
+ return null;
325
+ }
326
+
327
+ const liveRepo = snapshotRepo(input.cwd);
328
+ const briefing = [
329
+ renderRecoveryBriefing(capsule, liveRepo),
330
+ renderRecoveryInput(coordRoot, input.instanceId),
331
+ ]
332
+ .filter(Boolean)
333
+ .join("\n\n");
334
+ return {
335
+ state: current,
336
+ capsule,
337
+ briefing,
338
+ checkpoint: Object.freeze({
339
+ session_id: input.sessionId,
340
+ instance_id: input.instanceId,
341
+ generation: capsule.generation,
342
+ capsule_id: capsule.capsule_id,
343
+ capsule_sha256: capsuleDigest(capsule),
344
+ }),
287
345
  };
288
- writeState(coordRoot, degraded);
289
- return null;
346
+ });
347
+ }
348
+
349
+ /** Recovery lists input without surfacing it again or changing its receipts. */
350
+ function renderRecoveryInput(coordRoot: string, instanceId: string): string {
351
+ const readCommand = `${resolveBinName(coordRoot)} agents inbox list ${instanceId}`;
352
+ try {
353
+ const records = peerInbox(coordRoot).records(instanceId);
354
+ if (
355
+ records.some(
356
+ (record) =>
357
+ (record.kind !== "message" && record.kind !== "surfaced") ||
358
+ typeof record.message_id !== "string" ||
359
+ Buffer.byteLength(record.message_id) > 128,
360
+ )
361
+ ) {
362
+ throw new Error("inbox input cannot be listed safely");
363
+ }
364
+ const { surfaced } = project(records);
365
+ const messages = records.filter(
366
+ (record): record is HarneryInboxMessageRecordV1 =>
367
+ record.kind === "message" && record.recipient_instance_id === instanceId,
368
+ );
369
+ const pending = messages.filter((message) => !surfaced.has(message.message_id));
370
+ const recent = messages.filter((message) => surfaced.has(message.message_id)).slice(-10);
371
+ if (pending.length === 0 && recent.length === 0) return "";
372
+ const lines = [
373
+ "Coordination input after compaction (surfaced does not mean read):",
374
+ ...pending
375
+ .slice(0, 25)
376
+ .map((message) => inputLine(message.message_id, "pending", message.body)),
377
+ ...recent.map((message) =>
378
+ inputLine(message.message_id, "previously surfaced", message.body),
379
+ ),
380
+ ];
381
+ if (pending.length > 25)
382
+ lines.push(`${pending.length - 25} more pending messages are not listed.`);
383
+ lines.push(
384
+ `Recovery did not acknowledge these messages. Read pending input with \`${readCommand}\`.`,
385
+ );
386
+ return lines.join("\n");
387
+ } catch {
388
+ return `Coordination input could not be read. Check \`${readCommand}\` before continuing.`;
290
389
  }
390
+ }
291
391
 
292
- const liveRepo = snapshotRepo(input.cwd);
293
- const briefing = renderRecoveryBriefing(capsule, liveRepo);
294
- return { state: current, capsule, briefing };
392
+ function inputLine(messageId: string, status: string, body: string): string {
393
+ const text = body.replace(/\s+/g, " ").trim();
394
+ let excerpt = "";
395
+ let bytes = 0;
396
+ for (const character of text) {
397
+ const size = Buffer.byteLength(character);
398
+ if (bytes + size > MAX_RECOVERY_INPUT_BODY_BYTES) break;
399
+ excerpt += character;
400
+ bytes += size;
401
+ }
402
+ return `- ${messageId} (${status}): ${excerpt}${excerpt.length < text.length ? " [excerpt]" : ""}`;
295
403
  }
296
404
 
297
- /** Advance continuity only after the adapter context channel accepted the briefing. */
405
+ /**
406
+ * Advance continuity only after the adapter accepted the prepared briefing.
407
+ * Missing, altered, foreign or superseded checkpoints refuse without writing.
408
+ * Repeating the same accepted checkpoint is an exact no-op.
409
+ */
298
410
  export function completeContextRecovery(
299
411
  coordRoot: string,
300
- input: { sessionId: string; instanceId: string },
301
- ): ContextContinuityState {
302
- const now = new Date().toISOString();
303
- const current =
304
- readContextState(coordRoot, input.sessionId) ??
305
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
306
- const recovered: ContextContinuityState = {
307
- ...current,
308
- instance_id: input.instanceId,
309
- phase: "recovered",
310
- updated_at: now,
311
- recovered_at: now,
312
- degraded_reason: undefined,
313
- };
314
- writeState(coordRoot, recovered);
315
- return recovered;
412
+ input: {
413
+ sessionId: string;
414
+ instanceId: string;
415
+ checkpoint: Readonly<PreparedContextCheckpoint>;
416
+ },
417
+ ): ContextContinuityState | null {
418
+ return withContextMutation(coordRoot, input.sessionId, () => {
419
+ const current = readContextState(coordRoot, input.sessionId);
420
+ const checkpoint = input.checkpoint;
421
+ if (
422
+ !current ||
423
+ !checkpoint ||
424
+ checkpoint.session_id !== input.sessionId ||
425
+ checkpoint.instance_id !== input.instanceId ||
426
+ !Number.isSafeInteger(checkpoint.generation) ||
427
+ checkpoint.generation < 1 ||
428
+ typeof checkpoint.capsule_id !== "string" ||
429
+ !checkpoint.capsule_id ||
430
+ !/^[a-f0-9]{64}$/.test(checkpoint.capsule_sha256) ||
431
+ (current.phase !== "checkpointed" && current.phase !== "recovered")
432
+ )
433
+ return null;
434
+ const capsule = readLatestCapsule(coordRoot, input.sessionId);
435
+ if (
436
+ !capsule ||
437
+ !capsuleMatchesState(current, capsule, input) ||
438
+ checkpoint.generation !== capsule.generation ||
439
+ checkpoint.capsule_id !== capsule.capsule_id ||
440
+ checkpoint.capsule_sha256 !== capsuleDigest(capsule)
441
+ )
442
+ return null;
443
+ if (current.phase === "recovered") return current;
444
+ const now = new Date().toISOString();
445
+ const recovered: ContextContinuityState = {
446
+ ...current,
447
+ phase: "recovered",
448
+ updated_at: now,
449
+ recovered_at: now,
450
+ degraded_reason: undefined,
451
+ };
452
+ writeState(coordRoot, recovered);
453
+ return recovered;
454
+ });
316
455
  }
317
456
 
318
457
  /** Record that the adapter, rather than Harnery, reported compaction complete. */
@@ -320,22 +459,25 @@ export function markContextCompactionCompleted(
320
459
  coordRoot: string,
321
460
  input: { sessionId: string; instanceId: string; observedAt?: string },
322
461
  ): ContextContinuityState {
323
- const now = input.observedAt ?? new Date().toISOString();
324
- const current =
325
- readContextState(coordRoot, input.sessionId) ??
326
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
327
- const next: ContextContinuityState = {
328
- ...current,
329
- instance_id: input.instanceId,
330
- phase: current.latest_capsule ? current.phase : "degraded",
331
- compaction_completed_at: now,
332
- updated_at: now,
333
- degraded_reason: current.latest_capsule
334
- ? current.degraded_reason
335
- : "post-compaction signal arrived without a continuity checkpoint",
336
- };
337
- writeState(coordRoot, next);
338
- return next;
462
+ return withContextMutation(coordRoot, input.sessionId, () => {
463
+ const now = input.observedAt ?? new Date().toISOString();
464
+ const current =
465
+ readContextState(coordRoot, input.sessionId) ??
466
+ newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
467
+ if (current.instance_id !== input.instanceId) return current;
468
+ const next: ContextContinuityState = {
469
+ ...current,
470
+ instance_id: input.instanceId,
471
+ phase: current.latest_capsule ? current.phase : "degraded",
472
+ compaction_completed_at: now,
473
+ updated_at: now,
474
+ degraded_reason: current.latest_capsule
475
+ ? current.degraded_reason
476
+ : "post-compaction signal arrived without a continuity checkpoint",
477
+ };
478
+ writeState(coordRoot, next);
479
+ return next;
480
+ });
339
481
  }
340
482
 
341
483
  /** Convenience API for callers whose injection is synchronous and guaranteed. */
@@ -348,7 +490,19 @@ export function recoverContext(
348
490
  const state = readContextState(coordRoot, input.sessionId)!;
349
491
  return { state, capsule: null, briefing: "", recovered: false };
350
492
  }
351
- const recovered = completeContextRecovery(coordRoot, input);
493
+ const recovered = completeContextRecovery(coordRoot, {
494
+ sessionId: input.sessionId,
495
+ instanceId: input.instanceId,
496
+ checkpoint: prepared.checkpoint,
497
+ });
498
+ if (!recovered) {
499
+ return {
500
+ state: readContextState(coordRoot, input.sessionId) ?? prepared.state,
501
+ capsule: null,
502
+ briefing: "",
503
+ recovered: false,
504
+ };
505
+ }
352
506
  return {
353
507
  state: recovered,
354
508
  capsule: prepared.capsule,
@@ -366,8 +520,29 @@ export function readContextState(
366
520
 
367
521
  export function readLatestCapsule(coordRoot: string, sessionId: string): ContinuityCapsule | null {
368
522
  const state = readContextState(coordRoot, sessionId);
369
- if (!state?.latest_capsule) return null;
370
- return readJson<ContinuityCapsule>(join(coordRoot, ".harnery", state.latest_capsule));
523
+ if (typeof state?.latest_capsule !== "string" || !state.latest_capsule) return null;
524
+ const capsule = readJson<ContinuityCapsule>(join(coordRoot, ".harnery", state.latest_capsule));
525
+ if (
526
+ !capsule ||
527
+ !record(capsule.repo) ||
528
+ !record(capsule.work) ||
529
+ typeof capsule.repo.cwd !== "string" ||
530
+ !Array.isArray(capsule.repo.dirty_paths) ||
531
+ !capsule.repo.dirty_paths.every((path) => typeof path === "string") ||
532
+ !Array.isArray(capsule.work.files_held) ||
533
+ !capsule.work.files_held.every((path) => typeof path === "string")
534
+ ) {
535
+ return null;
536
+ }
537
+ capsule.repo.observation = repoObservation(capsule.repo.observation);
538
+ capsule.repo.observation_detail = clampString(
539
+ string(capsule.repo.observation_detail),
540
+ MAX_OBSERVATION_DETAIL_CHARS,
541
+ );
542
+ capsule.repo.branch = string(capsule.repo.branch)?.trim() || undefined;
543
+ capsule.repo.head = string(capsule.repo.head)?.trim() || undefined;
544
+ capsule.repo.root = string(capsule.repo.root)?.trim() || undefined;
545
+ return capsule;
371
546
  }
372
547
 
373
548
  export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: RepoSnapshot): string {
@@ -382,6 +557,42 @@ export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: Rep
382
557
  lines.push(`Files held: ${capsule.work.files_held.join(", ")}`);
383
558
  }
384
559
 
560
+ const unknown: string[] = [];
561
+ if (!observationsComparable(capsule.repo, liveRepo)) {
562
+ for (const [label, repo] of [
563
+ ["checkpoint", capsule.repo],
564
+ ["current repository", liveRepo],
565
+ ] as const) {
566
+ if (repo.observation !== "ok") {
567
+ const detail = clampString(string(repo.observation_detail), MAX_OBSERVATION_DETAIL_CHARS);
568
+ unknown.push(
569
+ `${label}: ${repoObservation(repo.observation)}${detail ? ` (${detail})` : ""}`,
570
+ );
571
+ }
572
+ }
573
+ }
574
+ if (capsule.repo.dirty_paths_truncated || liveRepo.dirty_paths_truncated) {
575
+ unknown.push("dirty path list is truncated");
576
+ }
577
+ if (
578
+ !capsule.repo.root ||
579
+ !liveRepo.root ||
580
+ !isAbsolute(capsule.repo.root) ||
581
+ !isAbsolute(liveRepo.root)
582
+ ) {
583
+ unknown.push("repository root is unknown");
584
+ } else if (capsule.repo.root !== liveRepo.root) {
585
+ unknown.push("repository root differs from the checkpoint");
586
+ }
587
+ if (!capsule.repo.branch?.trim() || !liveRepo.branch?.trim()) unknown.push("branch is unknown");
588
+ if (!capsule.repo.head?.trim() || !liveRepo.head?.trim()) unknown.push("HEAD is unknown");
589
+ if (unknown.length > 0) {
590
+ lines.push(
591
+ `Repository could not be verified against the checkpoint: ${unknown.join("; ")}. Re-check before editing.`,
592
+ );
593
+ return lines.join("\n");
594
+ }
595
+
385
596
  const drift: string[] = [];
386
597
  if (capsule.repo.branch && liveRepo.branch && capsule.repo.branch !== liveRepo.branch) {
387
598
  drift.push(`branch changed ${capsule.repo.branch} -> ${liveRepo.branch}`);
@@ -405,23 +616,60 @@ export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: Rep
405
616
 
406
617
  export function snapshotRepo(cwd: string): RepoSnapshot {
407
618
  const root = git(cwd, ["rev-parse", "--show-toplevel"]);
408
- if (!root) return { cwd, dirty_paths: [] };
619
+ if (!root.ok || !root.out.trim()) {
620
+ return {
621
+ cwd: clampString(cwd, 2_000) ?? cwd,
622
+ observation: "unavailable",
623
+ observation_detail: `top-level: ${root.ok ? "empty result" : root.reason}`,
624
+ dirty_paths: [],
625
+ };
626
+ }
409
627
  const status = git(cwd, ["status", "--porcelain=v1", "--untracked-files=normal"]);
410
- const dirty = status
411
- ? status
628
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
629
+ const head = git(cwd, ["rev-parse", "HEAD"]);
630
+ const reads = [
631
+ ["status", status],
632
+ ["branch", branch],
633
+ ["HEAD", head],
634
+ ] as const;
635
+ const details = reads.flatMap(([label, read]) =>
636
+ !read.ok
637
+ ? [`${label}: ${read.reason}`]
638
+ : label !== "status" && !read.out.trim()
639
+ ? [`${label}: empty result`]
640
+ : [],
641
+ );
642
+ const dirty = status.ok
643
+ ? status.out
412
644
  .split("\n")
413
645
  .map((line) => line.trimEnd())
414
646
  .filter(Boolean)
415
- .map((line) => clampString(line.slice(3).trim(), MAX_SNAPSHOT_PATH_CHARS) ?? "")
647
+ .map((line) => line.slice(3).trim())
416
648
  .filter(Boolean)
417
649
  : [];
650
+ const truncated =
651
+ dirty.length > MAX_SNAPSHOT_PATHS ||
652
+ dirty.some((path) => path.length > MAX_SNAPSHOT_PATH_CHARS);
653
+ if (truncated) details.push("dirty path list is truncated");
418
654
  return {
419
655
  cwd: clampString(cwd, 2_000) ?? cwd,
420
- root: clampString(root, 2_000),
421
- branch: git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
422
- head: git(cwd, ["rev-parse", "HEAD"]),
423
- dirty_paths: dirty.slice(0, MAX_SNAPSHOT_PATHS),
424
- ...(dirty.length > MAX_SNAPSHOT_PATHS ? { dirty_paths_truncated: true } : {}),
656
+ observation: reads.some(([, read]) => !read.ok && read.reason === "timed_out")
657
+ ? "unavailable"
658
+ : details.length > 0
659
+ ? "partial"
660
+ : "ok",
661
+ ...(details.length > 0
662
+ ? {
663
+ observation_detail: clampString(details.join("; "), MAX_OBSERVATION_DETAIL_CHARS),
664
+ }
665
+ : {}),
666
+ root: clampString(root.out.trim(), 2_000),
667
+ branch: branch.ok ? branch.out.trim() || undefined : undefined,
668
+ head: head.ok ? head.out.trim() || undefined : undefined,
669
+ dirty_paths: dirty
670
+ .slice(0, MAX_SNAPSHOT_PATHS)
671
+ .map((path) => path.slice(0, MAX_SNAPSHOT_PATH_CHARS)),
672
+ ...(truncated ? { dirty_paths_truncated: true } : {}),
425
673
  };
426
674
  }
427
675
 
@@ -467,6 +715,47 @@ function newState(input: {
467
715
  };
468
716
  }
469
717
 
718
+ function capsuleMatchesState(
719
+ state: ContextContinuityState,
720
+ capsule: ContinuityCapsule,
721
+ input: { sessionId: string; instanceId: string },
722
+ ): boolean {
723
+ return (
724
+ state.session_id === input.sessionId &&
725
+ state.instance_id === input.instanceId &&
726
+ capsule.session?.session_id === input.sessionId &&
727
+ capsule.session?.instance_id === input.instanceId &&
728
+ capsule.schema_version === CONTEXT_SCHEMA_VERSION &&
729
+ Number.isSafeInteger(capsule.generation) &&
730
+ capsule.generation > 0 &&
731
+ capsule.generation === state.generation &&
732
+ typeof capsule.capsule_id === "string" &&
733
+ capsule.capsule_id.length > 0 &&
734
+ state.latest_capsule === capsuleRelativePath(input.sessionId, capsule.generation)
735
+ );
736
+ }
737
+
738
+ function capsuleDigest(capsule: ContinuityCapsule): string {
739
+ return createHash("sha256").update(JSON.stringify(capsule)).digest("hex");
740
+ }
741
+
742
+ /** Every state writer shares the same crash-recoverable session reservation. */
743
+ function withContextMutation<T>(coordRoot: string, sessionId: string, action: () => T): T {
744
+ const authority = createHash("sha256").update(sessionId).digest("hex");
745
+ const lease = acquireNoClobberLease({
746
+ path: join(contextDir(coordRoot, sessionId), "mutation.lease"),
747
+ scope: "context-continuity",
748
+ authoritySha256: authority,
749
+ staleAfterMs: 30_000,
750
+ validateStaleOwner: (owner) => owner.authority_sha256 === authority,
751
+ });
752
+ try {
753
+ return action();
754
+ } finally {
755
+ lease.release();
756
+ }
757
+ }
758
+
470
759
  function writeState(coordRoot: string, state: ContextContinuityState): void {
471
760
  writeBoundedJson(statePath(coordRoot, state.session_id), state, MAX_CAPSULE_BYTES);
472
761
  }
@@ -513,15 +802,36 @@ function readJson<T>(path: string): T | null {
513
802
  }
514
803
  }
515
804
 
516
- function git(cwd: string, args: string[]): string | undefined {
805
+ type GitReadResult =
806
+ | { ok: true; out: string }
807
+ | { ok: false; reason: "not_repository" | "failed" | "timed_out" | "spawn_error" };
808
+
809
+ function git(cwd: string, args: string[]): GitReadResult {
517
810
  try {
518
811
  const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 });
519
- return result.status === 0 ? result.stdout.trim() || undefined : undefined;
812
+ if (result.error) {
813
+ return {
814
+ ok: false,
815
+ reason:
816
+ (result.error as NodeJS.ErrnoException).code === "ETIMEDOUT"
817
+ ? "timed_out"
818
+ : "spawn_error",
819
+ };
820
+ }
821
+ if (result.status === 0) return { ok: true, out: result.stdout.trimEnd() };
822
+ return {
823
+ ok: false,
824
+ reason: /not a git repository/i.test(result.stderr) ? "not_repository" : "failed",
825
+ };
520
826
  } catch {
521
- return undefined;
827
+ return { ok: false, reason: "spawn_error" };
522
828
  }
523
829
  }
524
830
 
831
+ function repoObservation(value: unknown): RepoObservation {
832
+ return value === "ok" || value === "partial" ? value : "unavailable";
833
+ }
834
+
525
835
  function sameMeasurement(a: ContextSample | undefined, b: ContextSample): boolean {
526
836
  if (!a) return false;
527
837
  return (