@ours.network/fleet 1.1.0-nightly.2 → 1.1.0-nightly.21

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 (202) hide show
  1. package/README.md +204 -55
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.js +18 -6
  5. package/dist/application/fleet-query-service.js +20 -5
  6. package/dist/application/role-command-service.js +9 -0
  7. package/dist/application/role-creation-service.d.ts +6 -25
  8. package/dist/application/role-creation-service.js +34 -72
  9. package/dist/application/role-repository.d.ts +4 -2
  10. package/dist/application/role-repository.js +6 -3
  11. package/dist/application/session-control.d.ts +3 -1
  12. package/dist/application/session-control.js +4 -2
  13. package/dist/application/task-room-service.d.ts +58 -6
  14. package/dist/application/task-room-service.js +435 -79
  15. package/dist/briefing.js +74 -24
  16. package/dist/build-info.json +4 -4
  17. package/dist/canonical-json.d.ts +2 -0
  18. package/dist/canonical-json.js +10 -0
  19. package/dist/cli.js +241 -76
  20. package/dist/config-yaml.d.ts +1 -1
  21. package/dist/config.d.ts +75 -3
  22. package/dist/config.js +274 -34
  23. package/dist/creation.d.ts +1 -1
  24. package/dist/creation.js +3 -2
  25. package/dist/daemon-recovery.d.ts +93 -0
  26. package/dist/daemon-recovery.js +328 -0
  27. package/dist/docs.d.ts +1 -1
  28. package/dist/docs.js +192 -66
  29. package/dist/doctor.d.ts +2 -0
  30. package/dist/doctor.js +54 -4
  31. package/dist/fleet-command-audit.d.ts +165 -0
  32. package/dist/fleet-command-audit.js +555 -0
  33. package/dist/fleet-proxy.d.ts +7 -2
  34. package/dist/fleet-proxy.js +30 -8
  35. package/dist/harness/agent-session.d.ts +5 -0
  36. package/dist/harness/claude-code-session.d.ts +2 -0
  37. package/dist/harness/claude-code-session.js +6 -1
  38. package/dist/harness/claude-code.js +2 -0
  39. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  40. package/dist/harness/codex-app-server-proxy.js +348 -14
  41. package/dist/harness/codex-session.d.ts +11 -2
  42. package/dist/harness/codex-session.js +22 -2
  43. package/dist/harness/codex.d.ts +5 -1
  44. package/dist/harness/codex.js +110 -10
  45. package/dist/harness/types.d.ts +1 -1
  46. package/dist/index.d.ts +5 -4
  47. package/dist/index.js +4 -3
  48. package/dist/init-guidance.d.ts +1 -1
  49. package/dist/init-guidance.js +4 -3
  50. package/dist/init-wizard.d.ts +98 -0
  51. package/dist/init-wizard.js +629 -0
  52. package/dist/lifecycle-summary.d.ts +134 -0
  53. package/dist/lifecycle-summary.js +179 -0
  54. package/dist/loops/config.d.ts +10 -1
  55. package/dist/loops/config.js +38 -5
  56. package/dist/model-env.d.ts +2 -2
  57. package/dist/model-env.js +1 -1
  58. package/dist/model-recovery.d.ts +1 -1
  59. package/dist/ops.js +2 -17
  60. package/dist/owner-channel/channel.d.ts +58 -0
  61. package/dist/owner-channel/channel.js +321 -42
  62. package/dist/owner-channel/commands.d.ts +6 -1
  63. package/dist/owner-channel/commands.js +34 -19
  64. package/dist/owner-channel/state.d.ts +2 -0
  65. package/dist/owner-channel/state.js +8 -2
  66. package/dist/permissions.js +1 -1
  67. package/dist/preset-bootstrap.d.ts +10 -0
  68. package/dist/preset-bootstrap.js +85 -0
  69. package/dist/preset-migration.d.ts +42 -0
  70. package/dist/preset-migration.js +414 -0
  71. package/dist/reports/artifact.d.ts +10 -0
  72. package/dist/reports/artifact.js +32 -0
  73. package/dist/reports/index.d.ts +5 -0
  74. package/dist/reports/index.js +5 -0
  75. package/dist/reports/render.d.ts +6 -0
  76. package/dist/reports/render.js +240 -0
  77. package/dist/reports/service.d.ts +42 -0
  78. package/dist/reports/service.js +57 -0
  79. package/dist/reports/tasks.d.ts +52 -0
  80. package/dist/reports/tasks.js +177 -0
  81. package/dist/reports/types.d.ts +315 -0
  82. package/dist/reports/types.js +6 -0
  83. package/dist/resolved-plan.js +13 -1
  84. package/dist/rooms-tasks/cli.d.ts +2 -0
  85. package/dist/rooms-tasks/cli.js +514 -75
  86. package/dist/rooms-tasks/close.d.ts +9 -1
  87. package/dist/rooms-tasks/close.js +20 -4
  88. package/dist/rooms-tasks/config.js +16 -14
  89. package/dist/rooms-tasks/cowork-adapter.d.ts +1 -0
  90. package/dist/rooms-tasks/cowork-adapter.js +6 -0
  91. package/dist/rooms-tasks/deletion.d.ts +51 -0
  92. package/dist/rooms-tasks/deletion.js +217 -0
  93. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  94. package/dist/rooms-tasks/external-worker.js +14 -9
  95. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  96. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  97. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  98. package/dist/rooms-tasks/member-overrides.js +204 -0
  99. package/dist/rooms-tasks/provision.js +184 -49
  100. package/dist/rooms-tasks/room-state.d.ts +1 -0
  101. package/dist/rooms-tasks/room-state.js +12 -1
  102. package/dist/rooms-tasks/task-state.d.ts +115 -3
  103. package/dist/rooms-tasks/task-state.js +361 -11
  104. package/dist/rooms-tasks/templates.d.ts +4 -2
  105. package/dist/rooms-tasks/templates.js +42 -78
  106. package/dist/rooms-tasks/terminal.d.ts +3 -0
  107. package/dist/rooms-tasks/terminal.js +7 -3
  108. package/dist/rooms-tasks/types.d.ts +96 -17
  109. package/dist/rooms-tasks/types.js +14 -4
  110. package/dist/runner.d.ts +12 -2
  111. package/dist/runner.js +124 -43
  112. package/dist/session/acp.d.ts +9 -1
  113. package/dist/session/acp.js +35 -9
  114. package/dist/session/arbiter.d.ts +2 -1
  115. package/dist/session/arbiter.js +2 -0
  116. package/dist/session/codex-app-server-transport.d.ts +53 -0
  117. package/dist/session/codex-app-server-transport.js +184 -0
  118. package/dist/session/codex-app-server.d.ts +122 -0
  119. package/dist/session/codex-app-server.js +1031 -0
  120. package/dist/session/control.d.ts +14 -1
  121. package/dist/session/control.js +38 -0
  122. package/dist/session/conversation-types.d.ts +4 -4
  123. package/dist/session/types.d.ts +24 -6
  124. package/dist/session/types.js +28 -0
  125. package/dist/spawn.d.ts +15 -31
  126. package/dist/spawn.js +117 -237
  127. package/dist/temp-lifecycle.d.ts +1 -1
  128. package/dist/watchdog/config.d.ts +10 -7
  129. package/dist/watchdog/config.js +29 -19
  130. package/dist/watchdog/run.js +4 -13
  131. package/dist/web/fleet-config-service.d.ts +1 -0
  132. package/dist/web/fleet-config-service.js +27 -8
  133. package/dist/web/runtime.js +11 -6
  134. package/dist/web/server.js +28 -1
  135. package/dist/web/topology-promote.js +8 -1
  136. package/dist/web-app/assets/index-C0h3ALvs.js +10 -0
  137. package/dist/web-app/index.html +1 -1
  138. package/package.json +3 -2
  139. package/presets/brain-catalog.json +16 -0
  140. package/presets/fleet/agent_templates/Critic.yaml +4 -0
  141. package/presets/fleet/agent_templates/Developer.yaml +4 -0
  142. package/presets/fleet/agent_templates/LocalCoordinator.yaml +4 -0
  143. package/presets/fleet/agents/FleetCoordinator.yaml +3 -0
  144. package/presets/fleet/brains/claude-default.yaml +5 -0
  145. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  146. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  147. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  148. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  149. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  150. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  151. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  152. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  153. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  154. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  155. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  156. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  157. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  158. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  159. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  188. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  189. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  190. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  191. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  192. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  193. package/presets/fleet/roles/Coordinator.yaml +30 -0
  194. package/presets/fleet/roles/Critic.yaml +19 -0
  195. package/presets/fleet/roles/Developer.yaml +19 -0
  196. package/presets/fleet/roles/LocalCoordinator.yaml +25 -0
  197. package/presets/fleet/room_templates/pair.yaml +10 -0
  198. package/presets/fleet/room_templates/single.yaml +7 -0
  199. package/presets/fleet/room_templates/team.yaml +12 -0
  200. package/presets/fleet.yaml +7 -0
  201. package/presets/manifest.json +6 -0
  202. package/dist/web-app/assets/index-BbE9EX6n.js +0 -10
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
- import { TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
6
+ import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
7
7
  import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
8
8
  export const tasksDir = () => join(stateRoot(), 'tasks');
9
9
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
@@ -116,11 +116,27 @@ function assertNoPendingTerminalIntent(task) {
116
116
  throw new TaskStateError(`task ${task.task_id} has a pending '${task.terminal_intent.kind}' terminal intent`);
117
117
  }
118
118
  }
