@wrongstack/tools 0.303.0 → 0.305.1

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.
package/dist/task.js CHANGED
@@ -12,12 +12,17 @@ import {
12
12
  mutateTasks
13
13
  } from "@wrongstack/core/storage";
14
14
  import { deserializeTaskGraph } from "@wrongstack/core/tasking";
15
- import { resolveWstackPaths } from "@wrongstack/core/utils";
15
+ import { formatTodosForModel, resolveWstackPaths } from "@wrongstack/core/utils";
16
16
  import {
17
17
  bridgeKanbanSupervisor,
18
+ compactSessionMirrorBoard,
18
19
  createBoard,
20
+ DEFAULT_COLUMNS,
19
21
  getBoard,
22
+ getDependencyReadinessIssues,
23
+ getKanbanOrchestrationSnapshot,
20
24
  listBoards,
25
+ pruneSessionBoards,
21
26
  removeBoard,
22
27
  syncBoardFromTaskGraph,
23
28
  touchKanbanPresence,
@@ -25,16 +30,14 @@ import {
25
30
  } from "@wrongstack/kanban";
26
31
  var SESSION_BOARD_TAG = "session-work";
27
32
  var MIRROR_DISABLED_ENV = "WRONGSTACK_KANBAN_TASK_MIRROR";
28
- var SESSION_KANBAN_COLUMNS = [
29
- { id: "todo", title: "Todo", order: 0, wipLimit: 0, color: "#2563eb" },
30
- { id: "in-progress", title: "Running", order: 1, wipLimit: 1, color: "#d97706" },
31
- { id: "review", title: "Preview", order: 2, wipLimit: 0, color: "#7c3aed" },
32
- { id: "done", title: "Done", order: 3, wipLimit: 0, color: "#16a34a" }
33
- ];
33
+ var SESSION_KANBAN_COLUMNS = DEFAULT_COLUMNS.map((column) => ({
34
+ ...column
35
+ }));
34
36
  var boardQueue = /* @__PURE__ */ new Map();
35
37
  var boardEnsures = /* @__PURE__ */ new Map();
36
38
  var pendingMirrors = /* @__PURE__ */ new Map();
37
39
  var activeMirrors = /* @__PURE__ */ new Set();
40
+ var mirrorFailures = /* @__PURE__ */ new Map();
38
41
  var suppressedTodoMirrors = /* @__PURE__ */ new WeakSet();
39
42
  function boardKey(projectRoot, sessionId) {
40
43
  return `${projectRoot}\0${sessionId}`;
@@ -42,6 +45,33 @@ function boardKey(projectRoot, sessionId) {
42
45
  function mirrorKey(projectRoot, sessionId, sourceSystem) {
43
46
  return `${boardKey(projectRoot, sessionId)}\0${sourceSystem}`;
44
47
  }
48
+ function completedReconciliationGraph(latest, candidates) {
49
+ const latestNodeIds = new Set(latest.nodes.map((node) => node.id));
50
+ const carriedNodeIds = /* @__PURE__ */ new Set();
51
+ const completedNodes = candidates.flatMap(
52
+ (candidate) => candidate.nodes.filter((node) => {
53
+ if (node.status !== "completed" || latestNodeIds.has(node.id) || carriedNodeIds.has(node.id)) {
54
+ return false;
55
+ }
56
+ carriedNodeIds.add(node.id);
57
+ return true;
58
+ })
59
+ );
60
+ if (completedNodes.length === 0) return void 0;
61
+ const carriedRequirements = completedNodes.flatMap(
62
+ (node) => node.specRequirementId ? [node.specRequirementId] : []
63
+ );
64
+ return {
65
+ ...latest,
66
+ nodes: [...latest.nodes, ...completedNodes],
67
+ rootNodes: [.../* @__PURE__ */ new Set([...latest.rootNodes, ...completedNodes.map((node) => node.id)])],
68
+ ...latest.requiredRequirementIds ? {
69
+ requiredRequirementIds: [
70
+ .../* @__PURE__ */ new Set([...latest.requiredRequirementIds, ...carriedRequirements])
71
+ ]
72
+ } : {}
73
+ };
74
+ }
45
75
  function sessionTag(sessionId) {
46
76
  return `session:${sessionId}`;
47
77
  }
@@ -120,16 +150,44 @@ async function projectGraph(projectRoot, sessionId, graph, sourceSystem) {
120
150
  sourceSystem,
121
151
  tags: [.../* @__PURE__ */ new Set([...board.tags ?? [], ...sessionBoardTags(sessionId)])],
122
152
  archiveMissingTasks: true,
123
- includeCompletedTasks: true
153
+ includeCompletedTasks: true,
154
+ // The scope ledger stays declared and accurate, but it may not veto a
155
+ // projection. A session mirror reflects a tactical list that shrinks by
156
+ // design, and refusing the sync never protected the removed row — it
157
+ // froze the entire board, permanently, because the stored scope then
158
+ // outlived every later snapshot (`session-kanban.mirror-failed`).
159
+ // Nothing is lost by shrinking here: `archiveMissingTasks` keeps the
160
+ // removed card on the board as `archived`, the reconciliation pass
161
+ // first walks vanished completed rows to Done, and the session journal
162
+ // remains the durable record.
163
+ allowRequirementScopeShrink: true
124
164
  }
125
165
  );
126
- return result?.board ?? null;
166
+ if (!result) return null;
167
+ const compacted = await compactSessionMirrorBoard(projectRoot, board.id);
168
+ if (compacted?.removedTaskIds.length) {
169
+ return await getBoard(projectRoot, board.id) ?? result.board;
170
+ }
171
+ return result.board;
127
172
  });
128
173
  }
129
174
  function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
130
175
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return;
131
176
  const key = mirrorKey(projectRoot, sessionId, sourceSystem);
132
- pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });
177
+ const previous = pendingMirrors.get(key);
178
+ const reconciliationGraph = previous ? completedReconciliationGraph(
179
+ graph,
180
+ [previous.reconciliationGraph, previous.graph].filter(
181
+ (candidate) => candidate !== void 0
182
+ )
183
+ ) : void 0;
184
+ pendingMirrors.set(key, {
185
+ projectRoot,
186
+ sessionId,
187
+ graph,
188
+ ...reconciliationGraph ? { reconciliationGraph } : {},
189
+ sourceSystem
190
+ });
133
191
  if (activeMirrors.has(key)) return;
134
192
  activeMirrors.add(key);
135
193
  void (async () => {
@@ -139,20 +197,34 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
139
197
  if (!pending) break;
140
198
  pendingMirrors.delete(key);
141
199
  try {
200
+ if (pending.reconciliationGraph) {
201
+ await projectGraph(
202
+ pending.projectRoot,
203
+ pending.sessionId,
204
+ pending.reconciliationGraph,
205
+ pending.sourceSystem
206
+ );
207
+ }
142
208
  await projectGraph(
143
209
  pending.projectRoot,
144
210
  pending.sessionId,
145
211
  pending.graph,
146
212
  pending.sourceSystem
147
213
  );
214
+ mirrorFailures.delete(boardKey(pending.projectRoot, pending.sessionId));
148
215
  } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ mirrorFailures.set(boardKey(pending.projectRoot, pending.sessionId), {
218
+ message,
219
+ sourceSystem: pending.sourceSystem
220
+ });
149
221
  console.warn(
150
222
  JSON.stringify({
151
223
  level: "warn",
152
224
  event: "session-kanban.mirror-failed",
153
225
  sessionId: pending.sessionId,
154
226
  sourceSystem: pending.sourceSystem,
155
- message: error instanceof Error ? error.message : String(error),
227
+ message,
156
228
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
157
229
  })
158
230
  );
@@ -173,6 +245,14 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
173
245
  }
174
246
  })();
175
247
  }
