@wrongstack/core 0.282.1 → 0.283.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{agent-subagent-runner-DCczSoQj.d.ts → agent-subagent-runner-BsuWhB28.d.ts} +2 -2
  2. package/dist/coordination/index.d.ts +12 -12
  3. package/dist/coordination/index.js +315 -41
  4. package/dist/coordination/index.js.map +1 -1
  5. package/dist/defaults/index.d.ts +12 -12
  6. package/dist/defaults/index.js +316 -53
  7. package/dist/defaults/index.js.map +1 -1
  8. package/dist/{events-BOv8h6I1.d.ts → events-KmxSmvho.d.ts} +5 -0
  9. package/dist/execution/index.d.ts +7 -7
  10. package/dist/extension/index.d.ts +2 -2
  11. package/dist/{global-mailbox-MDDFhLYh.d.ts → global-mailbox-rWVt8YlO.d.ts} +70 -7
  12. package/dist/{goal-store-BEmDmSKF.d.ts → goal-store-8owBXJT2.d.ts} +1 -1
  13. package/dist/hq/index.d.ts +3 -3
  14. package/dist/hq/index.js +106 -32
  15. package/dist/hq/index.js.map +1 -1
  16. package/dist/{index-Dd-PJJ8A.d.ts → index-BhCteHAF.d.ts} +1 -1
  17. package/dist/index.d.ts +289 -19
  18. package/dist/index.js +919 -110
  19. package/dist/index.js.map +1 -1
  20. package/dist/infrastructure/index.d.ts +1 -1
  21. package/dist/kernel/index.d.ts +4 -4
  22. package/dist/kernel/index.js.map +1 -1
  23. package/dist/models/index.js +105 -42
  24. package/dist/models/index.js.map +1 -1
  25. package/dist/{multi-agent-coordinator-BRqtpn-a.d.ts → multi-agent-coordinator-CrjeTB9i.d.ts} +1 -1
  26. package/dist/{null-fleet-bus-BkptvIVo.d.ts → null-fleet-bus-D4e9R7Qc.d.ts} +9 -4
  27. package/dist/observability/index.d.ts +1 -1
  28. package/dist/{parallel-eternal-engine-BXECuOVG.d.ts → parallel-eternal-engine-iY2uxocj.d.ts} +4 -4
  29. package/dist/{provider-runner-CJtCs1Rw.d.ts → provider-runner-oQhDaZmH.d.ts} +1 -1
  30. package/dist/sdd/index.d.ts +4 -4
  31. package/dist/sdd/index.js.map +1 -1
  32. package/dist/storage/index.d.ts +5 -5
  33. package/dist/storage/index.js.map +1 -1
  34. package/dist/{todos-checkpoint-Bw83WMh9.d.ts → todos-checkpoint-BwCrj4Cb.d.ts} +1 -1
  35. package/dist/{tool-executor-4mWN2vHW.d.ts → tool-executor-kElSEgO0.d.ts} +4 -4
  36. package/dist/types/index.d.ts +8 -8
  37. package/dist/types/index.js +105 -45
  38. package/dist/types/index.js.map +1 -1
  39. package/dist/{worktree-manager-BrtjUFYk.d.ts → worktree-manager-BjyAW30D.d.ts} +1 -1
  40. package/instructions/modes/audit-lite.md +13 -0
  41. package/instructions/modes/debug-lite.md +13 -0
  42. package/instructions/modes/plan-lite.md +14 -0
  43. package/instructions/modes/refactor-lite.md +13 -0
  44. package/instructions/modes/research-lite.md +13 -0
  45. package/instructions/modes/review-lite.md +14 -0
  46. package/instructions/modes/test-lite.md +13 -0
  47. package/package.json +1 -1
@@ -4974,6 +4974,16 @@ function getKanbanPath(projectRoot, boardId) {
4974
4974
  }
4975
4975
  return resolved;
4976
4976
  }
4977
+ function getKanbanEventsPath(projectRoot, boardId) {
4978
+ assertValidBoardId(boardId);
4979
+ const dir = path10.resolve(getKanbanDir(projectRoot));
4980
+ const resolved = path10.resolve(dir, `${boardId}.events.jsonl`);
4981
+ const rel = path10.relative(dir, resolved);
4982
+ if (rel.startsWith("..") || path10.isAbsolute(rel)) {
4983
+ throw invalidBoardId(boardId);
4984
+ }
4985
+ return resolved;
4986
+ }
4977
4987
  function isValidBoardId(boardId) {
4978
4988
  return BOARD_ID_RE.test(boardId) && !boardId.includes("..");
4979
4989
  }
@@ -5026,6 +5036,14 @@ async function readBoard(projectRoot, boardRef) {
5026
5036
  throw err;
5027
5037
  }
5028
5038
  }