119
+ /**
120
+ * Accepted deletion is the per-task epoch guard: once pending, every lifecycle
121
+ * mutation, terminal settlement, and room publication must fail boundedly.
122
+ */
123
+ export function assertNoPendingDeletion(task) {
124
+ if (task.deletion?.status === 'pending') {
125
+ throw new TaskStateError(`task ${task.task_id} is pending deletion; run 'ours-fleet task delete ${task.task_id} ${task.task_id}' to retry cleanup`);
126
+ }
127
+ }
119
128
  export function createTask(input) {
120
129
  const key = input.idempotency_key ?? randomUUID();
121
130
  const existing = findByIdempotencyKey(key);
122
- if (existing)
131
+ if (existing) {
132
+ const existingPlan = existing.execution_plan?.plan_hash;
133
+ const requestedPlan = input.execution_plan?.plan_hash;
134
+ const existingPolicy = storedRoomLaunchPolicy(existing.execution_plan?.room_policy);
135
+ const requestedPolicy = storedRoomLaunchPolicy(input.execution_plan?.room_policy);
136
+ if (existingPlan !== requestedPlan || JSON.stringify(existingPolicy) !== JSON.stringify(requestedPolicy))
137
+ throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
123
138
  return existing;
139
+ }
124
140
  const state = input.start === false ? 'backlog' : 'provisioning';
125
141
  const record = {
126
142
  task_id: generateTaskId(),
@@ -130,6 +146,7 @@ export function createTask(input) {
130
146
  brief_file: input.brief_file,
131
147
  state,
132
148
  template: input.template,
149
+ execution_plan: input.execution_plan,
133
150
  no_room: input.no_room || undefined,
134
151
  room_id: input.room_id,
135
152
  member_roles: [],
@@ -142,6 +159,25 @@ export function createTask(input) {
142
159
  return readTask(record.task_id);
143
160
  }
144
161
  export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
162
+ export function updateTaskExecutionPlan(id, executionPlan) {
163
+ return withTaskLock(id, () => {
164
+ const task = readTask(id);
165
+ assertNoPendingDeletion(task);
166
+ if (task.execution_plan) {
167
+ if (task.execution_plan.plan_hash !== executionPlan.plan_hash)
168
+ throw new TaskStateError(`task ${id} execution plan mismatch`);
169
+ const before = storedRoomLaunchPolicy(task.execution_plan.room_policy);
170
+ const after = storedRoomLaunchPolicy(executionPlan.room_policy);
171
+ if (task.room_id && JSON.stringify(before) !== JSON.stringify(after))
172
+ throw new TaskStateError(`task ${id} Room launch policy cannot change after Room creation`);
173
+ }
174
+ task.execution_plan = executionPlan;
175
+ task.template = { name: executionPlan.snapshot.name, version: executionPlan.snapshot.version,
176
+ content_hash: executionPlan.snapshot.content_hash };
177
+ writeTask(task);
178
+ return readTask(id);
179
+ });
180
+ }
145
181
  export function listTasks(filter) {
146
182
  const dir = tasksDir();
147
183
  if (!existsSync(dir))
@@ -154,6 +190,8 @@ export function listTasks(filter) {
154
190
  try {
155
191
  const id = f.slice(0, -5);
156
192
  const t = withTaskLock(id, () => presentTask(JSON.parse(readFileSync(join(dir, f), 'utf8'))));
193
+ if (t.deletion?.status === 'pending' && !filter?.includeDeleting)
194
+ continue;
157
195
  if ((!states || states.includes(t.state)) && (!filter?.listId || t.list_id === filter.listId))
158
196
  tasks.push(t);
159
197
  }
@@ -166,6 +204,7 @@ export function listTasks(filter) {
166
204
  export function moveTaskToList(id, listId) {
167
205
  return withTaskLock(id, () => {
168
206
  const task = readTask(id);
207
+ assertNoPendingDeletion(task);
169
208
  task.list_id = listId;
170
209
  writeTask(task);
171
210
  return readTask(id);
@@ -177,6 +216,7 @@ export function findByIdempotencyKey(key) {
177
216
  export function startTask(id) {
178
217
  return withTaskLock(id, () => {
179
218
  const t = readTask(id);
219
+ assertNoPendingDeletion(t);
180
220
  assertNoPendingTerminalIntent(t);
181
221
  assertTransition(t.state, 'provisioning');
182
222
  t.state = 'provisioning';
@@ -188,6 +228,7 @@ export function startTask(id) {
188
228
  export function activateTask(id) {
189
229
  return withTaskLock(id, () => {
190
230
  const t = readTask(id);
231
+ assertNoPendingDeletion(t);
191
232
  assertNoPendingTerminalIntent(t);
192
233
  assertTransition(t.state, 'active');
193
234
  t.state = 'active';
@@ -199,6 +240,7 @@ export function activateTask(id) {
199
240
  export function blockTask(id, reason) {
200
241
  return withTaskLock(id, () => {
201
242
  const t = readTask(id);
243
+ assertNoPendingDeletion(t);
202
244
  assertNoPendingTerminalIntent(t);
203
245
  if (TASK_TERMINAL_STATES.includes(t.state))
204
246
  throw new TaskStateError(`cannot block a '${t.state}' task`);
@@ -210,6 +252,7 @@ export function blockTask(id, reason) {
210
252
  export function unblockTask(id) {
211
253
  return withTaskLock(id, () => {
212
254
  const t = readTask(id);
255
+ assertNoPendingDeletion(t);
213
256
  assertNoPendingTerminalIntent(t);
214
257
  if (!t.blocked)
215
258
  throw new TaskStateError('task is not blocked');
@@ -221,6 +264,7 @@ export function unblockTask(id) {
221
264
  export function reviewTask(id) {
222
265
  return withTaskLock(id, () => {
223
266
  const t = readTask(id);
267
+ assertNoPendingDeletion(t);
224
268
  assertNoPendingTerminalIntent(t);
225
269
  assertTransition(t.state, 'review');
226
270
  t.state = 'review';
@@ -232,6 +276,7 @@ export function reviewTask(id) {
232
276
  export function completeTask(id, outcome) {
233
277
  return withTaskLock(id, () => {
234
278
  const t = readTask(id);
279
+ assertNoPendingDeletion(t);
235
280
  assertNoPendingTerminalIntent(t);
236
281
  assertTransition(t.state, 'done');
237
282
  t.state = 'done';
@@ -246,6 +291,7 @@ export function completeTask(id, outcome) {
246
291
  export function cancelTask(id) {
247
292
  return withTaskLock(id, () => {
248
293
  const t = readTask(id);
294
+ assertNoPendingDeletion(t);
249
295
  assertNoPendingTerminalIntent(t);
250
296
  if (!TASK_CANCELLABLE_STATES.includes(t.state))
251
297
  throw new TaskStateError(`cannot cancel a '${t.state}' task`);
@@ -263,6 +309,7 @@ function sameOutcome(left, right) {
263
309
  export function beginTaskTerminalIntent(id, input) {
264
310
  return withTaskLock(id, () => {
265
311
  const t = readTask(id);
312
+ assertNoPendingDeletion(t);
266
313
  const existing = t.terminal_intent;
267
314
  if (existing) {
268
315
  if (existing.kind !== input.kind || existing.room_id !== input.roomId
@@ -316,6 +363,7 @@ export function setTaskTerminalIntentError(id, error, recoveryHint) {
316
363
  export function finishTaskTerminalIntent(id) {
317
364
  return withTaskLock(id, () => {
318
365
  const t = readTask(id);
366
+ assertNoPendingDeletion(t);
319
367
  const intent = t.terminal_intent;
320
368
  if (!intent)
321
369
  throw new TaskStateError(`task ${id} has no terminal intent`);
@@ -346,6 +394,7 @@ export function finishTaskTerminalIntent(id) {
346
394
  export function failTask(id, error) {
347
395
  return withTaskLock(id, () => {
348
396
  const t = readTask(id);
397
+ assertNoPendingDeletion(t);
349
398
  assertNoPendingTerminalIntent(t);
350
399
  assertTransition(t.state, 'failed');
351
400
  t.state = 'failed';
@@ -359,6 +408,7 @@ export function failTask(id, error) {
359
408
  export function updateTaskRoom(id, roomId, roomIdentityCid) {
360
409
  return withTaskLock(id, () => {
361
410
  const t = readTask(id);
411
+ assertNoPendingDeletion(t);
362
412
  assertNoPendingTerminalIntent(t);
363
413
  t.room_id = roomId;
364
414
  t.room_identity_cid = roomIdentityCid;
@@ -369,6 +419,7 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
369
419
  export function updateTaskTemplate(id, template) {
370
420
  return withTaskLock(id, () => {
371
421
  const t = readTask(id);
422
+ assertNoPendingDeletion(t);
372
423
  assertNoPendingTerminalIntent(t);
373
424
  t.template = template;
374
425
  writeTask(t);
@@ -378,31 +429,330 @@ export function updateTaskTemplate(id, template) {
378
429
  export function updateTaskMembers(id, members) {
379
430
  return withTaskLock(id, () => {
380
431
  const t = readTask(id);
432
+ assertNoPendingDeletion(t);
381
433
  assertNoPendingTerminalIntent(t);
382
434
  t.member_roles = members;
383
435
  writeTask(t);
384
436
  return t;
385
437
  });
386
438
  }
387
- /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
388
- export function deleteTask(id) {
439
+ function presentTaskLenient(record) {
440
+ try {
441
+ return presentTask(record);
442
+ }
443
+ catch {
444
+ // A broken list reference must never make a task undeletable.
445
+ const listId = record.list_id ?? DEFAULT_TASK_LIST_ID;
446
+ return { ...record, list_id: listId, list_name: listId === DEFAULT_TASK_LIST_ID ? 'default' : listId };
447
+ }
448
+ }
449
+ /**
450
+ * Lenient read for deletion settlement and status surfaces: a broken list
451
+ * reference must never make a task unreadable or undeletable. Missing records
452
+ * throw the canonical task-not-found error; other read failures propagate.
453
+ */
454
+ export function getDeletingTask(id) {
389
455
  return withTaskLock(id, () => {
390
456
  assertCanonicalTaskId(id);
391
- const p = taskPath(id);
392
- let task;
393
457
  try {
394
- task = JSON.parse(readFileSync(p, 'utf8'));
458
+ return presentTaskLenient(JSON.parse(readFileSync(taskPath(id), 'utf8')));
395
459
  }
396
460
  catch (error) {
397
461
  if (isNotFoundError(error))
398
- return false;
462
+ throw new TaskStateError(`task not found: ${id}`);
463
+ throw error;
464
+ }
465
+ });
466
+ }
467
+ /** Cheap durable deletion-epoch probe for room/member publication guards. */
468
+ export function taskDeletionState(id) {
469
+ return withTaskLock(id, () => {
470
+ assertCanonicalTaskId(id);
471
+ try {
472
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
473
+ return stored.deletion?.status === 'pending' ? 'pending' : 'none';
474
+ }
475
+ catch (error) {
476
+ if (isNotFoundError(error))
477
+ return 'absent';
478
+ throw error;
479
+ }
480
+ });
481
+ }
482
+ export const deletionReceiptsDir = () => join(stateRoot(), 'deletion-receipts');
483
+ function deletionReceiptPath(id) {
484
+ return join(deletionReceiptsDir(), `${id}.json`);
485
+ }
486
+ export function readTaskDeletionReceipt(id) {
487
+ assertCanonicalTaskId(id);
488
+ try {
489
+ return JSON.parse(readFileSync(deletionReceiptPath(id), 'utf8'));
490
+ }
491
+ catch (error) {
492
+ if (isNotFoundError(error))
493
+ return undefined;
494
+ throw error;
495
+ }
496
+ }
497
+ function writeDeletionReceiptForIntent(stored) {
498
+ const deletion = stored.deletion;
499
+ mkdirSync(deletionReceiptsDir(), { recursive: true });
500
+ const receipt = {
501
+ schema_version: 1,
502
+ task_id: stored.task_id,
503
+ title: stored.title.slice(0, 256),
504
+ accepted_at: deletion.accepted_at,
505
+ actor: deletion.actor,
506
+ original_state: stored.state,
507
+ room_id: deletion.room_id,
508
+ member_count: deletion.members.length,
509
+ };
510
+ replaceFileAtomically(deletionReceiptPath(stored.task_id), JSON.stringify(receipt, null, 2) + '\n');
511
+ }
512
+ /**
513
+ * Ensure the acceptance receipt exists before any cleanup side effect,
514
+ * backfilling it from the durable intent (heals a crash between the intent
515
+ * write and the receipt write). Fails closed: a receipt write error aborts
516
+ * settlement rather than deleting resources without audit evidence.
517
+ */
518
+ export function ensureTaskDeletionReceipt(id) {
519
+ return withTaskLock(id, () => {
520
+ assertCanonicalTaskId(id);
521
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
522
+ if (stored.deletion?.status !== 'pending')
523
+ throw new TaskStateError(`task ${id} has no pending deletion`);
524
+ if (!readTaskDeletionReceipt(id))
525
+ writeDeletionReceiptForIntent(stored);
526
+ });
527
+ }
528
+ /** Record settlement on the receipt; idempotent, tolerant of legacy absence. */
529
+ export function completeTaskDeletionReceipt(id) {
530
+ const receipt = readTaskDeletionReceipt(id);
531
+ if (!receipt || receipt.settled_at)
532
+ return;
533
+ receipt.settled_at = new Date().toISOString();
534
+ receipt.result = 'deleted';
535
+ replaceFileAtomically(deletionReceiptPath(id), JSON.stringify(receipt, null, 2) + '\n');
536
+ }
537
+ /**
538
+ * Persist the first-wins durable deletion intent. Accepts every lifecycle
539
+ * state, takes precedence over a pending terminal intent, and never touches
540
+ * remote resources. Repeat requests while pending re-arm settlement; a request
541
+ * for a missing record reports the idempotent already-absent outcome.
542
+ *
543
+ * Low-level record mutation only. Callers serialize acceptance with settlement
544
+ * through deletion.ts, which holds the common task-operation lock.
545
+ */
546
+ export function beginTaskDeletionIntent(id, actor) {
547
+ return withTaskLock(id, () => {
548
+ assertCanonicalTaskId(id);
549
+ let stored;
550
+ try {
551
+ stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
552
+ }
553
+ catch (error) {
554
+ if (isNotFoundError(error))
555
+ return { status: 'already_absent' };
399
556
  throw error;
400
557
  }
401
- if (task.state !== 'done') {
402
- throw new TaskStateError(`cannot delete a '${task.state}' task; only 'done' tasks can be deleted`);
558
+ if (stored.deletion?.status === 'pending') {
559
+ // Heal a crash between the intent write and the receipt write.
560
+ if (!readTaskDeletionReceipt(id))
561
+ writeDeletionReceiptForIntent(stored);
562
+ return { status: 'pending', task: presentTaskLenient(stored) };
563
+ }
564
+ const now = new Date().toISOString();
565
+ stored.deletion = {
566
+ status: 'pending',
567
+ accepted_at: now,
568
+ actor,
569
+ room_id: stored.room_id,
570
+ members: (stored.member_roles ?? []).map(member => ({
571
+ name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
572
+ })),
573
+ };
574
+ writeTask(stored);
575
+ writeDeletionReceiptForIntent(stored);
576
+ return { status: 'accepted', task: presentTaskLenient(stored) };
577
+ });
578
+ }
579
+ /** Record an actionable settlement failure; the task stays hidden and recoverable. */
580
+ export function setTaskDeletionError(id, error, recoveryHint) {
581
+ return withTaskLock(id, () => {
582
+ assertCanonicalTaskId(id);
583
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
584
+ if (stored.deletion?.status !== 'pending')
585
+ throw new TaskStateError(`task ${id} has no pending deletion`);
586
+ stored.deletion.first_failure ??= error;
587
+ stored.deletion.first_recovery_hint ??= recoveryHint;
588
+ stored.deletion.error = error;
589
+ const previousErrorAt = Date.parse(stored.deletion.error_at ?? '');
590
+ stored.deletion.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
591
+ stored.deletion.recovery_hint = recoveryHint;
592
+ writeTask(stored);
593
+ return presentTaskLenient(stored);
594
+ });
595
+ }
596
+ const DELETION_MEMBER_PHASE_ORDER = {
597
+ pending: 0, stop_requested: 1, liveness_absent: 2, archive_secured: 3, identity_absent: 4,
598
+ };
599
+ /** Marker proving a member never launched, mirroring close.ts's short path. */
600
+ export const DELETION_MEMBER_NEVER_LAUNCHED = 'never-launched';
601
+ /** Marker proving absence verified against temp state AND the CID-wide identity scan. */
602
+ export const DELETION_MEMBER_ABSENT_VERIFIED = 'absent-verified';
603
+ /**
604
+ * Advance a member retirement cursor carried by the deletion intent. Only
605
+ * adjacent forward transitions are legal — plus the explicit
606
+ * pending → identity_absent short path proved by the 'never-launched' marker —
607
+ * so no managed agent can be claimed retired without the full evidence chain.
608
+ * launch_id and archive_path are immutable once recorded; same-phase retries
609
+ * are idempotent but must not change evidence.
610
+ */
611
+ export function advanceTaskDeletionMember(id, name, phase, launchId, archivePath) {
612
+ return withTaskLock(id, () => {
613
+ assertCanonicalTaskId(id);
614
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
615
+ if (stored.deletion?.status !== 'pending')
616
+ throw new TaskStateError(`task ${id} has no pending deletion`);
617
+ const cursor = stored.deletion.members.find(member => member.name === name);
618
+ if (!cursor)
619
+ throw new TaskStateError(`task ${id} deletion has no member cursor '${name}'`);
620
+ if (cursor.launch_id !== undefined && launchId !== undefined && launchId !== cursor.launch_id)
621
+ throw new TaskStateError(`task ${id} deletion member '${name}' launch ownership proof is immutable`);
622
+ if (cursor.archive_path !== undefined && archivePath !== undefined && archivePath !== cursor.archive_path)
623
+ throw new TaskStateError(`task ${id} deletion member '${name}' archive evidence is immutable`);
624
+ const step = DELETION_MEMBER_PHASE_ORDER[phase] - DELETION_MEMBER_PHASE_ORDER[cursor.phase];
625
+ const launch = cursor.launch_id ?? launchId;
626
+ if (step < 0)
627
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot move back from '${cursor.phase}' to '${phase}'`);
628
+ if (step === 0)
629
+ return presentTaskLenient(stored); // idempotent retry, evidence already verified
630
+ const neverLaunched = cursor.phase === 'pending' && phase === 'identity_absent'
631
+ && (launch === DELETION_MEMBER_NEVER_LAUNCHED || launch === DELETION_MEMBER_ABSENT_VERIFIED);
632
+ if (step !== 1 && !neverLaunched)
633
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot skip from '${cursor.phase}' to '${phase}' without evidence`);
634
+ if (launch === undefined)
635
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot reach '${phase}' without a launch ownership proof`);
636
+ if (phase === 'archive_secured' && (cursor.archive_path ?? archivePath) === undefined)
637
+ throw new TaskStateError(`task ${id} deletion member '${name}' cannot reach 'archive_secured' without archive evidence`);
638
+ cursor.phase = phase;
639
+ cursor.launch_id = launch;
640
+ if (archivePath !== undefined)
641
+ cursor.archive_path = archivePath;
642
+ cursor.updated_at = new Date().toISOString();
643
+ writeTask(stored);
644
+ return presentTaskLenient(stored);
645
+ });
646
+ }
647
+ function findCursorForSeat(id, stored, seat) {
648
+ const cursor = stored.deletion.members.find(member => member.name === seat.role_name);
649
+ if (cursor && seat.identity_cid
650
+ && cursor.identity_cid.toLowerCase() !== seat.identity_cid.toLowerCase())
651
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' identity CID mismatch between cursor and room seat`);
652
+ return cursor;
653
+ }
654
+ /**
655
+ * Durably register cursors for late-provisioned members before retirement
656
+ * begins: provisioning publishes room seats before updateTaskMembers, so the
657
+ * acceptance snapshot can be empty while live seats exist. Seats without a
658
+ * recorded identity CID are not registered here — until a CID is recorded
659
+ * there is no managed identity to orphan, and the room record still carries
660
+ * those seats through the close saga that follows.
661
+ */
662
+ export function upsertTaskDeletionMembersFromSeats(id, seats) {
663
+ return withTaskLock(id, () => {
664
+ assertCanonicalTaskId(id);
665
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
666
+ if (stored.deletion?.status !== 'pending')
667
+ throw new TaskStateError(`task ${id} has no pending deletion`);
668
+ const now = new Date().toISOString();
669
+ for (const seat of seats) {
670
+ if (!seat.identity_cid)
671
+ continue;
672
+ if (!findCursorForSeat(id, stored, seat)) {
673
+ stored.deletion.members.push({
674
+ name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now,
675
+ });
676
+ }
677
+ }
678
+ writeTask(stored);
679
+ return presentTaskLenient(stored);
680
+ });
681
+ }
682
+ /**
683
+ * The pre-room-delete checkpoint: import completed retirement evidence from
684
+ * room seats into the deletion cursors, so a crash between room-record
685
+ * deletion and task unlink retries from durable cursors instead of
686
+ * reconstructing consumed evidence. Call ONLY after closeManagedRoom has
687
+ * completed retirement and BEFORE cowork.deleteRoom/deleteRoomRecord.
688
+ *
689
+ * The room saga is trusted for phase jumps, but partial or corrupt retained
690
+ * records must not become false success: every seat must be identity_absent;
691
+ * a real launch requires archive evidence; an identity-less seat is accepted
692
+ * only via the never-launched proof.
693
+ */
694
+ export function importTaskDeletionRetirementEvidence(id, seats) {
695
+ return withTaskLock(id, () => {
696
+ assertCanonicalTaskId(id);
697
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
698
+ if (stored.deletion?.status !== 'pending')
699
+ throw new TaskStateError(`task ${id} has no pending deletion`);
700
+ const now = new Date().toISOString();
701
+ for (const seat of seats) {
702
+ const evidence = seat.retirement;
703
+ if (evidence?.phase !== 'identity_absent')
704
+ throw new TaskStateError(`task ${id} deletion cannot checkpoint member '${seat.role_name}' before completed retirement`);
705
+ const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED;
706
+ if (!neverLaunched && evidence.archive_path === undefined)
707
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' retirement evidence lacks its archive`);
708
+ if (!seat.identity_cid) {
709
+ if (neverLaunched)
710
+ continue; // provably never held a managed identity
711
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' has retirement evidence but no identity CID`);
712
+ }
713
+ let cursor = findCursorForSeat(id, stored, seat);
714
+ if (!cursor) {
715
+ cursor = { name: seat.role_name, identity_cid: seat.identity_cid, phase: 'pending', updated_at: now };
716
+ stored.deletion.members.push(cursor);
717
+ }
718
+ if (DELETION_MEMBER_PHASE_ORDER[evidence.phase] < DELETION_MEMBER_PHASE_ORDER[cursor.phase])
719
+ continue;
720
+ if (cursor.launch_id !== undefined && cursor.launch_id !== evidence.launch_id)
721
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' launch ownership proof is immutable`);
722
+ if (cursor.archive_path !== undefined && evidence.archive_path !== undefined
723
+ && cursor.archive_path !== evidence.archive_path)
724
+ throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' archive evidence is immutable`);
725
+ cursor.phase = evidence.phase;
726
+ cursor.launch_id = evidence.launch_id;
727
+ if (evidence.archive_path !== undefined)
728
+ cursor.archive_path = evidence.archive_path;
729
+ cursor.updated_at = now;
730
+ }
731
+ writeTask(stored);
732
+ return presentTaskLenient(stored);
733
+ });
734
+ }
735
+ /**
736
+ * Physically remove a deletion-pending task record. Settlement-only: callers
737
+ * must have completed member retirement and room cleanup first. Missing
738
+ * records are the idempotent already-settled outcome.
739
+ */
740
+ export function unlinkDeletedTask(id) {
741
+ return withTaskLock(id, () => {
742
+ assertCanonicalTaskId(id);
743
+ let stored;
744
+ try {
745
+ stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
746
+ }
747
+ catch (error) {
748
+ if (isNotFoundError(error))
749
+ return false;
750
+ throw error;
403
751
  }
752
+ if (stored.deletion?.status !== 'pending')
753
+ throw new TaskStateError(`task ${id} has no pending deletion`);
404
754
  try {
405
- unlinkSync(p);
755
+ unlinkSync(taskPath(id));
406
756
  }
407
757
  catch (error) {
408
758
  if (isNotFoundError(error))
@@ -1,6 +1,8 @@
1
1
  import type { TemplateDefinition, TemplateSnapshot } from './types.js';
2
- export declare const BUILTIN_TEMPLATES: readonly TemplateDefinition[];
2
+ import type { AgentTemplateDefinition } from '../config.js';
3
3
  export declare function hashTemplate(t: TemplateDefinition): string;
4
- export declare function snapshotTemplate(t: TemplateDefinition): TemplateSnapshot;
4
+ export declare function snapshotTemplate(t: TemplateDefinition, agentTemplates?: Record<string, AgentTemplateDefinition>): TemplateSnapshot;
5
+ /** Explicit mutation boundary immediately before Task/Room plan persistence. */
6
+ export declare function sealTemplateSnapshot(snapshot: TemplateSnapshot, agentTemplates: Record<string, AgentTemplateDefinition>, launchDefinitions?: Record<string, AgentTemplateDefinition>): TemplateSnapshot;
5
7
  export declare function resolveTemplate(name: string, customTemplates: Record<string, TemplateDefinition>): TemplateDefinition | undefined;
6
8
  export declare function listTemplates(customTemplates: Record<string, TemplateDefinition>): TemplateDefinition[];
@@ -1,94 +1,58 @@
1
1
  import { createHash } from 'node:crypto';
2
- const TEAM = {
3
- name: 'team',
4
- version: 1,
5
- builtin: true,
6
- description: 'Phased task pipeline: Architect specifies, Developer implements, Tester verifies',
7
- room: { quiet_membership: false, anonymous: false },
8
- contract: [
9
- 'Architect produces a spec and posts it to the room. Work pauses for owner approval.',
10
- 'Developer implements per approved spec.',
11
- 'Tester verifies spec conformance and test coverage.',
12
- 'Completion requires tester sign-off.',
13
- ].join('\n'),
14
- members: [
15
- { slot: 'architect', role: 'Architect', count: 1, role_ref: 'Architect' },
16
- { slot: 'developer', role: 'Developer', count: 1, role_ref: 'Developer' },
17
- { slot: 'tester', role: 'Tester', count: 1, role_ref: 'Tester' },
18
- ],
19
- };
20
- const PAIR = {
21
- name: 'pair',
22
- version: 1,
23
- builtin: true,
24
- description: 'Deliberation pair: Secretary writes code, Critic reviews — every decision deliberated together',
25
- room: { quiet_membership: false, anonymous: false },
26
- contract: [
27
- 'Secretary and Critic deliberate every decision together before acting.',
28
- 'Secretary writes code; Critic reviews and challenges.',
29
- 'No material change lands without both agreeing.',
30
- 'Completion requires joint sign-off.',
31
- ].join('\n'),
32
- members: [
33
- { slot: 'secretary', role: 'Secretary', count: 1, role_ref: 'Secretary' },
34
- { slot: 'critic', role: 'Critic', count: 1, role_ref: 'Critic' },
35
- ],
36
- };
37
- const SINGLE = {
38
- name: 'single',
39
- version: 1,
40
- builtin: true,
41
- description: 'Solo agent: one general-purpose agent works the task with owner oversight',
42
- room: { quiet_membership: false, anonymous: false },
43
- contract: [
44
- 'Agent works the task autonomously, consulting the owner when blocked.',
45
- 'Owner reviews and approves completion.',
46
- ].join('\n'),
47
- members: [
48
- { slot: 'agent', role: 'Agent', count: 1, role_ref: 'Agent' },
49
- ],
50
- };
51
- export const BUILTIN_TEMPLATES = [TEAM, PAIR, SINGLE];
2
+ import { canonicalJson } from '../canonical-json.js';
3
+ import { redactLaunchDefinition, sealLaunchSnapshot } from './launch-snapshot.js';
52
4
  export function hashTemplate(t) {
53
- const canonical = JSON.stringify({
5
+ const canonical = canonicalJson({
54
6
  name: t.name, version: t.version, description: t.description,
55
7
  room: t.room, contract: t.contract, members: t.members,
56
8
  });
57
9
  return createHash('sha256').update(canonical).digest('hex');
58
10
  }
59
- export function snapshotTemplate(t) {
60
- return { ...t, content_hash: hashTemplate(t) };
11
+ export function snapshotTemplate(t, agentTemplates) {
12
+ const { sourceFile: _, ...semantic } = t;
13
+ return { ...semantic, members: semantic.members.map(member => {
14
+ const definition = agentTemplates?.[member.agent_template];
15
+ return {
16
+ ...member,
17
+ ...(definition ? {
18
+ agent_projection: redactLaunchDefinition(definition),
19
+ agent_template_hash: createHash('sha256').update(canonicalJson(definition)).digest('hex'),
20
+ } : {}),
21
+ };
22
+ }), content_hash: agentTemplates ? createHash('sha256').update(canonicalJson({
23
+ template: hashTemplate(t),
24
+ members: semantic.members.map(member => ({
25
+ agent_template: member.agent_template,
26
+ agent_template_hash: agentTemplates?.[member.agent_template]
27
+ ? createHash('sha256').update(canonicalJson(agentTemplates[member.agent_template])).digest('hex')
28
+ : null,
29
+ })),
30
+ })).digest('hex') : hashTemplate(t) };
31
+ }
32
+ /** Explicit mutation boundary immediately before Task/Room plan persistence. */
33
+ export function sealTemplateSnapshot(snapshot, agentTemplates, launchDefinitions) {
34
+ const referenced = Object.fromEntries([...new Set(snapshot.members.map(member => member.launch_definition_id ?? member.agent_template))]
35
+ .sort().map(id => {
36
+ const member = snapshot.members.find(candidate => (candidate.launch_definition_id ?? candidate.agent_template) === id);
37
+ const definition = launchDefinitions?.[id] ?? agentTemplates[member.agent_template];
38
+ if (!definition)
39
+ throw new Error(`Agent Template '${id}' not found`);
40
+ return [id, definition];
41
+ }));
42
+ return { ...snapshot, launch_snapshot_hash: sealLaunchSnapshot(referenced) };
61
43
  }
62
44
  export function resolveTemplate(name, customTemplates) {
63
45
  const atIdx = name.lastIndexOf('@');
64
46
  const baseName = atIdx > 0 ? name.slice(0, atIdx) : name;
65
47
  const versionStr = atIdx > 0 ? name.slice(atIdx + 1) : undefined;
66
48
  const version = versionStr ? parseInt(versionStr, 10) : undefined;
67
- const custom = customTemplates[baseName];
68
- if (custom) {
69
- if (version !== undefined && custom.version !== version)
70
- return undefined;
71
- return custom;
72
- }
73
- const builtin = BUILTIN_TEMPLATES.find(b => b.name === baseName);
74
- if (builtin) {
75
- if (version !== undefined && builtin.version !== version)
76
- return undefined;
77
- return builtin;
78
- }
79
- return undefined;
49
+ const template = customTemplates[baseName];
50
+ if (!template || (version !== undefined && template.version !== version))
51
+ return undefined;
52
+ return template;
80
53
  }
81
54
  export function listTemplates(customTemplates) {
82
- const result = [];
83
- const overridden = new Set();
84
- for (const [name, t] of Object.entries(customTemplates)) {
85
- result.push({ ...t, name });
86
- if (BUILTIN_TEMPLATES.some(b => b.name === name))
87
- overridden.add(name);
88
- }
89
- for (const b of BUILTIN_TEMPLATES) {
90
- if (!overridden.has(b.name))
91
- result.push(b);
92
- }
93
- return result.sort((a, b) => a.name.localeCompare(b.name));
55
+ return Object.entries(customTemplates)
56
+ .map(([name, template]) => ({ ...template, name }))
57
+ .sort((a, b) => a.name.localeCompare(b.name));
94
58
  }
@@ -1,6 +1,9 @@
1
1
  import { type RoomCloseDeps } from './close.js';
2
2
  import type { CoworkAdapter } from './cowork-adapter.js';
3
3
  import type { TaskOutcome, TaskRecord, TaskTerminalIntent } from './types.js';
4
+ export declare const TASK_OPERATION_LOCK_STALE_MS: number;
5
+ /** Common per-task operation lock serializing terminal, deletion, and recovery acceptance. */
6
+ export declare function taskOperationLockPath(taskId: string): string;
4
7
  export interface TaskTerminalInput {
5
8
  taskId: string;
6
9
  kind: TaskTerminalIntent['kind'];