248
+ function takeSessionMirrorFailure(projectRoot, sessionId) {
249
+ if (!projectRoot || !sessionId) return void 0;
250
+ const key = boardKey(projectRoot, sessionId);
251
+ const failure = mirrorFailures.get(key);
252
+ if (!failure) return void 0;
253
+ mirrorFailures.delete(key);
254
+ return `Kanban mirror (${failure.sourceSystem}) failed and the board may be stale: ${failure.message}`;
255
+ }
176
256
  function todoListToSerializedGraph(todos, sessionId) {
177
257
  const graphId = `todo:${sessionId}`;
178
258
  const nodes = todos.map((todo, index) => ({
@@ -266,11 +346,11 @@ function broadcastTodoUpdate(context, todos) {
266
346
  });
267
347
  }
268
348
  function notifyTodoUpdate(context, todos) {
269
- const summary = todos.length ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join("\n") : "- No active todos remain.";
349
+ const summary = formatTodosForModel(todos);
270
350
  const text = `[KANBAN TODO UPDATE]
271
351
  Another Kanban agent reassessed the shared board. The canonical todo list is now:
272
352
  ${summary}
273
- Reassess your current plan before continuing; do not rely on the initial todo snapshot.`;
353
+ Reassess your current plan before continuing; do not rely on the initial todo snapshot. Preserve each row's <kanban board/task> binding verbatim on your next \`todo\` call \u2014 a row that loses it stops advancing its card.`;
274
354
  const state = context.state;
275
355
  if (typeof state.appendBlockToLastUserMessage === "function") {
276
356
  if (state.appendBlockToLastUserMessage({ type: "text", text })) return;
@@ -309,26 +389,68 @@ function todoStatus(task) {
309
389
  if (status === "in_progress" || status === "review") return "in_progress";
310
390
  return "pending";
311
391
  }
312
- function sessionTodoFromTask(task, boardId) {
392
+ function sessionTodoFromTask(task, board) {
393
+ const blockedBy = board ? blockingTitles(board, task) : [];
313
394
  return {
314
395
  id: task.origin?.taskId ?? task.id,
315
396
  content: task.title,
316
397
  status: todoStatus(task),
317
- kanbanBoardId: boardId,
318
- kanbanTaskId: task.id,
319
- ...task.description ? { activeForm: task.description } : {}
398
+ ...task.description ? { activeForm: task.description } : {},
399
+ ...blockedBy.length ? { blockedBy } : {}
320
400
  };
321
401
  }
322
- function managedTodoFromTask(task, boardId) {
402
+ function managedTodoFromTask(task, board) {
323
403
  return {
324
- ...sessionTodoFromTask(task, boardId),
325
- status: task.status === "completed" ? "completed" : task.status === "in_progress" ? "in_progress" : "pending"
404
+ ...sessionTodoFromTask(task, board),
405
+ kanbanBoardId: board.id,
406
+ kanbanTaskId: task.id
326
407
  };
327
408
  }
409
+ function blockingTitles(board, task) {
410
+ return getDependencyReadinessIssues(board, task).map((issue) => {
411
+ const dependency = board.tasks.find((candidate) => candidate.id === issue.dependencyId);
412
+ if (!dependency) return `${issue.dependencyId} (missing)`;
413
+ return dependency.title;
414
+ });
415
+ }
416
+ var PRIORITY_ORDER = {
417
+ critical: 0,
418
+ high: 1,
419
+ medium: 2,
420
+ low: 3
421
+ };
422
+ function orderTasksForTodos(board, tasks) {
423
+ const columnOrder = new Map(board.columns.map((column) => [column.id, column.order]));
424
+ const baseline = [...tasks].sort(
425
+ (left, right) => (columnOrder.get(left.columnId) ?? 0) - (columnOrder.get(right.columnId) ?? 0) || (PRIORITY_ORDER[left.priority] ?? 2) - (PRIORITY_ORDER[right.priority] ?? 2) || left.order - right.order || left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)
426
+ );
427
+ const included = new Set(baseline.map((task) => task.id));
428
+ const remaining = new Map(baseline.map((task) => [task.id, task]));
429
+ const emitted = [];
430
+ const done = /* @__PURE__ */ new Set();
431
+ while (remaining.size > 0) {
432
+ const ready = baseline.filter(
433
+ (task) => remaining.has(task.id) && (task.dependsOn ?? []).every(
434
+ (dependencyId) => !included.has(dependencyId) || done.has(dependencyId)
435
+ )
436
+ );
437
+ if (ready.length === 0) break;
438
+ for (const task of ready) {
439
+ remaining.delete(task.id);
440
+ done.add(task.id);
441
+ emitted.push(task);
442
+ }
443
+ }
444
+ for (const task of baseline) if (remaining.has(task.id)) emitted.push(task);
445
+ return emitted;
446
+ }
328
447
  function sameTodos(left, right) {
329
448
  return left.length === right.length && left.every((todo, index) => {
330
449
  const candidate = right[index];
331
- return candidate?.id === todo.id && candidate.content === todo.content && candidate.status === todo.status && candidate.activeForm === todo.activeForm && candidate.promotedFromPlan === todo.promotedFromPlan && candidate.promotedFromTask === todo.promotedFromTask && candidate.kanbanBoardId === todo.kanbanBoardId && candidate.kanbanTaskId === todo.kanbanTaskId;
450
+ return candidate?.id === todo.id && candidate.content === todo.content && candidate.status === todo.status && candidate.activeForm === todo.activeForm && candidate.promotedFromPlan === todo.promotedFromPlan && candidate.promotedFromTask === todo.promotedFromTask && candidate.kanbanBoardId === todo.kanbanBoardId && candidate.kanbanTaskId === todo.kanbanTaskId && // Readiness is part of the projection: when a dependency completes,
451
+ // the rows are otherwise identical and the unblocking would never
452
+ // reach the model.
453
+ (candidate.blockedBy ?? []).join("\0") === (todo.blockedBy ?? []).join("\0");
332
454
  });
333
455
  }
334
456
  function applyManagedKanbanBoardToTodos(context, board) {
@@ -338,11 +460,12 @@ function applyManagedKanbanBoardToTodos(context, board) {
338
460
  if (!activeBoardId2 || board.id !== activeBoardId2 || board.lifecycle?.mode !== "managed") {
339
461
  return [...context.todos];
340
462
  }
341
- const projectedTodos = board.tasks.filter(
342
- (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
343
- ).sort(
344
- (left, right) => left.createdAt.localeCompare(right.createdAt) || left.order - right.order
345
- ).map((task) => managedTodoFromTask(task, board.id));
463
+ const projectedTodos = orderTasksForTodos(
464
+ board,
465
+ board.tasks.filter(
466
+ (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
467
+ )
468
+ ).map((task) => managedTodoFromTask(task, board));
346
469
  if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
347
470
  suppressedTodoMirrors.add(context);
348
471
  try {
@@ -363,14 +486,13 @@ import {
363
486
  saveTasks,
364
487
  setPlanItemStatus
365
488
  } from "@wrongstack/core/storage";
366
- import { getBoard as getBoard4 } from "@wrongstack/kanban";
489
+ import { addTask as addTask2, getBoard as getBoard4 } from "@wrongstack/kanban";
367
490
 
368
491
  // src/kanban.ts
369
492
  import { randomUUID as randomUUID2 } from "node:crypto";
370
493
  import { loadTasks as loadTasks2 } from "@wrongstack/core/storage";
371
494
  import { deserializeTaskGraph as deserializeTaskGraph2, serializeTaskGraph } from "@wrongstack/core/tasking";
372
495
  import {
373
- addColumn,
374
496
  addTask,
375
497
  adoptManagedLifecycle,
376
498
  assignTask,
@@ -385,7 +507,7 @@ import {
385
507
  exportBoardToTaskGraph,
386
508
  finalizeTaskCompletion,
387
509
  getBoard as getBoard3,
388
- getKanbanOrchestrationSnapshot,
510
+ getKanbanOrchestrationSnapshot as getKanbanOrchestrationSnapshot2,
389
511
  getKanbanQueueHealth,
390
512
  getTask,
391
513
  getTaskChain,
@@ -399,16 +521,16 @@ import {
399
521
  recoverStaleTaskAssignments,
400
522
  releaseTaskClaim,
401
523
  removeBoard as removeBoard2,
402
- removeColumn,
403
524
  removeTask,
404
525
  repairManagedTaskProjection,
526
+ resolveAutoAccept,
405
527
  searchKanban,
406
528
  setTaskChain,
529
+ stripLifecycleIssues,
407
530
  syncBoardFromTaskGraph as syncBoardFromTaskGraph2,
408
531
  transferTaskToBoard,
409
532
  transitionTask,
410
533
  updateBoard as updateBoard2,
411
- updateColumn,
412
534
  updateTask as updateTask2,
413
535
  updateTaskAssignment,
414
536
  verifyTaskCompletion as verifyTaskCompletion2
@@ -458,6 +580,137 @@ function duplicateBoardOptions(input) {
458
580
  };
459
581
  }
460
582
 
583
+ // src/kanban-contract-actions.ts
584
+ import {
585
+ addContractEdge,
586
+ configureContractGraph,
587
+ evaluateTaskContractGraph,
588
+ getContractGraph,
589
+ removeContractEdge,
590
+ removeContractNode,
591
+ upsertContractNode
592
+ } from "@wrongstack/kanban";
593
+
594
+ // src/kanban-tool-results.ts
595
+ function atomicityNudge(task) {
596
+ if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
597
+ const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
598
+ return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
599
+ }
600
+ function readEnvGateEnforcement() {
601
+ const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
602
+ return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
603
+ }
604
+ function fail(message) {
605
+ return { ok: false, message };
606
+ }
607
+ function okBoard(board, message = "Board loaded.") {
608
+ return { ok: true, message, board };
609
+ }
610
+ function okTask(board, task, message) {
611
+ return { ok: true, message, board, task };
612
+ }
613
+
614
+ // src/kanban-contract-actions.ts
615
+ async function handleKanbanContractAction(projectRoot, input, actor) {
616
+ switch (input.action) {
617
+ case "get_contract_graph": {
618
+ if (!input.boardId) return fail("get_contract_graph requires boardId.");
619
+ const found = await getContractGraph(projectRoot, input.boardId);
620
+ if (!found) return fail("Board not found.");
621
+ const evaluated = input.taskId ? await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId) : null;
622
+ if (input.taskId && !evaluated) return fail("Task not found on this board.");
623
+ return {
624
+ ok: true,
625
+ message: found.graph ? `Contract map: ${found.graph.nodes.length} node(s), ${found.graph.edges.length} edge(s), enforcement ${found.graph.enforcement}.` : "No contract map on this board yet. Call configure_contract_graph to start one.",
626
+ board: found.board,
627
+ contractGraph: found.graph,
628
+ ...evaluated ? { contractEvaluation: evaluated.evaluation } : {}
629
+ };
630
+ }
631
+ case "configure_contract_graph": {
632
+ if (!input.boardId) return fail("configure_contract_graph requires boardId.");
633
+ const enforcement = input.contractEnforcement ?? "advisory";
634
+ const board = await configureContractGraph(projectRoot, input.boardId, enforcement);
635
+ return board ? okBoard(board, `Contract map enforcement set to ${enforcement}.`) : fail("Board not found.");
636
+ }
637
+ case "upsert_contract_node": {
638
+ if (!input.boardId || !input.taskId) {
639
+ return fail("upsert_contract_node requires boardId and taskId.");
640
+ }
641
+ if (!input.contractNodeKind || !input.contractNodeTitle) {
642
+ return fail("upsert_contract_node requires contractNodeKind and contractNodeTitle.");
643
+ }
644
+ const waiver = input.contractNodeState === "waived" ? {
645
+ actor: actor ?? "agent",
646
+ reason: input.contractWaiverReason ?? "",
647
+ at: (/* @__PURE__ */ new Date()).toISOString()
648
+ } : void 0;
649
+ if (waiver && !waiver.reason.trim()) {
650
+ return fail("A waived contract node requires contractWaiverReason.");
651
+ }
652
+ const result = await upsertContractNode(projectRoot, input.boardId, {
653
+ taskId: input.taskId,
654
+ kind: input.contractNodeKind,
655
+ title: input.contractNodeTitle,
656
+ ...input.contractNodeId !== void 0 ? { id: input.contractNodeId } : {},
657
+ ...input.contractNodeDescription !== void 0 ? { description: input.contractNodeDescription } : {},
658
+ ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
659
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
660
+ ...input.contractCheckId !== void 0 ? { checkId: input.contractCheckId } : {},
661
+ ...input.contractMetricId !== void 0 ? { metricId: input.contractMetricId } : {},
662
+ ...waiver ? { waiver } : {},
663
+ ...actor !== void 0 ? { createdBy: actor } : {}
664
+ });
665
+ if (!result) return fail("Board or task not found.");
666
+ return {
667
+ ok: true,
668
+ message: `Contract node ${result.node.kind} "${result.node.title}" saved (${result.node.id}).`,
669
+ board: result.board,
670
+ contractGraph: result.board.contractGraph ?? null
671
+ };
672
+ }
673
+ case "remove_contract_node": {
674
+ if (!input.boardId || !input.contractNodeId) {
675
+ return fail("remove_contract_node requires boardId and contractNodeId.");
676
+ }
677
+ const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
678
+ return board ? okBoard(board, "Contract node removed, along with every edge that touched it.") : fail("Contract node not found.");
679
+ }
680
+ case "add_contract_edge": {
681
+ if (!input.boardId || !input.contractEdgeFrom || !input.contractEdgeTo) {
682
+ return fail("add_contract_edge requires boardId, contractEdgeFrom, and contractEdgeTo.");
683
+ }
684
+ if (!input.contractEdgeType) return fail("add_contract_edge requires contractEdgeType.");
685
+ const result = await addContractEdge(projectRoot, input.boardId, {
686
+ from: input.contractEdgeFrom,
687
+ to: input.contractEdgeTo,
688
+ type: input.contractEdgeType,
689
+ ...input.contractEdgeId !== void 0 ? { id: input.contractEdgeId } : {},
690
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
691
+ ...input.contractEdgeRationale !== void 0 ? { rationale: input.contractEdgeRationale } : {},
692
+ ...actor !== void 0 ? { createdBy: actor } : {}
693
+ });
694
+ if (!result) return fail("Board not found.");
695
+ return {
696
+ ok: true,
697
+ message: `Contract edge ${result.edge.type}: ${result.edge.from} \u2192 ${result.edge.to}.`,
698
+ board: result.board,
699
+ contractGraph: result.board.contractGraph ?? null
700
+ };
701
+ }
702
+ case "remove_contract_edge": {
703
+ if (!input.boardId || !input.contractEdgeId) {
704
+ return fail("remove_contract_edge requires boardId and contractEdgeId.");
705
+ }
706
+ const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
707
+ return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
708
+ }
709
+ default:
710
+ return void 0;
711
+ }
712
+ }
713
+
461
714
  // src/kanban-decomposition-actions.ts
462
715
  import {
463
716
  assessTaskAtomicity,
@@ -489,26 +742,6 @@ function recordKanbanVerificationEvidence(ctx, report) {
489
742
  }
490
743
  }
491
744
 
492
- // src/kanban-tool-results.ts
493
- function atomicityNudge(task) {
494
- if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
495
- const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
496
- return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
497
- }
498
- function readEnvGateEnforcement() {
499
- const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
500
- return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
501
- }
502
- function fail(message) {
503
- return { ok: false, message };
504
- }
505
- function okBoard(board, message = "Board loaded.") {
506
- return { ok: true, message, board };
507
- }
508
- function okTask(board, task, message) {
509
- return { ok: true, message, board, task };
510
- }
511
-
512
745
  // src/kanban-decomposition-actions.ts
513
746
  async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
514
747
  switch (input.action) {
@@ -593,20 +826,14 @@ async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
593
826
  // src/kanban-detail-actions.ts
594
827
  import {
595
828
  addCheckToTask,
596
- addContractEdge,
597
829
  addDependency,
598
830
  addGoalMetricToTask,
599
831
  addLinkToTask,
600
832
  addNoteToTask,
601
- configureContractGraph,
602
- evaluateTaskContractGraph,
603
- getContractGraph,
604
833
  getKanbanWorkbench,
605
- removeContractEdge,
606
- removeContractNode,
834
+ removeCheckFromTask,
607
835
  updateCheckOnTask,
608
- updateGoalMetricOnTask,
609
- upsertContractNode
836
+ updateGoalMetricOnTask
610
837
  } from "@wrongstack/kanban";
611
838
 
612
839
  // src/kanban-split-task-handler.ts
@@ -672,131 +899,6 @@ async function handleKanbanDetailAction(projectRoot, input) {
672
899
  workbench
673
900
  };
674
901
  }
675
- case "get_contract_graph": {
676
- if (!input.boardId) return fail("get_contract_graph requires boardId.");
677
- const result = await getContractGraph(projectRoot, input.boardId);
678
- return result ? {
679
- ok: true,
680
- message: result.graph ? `${result.graph.nodes.length} contract node(s), ${result.graph.edges.length} edge(s).` : "Contract graph is not configured.",
681
- board: result.board,
682
- ...result.graph ? { contractGraph: result.graph } : {}
683
- } : fail("Board not found.");
684
- }
685
- case "configure_contract_graph": {
686
- if (!input.boardId || !input.contractGraphEnforcement) {
687
- return fail("configure_contract_graph requires boardId and contractGraphEnforcement.");
688
- }
689
- const current = await getContractGraph(projectRoot, input.boardId);
690
- if (!current) return fail("Board not found.");
691
- if (input.contractGraphEnforcement === "strict" && current.graph?.enforcement !== "strict") {
692
- return fail(
693
- "Strict Contract Map enforcement is operator-owned. Autonomous agents may use advisory maps but may not turn them into an execution gate."
694
- );
695
- }
696
- if (current.graph?.enforcement === "strict" && input.contractGraphEnforcement !== "strict") {
697
- return fail("An autonomous agent may not loosen a strict contract graph.");
698
- }
699
- const board = await configureContractGraph(
700
- projectRoot,
701
- input.boardId,
702
- input.contractGraphEnforcement
703
- );
704
- return board ? okBoard(board, "Contract graph configured.") : fail("Board not found.");
705
- }
706
- case "upsert_contract_node": {
707
- if (!input.boardId || !input.taskId || !input.contractNodeKind || !input.title) {
708
- return fail("upsert_contract_node requires boardId, taskId, contractNodeKind, and title.");
709
- }
710
- if (input.contractNodeState === "waived") {
711
- return fail(
712
- "The autonomous kanban tool may not waive contract nodes; a human-owned review surface must record that exception."
713
- );
714
- }
715
- if (input.contractNodeId) {
716
- const current = await getContractGraph(projectRoot, input.boardId);
717
- const existing = current?.graph?.nodes.find((node) => node.id === input.contractNodeId);
718
- if (current?.graph?.enforcement === "strict" && existing && (existing.kind !== input.contractNodeKind || input.contractEnforcement !== void 0 && input.contractEnforcement !== existing.enforcement)) {
719
- return fail(
720
- "The autonomous kanban tool may not change the kind or enforcement of an existing strict contract node."
721
- );
722
- }
723
- }
724
- const result = await upsertContractNode(projectRoot, input.boardId, {
725
- ...input.contractNodeId ? { id: input.contractNodeId } : {},
726
- taskId: input.taskId,
727
- kind: input.contractNodeKind,
728
- title: input.title,
729
- ...input.description !== void 0 ? { description: input.description } : {},
730
- ...input.contractEnforcement !== void 0 ? { enforcement: input.contractEnforcement } : {},
731
- ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
732
- ...input.checkId !== void 0 ? { checkId: input.checkId } : {},
733
- ...input.metricId !== void 0 ? { metricId: input.metricId } : {},
734
- ...input.baseline !== void 0 ? { baseline: input.baseline } : {},
735
- ...input.threshold !== void 0 ? { threshold: input.threshold } : {},
736
- ...input.author !== void 0 ? { createdBy: input.author } : {}
737
- });
738
- return result ? {
739
- ...okBoard(result.board, "Contract node saved."),
740
- contractGraph: result.board.contractGraph
741
- } : fail("Task not found.");
742
- }
743
- case "link_contract_nodes": {
744
- if (!input.boardId || !input.fromNodeId || !input.toNodeId || !input.contractEdgeType) {
745
- return fail(
746
- "link_contract_nodes requires boardId, fromNodeId, toNodeId, and contractEdgeType."
747
- );
748
- }
749
- const result = await addContractEdge(projectRoot, input.boardId, {
750
- from: input.fromNodeId,
751
- to: input.toNodeId,
752
- type: input.contractEdgeType,
753
- ...input.contractEdgeId ? { id: input.contractEdgeId } : {},
754
- ...input.contractEnforcement ? { enforcement: input.contractEnforcement } : {},
755
- ...input.contractRationale ? { rationale: input.contractRationale } : {},
756
- ...input.author ? { createdBy: input.author } : {}
757
- });
758
- return result ? {
759
- ...okBoard(result.board, "Contract edge added."),
760
- contractGraph: result.board.contractGraph
761
- } : fail("Board not found.");
762
- }
763
- case "remove_contract_node": {
764
- if (!input.boardId || !input.contractNodeId) {
765
- return fail("remove_contract_node requires boardId and contractNodeId.");
766
- }
767
- const current = await getContractGraph(projectRoot, input.boardId);
768
- const node = current?.graph?.nodes.find((candidate) => candidate.id === input.contractNodeId);
769
- if (current?.graph?.enforcement === "strict" && node?.enforcement === "blocking") {
770
- return fail("The autonomous kanban tool may not remove a blocking strict contract node.");
771
- }
772
- const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
773
- return board ? okBoard(board, "Contract node removed.") : fail("Contract node not found.");
774
- }
775
- case "remove_contract_edge": {
776
- if (!input.boardId || !input.contractEdgeId) {
777
- return fail("remove_contract_edge requires boardId and contractEdgeId.");
778
- }
779
- const current = await getContractGraph(projectRoot, input.boardId);
780
- const edge = current?.graph?.edges.find((candidate) => candidate.id === input.contractEdgeId);
781
- if (current?.graph?.enforcement === "strict" && edge?.enforcement === "blocking") {
782
- return fail("The autonomous kanban tool may not remove a blocking strict contract edge.");
783
- }
784
- const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
785
- return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
786
- }
787
- case "evaluate_contract_graph": {
788
- if (!input.boardId || !input.taskId) {
789
- return fail("evaluate_contract_graph requires boardId and taskId.");
790
- }
791
- const result = await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId);
792
- return result ? {
793
- ok: result.evaluation.allowed,
794
- message: result.evaluation.allowed ? "Contract graph is closed." : `Contract graph has ${result.evaluation.issues.length} unresolved issue(s).`,
795
- board: result.board,
796
- contractGraph: result.board.contractGraph,
797
- contractEvaluation: result.evaluation
798
- } : fail("Task not found.");
799
- }
800
902
  case "add_dependency": {
801
903
  if (!input.boardId || !input.taskId || !input.dependencyTaskId) {
802
904
  return fail("add_dependency requires boardId, taskId, and dependencyTaskId.");
@@ -849,8 +951,9 @@ async function handleKanbanDetailAction(projectRoot, input) {
849
951
  }
850
952
  const board = await addCheckToTask(projectRoot, input.boardId, input.taskId, {
851
953
  description: input.checkDescription,
852
- type: "manual",
853
- status: input.checkStatus
954
+ type: input.checkType ?? "manual",
955
+ status: input.checkStatus,
956
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
854
957
  });
855
958
  return board ? okBoard(board, "Check added.") : fail("Task not found.");
856
959
  }
@@ -865,11 +968,27 @@ async function handleKanbanDetailAction(projectRoot, input) {
865
968
  input.checkId,
866
969
  {
867
970
  ...input.checkDescription !== void 0 ? { description: input.checkDescription } : {},
868
- ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {}
971
+ ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {},
972
+ // Promoting an existing manual criterion to an executable one is the
973
+ // common repair: the card was written before anyone knew the command.
974
+ ...input.checkType !== void 0 ? { type: input.checkType } : {},
975
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
869
976
  }
870
977
  );
871
978
  return board ? okBoard(board, "Check updated.") : fail("Check not found.");
872
979
  }
980
+ case "remove_check": {
981
+ if (!input.boardId || !input.taskId || !input.checkId) {
982
+ return fail("remove_check requires boardId, taskId, and checkId.");
983
+ }
984
+ const board = await removeCheckFromTask(
985
+ projectRoot,
986
+ input.boardId,
987
+ input.taskId,
988
+ input.checkId
989
+ );
990
+ return board ? okBoard(board, "Acceptance criterion removed.") : fail("Check not found on this task.");
991
+ }
873
992
  case "add_note": {
874
993
  if (!input.boardId || !input.taskId || !input.note)
875
994
  return fail("add_note requires boardId, taskId, and note.");
@@ -944,14 +1063,25 @@ function taskInput(input) {
944
1063
  ...input.order !== void 0 ? { order: input.order } : {},
945
1064
  ...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
946
1065
  ...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
1066
+ // The system prompt has always told the model it may "set atomic: true"
1067
+ // when creating a composite parent. It could not: the field reached
1068
+ // neither the create input nor the patch, so the instruction described a
1069
+ // capability that did not exist and the attempt was silently dropped.
1070
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
947
1071
  ...input.childTitles !== void 0 ? { childTaskIds: input.childTitles } : {},
948
1072
  ...input.checkDescription !== void 0 ? {
949
1073
  successCriteria: [
950
1074
  {
951
1075
  id: randomUUID(),
952
1076
  description: input.checkDescription,
953
- type: "manual",
954
- status: input.checkStatus ?? "pending"
1077
+ // `manual` only as the fallback. Hard-coding it here meant every
1078
+ // agent-authored criterion was unverifiable by construction: the
1079
+ // deterministic plugins never matched, the registry passed the
1080
+ // hand-set status straight through, and "verified" collapsed into
1081
+ // "the author ticked its own box".
1082
+ type: input.checkType ?? "manual",
1083
+ status: input.checkStatus ?? "pending",
1084
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
955
1085
  }
956
1086
  ]
957
1087
  } : {},
@@ -999,11 +1129,11 @@ function taskInput(input) {
999
1129
  };
1000
1130
  }
1001
1131
  function mergedDependsOn(input) {
1002
- const ids = [
1132
+ if (input.dependsOn === void 0 && input.dependencyTaskId === void 0) return void 0;
1133
+ return [
1003
1134
  ...input.dependsOn ?? [],
1004
1135
  ...input.dependencyTaskId !== void 0 ? [input.dependencyTaskId] : []
1005
1136
  ].filter((id, i, arr) => id && arr.indexOf(id) === i);
1006
- return ids.length > 0 ? ids : void 0;
1007
1137
  }
1008
1138
  function taskPatch(input) {
1009
1139
  return {
@@ -1017,7 +1147,15 @@ function taskPatch(input) {
1017
1147
  status: input.status,
1018
1148
  labels: input.labels,
1019
1149
  assignedAgent: input.agentId,
1020
- ...mergedDependsOn(input) ? { dependsOn: mergedDependsOn(input) } : {},
1150
+ ...mergedDependsOn(input) !== void 0 ? { dependsOn: mergedDependsOn(input) } : {},
1151
+ // `atomic` and `childTaskIds` are the composite-parent contract, and the
1152
+ // managed gate reads both: an `atomic` parent may not move forward without
1153
+ // children, and may not reach Done until every child is completed. The
1154
+ // manager has always accepted both on a patch; only this surface withheld
1155
+ // them, so `split_atomic` was a one-way door — delete the children and the
1156
+ // parent was stranded with no way to declare itself a leaf again.
1157
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
1158
+ ...input.childTaskIds !== void 0 ? { childTaskIds: input.childTaskIds } : {},
1021
1159
  ...input.estimatedHours !== void 0 ? { estimatedHours: input.estimatedHours } : {},
1022
1160
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
1023
1161
  };
@@ -1077,8 +1215,8 @@ function assignmentForTaskCreate(input) {
1077
1215
  }
1078
1216
 
1079
1217
  // src/kanban-tool-schema.ts
1080
- var KANBAN_TOOL_DESCRIPTION = "Manage and audit project-scoped multi-kanban boards through the shared IPC Kanban server and its SQLite store. Managed cards enforce fully specified details and adjacent Backlog \u2192 Todo \u2192 Running \u2192 Review \u2192 Done transitions with persistent comments and evidence. Contract Map actions are optional advisory metadata unless an operator explicitly enabled strict enforcement. Use verify_completion to validate executable success criteria before Done.";
1081
- var KANBAN_TOOL_USAGE_HINT = "Use this for durable project kanban state. Read workbench when orienting across boards; it returns bounded Now, Next, Blocked, Review lanes and operational alerts. Before coding mutation, create a fully detailed card on a managed board with executable acceptance criteria, then call start_task. The runtime blocks product mutations until start_task binds a ready Running card. Do not create, inspect, repair, or enable a Contract Map during ordinary work; every map mode stays off the execution and completion path. Worker completion enters Review; Done requires passed acceptance criteria and review evidence. Surface existing strict-map findings for operator review without stopping work to repair them.";
1218
+ var KANBAN_TOOL_DESCRIPTION = "Durable project task boards: create and move cards, record checks, notes, links and assignments. The board is a record of the work, not a permit for it \u2014 nothing here gates other tools. Managed boards additionally enforce ordered Backlog \u2192 Todo \u2192 Running \u2192 Review \u2192 Done transitions; release_managed_lifecycle turns that off.";
1219
+ var KANBAN_TOOL_USAGE_HINT = 'Track substantial or multi-step work so it survives the session; a trivial edit or a question needs no card. Work stays on ONE board: call list_boards first and add_task to the board this project already uses. create_board is for a genuinely separate line of work, not for each new piece of it \u2014 a second board splits the same effort in two, and a board holding a single card is the usual sign. Common flow: list_boards or search_tasks to orient, add_task to record work, start_task when you begin, update_check with checkStatus "passed" to tick acceptance criteria (read their ids from get_task), then transition_task. On a managed board a refused transition names the field it wants \u2014 supply it and retry. When the acceptance criterion is something a machine can run, say so: set checkType ("command", "test", "file_exists", "file_matches", "git_diff", "metric") and put the command, pattern or path in checkNotes, then verify_completion executes it and the result is real evidence. Leave checkType off (or "manual") only for criteria that genuinely need a human eye \u2014 a manual check records your assertion, it does not test anything.';
1082
1220
  var KANBAN_INPUT_SCHEMA = {
1083
1221
  type: "object",
1084
1222
  properties: {
@@ -1091,6 +1229,7 @@ var KANBAN_INPUT_SCHEMA = {
1091
1229
  "duplicate_board",
1092
1230
  "update_board",
1093
1231
  "adopt_managed_lifecycle",
1232
+ "release_managed_lifecycle",
1094
1233
  "delete_board",
1095
1234
  "generate_board",
1096
1235
  "export_markdown",
@@ -1102,9 +1241,6 @@ var KANBAN_INPUT_SCHEMA = {
1102
1241
  "ready_tasks",
1103
1242
  "snapshot",
1104
1243
  "workbench",
1105
- "add_column",
1106
- "update_column",
1107
- "delete_column",
1108
1244
  "add_task",
1109
1245
  "split_task",
1110
1246
  "merge_tasks",
@@ -1119,13 +1255,6 @@ var KANBAN_INPUT_SCHEMA = {
1119
1255
  "delete_task",
1120
1256
  "set_chain",
1121
1257
  "get_chain",
1122
- "get_contract_graph",
1123
- "configure_contract_graph",
1124
- "upsert_contract_node",
1125
- "link_contract_nodes",
1126
- "remove_contract_node",
1127
- "remove_contract_edge",
1128
- "evaluate_contract_graph",
1129
1258
  "claim_task",
1130
1259
  "release_task",
1131
1260
  "assign_task",
@@ -1139,49 +1268,27 @@ var KANBAN_INPUT_SCHEMA = {
1139
1268
  "update_goal_metric",
1140
1269
  "add_check",
1141
1270
  "update_check",
1271
+ "remove_check",
1142
1272
  "add_note",
1143
1273
  "add_link",
1144
1274
  "verify_completion",
1145
1275
  "split_atomic",
1146
1276
  "assess_atomicity",
1147
- "propose_decomposition"
1277
+ "propose_decomposition",
1278
+ "get_contract_graph",
1279
+ "configure_contract_graph",
1280
+ "upsert_contract_node",
1281
+ "remove_contract_node",
1282
+ "add_contract_edge",
1283
+ "remove_contract_edge"
1148
1284
  ]
1149
1285
  },
1150
1286
  boardId: { type: "string" },
1151
1287
  taskId: { type: "string" },
1152
1288
  taskIds: { type: "array", items: { type: "string" } },
1153
1289
  chainId: { type: "string" },
1154
- contractNodeId: { type: "string" },
1155
- contractNodeKind: {
1156
- type: "string",
1157
- enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"]
1158
- },
1159
- contractNodeState: {
1160
- type: "string",
1161
- enum: ["unknown", "active", "satisfied", "violated", "resolved"]
1162
- },
1163
- contractEnforcement: {
1164
- type: "string",
1165
- enum: ["blocking", "advisory", "informational"]
1166
- },
1167
- contractGraphEnforcement: { type: "string", enum: ["off", "advisory", "strict"] },
1168
- contractEdgeId: { type: "string" },
1169
- contractEdgeType: {
1170
- type: "string",
1171
- enum: [
1172
- "targets",
1173
- "affects",
1174
- "must_preserve",
1175
- "exposes",
1176
- "verified_by",
1177
- "conflicts_with",
1178
- "derived_from",
1179
- "relates_to"
1180
- ]
1181
- },
1182
1290
  fromNodeId: { type: "string" },
1183
1291
  toNodeId: { type: "string" },
1184
- contractRationale: { type: "string" },
1185
1292
  baseline: { oneOf: [{ type: "string" }, { type: "number" }] },
1186
1293
  threshold: { oneOf: [{ type: "string" }, { type: "number" }] },
1187
1294
  columnId: { type: "string" },
@@ -1265,7 +1372,20 @@ var KANBAN_INPUT_SCHEMA = {
1265
1372
  costCeilingUsd: { type: "number" },
1266
1373
  retryPolicy: { type: "string", enum: ["off", "incremental", "exponential"] },
1267
1374
  lastFailureKind: { type: "string" },
1268
- dependsOn: { type: "array", items: { type: "string" } },
1375
+ dependsOn: {
1376
+ type: "array",
1377
+ items: { type: "string" },
1378
+ description: "Task ids this card waits on. On update_task an explicit empty array clears them \u2014 use it when a dependency was recorded in error rather than completing work nobody wants."
1379
+ },
1380
+ atomic: {
1381
+ type: "boolean",
1382
+ description: "Composite parent (true) or executable leaf (false). Set false to make a stranded parent a leaf again after its children were dropped."
1383
+ },
1384
+ childTaskIds: {
1385
+ type: "array",
1386
+ items: { type: "string" },
1387
+ description: "Children of a composite parent. On update_task an explicit empty array detaches them all."
1388
+ },
1269
1389
  estimatedHours: { type: "number" },
1270
1390
  actualHours: { type: "number" },
1271
1391
  taskGraph: { type: "object" },
@@ -1299,6 +1419,74 @@ var KANBAN_INPUT_SCHEMA = {
1299
1419
  checkId: { type: "string" },
1300
1420
  checkDescription: { type: "string" },
1301
1421
  checkStatus: { type: "string", enum: ["pending", "passed", "failed", "skipped"] },
1422
+ checkType: {
1423
+ type: "string",
1424
+ // Only types a verifier can actually execute. `manual` is the default and
1425
+ // means a human or agent asserts the status by hand. The rest are run by
1426
+ // `verify_completion` against the default deterministic registry. Types
1427
+ // with no plugin in that registry (`auto`, `review`, `agent`, `council`)
1428
+ // are deliberately omitted: offering them would produce criteria that
1429
+ // silently report `skipped — no verifier plugin registered`.
1430
+ enum: ["manual", "command", "test", "file_exists", "file_matches", "git_diff", "metric"],
1431
+ description: 'How this acceptance criterion is verified. Default "manual" (status set by hand). Any other value makes verify_completion execute it, so the criterion becomes real evidence rather than a self-assertion. Pair with checkNotes.'
1432
+ },
1433
+ checkNotes: {
1434
+ type: "string",
1435
+ description: 'The executable body for a non-manual checkType, read in preference to checkDescription. command/test: the shell command or test pattern. file_exists: the path. file_matches: JSON {"file","pattern","flags"}. git_diff: JSON {"expectedFiles","minChanges","maxChanges"}.'
1436
+ },
1437
+ // ── Contract map ───────────────────────────────────────────────────
1438
+ // The card contract: what this work targets, what it must not break, what
1439
+ // it risks, and what verifies it. Advisory by default — the readiness gate
1440
+ // deliberately does not require map structure, so a map is an operator
1441
+ // review aid, not work the model must complete before implementing.
1442
+ contractEnforcement: {
1443
+ type: "string",
1444
+ enum: ["off", "advisory", "strict"],
1445
+ description: "Board-level contract map enforcement. Default when first configured: advisory."
1446
+ },
1447
+ contractNodeId: { type: "string" },
1448
+ contractNodeKind: {
1449
+ type: "string",
1450
+ enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"],
1451
+ description: "objective = what this card is for; guardrail = what must keep working; risk = what could go wrong; component/artifact = what it touches; verification = what settles it."
1452
+ },
1453
+ contractNodeTitle: { type: "string" },
1454
+ contractNodeDescription: { type: "string" },
1455
+ contractNodeState: {
1456
+ type: "string",
1457
+ enum: ["unknown", "active", "satisfied", "violated", "waived", "resolved"]
1458
+ },
1459
+ contractNodeEnforcement: {
1460
+ type: "string",
1461
+ enum: ["blocking", "advisory", "informational"]
1462
+ },
1463
+ /** Bind a node to an acceptance criterion or goal metric already on the task. */
1464
+ contractCheckId: { type: "string" },
1465
+ contractMetricId: { type: "string" },
1466
+ contractWaiverReason: {
1467
+ type: "string",
1468
+ description: 'Required, with an actor, when contractNodeState is "waived".'
1469
+ },
1470
+ contractEdgeId: { type: "string" },
1471
+ contractEdgeFrom: {
1472
+ type: "string",
1473
+ description: 'A contract node id, or a task id (bare or "task:<id>") for the card endpoint.'
1474
+ },
1475
+ contractEdgeTo: { type: "string" },
1476
+ contractEdgeType: {
1477
+ type: "string",
1478
+ enum: [
1479
+ "targets",
1480
+ "affects",
1481
+ "must_preserve",
1482
+ "exposes",
1483
+ "verified_by",
1484
+ "conflicts_with",
1485
+ "derived_from",
1486
+ "relates_to"
1487
+ ]
1488
+ },
1489
+ contractEdgeRationale: { type: "string" },
1302
1490
  note: { type: "string" },
1303
1491
  author: { type: "string" },
1304
1492
  url: { type: "string" },
@@ -1373,8 +1561,14 @@ var kanbanTool = {
1373
1561
  }
1374
1562
  case "create_board": {
1375
1563
  if (!input.title) return fail("create_board requires title.");
1564
+ const existing = (await listBoards2(projectRoot)).filter(
1565
+ (candidate) => (candidate.kind ?? "project") === "project"
1566
+ );
1376
1567
  const board = await createBoard2(projectRoot, boardCreateInput(input, input.title));
1377
- return { ok: true, message: `Board created: ${board.title}`, board };
1568
+ const note = existing.length ? ` ${existing.length} other project board(s) already exist: ${existing.slice(0, 3).map((candidate) => `"${candidate.title}" (${candidate.taskCount} task(s))`).join(
1569
+ ", "
1570
+ )}${existing.length > 3 ? ", \u2026" : ""}. If this work belongs to one of them, add_task there instead and delete this board.` : "";
1571
+ return { ok: true, message: `Board created: ${board.title}.${note}`, board };
1378
1572
  }
1379
1573
  case "update_board": {
1380
1574
  if (!input.boardId) return fail("update_board requires boardId.");
@@ -1403,6 +1597,20 @@ var kanbanTool = {
1403
1597
  });
1404
1598
  return board ? okBoard(board, "Managed lifecycle adopted without moving existing cards.") : fail("Board not found.");
1405
1599
  }
1600
+ // Adoption used to be a one-way door: the strict lifecycle carries
1601
+ // acceptance-criteria, verification-report, review-evidence and
1602
+ // one-stage-at-a-time gates, and nothing on the tool surface could
1603
+ // undo it, so a board adopted once kept its ceremony forever. The
1604
+ // gates are worth having where a fleet is supervised; they are not
1605
+ // worth being unable to leave. Cards and columns are untouched.
1606
+ case "release_managed_lifecycle": {
1607
+ if (!input.boardId) return fail("release_managed_lifecycle requires boardId.");
1608
+ const board = await updateBoard2(projectRoot, input.boardId, { lifecycle: null });
1609
+ return board ? okBoard(
1610
+ board,
1611
+ "Managed lifecycle released; the board now tracks work without strict gates."
1612
+ ) : fail("Board not found.");
1613
+ }
1406
1614
  case "duplicate_board": {
1407
1615
  if (!input.boardId) return fail("duplicate_board requires boardId.");
1408
1616
  const board = await duplicateBoard(
@@ -1422,8 +1630,7 @@ var kanbanTool = {
1422
1630
  const boardInput = createBoardFromText({
1423
1631
  description: input.description,
1424
1632
  ...input.title !== void 0 ? { title: input.title } : {},
1425
- ...input.context !== void 0 ? { context: input.context } : {},
1426
- ...input.columns !== void 0 ? { columns: input.columns } : {}
1633
+ ...input.context !== void 0 ? { context: input.context } : {}
1427
1634
  });
1428
1635
  const board = await createBoard2(projectRoot, boardInput);
1429
1636
  for (const taskInput2 of parseLinesIntoTasks(
@@ -1561,7 +1768,7 @@ var kanbanTool = {
1561
1768
  return { ok: true, message: `${tasks.length} ready task(s).`, tasks };
1562
1769
  }
1563
1770
  case "snapshot": {
1564
- const snapshot = await getKanbanOrchestrationSnapshot(projectRoot, {
1771
+ const snapshot = await getKanbanOrchestrationSnapshot2(projectRoot, {
1565
1772
  query: input.query,
1566
1773
  boardId: input.boardId,
1567
1774
  assignedAgent: input.agentId,
@@ -1576,33 +1783,6 @@ var kanbanTool = {
1576
1783
  snapshot
1577
1784
  };
1578
1785
  }
1579
- case "add_column": {
1580
- if (!input.boardId || !input.title)
1581
- return fail("add_column requires boardId and title.");
1582
- const result2 = await addColumn(projectRoot, input.boardId, {
1583
- title: input.title,
1584
- ...input.description !== void 0 ? { description: input.description } : {}
1585
- });
1586
- return result2 ? okBoard(result2.board, "Column added.") : fail("Board not found.");
1587
- }
1588
- case "update_column": {
1589
- if (!input.boardId || !input.columnId)
1590
- return fail("update_column requires boardId and columnId.");
1591
- const board = await updateColumn(projectRoot, input.boardId, input.columnId, {
1592
- ...input.title !== void 0 ? { title: input.title } : {},
1593
- ...input.description !== void 0 ? { description: input.description } : {},
1594
- ...input.order !== void 0 ? { order: input.order } : {}
1595
- });
1596
- return board ? okBoard(board, "Column updated.") : fail("Column not found.");
1597
- }
1598
- case "delete_column": {
1599
- if (!input.boardId || !input.columnId)
1600
- return fail("delete_column requires boardId and columnId.");
1601
- const board = await removeColumn(projectRoot, input.boardId, input.columnId, {
1602
- moveTasksToColumnId: input.moveTasksToColumnId
1603
- });
1604
- return board ? okBoard(board, "Column deleted.") : fail("Column not found.");
1605
- }
1606
1786
  case "add_task": {
1607
1787
  if (!input.boardId || !input.title) return fail("add_task requires boardId and title.");
1608
1788
  const result2 = await addTask(projectRoot, input.boardId, taskInput(input));
@@ -1684,6 +1864,32 @@ var kanbanTool = {
1684
1864
  `Task is not implementation-ready: ${readiness.issues.map((issue) => issue.message).join(" | ")}`
1685
1865
  );
1686
1866
  }
1867
+ if (board.lifecycle?.mode !== "managed") {
1868
+ const now = /* @__PURE__ */ new Date();
1869
+ const assigned = await updateTaskAssignment(projectRoot, board.id, task.id, {
1870
+ status: "running",
1871
+ agentId: input.agentId ?? input.author,
1872
+ leaseId: input.leaseId ?? randomUUID2(),
1873
+ claimedAt: input.claimedAt ?? now.toISOString(),
1874
+ heartbeatAt: input.heartbeatAt ?? now.toISOString(),
1875
+ leaseExpiresAt: input.leaseExpiresAt ?? new Date(now.getTime() + 15 * 6e4).toISOString(),
1876
+ attempt: input.attempt ?? 1,
1877
+ maxAttempts: input.maxAttempts ?? 3
1878
+ });
1879
+ if (!assigned) return fail("Task assignment could not be started.");
1880
+ const started = await updateTask2(projectRoot, board.id, task.id, {
1881
+ status: "in_progress"
1882
+ });
1883
+ const current = started ?? assigned;
1884
+ const claimed = task;
1885
+ const currentTask = current.tasks.find((candidate) => candidate.id === claimed.id) ?? claimed;
1886
+ ctx.setCurrentKanbanTask?.(currentTask.id, current.id);
1887
+ return okTask(
1888
+ current,
1889
+ currentTask,
1890
+ "Task is active and bound to this run for attribution. This board is not in managed lifecycle mode, so runtime Kanban governance was not bound to it."
1891
+ );
1892
+ }
1687
1893
  let stage = task.lifecycle?.currentStage;
1688
1894
  if (stage === "backlog") {
1689
1895
  const moved = await transitionTask(projectRoot, board.id, task.id, {
@@ -1824,6 +2030,9 @@ var kanbanTool = {
1824
2030
  if (!input.boardId || !input.taskId)
1825
2031
  return fail("delete_task requires boardId and taskId.");
1826
2032
  const board = await removeTask(projectRoot, input.boardId, input.taskId);
2033
+ if (board && ctx.currentKanbanTaskId === input.taskId) {
2034
+ ctx.setCurrentKanbanTask?.(void 0, ctx.currentKanbanBoardId);
2035
+ }
1827
2036
  return board ? okBoard(board, "Task deleted.") : fail("Task not found.");
1828
2037
  }
1829
2038
  case "set_chain": {
@@ -1956,7 +2165,7 @@ var kanbanTool = {
1956
2165
  });
1957
2166
  } catch (err) {
1958
2167
  lifecycleWarnings.push(
1959
- `Lifecycle transition to Running deferred: ${err instanceof Error ? err.message : String(err)}`
2168
+ `Lifecycle transition to Running deferred: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
1960
2169
  );
1961
2170
  }
1962
2171
  }
@@ -1980,7 +2189,7 @@ var kanbanTool = {
1980
2189
  });
1981
2190
  } catch (err) {
1982
2191
  lifecycleWarnings.push(
1983
- `Lifecycle transition to Review failed: ${err instanceof Error ? err.message : String(err)}`
2192
+ `Lifecycle transition to Review failed: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
1984
2193
  );
1985
2194
  }
1986
2195
  if (transitionResult) {
@@ -2000,7 +2209,11 @@ var kanbanTool = {
2000
2209
  successCriteria: verResult.task.successCriteria
2001
2210
  });
2002
2211
  const verdict = verResult.report.verdict;
2003
- if (verdict === "passed") {
2212
+ if (verdict === "passed" && !resolveAutoAccept(board)) {
2213
+ lifecycleWarnings.push(
2214
+ "Verification passed, but this board does not auto-accept. The card is in Review awaiting an explicit transition_task to done."
2215
+ );
2216
+ } else if (verdict === "passed") {
2004
2217
  try {
2005
2218
  const doneResult = await transitionTask(
2006
2219
  projectRoot,
@@ -2118,11 +2331,34 @@ var kanbanTool = {
2118
2331
  });
2119
2332
  return {
2120
2333
  ok: true,
2121
- message: `Counts: ready=${health.counts.ready}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2334
+ message: `Counts: startable=${health.counts.startable}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2122
2335
  queueHealth: health
2123
2336
  };
2124
2337
  }
2338
+ // Not every action is handled above. These are dispatched from here,
2339
+ // and the split has already cost real time: an agent that read this
2340
+ // file concluded `add_check` / `update_check` did not exist, wrote
2341
+ // that on a card, and spent a session trying to satisfy a gate it
2342
+ // already had the tool to clear. Keep this index in step with the
2343
+ // handlers.
2344
+ //
2345
+ // kanban-detail-actions.ts workbench · add_dependency ·
2346
+ // add_goal_metric · update_goal_metric · add_check ·
2347
+ // update_check · add_note · add_link · split_atomic
2348
+ // kanban-decomposition-actions.ts verify_completion ·
2349
+ // assess_atomicity · propose_decomposition
2350
+ // kanban-contract-actions.ts get_contract_graph ·
2351
+ // configure_contract_graph · upsert_contract_node ·
2352
+ // remove_contract_node · add_contract_edge · remove_contract_edge
2125
2353
  default:
2354
+ {
2355
+ const contractResult = await handleKanbanContractAction(
2356
+ projectRoot,
2357
+ input,
2358
+ input.author ?? input.agentId
2359
+ );
2360
+ if (contractResult !== void 0) return contractResult;
2361
+ }
2126
2362
  {
2127
2363
  const detailResult = await handleKanbanDetailAction(projectRoot, input);
2128
2364
  if (detailResult !== void 0) return detailResult;
@@ -2132,7 +2368,7 @@ var kanbanTool = {
2132
2368
  })();
2133
2369
  return withPresence(result);
2134
2370
  } catch (err) {
2135
- return fail(err instanceof Error ? err.message : String(err));
2371
+ return fail(stripLifecycleIssues(err instanceof Error ? err.message : String(err)));
2136
2372
  }
2137
2373
  }
2138
2374
  };
@@ -2165,11 +2401,51 @@ function bindTodosToBoard(items, previous, board) {
2165
2401
  available.find((task2) => !used.has(task2.id) && normalizedTitle(task2.title) === title)
2166
2402
  ];
2167
2403
  const task = candidates.find((candidate) => candidate && !used.has(candidate.id));
2168
- if (!task) return { ...item };
2404
+ if (!task) {
2405
+ const { blockedBy: _discarded, ...rest } = item;
2406
+ return { ...rest };
2407
+ }
2169
2408
  used.add(task.id);
2170
- return { ...item, kanbanBoardId: board.id, kanbanTaskId: task.id };
2409
+ const blockedBy = blockingTitles(board, task);
2410
+ return {
2411
+ ...item,
2412
+ kanbanBoardId: board.id,
2413
+ kanbanTaskId: task.id,
2414
+ ...blockedBy.length ? { blockedBy } : { blockedBy: void 0 }
2415
+ };
2416
+ });
2417
+ }
2418
+ function demoteBlockedInProgress(items, warnings) {
2419
+ return items.map((item) => {
2420
+ if (item.status !== "in_progress" || !item.blockedBy?.length) return item;
2421
+ warnings.push(
2422
+ `"${item.content}" cannot start yet \u2014 it waits on: ${item.blockedBy.join("; ")}. Kept as pending; complete the blocking work first.`
2423
+ );
2424
+ return { ...item, status: "pending" };
2171
2425
  });
2172
2426
  }
2427
+ async function createMissingManagedCards(items, board, ctx, warnings) {
2428
+ const created = /* @__PURE__ */ new Map();
2429
+ for (const item of items) {
2430
+ if (item.kanbanBoardId === board.id && item.kanbanTaskId) continue;
2431
+ try {
2432
+ const result = await addTask2(ctx.projectRoot, board.id, {
2433
+ title: item.content,
2434
+ description: item.activeForm?.trim() || `Added from the session todo list: ${item.content}`
2435
+ });
2436
+ if (!result) {
2437
+ warnings.push(`Could not open a Kanban card for "${item.content}": board not found.`);
2438
+ continue;
2439
+ }
2440
+ created.set(item.id, result.task.id);
2441
+ } catch (error) {
2442
+ warnings.push(
2443
+ `Could not open a Kanban card for "${item.content}": ${error instanceof Error ? error.message : String(error)}`
2444
+ );
2445
+ }
2446
+ }
2447
+ return created;
2448
+ }
2173
2449
  async function synchronizeManagedKanban(items, board, ctx, signal) {
2174
2450
  let synced = 0;
2175
2451
  const warnings = [];
@@ -2207,6 +2483,16 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2207
2483
  transitionComment: `Todo returned to queue: ${item.content}`
2208
2484
  });
2209
2485
  }
2486
+ for (const item of items) {
2487
+ if (item.status === "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2488
+ continue;
2489
+ }
2490
+ const task = board.tasks.find((candidate) => candidate.id === item.kanbanTaskId);
2491
+ if (task?.status !== "completed") continue;
2492
+ warnings.push(
2493
+ `"${item.content}" is already Done on the Kanban board and a completed card cannot be reopened; the row stays completed. Create a follow-up card for any remaining work.`
2494
+ );
2495
+ }
2210
2496
  for (const item of items) {
2211
2497
  if (item.status !== "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2212
2498
  continue;
@@ -2258,17 +2544,25 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2258
2544
  const active = items.find(
2259
2545
  (item) => item.status === "in_progress" && item.kanbanBoardId === board.id && Boolean(item.kanbanTaskId)
2260
2546
  );
2547
+ const activeStage = active?.kanbanTaskId ? afterCompletions?.tasks.find((task) => task.id === active.kanbanTaskId)?.lifecycle?.currentStage : void 0;
2261
2548
  if (active?.kanbanTaskId) {
2262
- await execute({
2263
- action: "start_task",
2264
- boardId: board.id,
2265
- taskId: active.kanbanTaskId,
2266
- author: actor,
2267
- agentId: actor,
2268
- transitionComment: `Todo activated: ${active.content}`
2269
- });
2549
+ if (activeStage === "review" || activeStage === "done") {
2550
+ warnings.push(
2551
+ `"${active.content}" is in ${activeStage === "review" ? "Review" : "Done"} awaiting acceptance; not re-activating it from the todo list. ` + (activeStage === "review" ? "Call kanban start_task explicitly to reopen it as a repair." : "Done is terminal; reopen only by creating a follow-up card.")
2552
+ );
2553
+ } else {
2554
+ await execute({
2555
+ action: "start_task",
2556
+ boardId: board.id,
2557
+ taskId: active.kanbanTaskId,
2558
+ author: actor,
2559
+ agentId: actor,
2560
+ transitionComment: `Todo activated: ${active.content}`
2561
+ });
2562
+ }
2270
2563
  }
2271
- if (active?.kanbanTaskId && completionPending) {
2564
+ if (active?.kanbanTaskId && activeStage === "review") {
2565
+ } else if (active?.kanbanTaskId && completionPending) {
2272
2566
  warnings.push(
2273
2567
  "A completed todo is still awaiting acceptance; the next independent Kanban task was started."
2274
2568
  );
@@ -2355,29 +2649,47 @@ var todoTool = {
2355
2649
  }
2356
2650
  }
2357
2651
  const boardId = activeBoardId(items, ctx);
2358
- const board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2359
- const boundItems = board?.lifecycle?.mode === "managed" ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2652
+ let board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2653
+ const managed = board?.lifecycle?.mode === "managed";
2654
+ let boundItems = managed && board ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2655
+ const creationWarnings = [];
2656
+ if (managed && board) {
2657
+ const managedBoardId = board.id;
2658
+ const created = await createMissingManagedCards(boundItems, board, ctx, creationWarnings);
2659
+ if (created.size > 0) {
2660
+ boundItems = boundItems.map((item) => {
2661
+ const taskId = created.get(item.id);
2662
+ return taskId ? { ...item, kanbanBoardId: managedBoardId, kanbanTaskId: taskId } : item;
2663
+ });
2664
+ board = await getBoard4(ctx.projectRoot, managedBoardId) ?? board;
2665
+ boundItems = bindTodosToBoard(boundItems, ctx.todos ?? [], board);
2666
+ }
2667
+ boundItems = demoteBlockedInProgress(boundItems, creationWarnings);
2668
+ }
2360
2669
  ctx.state.replaceTodos(boundItems);
2361
- const kanbanSync = board?.lifecycle?.mode === "managed" ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2362
- if (board?.lifecycle?.mode === "managed") {
2670
+ const kanbanSync = managed && board ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2671
+ kanbanSync.warnings.unshift(...creationWarnings);
2672
+ if (managed && board) {
2363
2673
  const unresolved = boundItems.filter(
2364
2674
  (item) => item.kanbanBoardId !== board.id || !item.kanbanTaskId
2365
2675
  );
2366
2676
  if (unresolved.length > 0) {
2367
2677
  kanbanSync.warnings.push(
2368
- `${unresolved.length} Todo row(s) did not match a real Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2678
+ `${unresolved.length} Todo row(s) could not be bound to a Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2369
2679
  );
2370
2680
  }
2371
2681
  }
2682
+ const mirrorFailure = takeSessionMirrorFailure(ctx.projectRoot, ctx.session?.id ?? "");
2683
+ if (mirrorFailure) kanbanSync.warnings.push(mirrorFailure);
2372
2684
  let projectedBoard = board;
2373
- if (board?.lifecycle?.mode === "managed") {
2685
+ if (managed && board) {
2374
2686
  const refreshed = await getBoard4(ctx.projectRoot, board.id);
2375
2687
  if (refreshed) {
2376
2688
  projectedBoard = refreshed;
2377
2689
  applyManagedKanbanBoardToTodos(ctx, refreshed);
2378
2690
  }
2379
2691
  }
2380
- if (board?.lifecycle?.mode !== "managed") {
2692
+ if (!managed) {
2381
2693
  mirrorSessionTodosToKanban(ctx.projectRoot, items, ctx.session?.id ?? "session");
2382
2694
  }
2383
2695
  const completedPlanIds = /* @__PURE__ */ new Set();