5039
+ async function appendKanbanEvent(projectRoot, boardId, event) {
5040
+ const filePath = getKanbanEventsPath(projectRoot, boardId);
5041
+ await withFileLock(filePath, async () => {
5042
+ await fsp7.mkdir(path10.dirname(filePath), { recursive: true });
5043
+ await fsp7.appendFile(filePath, `${JSON.stringify(event)}
5044
+ `, "utf8");
5045
+ });
5046
+ }
5029
5047
  async function mutateBoard(projectRoot, boardRef, mutator) {
5030
5048
  const boardId = await resolveBoardRef(projectRoot, boardRef);
5031
5049
  if (!boardId) return null;
@@ -5124,9 +5142,12 @@ async function listBoards(projectRoot) {
5124
5142
  return listBoardSummaries(projectRoot);
5125
5143
  }
5126
5144
  async function updateTaskAssignment(projectRoot, boardId, taskId, patch) {
5145
+ let event;
5127
5146
  const updated = await mutateBoard(projectRoot, boardId, (board) => {
5128
5147
  const task = findTask(board, taskId);
5129
5148
  if (!task) return null;
5149
+ const previousColumnId = task.columnId;
5150
+ const beforeAssignment = task.assignment ? { ...task.assignment } : void 0;
5130
5151
  const nextAssignment = {
5131
5152
  ...task.assignment ?? { status: "assigned" }
5132
5153
  };
@@ -5164,10 +5185,17 @@ async function updateTaskAssignment(projectRoot, boardId, taskId, patch) {
5164
5185
  }
5165
5186
  delete task.completedAt;
5166
5187
  }
5188
+ syncTaskColumnForStatus(board, task, previousColumnId);
5167
5189
  task.updatedAt = nowIso();
5168
5190
  board.updatedAt = task.updatedAt;
5191
+ event = createKanbanEvent(board.id, task, assignmentEventType(task.assignment.status), {
5192
+ before: beforeAssignment,
5193
+ after: { ...task.assignment },
5194
+ note: patch.error ?? patch.lastResult
5195
+ });
5169
5196
  return task;
5170
5197
  });
5198
+ if (updated && event) await emitKanbanEvent(projectRoot, event);
5171
5199
  return updated?.result ? updated.board : null;
5172
5200
  }
5173
5201
  async function claimReadyTask(projectRoot, input = {}) {
@@ -5214,14 +5242,25 @@ function buildAssignment(input) {
5214
5242
  ...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
5215
5243
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
5216
5244
  ...input.tools !== void 0 ? { tools: input.tools } : {},
5217
- ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {}
5245
+ ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
5246
+ ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
5247
+ ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
5248
+ ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
5249
+ ...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
5250
+ ...input.attempt !== void 0 ? { attempt: input.attempt } : {},
5251
+ ...input.maxAttempts !== void 0 ? { maxAttempts: input.maxAttempts } : {},
5252
+ ...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
5253
+ ...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
5254
+ ...input.lastFailureKind !== void 0 ? { lastFailureKind: input.lastFailureKind } : {}
5218
5255
  };
5219
5256
  }
5220
5257
  async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
5258
+ let event;
5221
5259
  const updated = await mutateBoard(projectRoot, boardId, (board) => {
5222
5260
  const candidates = input.taskId ? [findTask(board, input.taskId)].filter((task2) => Boolean(task2)) : board.tasks.filter((task2) => isTaskReadyForWork(board, task2)).sort(compareTasksForWork);
5223
5261
  const task = candidates.find((candidate) => isTaskReadyForWork(board, candidate));
5224
5262
  if (!task) return null;
5263
+ const previousColumnId = task.columnId;
5225
5264
  const current = task.assignment;
5226
5265
  const assignment = buildAssignment({
5227
5266
  ...current?.agentId !== void 0 ? { agentId: current.agentId } : {},
@@ -5233,6 +5272,15 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
5233
5272
  ...current?.fallbackModels !== void 0 ? { fallbackModels: current.fallbackModels } : {},
5234
5273
  ...current?.tools !== void 0 ? { tools: current.tools } : {},
5235
5274
  ...current?.allowedCapabilities !== void 0 ? { allowedCapabilities: current.allowedCapabilities } : {},
5275
+ ...current?.leaseId !== void 0 ? { leaseId: current.leaseId } : {},
5276
+ ...current?.claimedAt !== void 0 ? { claimedAt: current.claimedAt } : {},
5277
+ ...current?.heartbeatAt !== void 0 ? { heartbeatAt: current.heartbeatAt } : {},
5278
+ ...current?.leaseExpiresAt !== void 0 ? { leaseExpiresAt: current.leaseExpiresAt } : {},
5279
+ ...current?.attempt !== void 0 ? { attempt: current.attempt } : {},
5280
+ ...current?.maxAttempts !== void 0 ? { maxAttempts: current.maxAttempts } : {},
5281
+ ...current?.costCeilingUsd !== void 0 ? { costCeilingUsd: current.costCeilingUsd } : {},
5282
+ ...current?.retryPolicy !== void 0 ? { retryPolicy: current.retryPolicy } : {},
5283
+ ...current?.lastFailureKind !== void 0 ? { lastFailureKind: current.lastFailureKind } : {},
5236
5284
  ...input.agentId !== void 0 ? { agentId: input.agentId } : {},
5237
5285
  ...input.name !== void 0 ? { name: input.name } : {},
5238
5286
  ...input.role !== void 0 ? { role: input.role } : {},
@@ -5242,10 +5290,20 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
5242
5290
  ...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
5243
5291
  ...input.tools !== void 0 ? { tools: input.tools } : {},
5244
5292
  ...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
5293
+ ...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
5294
+ ...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
5295
+ ...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
5296
+ ...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
5297
+ ...input.attempt !== void 0 ? { attempt: input.attempt } : {},
5298
+ ...input.maxAttempts !== void 0 ? { maxAttempts: input.maxAttempts } : {},
5299
+ ...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
5300
+ ...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
5301
+ ...input.lastFailureKind !== void 0 ? { lastFailureKind: input.lastFailureKind } : {},
5245
5302
  ...input.assignee !== void 0 ? { assignee: input.assignee } : {},
5246
5303
  status: input.status ?? "queued"
5247
5304
  });
5248
- assignment.dispatchedAt = assignment.dispatchedAt ?? nowIso();
5305
+ assignment.claimedAt = assignment.claimedAt ?? nowIso();
5306
+ assignment.dispatchedAt = assignment.dispatchedAt ?? assignment.claimedAt;
5249
5307
  task.assignment = assignment;
5250
5308
  if (assignment.agentId ?? assignment.role ?? assignment.name) {
5251
5309
  task.assignedAgent = assignment.agentId ?? assignment.role ?? assignment.name;
@@ -5255,10 +5313,16 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
5255
5313
  }
5256
5314
  task.status = assignment.status === "running" ? "in_progress" : "ready";
5257
5315
  delete task.completedAt;
5316
+ syncTaskColumnForStatus(board, task, previousColumnId);
5258
5317
  task.updatedAt = nowIso();
5259
5318
  board.updatedAt = task.updatedAt;
5319
+ event = createKanbanEvent(board.id, task, "task.claimed", {
5320
+ before: current ? { ...current } : void 0,
5321
+ after: { ...assignment }
5322
+ });
5260
5323
  return task;
5261
5324
  });
