@orion-agents/orion-code 0.1.0 → 0.1.2

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 (251) hide show
  1. package/README.md +107 -67
  2. package/README.zh-CN.md +192 -217
  3. package/dist/cli.js +23 -21
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/index.d.ts +1 -1
  6. package/dist/commands/index.d.ts.map +1 -1
  7. package/dist/commands/index.js +397 -212
  8. package/dist/commands/index.js.map +1 -1
  9. package/dist/commands/target-command.d.ts.map +1 -1
  10. package/dist/commands/target-command.js +43 -9
  11. package/dist/commands/target-command.js.map +1 -1
  12. package/dist/commands/types.d.ts +33 -0
  13. package/dist/commands/types.d.ts.map +1 -1
  14. package/dist/commands/types.js +5 -1
  15. package/dist/commands/types.js.map +1 -1
  16. package/dist/core/agent.d.ts.map +1 -1
  17. package/dist/core/agent.js +2 -2
  18. package/dist/core/agent.js.map +1 -1
  19. package/dist/framework/external-assertion.d.ts +65 -0
  20. package/dist/framework/external-assertion.d.ts.map +1 -0
  21. package/dist/framework/external-assertion.js +571 -0
  22. package/dist/framework/external-assertion.js.map +1 -0
  23. package/dist/framework/index.d.ts +6 -4
  24. package/dist/framework/index.d.ts.map +1 -1
  25. package/dist/framework/index.js +4 -1
  26. package/dist/framework/index.js.map +1 -1
  27. package/dist/framework/query.d.ts +18 -1
  28. package/dist/framework/query.d.ts.map +1 -1
  29. package/dist/framework/query.js +65 -28
  30. package/dist/framework/query.js.map +1 -1
  31. package/dist/framework/tool-scheduler.d.ts +2 -0
  32. package/dist/framework/tool-scheduler.d.ts.map +1 -1
  33. package/dist/framework/tool-scheduler.js +23 -9
  34. package/dist/framework/tool-scheduler.js.map +1 -1
  35. package/dist/framework/tool-serializer.d.ts.map +1 -1
  36. package/dist/framework/tool-serializer.js +20 -4
  37. package/dist/framework/tool-serializer.js.map +1 -1
  38. package/dist/framework/tool.d.ts +11 -5
  39. package/dist/framework/tool.d.ts.map +1 -1
  40. package/dist/framework/tool.js.map +1 -1
  41. package/dist/harness/context-harness.d.ts.map +1 -1
  42. package/dist/harness/context-harness.js +4 -3
  43. package/dist/harness/context-harness.js.map +1 -1
  44. package/dist/harness/drift-guard.d.ts.map +1 -1
  45. package/dist/harness/drift-guard.js +14 -5
  46. package/dist/harness/drift-guard.js.map +1 -1
  47. package/dist/harness/safety.d.ts.map +1 -1
  48. package/dist/harness/safety.js +19 -3
  49. package/dist/harness/safety.js.map +1 -1
  50. package/dist/init.d.ts +1 -1
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +115 -34
  53. package/dist/init.js.map +1 -1
  54. package/dist/ink-ui/screens/ReplScreen.d.ts.map +1 -1
  55. package/dist/ink-ui/screens/ReplScreen.js +4 -1
  56. package/dist/ink-ui/screens/ReplScreen.js.map +1 -1
  57. package/dist/memory/storage.d.ts.map +1 -1
  58. package/dist/memory/storage.js +1 -0
  59. package/dist/memory/storage.js.map +1 -1
  60. package/dist/memory/store.d.ts.map +1 -1
  61. package/dist/memory/store.js +11 -5
  62. package/dist/memory/store.js.map +1 -1
  63. package/dist/migration/command.d.ts.map +1 -1
  64. package/dist/migration/command.js +6 -1
  65. package/dist/migration/command.js.map +1 -1
  66. package/dist/migration/migrate.d.ts.map +1 -1
  67. package/dist/migration/migrate.js +4 -1
  68. package/dist/migration/migrate.js.map +1 -1
  69. package/dist/print-ui/launch.d.ts +4 -0
  70. package/dist/print-ui/launch.d.ts.map +1 -1
  71. package/dist/print-ui/launch.js +102 -9
  72. package/dist/print-ui/launch.js.map +1 -1
  73. package/dist/product/identity.d.ts +2 -2
  74. package/dist/product/identity.d.ts.map +1 -1
  75. package/dist/product/identity.js +2 -2
  76. package/dist/product/identity.js.map +1 -1
  77. package/dist/product/index.d.ts +2 -1
  78. package/dist/product/index.d.ts.map +1 -1
  79. package/dist/product/index.js +5 -1
  80. package/dist/product/index.js.map +1 -1
  81. package/dist/product/version.d.ts +12 -0
  82. package/dist/product/version.d.ts.map +1 -0
  83. package/dist/product/version.js +31 -0
  84. package/dist/product/version.js.map +1 -0
  85. package/dist/runtime/agent-runtime-controller.d.ts +49 -0
  86. package/dist/runtime/agent-runtime-controller.d.ts.map +1 -1
  87. package/dist/runtime/agent-runtime-controller.js +1048 -81
  88. package/dist/runtime/agent-runtime-controller.js.map +1 -1
  89. package/dist/runtime/agent-runtime-protocol.d.ts +14 -2
  90. package/dist/runtime/agent-runtime-protocol.d.ts.map +1 -1
  91. package/dist/runtime/agent-runtime-protocol.js +18 -0
  92. package/dist/runtime/agent-runtime-protocol.js.map +1 -1
  93. package/dist/runtime/chat-controller.d.ts +6 -0
  94. package/dist/runtime/chat-controller.d.ts.map +1 -1
  95. package/dist/runtime/chat-controller.js +226 -20
  96. package/dist/runtime/chat-controller.js.map +1 -1
  97. package/dist/runtime/goals/accounting.d.ts +28 -0
  98. package/dist/runtime/goals/accounting.d.ts.map +1 -1
  99. package/dist/runtime/goals/accounting.js +69 -0
  100. package/dist/runtime/goals/accounting.js.map +1 -1
  101. package/dist/runtime/goals/completion-audit.d.ts +13 -8
  102. package/dist/runtime/goals/completion-audit.d.ts.map +1 -1
  103. package/dist/runtime/goals/completion-audit.js +598 -16
  104. package/dist/runtime/goals/completion-audit.js.map +1 -1
  105. package/dist/runtime/goals/coordinator.d.ts +44 -5
  106. package/dist/runtime/goals/coordinator.d.ts.map +1 -1
  107. package/dist/runtime/goals/coordinator.js +1124 -58
  108. package/dist/runtime/goals/coordinator.js.map +1 -1
  109. package/dist/runtime/goals/evidence.d.ts +37 -0
  110. package/dist/runtime/goals/evidence.d.ts.map +1 -0
  111. package/dist/runtime/goals/evidence.js +300 -0
  112. package/dist/runtime/goals/evidence.js.map +1 -0
  113. package/dist/runtime/goals/presentation.d.ts +4 -0
  114. package/dist/runtime/goals/presentation.d.ts.map +1 -0
  115. package/dist/runtime/goals/presentation.js +50 -0
  116. package/dist/runtime/goals/presentation.js.map +1 -0
  117. package/dist/runtime/goals/prompt.d.ts.map +1 -1
  118. package/dist/runtime/goals/prompt.js +38 -8
  119. package/dist/runtime/goals/prompt.js.map +1 -1
  120. package/dist/runtime/goals/tools.d.ts +18 -1
  121. package/dist/runtime/goals/tools.d.ts.map +1 -1
  122. package/dist/runtime/goals/tools.js +308 -28
  123. package/dist/runtime/goals/tools.js.map +1 -1
  124. package/dist/runtime/goals/types.d.ts +241 -7
  125. package/dist/runtime/goals/types.d.ts.map +1 -1
  126. package/dist/runtime/goals/types.js +8 -2
  127. package/dist/runtime/goals/types.js.map +1 -1
  128. package/dist/runtime/subagents/child-executor-guard.d.ts +12 -4
  129. package/dist/runtime/subagents/child-executor-guard.d.ts.map +1 -1
  130. package/dist/runtime/subagents/child-executor-guard.js +397 -61
  131. package/dist/runtime/subagents/child-executor-guard.js.map +1 -1
  132. package/dist/runtime/subagents/presets.d.ts +4 -5
  133. package/dist/runtime/subagents/presets.d.ts.map +1 -1
  134. package/dist/runtime/subagents/presets.js +18 -33
  135. package/dist/runtime/subagents/presets.js.map +1 -1
  136. package/dist/runtime/subagents/production.d.ts +3 -1
  137. package/dist/runtime/subagents/production.d.ts.map +1 -1
  138. package/dist/runtime/subagents/production.js +37 -7
  139. package/dist/runtime/subagents/production.js.map +1 -1
  140. package/dist/runtime/subagents/runner.d.ts.map +1 -1
  141. package/dist/runtime/subagents/runner.js +24 -3
  142. package/dist/runtime/subagents/runner.js.map +1 -1
  143. package/dist/runtime/subagents/runtime-integration.d.ts +3 -0
  144. package/dist/runtime/subagents/runtime-integration.d.ts.map +1 -1
  145. package/dist/runtime/subagents/runtime-integration.js +18 -7
  146. package/dist/runtime/subagents/runtime-integration.js.map +1 -1
  147. package/dist/runtime/subagents/supervisor.d.ts +4 -4
  148. package/dist/runtime/subagents/supervisor.d.ts.map +1 -1
  149. package/dist/runtime/subagents/supervisor.js +39 -19
  150. package/dist/runtime/subagents/supervisor.js.map +1 -1
  151. package/dist/runtime/subagents/types.d.ts +7 -0
  152. package/dist/runtime/subagents/types.d.ts.map +1 -1
  153. package/dist/runtime/subagents/types.js +12 -1
  154. package/dist/runtime/subagents/types.js.map +1 -1
  155. package/dist/runtime/ui-events.d.ts +10 -0
  156. package/dist/runtime/ui-events.d.ts.map +1 -1
  157. package/dist/runtime/ui-events.js +5 -5
  158. package/dist/runtime/ui-events.js.map +1 -1
  159. package/dist/runtime/ui-view-model.d.ts +3 -3
  160. package/dist/runtime/ui-view-model.d.ts.map +1 -1
  161. package/dist/runtime/ui-view-model.js +11 -4
  162. package/dist/runtime/ui-view-model.js.map +1 -1
  163. package/dist/services/config.d.ts +18 -7
  164. package/dist/services/config.d.ts.map +1 -1
  165. package/dist/services/config.js +79 -57
  166. package/dist/services/config.js.map +1 -1
  167. package/dist/services/doctor.d.ts.map +1 -1
  168. package/dist/services/doctor.js +164 -18
  169. package/dist/services/doctor.js.map +1 -1
  170. package/dist/services/global-config.d.ts +1 -1
  171. package/dist/services/global-config.d.ts.map +1 -1
  172. package/dist/services/goal-storage.d.ts +9 -4
  173. package/dist/services/goal-storage.d.ts.map +1 -1
  174. package/dist/services/goal-storage.js +1113 -75
  175. package/dist/services/goal-storage.js.map +1 -1
  176. package/dist/services/llm.d.ts +19 -0
  177. package/dist/services/llm.d.ts.map +1 -1
  178. package/dist/services/llm.js +153 -20
  179. package/dist/services/llm.js.map +1 -1
  180. package/dist/services/model-registry.d.ts +1 -1
  181. package/dist/services/model-registry.d.ts.map +1 -1
  182. package/dist/services/model-registry.js +69 -19
  183. package/dist/services/model-registry.js.map +1 -1
  184. package/dist/services/provider-resilience/coordinator.d.ts +2 -1
  185. package/dist/services/provider-resilience/coordinator.d.ts.map +1 -1
  186. package/dist/services/provider-resilience/coordinator.js +7 -6
  187. package/dist/services/provider-resilience/coordinator.js.map +1 -1
  188. package/dist/services/provider-resilience/error-classifier.d.ts.map +1 -1
  189. package/dist/services/provider-resilience/error-classifier.js +67 -17
  190. package/dist/services/provider-resilience/error-classifier.js.map +1 -1
  191. package/dist/services/provider-resilience/types.d.ts +1 -1
  192. package/dist/services/provider-resilience/types.d.ts.map +1 -1
  193. package/dist/services/provider-resilience/types.js.map +1 -1
  194. package/dist/services/redaction.d.ts.map +1 -1
  195. package/dist/services/redaction.js +9 -1
  196. package/dist/services/redaction.js.map +1 -1
  197. package/dist/services/session-storage.d.ts +12 -1
  198. package/dist/services/session-storage.d.ts.map +1 -1
  199. package/dist/services/session-storage.js +23 -7
  200. package/dist/services/session-storage.js.map +1 -1
  201. package/dist/services/storage-maintenance.d.ts +73 -3
  202. package/dist/services/storage-maintenance.d.ts.map +1 -1
  203. package/dist/services/storage-maintenance.js +771 -104
  204. package/dist/services/storage-maintenance.js.map +1 -1
  205. package/dist/services/task-manager.d.ts.map +1 -1
  206. package/dist/services/task-manager.js +2 -2
  207. package/dist/services/task-manager.js.map +1 -1
  208. package/dist/services/web-search-adapters.d.ts.map +1 -1
  209. package/dist/services/web-search-adapters.js +89 -49
  210. package/dist/services/web-search-adapters.js.map +1 -1
  211. package/dist/services/web-search-mcp.d.ts.map +1 -1
  212. package/dist/services/web-search-mcp.js +34 -37
  213. package/dist/services/web-search-mcp.js.map +1 -1
  214. package/dist/services/workspace-state.d.ts +4 -0
  215. package/dist/services/workspace-state.d.ts.map +1 -1
  216. package/dist/services/workspace-state.js +30 -9
  217. package/dist/services/workspace-state.js.map +1 -1
  218. package/dist/terminal-ui/completion.js +1 -1
  219. package/dist/terminal-ui/completion.js.map +1 -1
  220. package/dist/terminal-ui/launch.d.ts +3 -0
  221. package/dist/terminal-ui/launch.d.ts.map +1 -1
  222. package/dist/terminal-ui/launch.js +26 -98
  223. package/dist/terminal-ui/launch.js.map +1 -1
  224. package/dist/tools/git.d.ts.map +1 -1
  225. package/dist/tools/git.js +275 -27
  226. package/dist/tools/git.js.map +1 -1
  227. package/dist/tools/index.d.ts +2 -0
  228. package/dist/tools/index.d.ts.map +1 -1
  229. package/dist/tools/index.js +29 -10
  230. package/dist/tools/index.js.map +1 -1
  231. package/dist/tools/mcp.d.ts.map +1 -1
  232. package/dist/tools/mcp.js +20 -12
  233. package/dist/tools/mcp.js.map +1 -1
  234. package/dist/tools/web.d.ts.map +1 -1
  235. package/dist/tools/web.js +38 -14
  236. package/dist/tools/web.js.map +1 -1
  237. package/dist/tui-ui/layout.d.ts.map +1 -1
  238. package/dist/tui-ui/layout.js +36 -23
  239. package/dist/tui-ui/layout.js.map +1 -1
  240. package/dist/tui-ui/pickers.d.ts.map +1 -1
  241. package/dist/tui-ui/pickers.js +5 -4
  242. package/dist/tui-ui/pickers.js.map +1 -1
  243. package/dist/tui-ui/runner.d.ts +1 -0
  244. package/dist/tui-ui/runner.d.ts.map +1 -1
  245. package/dist/tui-ui/runner.js +15 -17
  246. package/dist/tui-ui/runner.js.map +1 -1
  247. package/dist/tui-ui/state.d.ts +6 -0
  248. package/dist/tui-ui/state.d.ts.map +1 -1
  249. package/dist/tui-ui/state.js +61 -30
  250. package/dist/tui-ui/state.js.map +1 -1
  251. package/package.json +14 -9