5325
+ if (updated && event) await emitKanbanEvent(projectRoot, event);
5262
5326
  return updated?.result ? { board: updated.board, task: updated.result } : null;
5263
5327
  }
5264
5328
  function compareTasksForWork(a, b) {
@@ -5292,6 +5356,18 @@ function findTask(board, taskId) {
5292
5356
  }
5293
5357
  return matches[0];
5294
5358
  }
5359
+ function existingColumnId(board, columnId) {
5360
+ if (!columnId) return void 0;
5361
+ const exact = board.columns.find((column) => column.id === columnId);
5362
+ if (exact) return exact.id;
5363
+ const matches = board.columns.filter((column) => column.id.startsWith(columnId));
5364
+ if (matches.length > 1) {
5365
+ throw new Error(
5366
+ `Ambiguous kanban column id "${columnId}": ${matches.slice(0, 5).map((column) => column.id).join(", ")}`
5367
+ );
5368
+ }
5369
+ return matches[0]?.id;
5370
+ }
5295
5371
  function isTaskReadyForWork(board, task) {
5296
5372
  if (!["pending", "ready"].includes(task.status)) return false;
5297
5373
  if (task.assignment && ["queued", "running"].includes(task.assignment.status)) return false;
@@ -5299,6 +5375,60 @@ function isTaskReadyForWork(board, task) {
5299
5375
  if (!areDependenciesMet(board, task.id)) return false;
5300
5376
  return true;
5301
5377
  }
5378
+ function createKanbanEvent(boardId, task, type, details = {}) {
5379
+ return {
5380
+ id: randomUUID(),
5381
+ boardId,
5382
+ taskId: task.id,
5383
+ type,
5384
+ ts: nowIso(),
5385
+ ...task.assignment?.agentId !== void 0 ? { actor: task.assignment.agentId } : {},
5386
+ ...task.assignment?.subagentId !== void 0 ? { subagentId: task.assignment.subagentId } : {},
5387
+ ...task.assignment?.runTaskId !== void 0 ? { runTaskId: task.assignment.runTaskId } : {},
5388
+ ...details
5389
+ };
5390
+ }
5391
+ async function emitKanbanEvent(projectRoot, event) {
5392
+ try {
5393
+ await appendKanbanEvent(projectRoot, event.boardId, event);
5394
+ } catch {
5395
+ }
5396
+ }
5397
+ function assignmentEventType(status) {
5398
+ return status === "completed" ? "task.assignment.completed" : status === "failed" ? "task.assignment.failed" : status === "running" ? "task.assignment.running" : status === "cancelled" ? "task.assignment.cancelled" : "task.assignment.updated";
5399
+ }
5400
+ function columnIdForKanbanStatus(board, status) {
5401
+ const preferred = status === "completed" ? ["done", "completed"] : status === "in_progress" ? ["in-progress", "progress", "doing"] : status === "review" || status === "failed" ? ["review"] : status === "blocked" ? ["blocked", "backlog"] : status === "ready" ? ["todo", "ready", "backlog"] : status === "archived" ? ["done", "archive", "backlog"] : ["todo", "backlog"];
5402
+ for (const columnRef of preferred) {
5403
+ const columnId = existingColumnId(board, columnRef);
5404
+ if (columnId) return columnId;
5405
+ }
5406
+ return board.columns[0]?.id;
5407
+ }
5408
+ function normalizeColumnTaskOrders(board, columnId) {
5409
+ board.tasks.filter((task) => task.columnId === columnId).sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt)).forEach((task, index) => {
5410
+ task.order = index;
5411
+ });
5412
+ }
5413
+ function syncTaskColumnForStatus(board, task, previousColumnId) {
5414
+ const nextColumnId = columnIdForKanbanStatus(board, task.status);
5415
+ if (!nextColumnId || nextColumnId === task.columnId) return;
5416
+ task.columnId = nextColumnId;
5417
+ normalizeColumnTaskOrders(board, previousColumnId);
5418
+ placeTaskInColumn(board, task, nextColumnId, void 0);
5419
+ }
5420
+ function placeTaskInColumn(board, task, columnId, targetOrder) {
5421
+ const tasks = board.tasks.filter((candidate) => candidate.columnId === columnId && candidate.id !== task.id).sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt));
5422
+ const index = clampOrder(targetOrder, tasks.length);
5423
+ tasks.splice(index, 0, task);
5424
+ tasks.forEach((candidate, order) => {
5425
+ candidate.order = order;
5426
+ });
5427
+ }
5428
+ function clampOrder(order, max) {
5429
+ if (!Number.isFinite(order)) return max;
5430
+ return Math.max(0, Math.min(Math.trunc(order), max));
5431
+ }
5302
5432
  function nowIso() {
5303
5433
  return (/* @__PURE__ */ new Date()).toISOString();
5304
5434
  }
@@ -5426,6 +5556,9 @@ function makeLLMClassifier(complete) {
5426
5556
  }
5427
5557
 
5428
5558
  // src/coordination/director-tools.ts
5559
+ function nowIso2() {
5560
+ return (/* @__PURE__ */ new Date()).toISOString();
5561
+ }
5429
5562
  function makeSpawnTool(director, roster) {
5430
5563
  const inputSchema = {
5431
5564
  type: "object",
@@ -5983,8 +6116,8 @@ function makeAssignTool(director) {
5983
6116
  function makeKanbanQueueTool(director, roster) {
5984
6117
  return {
5985
6118
  name: "kanban_queue",
5986
- description: "Claim dependency-ready Kanban tasks and dispatch them into the Director fleet. Preserves per-task provider/model/role/tool routing metadata, assigns each claimed task to a spawned subagent, and can await results while writing completion back to Kanban.",
5987
- usageHint: 'Use action:"dispatch_ready" with optional boardId/taskId/maxTasks. Set awaitCompletion:true when you want this call to update Kanban to completed/failed before returning; otherwise use await_tasks and kanban mark_assignment later.',
6119
+ description: "Claim dependency-ready Kanban tasks and dispatch them into the Director fleet. Preserves per-task provider/model/role/tool routing metadata, seeds lease metadata (claimedAt, leaseExpiresAt, heartbeatAt), assigns each claimed task to a spawned subagent, instructs workers to call kanban heartbeat_assignment periodically, and can await results while writing completion back to Kanban.",
6120
+ usageHint: 'Use action:"dispatch_ready" with optional boardId/taskId/maxTasks. Set heartbeatIntervalMs (default 60s) and leaseTtlMs (default 5m) to size stale-recovery windows. Set awaitCompletion:true when you want this call to update Kanban to completed/failed before returning; otherwise use await_tasks and kanban mark_assignment later.',
5988
6121
  permission: "auto",
5989
6122
  mutating: true,
5990
6123
  capabilities: [ToolCapabilities.SUBAGENT_SPAWN, ToolCapabilities.FS_WRITE],
@@ -6015,6 +6148,16 @@ function makeKanbanQueueTool(director, roster) {
6015
6148
  minimum: 1,
6016
6149
  description: "Optional per-assigned-task tool-call cap."
6017
6150
  },
6151
+ heartbeatIntervalMs: {
6152
+ type: "number",
6153
+ minimum: 1e3,
6154
+ description: "Suggested heartbeat interval for the worker. Default 60000 (60s). Workers are instructed to call kanban heartbeat_assignment at this cadence."
6155
+ },
6156
+ leaseTtlMs: {
6157
+ type: "number",
6158
+ minimum: 1e3,
6159
+ description: "Lease time-to-live seeded on dispatch. Default 300000 (5m). Workers should refresh via heartbeat_assignment before expiry."
6160
+ },
6018
6161
  agentId: { type: "string" },
6019
6162
  name: { type: "string" },
6020
6163
  role: { type: "string" },
@@ -6037,6 +6180,14 @@ function makeKanbanQueueTool(director, roster) {
6037
6180
  const projectRoot = ctx.projectRoot;
6038
6181
  if (!projectRoot) return { error: "kanban_queue requires ctx.projectRoot." };
6039
6182
  const maxTasks = Math.max(1, Math.min(20, Math.floor(i.maxTasks ?? 1)));
6183
+ const leaseTtlMs = Math.max(1e3, Math.floor(i.leaseTtlMs ?? 5 * 60 * 1e3));
6184
+ const claimedAt = nowIso2();
6185
+ const leaseSeeding = {
6186
+ leaseId: randomUUID(),
6187
+ claimedAt,
6188
+ heartbeatAt: claimedAt,
6189
+ leaseExpiresAt: new Date(Date.now() + leaseTtlMs).toISOString()
6190
+ };
6040
6191
  const candidateTaskIds = i.taskId !== void 0 ? [i.taskId] : i.query ? (await listReadyTasks(projectRoot, {
6041
6192
  ...i.boardId !== void 0 ? { boardId: i.boardId } : {}
6042
6193
  })).filter((candidate) => matchesKanbanQueueQuery(candidate.task, i.query ?? "")).slice(0, maxTasks).map((candidate) => candidate.task.id) : void 0;
@@ -6057,6 +6208,7 @@ function makeKanbanQueueTool(director, roster) {
6057
6208
  ...i.fallbackModels !== void 0 ? { fallbackModels: i.fallbackModels } : {},
6058
6209
  ...i.tools !== void 0 ? { tools: i.tools } : {},
6059
6210
  ...i.allowedCapabilities !== void 0 ? { allowedCapabilities: i.allowedCapabilities } : {},
6211
+ ...leaseSeeding !== void 0 ? leaseSeeding : {},
6060
6212
  status: "queued"
6061
6213
  });
6062
6214
  if (!claim) {
@@ -6065,13 +6217,34 @@ function makeKanbanQueueTool(director, roster) {
6065
6217
  }
6066
6218
  let subagentId;
6067
6219
  let runTaskId;
6220
+ const costCeiling = claim.task.assignment?.costCeilingUsd;
6221
+ if (costCeiling !== void 0) {
6222
+ const remaining = director.getRemainingBudgetUsd();
6223
+ if (remaining !== void 0 && remaining < costCeiling) {
6224
+ await updateTaskAssignment(projectRoot, claim.board.id, claim.task.id, {
6225
+ status: "failed",
6226
+ error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`,
6227
+ lastResult: "Skipped by kanban_queue cost gate (Sprint 3)"
6228
+ });
6229
+ errors.push({
6230
+ taskId: claim.task.id,
6231
+ error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`
6232
+ });
6233
+ continue;
6234
+ }
6235
+ }
6068
6236
  try {
6069
6237
  const config = buildKanbanSubagentConfig(claim.task, i, roster);
6070
6238
  subagentId = await director.spawn(config);
6071
6239
  runTaskId = await director.assign({
6072
6240
  id: randomUUID(),
6073
6241
  subagentId,
6074
- description: buildKanbanFleetTaskPrompt(claim.board, claim.task),
6242
+ description: buildKanbanFleetTaskPrompt(claim.board, claim.task, {
6243
+ heartbeatIntervalMs: i.heartbeatIntervalMs ?? 6e4,
6244
+ leaseTtlMs,
6245
+ leaseId: leaseSeeding.leaseId,
6246
+ leaseExpiresAt: leaseSeeding.leaseExpiresAt
6247
+ }),
6075
6248
  ...i.maxToolCalls !== void 0 ? { maxToolCalls: i.maxToolCalls } : {},
6076
6249
  ...i.timeoutMs !== void 0 ? { timeoutMs: i.timeoutMs } : {},
6077
6250
  context: {
@@ -6169,7 +6342,9 @@ function normalizeKanbanQueueInput(input) {
6169
6342
  fallbackModels: stringArray(raw.fallbackModels),
6170
6343
  tools: stringArray(raw.tools),
6171
6344
  allowedCapabilities: stringArray(raw.allowedCapabilities),
6172
- worktree: normalizeWorktreeOverride(raw.worktree)
6345
+ worktree: normalizeWorktreeOverride(raw.worktree),
6346
+ heartbeatIntervalMs: typeof raw.heartbeatIntervalMs === "number" ? raw.heartbeatIntervalMs : void 0,
6347
+ leaseTtlMs: typeof raw.leaseTtlMs === "number" ? raw.leaseTtlMs : void 0
6173
6348
  };
6174
6349
  }
6175
6350
  function buildKanbanSubagentConfig(task, input, roster) {
@@ -6187,7 +6362,8 @@ function buildKanbanSubagentConfig(task, input, roster) {
6187
6362
  ...input.fallbackModels ?? assignment?.fallbackModels ? { fallbackModels: input.fallbackModels ?? assignment?.fallbackModels } : {},
6188
6363
  ...tools ? { tools: ensureKanbanTool(tools) } : {},
6189
6364
  ...input.allowedCapabilities ?? assignment?.allowedCapabilities ? { allowedCapabilities: input.allowedCapabilities ?? assignment?.allowedCapabilities } : {},
6190
- ...input.worktree !== void 0 ? { worktree: input.worktree } : {}
6365
+ ...input.worktree !== void 0 ? { worktree: input.worktree } : {},
6366
+ ...assignment?.costCeilingUsd !== void 0 ? { maxCostUsd: assignment.costCeilingUsd } : {}
6191
6367
  };
6192
6368
  }
6193
6369
  function ensureKanbanTool(tools) {
@@ -6207,7 +6383,7 @@ function matchesKanbanQueueQuery(task, query) {
6207
6383
  ...task.labels ?? []
6208
6384
  ].filter(Boolean).some((value) => String(value).toLowerCase().includes(normalized));
6209
6385
  }
6210
- function buildKanbanFleetTaskPrompt(board, task) {
6386
+ function buildKanbanFleetTaskPrompt(board, task, lease) {
6211
6387
  const dependencyLines = (task.dependsOn ?? []).map((depId) => board.tasks.find((candidate) => candidate.id === depId)).filter((dep) => Boolean(dep)).map((dep) => `- ${dep.title} [${dep.status}] (${dep.id})`);
6212
6388
  const checks = task.successCriteria?.map((check) => `- ${check.description}`).join("\n");
6213
6389
  const metrics = task.goalMetrics?.map(
@@ -6254,9 +6430,24 @@ ${checks}` : "",
6254
6430
  metrics ? `Goal metrics:
6255
6431
  ${metrics}` : "",
6256
6432
  task.labels?.length ? `Labels: ${task.labels.join(", ")}` : "",
6433
+ "Lease contract (Sprint 1):",
6434
+ `- leaseId: ${lease.leaseId}`,
6435
+ `- claimedAt: ${task.assignment?.claimedAt ?? "<unknown>"}`,
6436
+ `- leaseExpiresAt: ${lease.leaseExpiresAt}`,
6437
+ `- expected heartbeatIntervalMs: ${lease.heartbeatIntervalMs}`,
6438
+ `- expected leaseTtlMs: ${lease.leaseTtlMs}`,
6439
+ "",
6440
+ "Retry policy:",
6441
+ `- retryPolicy: ${task.assignment?.retryPolicy ?? task.retryPolicy ?? "<unset>"}`,
6442
+ `- maxAttempts: ${task.assignment?.maxAttempts ?? "<unset>"}`,
6443
+ `- costCeilingUsd: ${task.assignment?.costCeilingUsd ?? task.costCeilingUsd ?? "<unset>"}`,
6257
6444
  "",
6258
6445
  "Work this task end-to-end. If scope is too broad or too small, use the kanban tool to split_task or merge_tasks instead of losing traceability.",
6259
- `When you start or finish, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "running", "completed", or "failed". Include lastResult or error when you finish.`,
6446
+ `When you start, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "running". Include subagentId and runTaskId when you have them.`,
6447
+ `To stay alive in the queue, call kanban with action "heartbeat_assignment", boardId "${board.id}", taskId "${task.id}", and heartbeatAt set to the current time. Cadence must be <= heartbeatIntervalMs and well before leaseExpiresAt.`,
6448
+ `When you finish, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "completed" or "failed". Include lastResult or error.`,
6449
+ `If you cannot finish in time, call kanban with action "heartbeat_assignment" to extend the lease, or with action "release_task" to release so another worker can claim. Do NOT silently abandon the assignment.`,
6450
+ 'On failure the host may call kanban with action "recover_stale" (mode: retry/release/fail); respect its decisions and do not duplicate work in parallel.',
6260
6451
  "When finished, report what changed, what you verified, and any remaining blockers."
6261
6452
  ].filter(Boolean).join("\n");
6262
6453
  }
@@ -8446,6 +8637,15 @@ var Director = class _Director {
8446
8637
  getLeaderContextPressure() {
8447
8638
  return this.leaderContextPressure;
8448
8639
  }
8640
+ /**
8641
+ * Remaining USD budget for the entire fleet (when a cap is configured).
8642
+ * Returns `undefined` when no cap was set (Infinity).
8643
+ */
8644
+ getRemainingBudgetUsd() {
8645
+ if (this.maxFleetCostUsd === Number.POSITIVE_INFINITY) return void 0;
8646
+ const totalCost = this.usage.snapshot().total?.cost ?? 0;
8647
+ return Math.max(0, this.maxFleetCostUsd - totalCost);
8648
+ }
8449
8649
  resolveMaxContext() {
8450
8650
  const resolved = typeof this.maxContext === "function" ? this.maxContext() : this.maxContext;
8451
8651
  return resolved && resolved > 0 ? resolved : 128e3;
@@ -12072,6 +12272,17 @@ var GlobalMailbox = class {
12072
12272
  _messageCacheMtime = -1;
12073
12273
  /** Size of the file when `_messageCache` was populated (extra guard). */
12074
12274
  _messageCacheSize = -1;
12275
+ /**
12276
+ * Serializes reads of the message file so overlapping concurrent
12277
+ * `_readMessagesCached()` calls don't both enter the incremental "file
12278
+ * only grew" branch against the same stale `_messageCacheSize` and
12279
+ * each push the same tail bytes onto the cache (duplicating every
12280
+ * appended message). The chain runs each read to completion before
12281
+ * the next starts, in issue order — readers are read-only and never
12282
+ * conflict with each other on content, only on the cache mutation
12283
+ * that follows the read. Pattern mirrors `DefaultMemoryStore.runSerialized`.
12284
+ */
12285
+ _readChain = Promise.resolve([]);
12075
12286
  /**
12076
12287
  * @param projectDir — `~/.wrongstack/projects/<slug>/`
12077
12288
  * @param events — optional EventBus for real-time TUI/WebUI notifications
@@ -12125,7 +12336,8 @@ var GlobalMailbox = class {
12125
12336
  await fsp7.mkdir(path10.dirname(this.messagePath), { recursive: true });
12126
12337
  await withFileLock(this.messagePath, async () => {
12127
12338
  await fsp7.appendFile(this.messagePath, line, "utf8");
12128
- this._pushToCache(msg);
12339
+ const { mtimeMs, size } = await this._statMessageFile();
12340
+ this._pushToCache(msg, mtimeMs, size);
12129
12341
  });
12130
12342
  this.publishHqMailboxEvent({
12131
12343
  mailboxId: this.hqMailboxId,
@@ -12169,7 +12381,6 @@ var GlobalMailbox = class {
12169
12381
  for (const a of input.acks) {
12170
12382
  byId.set(a.messageId, a);
12171
12383
  }
12172
- let cacheSnapshot = null;
12173
12384
  await withFileLock(this.messagePath, async () => {
12174
12385
  const all = await this._readMessagesFresh();
12175
12386
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -12197,9 +12408,9 @@ var GlobalMailbox = class {
12197
12408
  const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
12198
12409
  await fsp7.writeFile(this.messagePath, serialized, "utf8");
12199
12410
  }
12200
- cacheSnapshot = all;
12411
+ const { mtimeMs, size } = await this._statMessageFile();
12412
+ this._setMessageCache(all, mtimeMs, size);
12201
12413
  });
12202
- if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
12203
12414
  for (const message of updated) {
12204
12415
  this.publishHqMailboxEvent({
12205
12416
  mailboxId: this.hqMailboxId,
@@ -12223,7 +12434,6 @@ var GlobalMailbox = class {
12223
12434
  }
12224
12435
  async softDelete(mailId, by) {
12225
12436
  let updated = null;
12226
- let cacheSnapshot = null;
12227
12437
  await withFileLock(this.messagePath, async () => {
12228
12438
  const all = await this._readMessagesFresh();
12229
12439
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -12239,9 +12449,9 @@ var GlobalMailbox = class {
12239
12449
  }
12240
12450
  const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
12241
12451
  await fsp7.writeFile(this.messagePath, serialized, "utf8");
12242
- cacheSnapshot = all;
12452
+ const { mtimeMs, size } = await this._statMessageFile();
12453
+ this._setMessageCache(all, mtimeMs, size);
12243
12454
  });
12244
- if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
12245
12455
  if (updated !== null) {
12246
12456
  this.publishHqMailboxEvent({
12247
12457
  mailboxId: this.hqMailboxId,
@@ -12254,7 +12464,6 @@ var GlobalMailbox = class {
12254
12464
  }
12255
12465
  async restore(mailId) {
12256
12466
  let updated = null;
12257
- let cacheSnapshot = null;
12258
12467
  await withFileLock(this.messagePath, async () => {
12259
12468
  const all = await this._readMessagesFresh();
12260
12469
  for (const m of all) {
@@ -12265,9 +12474,9 @@ var GlobalMailbox = class {
12265
12474
  }
12266
12475
  const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
12267
12476
  await fsp7.writeFile(this.messagePath, serialized, "utf8");
12268
- cacheSnapshot = all;
12477
+ const { mtimeMs, size } = await this._statMessageFile();
12478
+ this._setMessageCache(all, mtimeMs, size);
12269
12479
  });
12270
- if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
12271
12480
  if (updated !== null) {
12272
12481
  this.publishHqMailboxEvent({
12273
12482
  mailboxId: this.hqMailboxId,
@@ -12512,8 +12721,9 @@ var GlobalMailbox = class {
12512
12721
  async clearAll() {
12513
12722
  await withFileLock(this.messagePath, async () => {
12514
12723
  await fsp7.writeFile(this.messagePath, "", "utf8");
12724
+ const { mtimeMs, size } = await this._statMessageFile();
12725
+ this._setMessageCache([], mtimeMs, size);
12515
12726
  });
12516
- this._setMessageCache([]);
12517
12727
  }
12518
12728
  async purgeStale(opts) {
12519
12729
  const COMPLETED_MAX_AGE_MS = opts?.completedMaxAgeMs ?? 864e5;
@@ -12545,7 +12755,8 @@ var GlobalMailbox = class {
12545
12755
  const content = kept.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
12546
12756
  await fsp7.writeFile(this.messagePath, content, "utf8");
12547
12757
  }
12548
- this._setMessageCache(kept);
12758
+ const { mtimeMs, size } = await this._statMessageFile();
12759
+ this._setMessageCache(kept, mtimeMs, size);
12549
12760
  });
12550
12761
  return {
12551
12762
  completedPurged,
@@ -12632,14 +12843,43 @@ var GlobalMailbox = class {
12632
12843
  * from writers that just took the file lock — the read reflects the
12633
12844
  * authoritative post-lock state and should be served to subsequent
12634
12845
  * queries without re-reading.
12846
+ *
12847
+ * The mtime/size are captured from the stat at read time so the cache
12848
+ * trackers match exactly what was parsed. Writers that subsequently
12849
+ * rewrite the file MUST re-stat after the write and re-promote with the
12850
+ * post-write values (see ackMany / softDelete / restore / purgeStale /
12851
+ * clearAll) — otherwise a concurrent reader misclassifies the rewrite
12852
+ * as a "file only grew" append and corrupts the cache.
12635
12853
  */
12636
12854
  async _readMessagesFresh() {
12637
12855
  const all = await this._readMessages();
12638
- this._setMessageCache(all);
12856
+ const { mtimeMs, size } = await this._statMessageFile();
12857
+ this._setMessageCache(all, mtimeMs, size);
12639
12858
  return all;
12640
12859
  }
12641
12860
  /**
12642
- * Read messages, consulting the mtime-bounded in-memory cache first.
12861
+ * Stat the message file, returning its mtimeMs and size. Returns
12862
+ * `-1/-1` when the file does not yet exist (ENOENT) so callers can
12863
+ * still promote a cache snapshot — the next read will re-stat and
12864
+ * fall through to a full re-read. Call from inside the file lock so
12865
+ * the result reflects the post-write on-disk state, not a later
12866
+ * intermediate state from another process.
12867
+ */
12868
+ async _statMessageFile() {
12869
+ try {
12870
+ const st = await fsp7.stat(this.messagePath);
12871
+ return { mtimeMs: st.mtimeMs, size: st.size };
12872
+ } catch (err) {
12873
+ if (err.code === "ENOENT") {
12874
+ return { mtimeMs: -1, size: -1 };
12875
+ }
12876
+ throw err;
12877
+ }
12878
+ }
12879
+ /**
12880
+ * Read messages, consulting the mtime-bounded in-memory cache first,
12881
+ * serialized so concurrent callers don't both mutate the cache.
12882
+ *
12643
12883
  * The mailbox file is shared across processes; every `send`/`ack`/
12644
12884
  * `clearAll`/`purgeStale` takes the file lock, so writes are serialized
12645
12885
  * and a changed mtimeMs is a definitive freshness signal. When the
@@ -12650,8 +12890,28 @@ var GlobalMailbox = class {
12650
12890
  * When the file only grew (new messages appended by another process),
12651
12891
  * we read and parse just the tail bytes instead of the entire file.
12652
12892
  * This avoids re-parsing the full 10K-message history on every check.
12893
+ *
12894
+ * SERIALIZATION: the actual work is chained onto `_readChain` so two
12895
+ * overlapping calls can't both pass the `st.size > _messageCacheSize`
12896
+ * incremental check against the same stale tracker and each push the
12897
+ * same tail bytes onto the cache (duplicating every appended message).
12898
+ * Readers don't conflict on file content — only on the cache mutation
12899
+ * that follows the read — so we run them one at a time in issue order.
12900
+ * Errors in the chain are swallowed so a failed read never poisons
12901
+ * subsequent reads; each caller observes and re-throws its own error.
12902
+ */
12903
+ _readMessagesCached() {
12904
+ const run = this._readChain.catch(() => void 0).then(() => this._readMessagesCachedWork());
12905
+ this._readChain = run.catch(() => []);
12906
+ return run;
12907
+ }
12908
+ /**
12909
+ * The un-serialized body of {@link _readMessagesCached}. Reads the
12910
+ * message file with the mtime-bounded cache + incremental-tail
12911
+ * optimization. Must only be called from `_readMessagesCached` so the
12912
+ * cache mutations here don't race a sibling read.
12653
12913
  */
12654
- async _readMessagesCached() {
12914
+ async _readMessagesCachedWork() {
12655
12915
  try {
12656
12916
  const st = await fsp7.stat(this.messagePath);
12657
12917
  if (this._messageCache !== null && this._messageCacheMtime === st.mtimeMs && this._messageCacheSize === st.size) {
@@ -12680,9 +12940,19 @@ var GlobalMailbox = class {
12680
12940
  }
12681
12941
  }
12682
12942
  /**
12683
- * Replace the in-memory cache. Caller is responsible for guaranteeing
12684
- * that `messages` reflects the current on-disk state (e.g. they just
12685
- * read or wrote it under the file lock).
12943
+ * Replace the in-memory cache, setting the mtime/size trackers
12944
+ * synchronously in the same step. Callers MUST pass the stat of the
12945
+ * on-disk file state that produced `messages`.
12946
+ *
12947
+ * Why both are required (not fire-and-forget): `_readMessagesCached()`
12948
+ * validates the cache against a fresh stat using these two trackers.
12949
+ * If the cache array is updated but the trackers lag behind (e.g. via
12950
+ * a deferred `stat().then(...)`), a concurrent reader landing in that
12951
+ * window sees a mismatched mtime and falls into the incremental
12952
+ * "file only grew" branch — parsing rewritten bytes as an appended
12953
+ * tail and corrupting the cache with duplicates/garbage. So both
12954
+ * values are captured under the file lock and applied synchronously
12955
+ * here, matching the fix already shipped in DefaultMailbox.
12686
12956
  */
12687
12957
  _setMessageCache(messages, mtime, size) {
12688
12958
  if (messages.length > MESSAGE_CACHE_MAX_ENTRIES) {
@@ -12692,25 +12962,27 @@ var GlobalMailbox = class {
12692
12962
  return;
12693
12963
  }
12694
12964
  this._messageCache = messages;
12695
- if (mtime !== void 0 && size !== void 0) {
12696
- this._messageCacheMtime = mtime;
12697
- this._messageCacheSize = size;
12698
- } else {
12699
- void fsp7.stat(this.messagePath).then((st) => {
12700
- this._messageCacheMtime = st.mtimeMs;
12701
- this._messageCacheSize = st.size;
12702
- }).catch(() => {
12703
- });
12704
- }
12965
+ this._messageCacheMtime = mtime;
12966
+ this._messageCacheSize = size;
12705
12967
  }
12706
12968
  /**
12707
12969
  * Append a single just-sent message to the in-memory cache without
12708
12970
  * re-reading the file. The caller must hold the file lock (or have
12709
- * just released it after a successful append) so the cache stays
12710
- * consistent with on-disk state.
12971
+ * just released it after a successful append) and MUST pass the
12972
+ * post-append stat so the mtime/size trackers advance in lock-step
12973
+ * with the pushed content.
12974
+ *
12975
+ * Why the stat is required here: without it, `_messageCacheSize`
12976
+ * stays at the pre-append value. A concurrent `_readMessagesCached()`
12977
+ * then sees `st.size > _messageCacheSize` (the file did grow), takes
12978
+ * the incremental branch, and re-reads the just-appended tail —
12979
+ * pushing the same message onto the cache a second time. Setting the
12980
+ * trackers here closes that window for the local-process append path.
12711
12981
  */
12712
- _pushToCache(msg) {
12713
- if (this._messageCache === null) return;
12982
+ _pushToCache(msg, mtime, size) {
12983
+ if (this._messageCache === null) {
12984
+ return;
12985
+ }
12714
12986
  if (this._messageCache.length >= MESSAGE_CACHE_MAX_ENTRIES) {
12715
12987
  this._messageCache = null;
12716
12988
  this._messageCacheMtime = -1;
@@ -12718,6 +12990,8 @@ var GlobalMailbox = class {
12718
12990
  return;
12719
12991
  }
12720
12992
  this._messageCache.push(msg);
12993
+ this._messageCacheMtime = mtime;
12994
+ this._messageCacheSize = size;
12721
12995
  }
12722
12996
  async _ensureRegistry() {
12723
12997
  await fsp7.mkdir(path10.dirname(this.registryPath), { recursive: true });