@@ -15,135 +15,1173 @@ const fs_1 = require("fs");
15
15
  const path_1 = require("path");
16
16
  const crypto_1 = require("crypto");
17
17
  const config_dir_1 = require("../services/config-dir");
18
+ const completion_audit_1 = require("../runtime/goals/completion-audit");
19
+ const external_assertion_1 = require("../framework/external-assertion");
18
20
  // ============================================================================
19
21
  // Sidecar path
20
22
  // ============================================================================
21
23
  function goalSidecarPath(projectPath, sessionId) {
22
24
  return (0, path_1.join)((0, config_dir_1.getProjectSessionsDir)(projectPath), `${sessionId}.goal.json`);
23
25
  }
24
- function loadGoal(projectPath, sessionId) {
25
- const path = goalSidecarPath(projectPath, sessionId);
26
- if (!(0, fs_1.existsSync)(path)) {
27
- return { ok: false, error: 'not_found', message: `No goal sidecar at ${path}` };
26
+ function goalDeletionFencePath(sidecarPath) {
27
+ return `${sidecarPath}.deleted`;
28
+ }
29
+ const GOAL_LOCK_WAIT_MS = 2000;
30
+ const GOAL_LOCK_RETRY_MS = 10;
31
+ const GOAL_LOCK_STALE_MS = 30000;
32
+ function sleepSync(ms) {
33
+ const signal = new Int32Array(new SharedArrayBuffer(4));
34
+ Atomics.wait(signal, 0, 0, ms);
35
+ }
36
+ function lockOwnerPath(lockPath) {
37
+ return (0, path_1.join)(lockPath, 'owner.json');
38
+ }
39
+ function readLockOwner(lockPath) {
40
+ try {
41
+ const parsed = JSON.parse((0, fs_1.readFileSync)(lockOwnerPath(lockPath), 'utf8'));
42
+ if (typeof parsed.token !== 'string' ||
43
+ !parsed.token ||
44
+ !Number.isInteger(parsed.pid) ||
45
+ typeof parsed.createdAt !== 'number') {
46
+ return null;
47
+ }
48
+ return parsed;
28
49
  }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function processIsAlive(pid) {
55
+ if (pid <= 0)
56
+ return false;
29
57
  try {
30
- const raw = (0, fs_1.readFileSync)(path, 'utf8');
31
- const parsed = JSON.parse(raw);
32
- if (parsed.version !== 1 || typeof parsed.goalId !== 'string' || !parsed.goalId) {
33
- quarantineSidecar(path, 'invalid schema or missing goalId');
34
- return { ok: false, error: 'corrupt', message: 'Goal sidecar has invalid schema' };
58
+ process.kill(pid, 0);
59
+ return true;
60
+ }
61
+ catch (error) {
62
+ return error.code === 'EPERM';
63
+ }
64
+ }
65
+ /**
66
+ * Move an abandoned lock out of the lock namespace before deleting it. The
67
+ * rename is important: another process may immediately acquire a fresh lock,
68
+ * and cleanup must never remove that new owner's directory.
69
+ */
70
+ function recoverStaleLock(lockPath) {
71
+ let stat;
72
+ try {
73
+ stat = (0, fs_1.statSync)(lockPath);
74
+ }
75
+ catch {
76
+ return true;
77
+ }
78
+ const firstOwner = readLockOwner(lockPath);
79
+ const lastActivity = Math.max(stat.mtimeMs, firstOwner?.createdAt ?? 0);
80
+ if (Date.now() - lastActivity <= GOAL_LOCK_STALE_MS)
81
+ return false;
82
+ if (firstOwner && processIsAlive(firstOwner.pid))
83
+ return false;
84
+ // Re-read the token immediately before rename. If ownership changed while we
85
+ // inspected it, leave the replacement lock alone and retry normally.
86
+ const currentOwner = readLockOwner(lockPath);
87
+ if (firstOwner?.token !== currentOwner?.token)
88
+ return false;
89
+ const stalePath = `${lockPath}.stale-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
90
+ try {
91
+ (0, fs_1.renameSync)(lockPath, stalePath);
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'ENOENT')
95
+ return true;
96
+ return false;
97
+ }
98
+ try {
99
+ (0, fs_1.rmSync)(stalePath, { recursive: true, force: true });
100
+ }
101
+ catch {
102
+ // It is already outside the lock namespace. A later maintenance pass can
103
+ // remove it without blocking safe writes.
104
+ }
105
+ return true;
106
+ }
107
+ function acquireGoalSidecarLock(sidecarPath) {
108
+ const lockPath = `${sidecarPath}.lock`;
109
+ const deadline = Date.now() + GOAL_LOCK_WAIT_MS;
110
+ while (true) {
111
+ const owner = {
112
+ token: (0, crypto_1.randomUUID)(),
113
+ pid: process.pid,
114
+ createdAt: Date.now(),
115
+ };
116
+ try {
117
+ (0, fs_1.mkdirSync)(lockPath, { mode: 0o700 });
118
+ try {
119
+ (0, fs_1.writeFileSync)(lockOwnerPath(lockPath), JSON.stringify(owner), {
120
+ encoding: 'utf8',
121
+ mode: 0o600,
122
+ });
123
+ }
124
+ catch (error) {
125
+ try {
126
+ (0, fs_1.rmSync)(lockPath, { recursive: true, force: true });
127
+ }
128
+ catch {
129
+ // The acquisition still fails closed below.
130
+ }
131
+ return {
132
+ ok: false,
133
+ error: 'io_error',
134
+ message: `Failed to initialize Goal sidecar lock: ${error instanceof Error ? error.message : String(error)}`,
135
+ };
136
+ }
137
+ return {
138
+ ok: true,
139
+ release: () => {
140
+ if (!(0, fs_1.existsSync)(lockPath))
141
+ return { ok: true, value: undefined };
142
+ const currentOwner = readLockOwner(lockPath);
143
+ if (currentOwner?.token !== owner.token) {
144
+ return {
145
+ ok: false,
146
+ error: 'io_error',
147
+ message: 'Goal sidecar lock ownership changed before cleanup',
148
+ };
149
+ }
150
+ try {
151
+ (0, fs_1.rmSync)(lockPath, { recursive: true });
152
+ return { ok: true, value: undefined };
153
+ }
154
+ catch (error) {
155
+ return {
156
+ ok: false,
157
+ error: 'io_error',
158
+ message: `Failed to release Goal sidecar lock: ${error instanceof Error ? error.message : String(error)}`,
159
+ };
160
+ }
161
+ },
162
+ };
35
163
  }
36
- if (parsed.sessionId !== sessionId) {
37
- quarantineSidecar(path, `sessionId mismatch: expected ${sessionId}, got ${parsed.sessionId}`);
38
- return { ok: false, error: 'corrupt', message: 'Goal sidecar sessionId mismatch' };
164
+ catch (error) {
165
+ const code = error.code;
166
+ if (code !== 'EEXIST') {
167
+ return {
168
+ ok: false,
169
+ error: 'io_error',
170
+ message: `Failed to acquire Goal sidecar lock: ${error instanceof Error ? error.message : String(error)}`,
171
+ };
172
+ }
39
173
  }
40
- if (typeof parsed.objective !== 'string' || !parsed.objective.trim()) {
41
- quarantineSidecar(path, 'missing or empty objective');
42
- return { ok: false, error: 'corrupt', message: 'Goal sidecar has empty objective' };
174
+ if (recoverStaleLock(lockPath))
175
+ continue;
176
+ if (Date.now() >= deadline) {
177
+ return {
178
+ ok: false,
179
+ error: 'io_error',
180
+ message: `Timed out waiting for Goal sidecar lock at ${lockPath}`,
181
+ };
43
182
  }
44
- return { ok: true, value: parsed };
183
+ sleepSync(GOAL_LOCK_RETRY_MS);
45
184
  }
46
- catch (err) {
47
- quarantineSidecar(path, err instanceof Error ? err.message : String(err));
48
- return { ok: false, error: 'corrupt', message: 'Failed to parse goal sidecar' };
185
+ }
186
+ function withGoalSidecarLock(sidecarPath, operation) {
187
+ const acquired = acquireGoalSidecarLock(sidecarPath);
188
+ if (!acquired.ok)
189
+ return acquired;
190
+ let result;
191
+ try {
192
+ result = operation();
193
+ }
194
+ catch (error) {
195
+ result = {
196
+ ok: false,
197
+ error: 'io_error',
198
+ message: error instanceof Error ? error.message : String(error),
199
+ };
200
+ }
201
+ const released = acquired.release();
202
+ if (!released.ok) {
203
+ const warning = {
204
+ code: 'lock_cleanup_failed',
205
+ message: released.message,
206
+ };
207
+ console.warn(`[goal-storage] ${warning.message}`);
208
+ // The operation result is authoritative. In particular, an atomic sidecar
209
+ // rename may already have committed the new Goal before lock-directory
210
+ // cleanup fails. Returning io_error here would make callers roll their
211
+ // in-memory state back even though the new revision is durable on disk.
212
+ // Keep pre-commit operation failures fail-closed, while reporting cleanup
213
+ // as a secondary diagnostic on successful operations.
214
+ if (result.ok) {
215
+ return {
216
+ ...result,
217
+ warnings: [...(result.warnings ?? []), warning],
218
+ };
219
+ }
220
+ return {
221
+ ...result,
222
+ message: `${result.message}; additionally, ${released.message}`,
223
+ };
49
224
  }
225
+ return result;
50
226
  }
51
- function saveGoal(projectPath, sessionId, goal, expectedRevision) {
227
+ const GOAL_STATUSES = new Set([
228
+ 'active',
229
+ 'paused',
230
+ 'blocked',
231
+ 'usage_limited',
232
+ 'budget_limited',
233
+ 'complete',
234
+ ]);
235
+ const BLOCKER_CATEGORIES = new Set(['user_input', 'permission', 'external_state']);
236
+ const STOP_REASON_KINDS = new Set([
237
+ 'user',
238
+ 'blocked',
239
+ 'usage_limit',
240
+ 'budget_limit',
241
+ 'rate_limit',
242
+ 'provider_busy',
243
+ 'auth',
244
+ 'network',
245
+ 'runtime_error',
246
+ ]);
247
+ const CRITERION_STATUSES = new Set(['pending', 'passed', 'failed', 'stale']);
248
+ const EVIDENCE_KINDS = new Set(['test', 'build', 'lint', 'file', 'runtime', 'external', 'user']);
249
+ const EVIDENCE_RESULTS = new Set(['passed', 'failed', 'inconclusive']);
250
+ const EVIDENCE_PROVENANCE = new Set(['runtime_automatic', 'external', 'user_acceptance']);
251
+ function isRecord(value) {
252
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
253
+ }
254
+ function isNonEmptyString(value) {
255
+ return typeof value === 'string' && value.trim().length > 0;
256
+ }
257
+ function isStringArray(value) {
258
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
259
+ }
260
+ function hasUniqueRecordIds(values) {
261
+ const ids = values.map(value => (isRecord(value) ? value.id : undefined));
262
+ return ids.every(id => typeof id === 'string') && new Set(ids).size === ids.length;
263
+ }
264
+ function isNonNegativeNumber(value) {
265
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
266
+ }
267
+ function isNonNegativeInteger(value) {
268
+ return isNonNegativeNumber(value) && Number.isSafeInteger(value);
269
+ }
270
+ function isOptionalNonNegativeNumber(value) {
271
+ return value === undefined || isNonNegativeNumber(value);
272
+ }
273
+ function isGoalBlocker(value) {
274
+ return (isRecord(value) &&
275
+ typeof value.category === 'string' &&
276
+ BLOCKER_CATEGORIES.has(value.category) &&
277
+ isNonEmptyString(value.fingerprint) &&
278
+ isNonNegativeNumber(value.firstSeenAt) &&
279
+ isNonNegativeNumber(value.lastSeenAt) &&
280
+ isNonNegativeInteger(value.consecutiveTurns) &&
281
+ typeof value.summary === 'string' &&
282
+ value.retryable === false);
283
+ }
284
+ function isGoalLastTurn(value) {
285
+ return (isRecord(value) &&
286
+ isNonEmptyString(value.turnId) &&
287
+ isNonEmptyString(value.finishReason) &&
288
+ isNonNegativeNumber(value.endedAt) &&
289
+ isNonNegativeInteger(value.promptTokens) &&
290
+ isNonNegativeInteger(value.completionTokens) &&
291
+ isNonNegativeInteger(value.subagentTokens) &&
292
+ isNonNegativeInteger(value.totalTokens) &&
293
+ typeof value.madeProgress === 'boolean');
294
+ }
295
+ function isGoalNoProgressTurn(value) {
296
+ return (isRecord(value) &&
297
+ isNonEmptyString(value.turnId) &&
298
+ value.turnId.length <= 80 &&
299
+ isNonNegativeNumber(value.endedAt) &&
300
+ isNonEmptyString(value.finishReason) &&
301
+ value.finishReason.length <= 80 &&
302
+ isNonNegativeInteger(value.passedEvidence) &&
303
+ isNonNegativeInteger(value.failedEvidence) &&
304
+ isNonNegativeInteger(value.inconclusiveEvidence) &&
305
+ typeof value.planUpdateProposed === 'boolean' &&
306
+ (value.blockerCategory === undefined ||
307
+ (typeof value.blockerCategory === 'string' && BLOCKER_CATEGORIES.has(value.blockerCategory))));
308
+ }
309
+ function isStopReason(value) {
310
+ return (isRecord(value) &&
311
+ typeof value.kind === 'string' &&
312
+ STOP_REASON_KINDS.has(value.kind) &&
313
+ isNonEmptyString(value.message) &&
314
+ isNonNegativeNumber(value.at));
315
+ }
316
+ function isBoundaryConfirmation(value) {
317
+ if (!isRecord(value))
318
+ return false;
319
+ return (isNonNegativeNumber(value.requiredAt) &&
320
+ isNonEmptyString(value.reason) &&
321
+ isNonNegativeInteger(value.objectiveRevision) &&
322
+ isOptionalNonNegativeNumber(value.confirmedAt) &&
323
+ (value.confirmedRevision === undefined || isNonNegativeInteger(value.confirmedRevision)));
324
+ }
325
+ function isGoalCriterion(value) {
326
+ return (isRecord(value) &&
327
+ isNonEmptyString(value.id) &&
328
+ isNonEmptyString(value.statement) &&
329
+ (value.source === 'user' || value.source === 'derived') &&
330
+ typeof value.status === 'string' &&
331
+ CRITERION_STATUSES.has(value.status) &&
332
+ Array.isArray(value.requiredEvidenceKinds) &&
333
+ value.requiredEvidenceKinds.every(kind => typeof kind === 'string' && EVIDENCE_KINDS.has(kind)) &&
334
+ isStringArray(value.evidenceRefs));
335
+ }
336
+ function isGoalContract(value) {
337
+ if (!isRecord(value))
338
+ return false;
339
+ if (!isNonEmptyString(value.originalObjective) ||
340
+ !isNonNegativeInteger(value.objectiveRevision) ||
341
+ !Array.isArray(value.constraints) ||
342
+ !value.constraints.every(constraint => isRecord(constraint) &&
343
+ isNonEmptyString(constraint.id) &&
344
+ isNonEmptyString(constraint.statement) &&
345
+ (constraint.source === 'user' || constraint.source === 'derived')) ||
346
+ !hasUniqueRecordIds(value.constraints) ||
347
+ !Array.isArray(value.successCriteria) ||
348
+ !value.successCriteria.every(isGoalCriterion) ||
349
+ !hasUniqueRecordIds(value.successCriteria)) {
350
+ return false;
351
+ }
352
+ if (value.objectiveHistory !== undefined &&
353
+ (!Array.isArray(value.objectiveHistory) ||
354
+ !value.objectiveHistory.every(revision => isRecord(revision) &&
355
+ isNonNegativeInteger(revision.revision) &&
356
+ typeof revision.previousObjective === 'string' &&
357
+ isNonEmptyString(revision.objective) &&
358
+ isNonEmptyString(revision.reason) &&
359
+ isNonNegativeNumber(revision.changedAt) &&
360
+ revision.source === 'user'))) {
361
+ return false;
362
+ }
363
+ if (value.planSnapshot !== undefined) {
364
+ const plan = value.planSnapshot;
365
+ if (!isRecord(plan) ||
366
+ !isNonNegativeInteger(plan.revision) ||
367
+ !isNonEmptyString(plan.phase) ||
368
+ !Array.isArray(plan.steps) ||
369
+ !plan.steps.every(step => isRecord(step) &&
370
+ isNonEmptyString(step.id) &&
371
+ isNonEmptyString(step.description) &&
372
+ typeof step.done === 'boolean') ||
373
+ !hasUniqueRecordIds(plan.steps) ||
374
+ (plan.nextAction !== undefined && typeof plan.nextAction !== 'string') ||
375
+ !isNonNegativeNumber(plan.updatedAt)) {
376
+ return false;
377
+ }
378
+ }
379
+ return true;
380
+ }
381
+ function isGoalEvidence(value, goalId) {
382
+ if (!isRecord(value))
383
+ return false;
384
+ const assertion = value.externalAssertion;
385
+ const assertionEnvelopeValid = assertion === undefined ||
386
+ ((0, external_assertion_1.isToolExternalAssertion)(assertion) &&
387
+ assertion.details !== undefined &&
388
+ isNonNegativeInteger(assertion.observedAt) &&
389
+ value.kind === 'external' &&
390
+ value.result === assertion.status &&
391
+ typeof value.sourceRef === 'string' &&
392
+ value.sourceRef.startsWith('tool:') &&
393
+ typeof value.capturedAt === 'number' &&
394
+ assertion.observedAt <= value.capturedAt &&
395
+ value.capturedAt - assertion.observedAt < 5 * 60000 &&
396
+ typeof value.expiresAt === 'number' &&
397
+ value.expiresAt > value.capturedAt &&
398
+ value.expiresAt - value.capturedAt <= 5 * 60000);
399
+ return (isNonEmptyString(value.id) &&
400
+ value.goalId === goalId &&
401
+ isNonNegativeInteger(value.goalRevision) &&
402
+ isNonNegativeInteger(value.objectiveRevision) &&
403
+ isNonEmptyString(value.turnId) &&
404
+ typeof value.kind === 'string' &&
405
+ EVIDENCE_KINDS.has(value.kind) &&
406
+ isNonEmptyString(value.subject) &&
407
+ typeof value.result === 'string' &&
408
+ EVIDENCE_RESULTS.has(value.result) &&
409
+ isNonEmptyString(value.sourceRef) &&
410
+ isNonNegativeNumber(value.capturedAt) &&
411
+ (value.workspaceFingerprint === undefined || typeof value.workspaceFingerprint === 'string') &&
412
+ isOptionalNonNegativeNumber(value.expiresAt) &&
413
+ assertionEnvelopeValid &&
414
+ typeof value.redacted === 'boolean');
415
+ }
416
+ const MAX_EVIDENCE_LEDGER_RECORDS = 500;
417
+ const MAX_EVIDENCE_TRUNCATION_COUNT = 1000000000;
418
+ function isGoalEvidenceLedgerTruncation(value) {
419
+ if (!isRecord(value))
420
+ return false;
421
+ const fields = Object.keys(value);
422
+ if (fields.length !== 4 ||
423
+ !fields.every(field => ['objectiveRevision', 'droppedPassed', 'droppedFailed', 'droppedInconclusive'].includes(field))) {
424
+ return false;
425
+ }
426
+ const boundedCount = (count) => isNonNegativeInteger(count) && count <= MAX_EVIDENCE_TRUNCATION_COUNT;
427
+ return (isNonNegativeInteger(value.objectiveRevision) &&
428
+ boundedCount(value.droppedPassed) &&
429
+ boundedCount(value.droppedFailed) &&
430
+ boundedCount(value.droppedInconclusive) &&
431
+ value.droppedPassed + value.droppedFailed + value.droppedInconclusive > 0);
432
+ }
433
+ function isCompletionCriterionResult(value) {
434
+ return (isRecord(value) &&
435
+ isNonEmptyString(value.criterionId) &&
436
+ typeof value.passed === 'boolean' &&
437
+ typeof value.status === 'string' &&
438
+ CRITERION_STATUSES.has(value.status) &&
439
+ isStringArray(value.evidenceRefs) &&
440
+ (value.reason === undefined || typeof value.reason === 'string'));
441
+ }
442
+ function isGoalCompletionAudit(value) {
443
+ if (!isRecord(value))
444
+ return false;
445
+ if (!isNonNegativeNumber(value.requestedAt) ||
446
+ !isNonNegativeNumber(value.auditedAt) ||
447
+ typeof value.passed !== 'boolean' ||
448
+ typeof value.verificationSummary !== 'string' ||
449
+ !isStringArray(value.remainingRequirements) ||
450
+ !isStringArray(value.evidenceRefs) ||
451
+ (value.criterionResults !== undefined &&
452
+ (!Array.isArray(value.criterionResults) ||
453
+ !value.criterionResults.every(isCompletionCriterionResult)))) {
454
+ return false;
455
+ }
456
+ if (value.finalSummary === undefined)
457
+ return true;
458
+ const summary = value.finalSummary;
459
+ if (!isRecord(summary) || !isRecord(summary.accounting))
460
+ return false;
461
+ return (isNonEmptyString(summary.originalObjective) &&
462
+ isNonEmptyString(summary.currentObjective) &&
463
+ isNonNegativeInteger(summary.objectiveRevision) &&
464
+ isNonNegativeNumber(summary.completedAt) &&
465
+ typeof summary.verificationSummary === 'string' &&
466
+ Array.isArray(summary.criterionResults) &&
467
+ summary.criterionResults.every(result => isRecord(result) &&
468
+ isNonEmptyString(result.criterionId) &&
469
+ typeof result.status === 'string' &&
470
+ CRITERION_STATUSES.has(result.status) &&
471
+ isStringArray(result.evidenceRefs) &&
472
+ (result.evidence === undefined ||
473
+ (Array.isArray(result.evidence) &&
474
+ result.evidence.every(evidence => isRecord(evidence) &&
475
+ isNonEmptyString(evidence.evidenceId) &&
476
+ typeof evidence.kind === 'string' &&
477
+ EVIDENCE_KINDS.has(evidence.kind) &&
478
+ typeof evidence.provenance === 'string' &&
479
+ EVIDENCE_PROVENANCE.has(evidence.provenance) &&
480
+ typeof evidence.result === 'string' &&
481
+ EVIDENCE_RESULTS.has(evidence.result) &&
482
+ isNonEmptyString(evidence.subject))))) &&
483
+ isStringArray(summary.evidenceRefs) &&
484
+ isNonNegativeInteger(summary.accounting.tokensUsed) &&
485
+ isNonNegativeNumber(summary.accounting.timeUsedMs) &&
486
+ isNonNegativeInteger(summary.accounting.continuationCount) &&
487
+ summary.accounting.usageComplete === true &&
488
+ Array.isArray(summary.remainingRequirements) &&
489
+ summary.remainingRequirements.length === 0 &&
490
+ summary.stopReason === 'completed');
491
+ }
492
+ function invalidCompleteGoalReason(value) {
493
+ if (value.status !== 'complete')
494
+ return null;
495
+ // v0.1.1 sidecars had no contract. They remain readable and are downgraded
496
+ // to a paused, pending contract by GoalCoordinator so v0.1.2 can re-verify
497
+ // them instead of trusting an unverifiable legacy terminal marker.
498
+ if (value.contract === undefined)
499
+ return null;
500
+ if (!isNonNegativeNumber(value.completedAt)) {
501
+ return 'complete goal requires completedAt';
502
+ }
503
+ if (value.blocker !== undefined) {
504
+ return 'complete goal cannot retain a blocker';
505
+ }
506
+ if (value.activeSince !== undefined) {
507
+ return 'complete goal cannot retain activeSince';
508
+ }
509
+ if (value.stopReason !== undefined) {
510
+ return 'complete goal cannot retain a top-level stopReason';
511
+ }
512
+ if (!isRecord(value.contract) || !Array.isArray(value.contract.successCriteria)) {
513
+ return 'complete goal requires a valid contract';
514
+ }
515
+ if (!isRecord(value.completionAudit) || value.completionAudit.passed !== true) {
516
+ return 'complete goal requires a passed completionAudit';
517
+ }
518
+ const audit = value.completionAudit;
519
+ if (!Array.isArray(audit.remainingRequirements) || audit.remainingRequirements.length !== 0) {
520
+ return 'complete goal cannot have remaining requirements';
521
+ }
522
+ const summary = audit.finalSummary;
523
+ if (!summary || !isRecord(summary)) {
524
+ return 'complete goal requires a finalSummary';
525
+ }
526
+ const contract = value.contract;
527
+ const criteria = contract.successCriteria;
528
+ const auditResults = audit.criterionResults ?? [];
529
+ const summaryResults = summary.criterionResults;
530
+ const criterionIds = new Set(criteria.map(criterion => criterion.id));
531
+ const matchesPassedCriteria = (results, includePassedFlag) => {
532
+ const resultIds = new Set(results.map(result => result.criterionId));
533
+ return (results.length === criterionIds.size &&
534
+ resultIds.size === criterionIds.size &&
535
+ results.every(result => typeof result.criterionId === 'string' &&
536
+ criterionIds.has(result.criterionId) &&
537
+ result.status === 'passed' &&
538
+ (!includePassedFlag || result.passed === true)));
539
+ };
540
+ if (criteria.length === 0 || criteria.some(criterion => criterion.status !== 'passed')) {
541
+ return 'complete goal requires every success criterion to be passed';
542
+ }
543
+ if (!matchesPassedCriteria(auditResults, true)) {
544
+ return 'complete goal completionAudit does not cover every passed criterion';
545
+ }
546
+ if (!matchesPassedCriteria(summaryResults, false)) {
547
+ return 'complete goal finalSummary does not cover every passed criterion';
548
+ }
549
+ if (summary.completedAt !== value.completedAt ||
550
+ summary.currentObjective !== value.objective ||
551
+ summary.originalObjective !== contract.originalObjective ||
552
+ summary.objectiveRevision !== contract.objectiveRevision) {
553
+ return 'complete goal finalSummary does not match the persisted goal contract';
554
+ }
555
+ if (summary.accounting.tokensUsed !== value.tokensUsed ||
556
+ summary.accounting.timeUsedMs !== value.timeUsedMs ||
557
+ summary.accounting.continuationCount !== value.continuationCount) {
558
+ return 'complete goal finalSummary accounting does not match the persisted goal';
559
+ }
560
+ if (summary.verificationSummary !== audit.verificationSummary) {
561
+ return 'complete goal finalSummary verification does not match completionAudit';
562
+ }
563
+ const ledger = Array.isArray(value.evidenceLedger)
564
+ ? value.evidenceLedger
565
+ : [];
566
+ const evidenceById = new Map(ledger.map(record => [record.id, record]));
567
+ const sameRefs = (left, right) => {
568
+ if (!isStringArray(left) || !isStringArray(right))
569
+ return false;
570
+ const leftSet = new Set(left);
571
+ const rightSet = new Set(right);
572
+ return (left.length === leftSet.size &&
573
+ right.length === rightSet.size &&
574
+ leftSet.size === rightSet.size &&
575
+ [...leftSet].every(ref => rightSet.has(ref)));
576
+ };
577
+ const auditByCriterion = new Map(auditResults.map(result => [result.criterionId, result]));
578
+ const summaryByCriterion = new Map(summaryResults.map(result => [result.criterionId, result]));
579
+ for (const criterion of criteria) {
580
+ const result = auditByCriterion.get(criterion.id);
581
+ const summaryResult = summaryByCriterion.get(criterion.id);
582
+ if (!isStringArray(criterion.evidenceRefs) ||
583
+ criterion.evidenceRefs.length === 0 ||
584
+ criterion.evidenceRefs.some(ref => !evidenceById.has(ref)) ||
585
+ !result ||
586
+ !summaryResult ||
587
+ !isStringArray(result.evidenceRefs) ||
588
+ result.evidenceRefs.length === 0 ||
589
+ !result.evidenceRefs.every(ref => criterion.evidenceRefs.includes(ref)) ||
590
+ !sameRefs(result.evidenceRefs, summaryResult.evidenceRefs)) {
591
+ return 'complete goal criteria require consistent non-empty ledger evidence';
592
+ }
593
+ if (!Array.isArray(summaryResult.evidence)) {
594
+ return 'complete goal finalSummary requires evidence provenance receipts';
595
+ }
596
+ const receiptById = new Map(summaryResult.evidence.map(receipt => [receipt.evidenceId, receipt]));
597
+ if (receiptById.size !== summaryResult.evidence.length ||
598
+ receiptById.size !== result.evidenceRefs.length ||
599
+ result.evidenceRefs.some(ref => {
600
+ const record = evidenceById.get(ref);
601
+ const receipt = receiptById.get(ref);
602
+ if (!record || !receipt)
603
+ return true;
604
+ const expectedProvenance = record.kind === 'user'
605
+ ? 'user_acceptance'
606
+ : record.kind === 'external'
607
+ ? 'external'
608
+ : 'runtime_automatic';
609
+ return (receipt.kind !== record.kind ||
610
+ receipt.result !== record.result ||
611
+ receipt.subject !== record.subject ||
612
+ receipt.provenance !== expectedProvenance);
613
+ })) {
614
+ return 'complete goal finalSummary evidence receipts do not match the ledger';
615
+ }
616
+ }
617
+ const resultEvidenceRefs = [...new Set(auditResults.flatMap(result => result.evidenceRefs))];
618
+ if (resultEvidenceRefs.length === 0 ||
619
+ !sameRefs(audit.evidenceRefs, resultEvidenceRefs) ||
620
+ !sameRefs(summary.evidenceRefs, resultEvidenceRefs)) {
621
+ return 'complete goal evidence summary does not match criterion evidence';
622
+ }
623
+ const workspaceBoundKinds = new Set(['test', 'build', 'lint', 'file', 'runtime']);
624
+ const workspaceFingerprints = new Set(resultEvidenceRefs.flatMap(ref => {
625
+ const record = evidenceById.get(ref);
626
+ return record && workspaceBoundKinds.has(record.kind) && record.workspaceFingerprint
627
+ ? [record.workspaceFingerprint]
628
+ : [];
629
+ }));
630
+ const hasWorkspaceBoundEvidence = resultEvidenceRefs.some(ref => {
631
+ const record = evidenceById.get(ref);
632
+ return Boolean(record && workspaceBoundKinds.has(record.kind));
633
+ });
634
+ if (hasWorkspaceBoundEvidence && workspaceFingerprints.size !== 1) {
635
+ return 'complete goal workspace-bound evidence lacks one stable fingerprint';
636
+ }
637
+ const replay = (0, completion_audit_1.auditCompletion)({
638
+ objective: value.objective,
639
+ contract,
640
+ evidenceLedger: ledger,
641
+ evidenceLedgerTruncation: value.evidenceLedgerTruncation,
642
+ goalId: value.goalId,
643
+ goalRevision: value.revision,
644
+ requestedAt: audit.requestedAt,
645
+ verificationSummary: audit.verificationSummary,
646
+ workspaceFingerprint: [...workspaceFingerprints][0],
647
+ now: audit.auditedAt,
648
+ });
649
+ const replayResults = replay.criterionResults ?? [];
650
+ if (!replay.passed ||
651
+ replayResults.length !== auditResults.length ||
652
+ replayResults.some(replayed => {
653
+ const persisted = auditByCriterion.get(replayed.criterionId);
654
+ return (!persisted ||
655
+ persisted.passed !== replayed.passed ||
656
+ persisted.status !== replayed.status ||
657
+ !sameRefs(persisted.evidenceRefs, replayed.evidenceRefs));
658
+ }) ||
659
+ !sameRefs(replay.evidenceRefs, audit.evidenceRefs)) {
660
+ return 'complete goal completionAudit replay failed';
661
+ }
662
+ return null;
663
+ }
664
+ function invalidNonTerminalGoalReason(value) {
665
+ if (value.contract === undefined || value.status === 'complete')
666
+ return null;
667
+ // These invariants are additive v0.1.2 guarantees. Contract-less v0.1.1
668
+ // sidecars remain readable and are normalized by GoalCoordinator.
669
+ if (value.completedAt !== undefined) {
670
+ return 'non-complete goal cannot have completedAt';
671
+ }
672
+ if (isRecord(value.completionAudit) && value.completionAudit.finalSummary !== undefined) {
673
+ return 'non-complete goal cannot have a finalSummary';
674
+ }
675
+ if (isRecord(value.completionAudit) && value.completionAudit.passed === true) {
676
+ return 'non-complete goal cannot have a passed completionAudit';
677
+ }
678
+ if (value.status === 'blocked') {
679
+ if (!isRecord(value.blocker)) {
680
+ return 'blocked goal requires a blocker';
681
+ }
682
+ if (!isRecord(value.stopReason) || value.stopReason.kind !== 'blocked') {
683
+ return 'blocked goal requires a blocked stopReason';
684
+ }
685
+ }
686
+ if (value.status === 'active' && value.stopReason !== undefined) {
687
+ return 'active goal cannot have a stopReason';
688
+ }
689
+ return null;
690
+ }
691
+ function invalidBoundaryConfirmationReason(value) {
692
+ if (value.boundaryConfirmation === undefined)
693
+ return null;
694
+ if (!isRecord(value.boundaryConfirmation))
695
+ return 'boundaryConfirmation is invalid';
696
+ const boundary = value.boundaryConfirmation;
697
+ const hasConfirmedAt = boundary.confirmedAt !== undefined;
698
+ const hasConfirmedRevision = boundary.confirmedRevision !== undefined;
699
+ if (hasConfirmedAt !== hasConfirmedRevision) {
700
+ return 'boundaryConfirmation confirmedAt and confirmedRevision must be paired';
701
+ }
702
+ if (!isRecord(value.contract)) {
703
+ return 'boundaryConfirmation requires a contract';
704
+ }
705
+ if (boundary.objectiveRevision !== value.contract.objectiveRevision) {
706
+ return 'boundaryConfirmation objectiveRevision must match the contract';
707
+ }
708
+ if (hasConfirmedRevision && boundary.confirmedRevision > value.revision) {
709
+ return 'boundaryConfirmation confirmedRevision cannot exceed the Goal revision';
710
+ }
711
+ if (!hasConfirmedAt) {
712
+ if (value.status !== 'paused') {
713
+ return 'pending boundaryConfirmation requires a paused Goal';
714
+ }
715
+ if (!isRecord(value.stopReason) || value.stopReason.kind !== 'user') {
716
+ return 'pending boundaryConfirmation requires a user stopReason';
717
+ }
718
+ }
719
+ return null;
720
+ }
721
+ function invalidGoalSchemaReason(value) {
722
+ if (!isNonEmptyString(value.goalId))
723
+ return 'missing or invalid goalId';
724
+ if (!isNonEmptyString(value.sessionId))
725
+ return 'missing or invalid sessionId';
726
+ if (!isNonNegativeInteger(value.revision))
727
+ return 'revision must be a non-negative integer';
728
+ if (!isNonEmptyString(value.objective))
729
+ return 'objective must be a non-empty string';
730
+ if (typeof value.status !== 'string' || !GOAL_STATUSES.has(value.status)) {
731
+ return 'status is invalid';
732
+ }
733
+ if (value.tokenBudget !== undefined &&
734
+ (!Number.isInteger(value.tokenBudget) ||
735
+ !isNonNegativeNumber(value.tokenBudget) ||
736
+ value.tokenBudget < 1)) {
737
+ return 'tokenBudget must be a positive integer';
738
+ }
739
+ if (!isNonNegativeInteger(value.tokensUsed))
740
+ return 'tokensUsed must be a non-negative integer';
741
+ if (!isNonNegativeNumber(value.timeUsedMs))
742
+ return 'timeUsedMs must be non-negative';
743
+ if (!isNonNegativeNumber(value.createdAt))
744
+ return 'createdAt must be non-negative';
745
+ if (!isNonNegativeNumber(value.updatedAt))
746
+ return 'updatedAt must be non-negative';
747
+ if (!isOptionalNonNegativeNumber(value.activeSince))
748
+ return 'activeSince must be non-negative';
749
+ if (!isOptionalNonNegativeNumber(value.completedAt))
750
+ return 'completedAt must be non-negative';
751
+ if (!isNonNegativeInteger(value.continuationCount)) {
752
+ return 'continuationCount must be a non-negative integer';
753
+ }
754
+ if (!isNonNegativeInteger(value.noProgressCount)) {
755
+ return 'noProgressCount must be a non-negative integer';
756
+ }
757
+ if (value.blocker !== undefined && !isGoalBlocker(value.blocker))
758
+ return 'blocker is invalid';
759
+ if (value.lastTurn !== undefined && !isGoalLastTurn(value.lastTurn))
760
+ return 'lastTurn is invalid';
761
+ if (value.recentNoProgressTurns !== undefined &&
762
+ (!Array.isArray(value.recentNoProgressTurns) ||
763
+ value.recentNoProgressTurns.length > 3 ||
764
+ !value.recentNoProgressTurns.every(isGoalNoProgressTurn))) {
765
+ return 'recentNoProgressTurns is invalid';
766
+ }
767
+ if (value.progressEvidenceKeys !== undefined &&
768
+ (!Array.isArray(value.progressEvidenceKeys) ||
769
+ value.progressEvidenceKeys.length > 1000 ||
770
+ !value.progressEvidenceKeys.every(key => typeof key === 'string' && /^[a-f0-9]{64}$/u.test(key)))) {
771
+ return 'progressEvidenceKeys is invalid';
772
+ }
773
+ if (value.completionAudit !== undefined && !isGoalCompletionAudit(value.completionAudit)) {
774
+ return 'completionAudit is invalid';
775
+ }
776
+ if (value.stopReason !== undefined && !isStopReason(value.stopReason)) {
777
+ return 'stopReason is invalid';
778
+ }
779
+ if (value.boundaryConfirmation !== undefined &&
780
+ !isBoundaryConfirmation(value.boundaryConfirmation)) {
781
+ return 'boundaryConfirmation is invalid';
782
+ }
783
+ if (value.contract !== undefined && !isGoalContract(value.contract))
784
+ return 'contract is invalid';
785
+ const boundaryConfirmationReason = invalidBoundaryConfirmationReason(value);
786
+ if (boundaryConfirmationReason)
787
+ return boundaryConfirmationReason;
788
+ if (value.evidenceLedger !== undefined &&
789
+ (!Array.isArray(value.evidenceLedger) ||
790
+ value.evidenceLedger.length > MAX_EVIDENCE_LEDGER_RECORDS ||
791
+ !value.evidenceLedger.every(evidence => isGoalEvidence(evidence, value.goalId)) ||
792
+ new Set(value.evidenceLedger.map(evidence => evidence.id)).size !==
793
+ value.evidenceLedger.length)) {
794
+ return 'evidenceLedger is invalid';
795
+ }
796
+ if (value.evidenceLedgerTruncation !== undefined &&
797
+ (!isGoalEvidenceLedgerTruncation(value.evidenceLedgerTruncation) ||
798
+ !isRecord(value.contract) ||
799
+ value.evidenceLedgerTruncation.objectiveRevision !== value.contract.objectiveRevision)) {
800
+ return 'evidenceLedgerTruncation is invalid';
801
+ }
802
+ const nonTerminalGoalReason = invalidNonTerminalGoalReason(value);
803
+ if (nonTerminalGoalReason)
804
+ return nonTerminalGoalReason;
805
+ const completeGoalReason = invalidCompleteGoalReason(value);
806
+ if (completeGoalReason)
807
+ return completeGoalReason;
808
+ return null;
809
+ }
810
+ const GOAL_LOAD_RACE_RETRIES = 8;
811
+ function loadGoalInternal(projectPath, sessionId, lockHeld, retriesRemaining) {
52
812
  const path = goalSidecarPath(projectPath, sessionId);
53
- const dir = (0, config_dir_1.getProjectSessionsDir)(projectPath);
813
+ let raw;
54
814
  try {
55
- (0, fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
815
+ raw = (0, fs_1.readFileSync)(path, 'utf8');
56
816
  }
57
- catch { /* dir exists */ }
58
- // CAS: if expectedRevision is provided, validate before writing.
59
- if (expectedRevision !== undefined) {
60
- const existing = loadGoal(projectPath, sessionId);
61
- if (existing.ok) {
62
- if (existing.value.revision !== expectedRevision) {
63
- return { ok: false, error: 'revision_stale', message: `Expected revision ${expectedRevision}, got ${existing.value.revision}` };
64
- }
817
+ catch (error) {
818
+ if (error.code === 'ENOENT') {
819
+ return { ok: false, error: 'not_found', message: `No goal sidecar at ${path}` };
820
+ }
821
+ return {
822
+ ok: false,
823
+ error: 'io_error',
824
+ message: `Failed to read goal sidecar: ${error instanceof Error ? error.message : String(error)}`,
825
+ };
826
+ }
827
+ const quarantineInvalid = (failure, reason) => {
828
+ const quarantined = quarantineSidecar(path, raw, reason, lockHeld);
829
+ if (!quarantined.ok)
830
+ return quarantined;
831
+ if (quarantined.value === 'quarantined')
832
+ return failure;
833
+ if (quarantined.value === 'missing') {
834
+ return { ok: false, error: 'not_found', message: `No goal sidecar at ${path}` };
65
835
  }
66
- else if (existing.error !== 'not_found' && expectedRevision !== 0) {
67
- return { ok: false, error: 'revision_stale', message: 'Goal exists but is corrupt' };
836
+ if (retriesRemaining <= 0) {
837
+ return {
838
+ ok: false,
839
+ error: 'io_error',
840
+ message: `Goal sidecar changed repeatedly while loading ${path}`,
841
+ };
68
842
  }
843
+ return loadGoalInternal(projectPath, sessionId, lockHeld, retriesRemaining - 1);
844
+ };
845
+ let parsed;
846
+ try {
847
+ parsed = JSON.parse(raw);
848
+ }
849
+ catch (error) {
850
+ return quarantineInvalid({ ok: false, error: 'corrupt', message: 'Failed to parse goal sidecar' }, error instanceof Error ? error.message : String(error));
851
+ }
852
+ if (!isRecord(parsed)) {
853
+ return quarantineInvalid({ ok: false, error: 'corrupt', message: 'Goal sidecar has invalid schema' }, 'sidecar root must be an object');
854
+ }
855
+ if (typeof parsed.version === 'number' && parsed.version !== 1) {
856
+ return quarantineInvalid({
857
+ ok: false,
858
+ error: 'incompatible_schema',
859
+ message: `Goal sidecar schema version ${String(parsed.version)} is not supported`,
860
+ }, `incompatible schema version: ${String(parsed.version)}`);
861
+ }
862
+ if (parsed.version !== 1) {
863
+ return quarantineInvalid({ ok: false, error: 'corrupt', message: 'Goal sidecar has invalid schema' }, 'missing or invalid schema version');
864
+ }
865
+ const invalidReason = invalidGoalSchemaReason(parsed);
866
+ if (invalidReason) {
867
+ return quarantineInvalid({ ok: false, error: 'corrupt', message: `Goal sidecar is corrupt: ${invalidReason}` }, invalidReason);
69
868
  }
70
- const tmpPath = `${path}.tmp-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
869
+ if (parsed.sessionId !== sessionId) {
870
+ return quarantineInvalid({
871
+ ok: false,
872
+ error: 'metadata_mismatch',
873
+ message: 'Goal sidecar sessionId mismatch',
874
+ }, `sessionId mismatch: expected ${sessionId}, got ${parsed.sessionId}`);
875
+ }
876
+ return { ok: true, value: parsed };
877
+ }
878
+ function loadGoal(projectPath, sessionId) {
879
+ return loadGoalInternal(projectPath, sessionId, false, GOAL_LOAD_RACE_RETRIES);
880
+ }
881
+ /**
882
+ * A successful delete atomically renames the last valid sidecar to this
883
+ * durable fence. Keeping the deleted identity on disk prevents a writer that
884
+ * survived in another process from treating the missing sidecar as a fresh
885
+ * revision-zero create after a restart.
886
+ */
887
+ function readGoalDeletionFence(sidecarPath, sessionId) {
888
+ const fencePath = goalDeletionFencePath(sidecarPath);
889
+ let parsed;
71
890
  try {
72
- (0, fs_1.writeFileSync)(tmpPath, JSON.stringify(goal, null, 2), { encoding: 'utf8', mode: 0o600 });
73
- (0, fs_1.renameSync)(tmpPath, path);
74
- return { ok: true, value: undefined };
891
+ parsed = JSON.parse((0, fs_1.readFileSync)(fencePath, 'utf8'));
75
892
  }
76
- catch (err) {
77
- try {
78
- (0, fs_1.unlinkSync)(tmpPath);
893
+ catch (error) {
894
+ if (error.code === 'ENOENT') {
895
+ return { ok: false, error: 'not_found', message: `No Goal deletion fence at ${fencePath}` };
79
896
  }
80
- catch { /* best effort */ }
81
- return { ok: false, error: 'io_error', message: err instanceof Error ? err.message : String(err) };
897
+ return {
898
+ ok: false,
899
+ error: 'io_error',
900
+ message: `Failed to read Goal deletion fence: ${error instanceof Error ? error.message : String(error)}`,
901
+ };
902
+ }
903
+ if (!isRecord(parsed) ||
904
+ parsed.sessionId !== sessionId ||
905
+ !isNonEmptyString(parsed.goalId) ||
906
+ !isNonNegativeInteger(parsed.revision)) {
907
+ return {
908
+ ok: false,
909
+ error: 'io_error',
910
+ message: `Goal deletion fence at ${fencePath} is invalid`,
911
+ };
912
+ }
913
+ return {
914
+ ok: true,
915
+ value: { goalId: parsed.goalId, revision: parsed.revision },
916
+ };
917
+ }
918
+ function staleRevision(message) {
919
+ return { ok: false, error: 'revision_stale', message };
920
+ }
921
+ function saveGoalInternal(projectPath, sessionId, goal, expectedRevision, allowGoalReplacement) {
922
+ const candidate = goal;
923
+ const invalidReason = goal.version !== 1
924
+ ? 'missing or invalid schema version'
925
+ : goal.sessionId !== sessionId
926
+ ? `sessionId mismatch: expected ${sessionId}, got ${goal.sessionId}`
927
+ : expectedRevision !== undefined && !isNonNegativeInteger(expectedRevision)
928
+ ? 'expectedRevision must be a non-negative safe integer'
929
+ : invalidGoalSchemaReason(candidate);
930
+ if (invalidReason) {
931
+ return {
932
+ ok: false,
933
+ error: 'io_error',
934
+ message: `Refusing to persist an invalid Goal sidecar: ${invalidReason}`,
935
+ };
936
+ }
937
+ const path = goalSidecarPath(projectPath, sessionId);
938
+ const dir = (0, config_dir_1.getProjectSessionsDir)(projectPath);
939
+ try {
940
+ (0, fs_1.mkdirSync)(dir, { recursive: true, mode: 0o700 });
941
+ }
942
+ catch {
943
+ /* dir exists */
82
944
  }
945
+ return withGoalSidecarLock(path, () => {
946
+ // The revision check and rename happen under the same per-sidecar lock. A
947
+ // second process cannot pass the comparison against a revision that the
948
+ // first process is concurrently replacing.
949
+ if (expectedRevision !== undefined) {
950
+ const existing = loadGoalInternal(projectPath, sessionId, true, GOAL_LOAD_RACE_RETRIES);
951
+ if (existing.ok) {
952
+ if (existing.value.revision !== expectedRevision) {
953
+ return staleRevision(`Expected revision ${expectedRevision}, got ${existing.value.revision}`);
954
+ }
955
+ if (!allowGoalReplacement && existing.value.goalId !== goal.goalId) {
956
+ return staleRevision(`Expected Goal ${goal.goalId}, got ${existing.value.goalId} at revision ${expectedRevision}`);
957
+ }
958
+ }
959
+ else if (existing.error === 'not_found') {
960
+ const fence = readGoalDeletionFence(path, sessionId);
961
+ if (fence.ok) {
962
+ // A deletion fence is authoritative for every CAS writer. Even a
963
+ // fresh Goal identity with the same expected revision may have been
964
+ // constructed by a stale coordinator before another process cleared
965
+ // the session. Only a truly fresh create (expectedRevision omitted)
966
+ // may replace the fence.
967
+ return staleRevision(`Goal ${fence.value.goalId} was deleted at revision ${fence.value.revision}`);
968
+ }
969
+ else if (fence.error !== 'not_found') {
970
+ return fence;
971
+ }
972
+ else if (!allowGoalReplacement || expectedRevision !== 0) {
973
+ return staleRevision(`Expected revision ${expectedRevision}, but the Goal sidecar is missing`);
974
+ }
975
+ }
976
+ else if (existing.error !== 'corrupt' || expectedRevision !== 0) {
977
+ return existing;
978
+ }
979
+ }
980
+ else if (!(0, fs_1.existsSync)(path)) {
981
+ const fence = readGoalDeletionFence(path, sessionId);
982
+ if (fence.ok && fence.value.goalId === goal.goalId) {
983
+ return staleRevision(`Goal ${fence.value.goalId} was deleted at revision ${fence.value.revision}`);
984
+ }
985
+ if (!fence.ok && fence.error !== 'not_found') {
986
+ return fence;
987
+ }
988
+ }
989
+ const tmpPath = `${path}.tmp-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
990
+ try {
991
+ (0, fs_1.writeFileSync)(tmpPath, JSON.stringify(goal, null, 2), { encoding: 'utf8', mode: 0o600 });
992
+ (0, fs_1.renameSync)(tmpPath, path);
993
+ try {
994
+ (0, fs_1.unlinkSync)(goalDeletionFencePath(path));
995
+ }
996
+ catch {
997
+ // The sidecar rename above is the commit point. Fence cleanup is only
998
+ // post-commit hygiene: a retained fence is ignored while the live
999
+ // sidecar exists and will be replaced by the next atomic delete. Never
1000
+ // report a failed save after the new Goal is already durable.
1001
+ }
1002
+ return { ok: true, value: undefined };
1003
+ }
1004
+ catch (err) {
1005
+ try {
1006
+ (0, fs_1.unlinkSync)(tmpPath);
1007
+ }
1008
+ catch {
1009
+ /* best effort */
1010
+ }
1011
+ return {
1012
+ ok: false,
1013
+ error: 'io_error',
1014
+ message: err instanceof Error ? err.message : String(err),
1015
+ };
1016
+ }
1017
+ });
1018
+ }
1019
+ function saveGoal(projectPath, sessionId, goal, expectedRevision) {
1020
+ return saveGoalInternal(projectPath, sessionId, goal, expectedRevision, false);
83
1021
  }
84
- function deleteGoal(projectPath, sessionId) {
1022
+ function deleteGoal(projectPath, sessionId, expectedRevision) {
85
1023
  const path = goalSidecarPath(projectPath, sessionId);
86
- if (!(0, fs_1.existsSync)(path))
87
- return { ok: true, value: undefined };
88
1024
  try {
89
- (0, fs_1.unlinkSync)(path);
90
- // Also clean up any stale temp files.
1025
+ (0, fs_1.mkdirSync)((0, config_dir_1.getProjectSessionsDir)(projectPath), { recursive: true, mode: 0o700 });
1026
+ }
1027
+ catch (error) {
1028
+ return {
1029
+ ok: false,
1030
+ error: 'io_error',
1031
+ message: error instanceof Error ? error.message : String(error),
1032
+ };
1033
+ }
1034
+ return withGoalSidecarLock(path, () => {
1035
+ if (expectedRevision !== undefined) {
1036
+ const existing = loadGoalInternal(projectPath, sessionId, true, GOAL_LOAD_RACE_RETRIES);
1037
+ if (!existing.ok)
1038
+ return existing;
1039
+ if (existing.value.revision !== expectedRevision) {
1040
+ return {
1041
+ ok: false,
1042
+ error: 'revision_stale',
1043
+ message: `Expected revision ${expectedRevision}, got ${existing.value.revision}`,
1044
+ };
1045
+ }
1046
+ }
1047
+ else if (!(0, fs_1.existsSync)(path)) {
1048
+ return { ok: true, value: undefined };
1049
+ }
91
1050
  try {
92
- const dir = (0, config_dir_1.getProjectSessionsDir)(projectPath);
93
- // Using require here would create a circular dependency, so we use a
94
- // simple readdirSync pattern that matches the tmp- prefix.
95
- const { readdirSync } = require('fs');
96
- for (const file of readdirSync(dir)) {
97
- if (file.startsWith(`${sessionId}.goal.json.tmp-`)) {
1051
+ // Renaming the valid sidecar itself makes deletion and durable fencing a
1052
+ // single atomic filesystem transition. A crash cannot leave a window in
1053
+ // which both the sidecar and its cross-process deletion record are absent.
1054
+ (0, fs_1.chmodSync)(path, 0o600);
1055
+ (0, fs_1.renameSync)(path, goalDeletionFencePath(path));
1056
+ // The atomic rename above is the safety boundary. Compact the retained
1057
+ // sidecar to the minimum identity needed for stale-writer rejection; if
1058
+ // compaction fails, the renamed full sidecar remains a valid fence.
1059
+ const fence = readGoalDeletionFence(path, sessionId);
1060
+ if (fence.ok) {
1061
+ const fencePath = goalDeletionFencePath(path);
1062
+ const compactPath = `${path}.tmp-delete-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
1063
+ try {
1064
+ (0, fs_1.writeFileSync)(compactPath, JSON.stringify({
1065
+ version: 1,
1066
+ kind: 'goal_deletion_fence',
1067
+ sessionId,
1068
+ goalId: fence.value.goalId,
1069
+ revision: fence.value.revision,
1070
+ deletedAt: Date.now(),
1071
+ }, null, 2), { encoding: 'utf8', mode: 0o600 });
1072
+ (0, fs_1.renameSync)(compactPath, fencePath);
1073
+ }
1074
+ catch {
98
1075
  try {
99
- (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
1076
+ (0, fs_1.unlinkSync)(compactPath);
1077
+ }
1078
+ catch {
1079
+ /* the atomically renamed full sidecar remains the fence */
100
1080
  }
101
- catch { /* ok */ }
102
1081
  }
103
1082
  }
1083
+ // Also clean up any stale temp files.
1084
+ try {
1085
+ const dir = (0, config_dir_1.getProjectSessionsDir)(projectPath);
1086
+ // Using require here would create a circular dependency, so we use a
1087
+ // simple readdirSync pattern that matches the tmp- prefix.
1088
+ const { readdirSync } = require('fs');
1089
+ for (const file of readdirSync(dir)) {
1090
+ if (file.startsWith(`${sessionId}.goal.json.tmp-`)) {
1091
+ try {
1092
+ (0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
1093
+ }
1094
+ catch {
1095
+ /* ok */
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ catch {
1101
+ /* ok */
1102
+ }
1103
+ return { ok: true, value: undefined };
104
1104
  }
105
- catch { /* ok */ }
106
- return { ok: true, value: undefined };
107
- }
108
- catch (err) {
109
- return { ok: false, error: 'io_error', message: err instanceof Error ? err.message : String(err) };
110
- }
1105
+ catch (err) {
1106
+ return {
1107
+ ok: false,
1108
+ error: 'io_error',
1109
+ message: err instanceof Error ? err.message : String(err),
1110
+ };
1111
+ }
1112
+ });
111
1113
  }
112
- function createGoal(projectPath, sessionId, objective) {
1114
+ function createGoal(projectPath, sessionId, objective, contract, expectedRevision, initialState) {
113
1115
  const now = Date.now();
1116
+ const status = initialState?.status ?? 'active';
114
1117
  const goal = {
115
1118
  version: 1,
116
1119
  goalId: (0, crypto_1.randomUUID)(),
117
1120
  sessionId,
118
1121
  revision: 0,
119
1122
  objective: objective.trim(),
120
- status: 'active',
1123
+ status,
121
1124
  tokensUsed: 0,
122
1125
  timeUsedMs: 0,
123
1126
  createdAt: now,
124
1127
  updatedAt: now,
125
- activeSince: now,
1128
+ ...(status === 'active' ? { activeSince: now } : {}),
126
1129
  continuationCount: 0,
127
1130
  noProgressCount: 0,
1131
+ // v0.1.2: persist contract if provided. Omitted for pre-v0.1.2 callers,
1132
+ // in which case the coordinator normalizes a minimal pending contract at
1133
+ // load time (additive, never rewrites history).
1134
+ ...(contract ? { contract } : {}),
1135
+ ...(initialState ?? {}),
128
1136
  };
129
- const result = saveGoal(projectPath, sessionId, goal);
1137
+ const result = saveGoalInternal(projectPath, sessionId, goal, expectedRevision, true);
130
1138
  if (!result.ok)
131
1139
  return result;
132
- return { ok: true, value: goal };
1140
+ return { ok: true, value: goal, ...(result.warnings ? { warnings: result.warnings } : {}) };
133
1141
  }
134
1142
  // ============================================================================
135
1143
  // Helpers
136
1144
  // ============================================================================
137
- function quarantineSidecar(path, reason) {
138
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
139
- const quarantinePath = `${path}.corrupt-${timestamp}`;
140
- try {
141
- (0, fs_1.renameSync)(path, quarantinePath);
142
- console.warn(`[goal-storage] Quarantined corrupt goal sidecar: ${path} (${reason})`);
143
- }
144
- catch {
145
- console.warn(`[goal-storage] Failed to quarantine corrupt goal sidecar: ${path} (${reason})`);
146
- }
1145
+ function quarantineSidecar(path, observedRaw, reason, lockHeld) {
1146
+ const quarantineObserved = () => {
1147
+ let currentRaw;
1148
+ try {
1149
+ currentRaw = (0, fs_1.readFileSync)(path, 'utf8');
1150
+ }
1151
+ catch (error) {
1152
+ if (error.code === 'ENOENT') {
1153
+ return { ok: true, value: 'missing' };
1154
+ }
1155
+ return {
1156
+ ok: false,
1157
+ error: 'io_error',
1158
+ message: `Failed to re-read goal sidecar before quarantine: ${error instanceof Error ? error.message : String(error)}`,
1159
+ };
1160
+ }
1161
+ // The invalid bytes were observed before taking the writer lock. Another
1162
+ // writer may have atomically installed a valid sidecar in the meantime.
1163
+ // Only quarantine the exact bytes that triggered this load failure.
1164
+ if (currentRaw !== observedRaw)
1165
+ return { ok: true, value: 'changed' };
1166
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1167
+ const quarantinePath = `${path}.corrupt-${timestamp}`;
1168
+ try {
1169
+ (0, fs_1.renameSync)(path, quarantinePath);
1170
+ console.warn(`[goal-storage] Quarantined corrupt goal sidecar: ${path} (${reason})`);
1171
+ return { ok: true, value: 'quarantined' };
1172
+ }
1173
+ catch (error) {
1174
+ if (error.code === 'ENOENT') {
1175
+ return { ok: true, value: 'missing' };
1176
+ }
1177
+ return {
1178
+ ok: false,
1179
+ error: 'io_error',
1180
+ message: `Failed to quarantine corrupt goal sidecar: ${error instanceof Error ? error.message : String(error)}`,
1181
+ };
1182
+ }
1183
+ };
1184
+ return lockHeld ? quarantineObserved() : withGoalSidecarLock(path, quarantineObserved);
147
1185
  }
148
1186
  // ============================================================================
149
1187
  // Sidecar suffix for storage-maintenance