@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/plan.js CHANGED
@@ -22,12 +22,17 @@ import {
22
22
  mutateTasks
23
23
  } from "@wrongstack/core/storage";
24
24
  import { deserializeTaskGraph } from "@wrongstack/core/tasking";
25
- import { resolveWstackPaths } from "@wrongstack/core/utils";
25
+ import { formatTodosForModel, resolveWstackPaths } from "@wrongstack/core/utils";
26
26
  import {
27
27
  bridgeKanbanSupervisor,
28
+ compactSessionMirrorBoard,
28
29
  createBoard,
30
+ DEFAULT_COLUMNS,
29
31
  getBoard,
32
+ getDependencyReadinessIssues,
33
+ getKanbanOrchestrationSnapshot,
30
34
  listBoards,
35
+ pruneSessionBoards,
31
36
  removeBoard,
32
37
  syncBoardFromTaskGraph,
33
38
  touchKanbanPresence,
@@ -35,16 +40,14 @@ import {
35
40
  } from "@wrongstack/kanban";
36
41
  var SESSION_BOARD_TAG = "session-work";
37
42
  var MIRROR_DISABLED_ENV = "WRONGSTACK_KANBAN_TASK_MIRROR";
38
- var SESSION_KANBAN_COLUMNS = [
39
- { id: "todo", title: "Todo", order: 0, wipLimit: 0, color: "#2563eb" },
40
- { id: "in-progress", title: "Running", order: 1, wipLimit: 1, color: "#d97706" },
41
- { id: "review", title: "Preview", order: 2, wipLimit: 0, color: "#7c3aed" },
42
- { id: "done", title: "Done", order: 3, wipLimit: 0, color: "#16a34a" }
43
- ];
43
+ var SESSION_KANBAN_COLUMNS = DEFAULT_COLUMNS.map((column) => ({
44
+ ...column
45
+ }));
44
46
  var boardQueue = /* @__PURE__ */ new Map();
45
47
  var boardEnsures = /* @__PURE__ */ new Map();
46
48
  var pendingMirrors = /* @__PURE__ */ new Map();
47
49
  var activeMirrors = /* @__PURE__ */ new Set();
50
+ var mirrorFailures = /* @__PURE__ */ new Map();
48
51
  var suppressedTodoMirrors = /* @__PURE__ */ new WeakSet();
49
52
  function boardKey(projectRoot, sessionId) {
50
53
  return `${projectRoot}\0${sessionId}`;
@@ -52,6 +55,33 @@ function boardKey(projectRoot, sessionId) {
52
55
  function mirrorKey(projectRoot, sessionId, sourceSystem) {
53
56
  return `${boardKey(projectRoot, sessionId)}\0${sourceSystem}`;
54
57
  }
58
+ function completedReconciliationGraph(latest, candidates) {
59
+ const latestNodeIds = new Set(latest.nodes.map((node) => node.id));
60
+ const carriedNodeIds = /* @__PURE__ */ new Set();
61
+ const completedNodes = candidates.flatMap(
62
+ (candidate) => candidate.nodes.filter((node) => {
63
+ if (node.status !== "completed" || latestNodeIds.has(node.id) || carriedNodeIds.has(node.id)) {
64
+ return false;
65
+ }
66
+ carriedNodeIds.add(node.id);
67
+ return true;
68
+ })
69
+ );
70
+ if (completedNodes.length === 0) return void 0;
71
+ const carriedRequirements = completedNodes.flatMap(
72
+ (node) => node.specRequirementId ? [node.specRequirementId] : []
73
+ );
74
+ return {
75
+ ...latest,
76
+ nodes: [...latest.nodes, ...completedNodes],
77
+ rootNodes: [.../* @__PURE__ */ new Set([...latest.rootNodes, ...completedNodes.map((node) => node.id)])],
78
+ ...latest.requiredRequirementIds ? {
79
+ requiredRequirementIds: [
80
+ .../* @__PURE__ */ new Set([...latest.requiredRequirementIds, ...carriedRequirements])
81
+ ]
82
+ } : {}
83
+ };
84
+ }
55
85
  function sessionTag(sessionId) {
56
86
  return `session:${sessionId}`;
57
87
  }
@@ -130,16 +160,44 @@ async function projectGraph(projectRoot, sessionId, graph, sourceSystem) {
130
160
  sourceSystem,
131
161
  tags: [.../* @__PURE__ */ new Set([...board.tags ?? [], ...sessionBoardTags(sessionId)])],
132
162
  archiveMissingTasks: true,
133
- includeCompletedTasks: true
163
+ includeCompletedTasks: true,
164
+ // The scope ledger stays declared and accurate, but it may not veto a
165
+ // projection. A session mirror reflects a tactical list that shrinks by
166
+ // design, and refusing the sync never protected the removed row — it
167
+ // froze the entire board, permanently, because the stored scope then
168
+ // outlived every later snapshot (`session-kanban.mirror-failed`).
169
+ // Nothing is lost by shrinking here: `archiveMissingTasks` keeps the
170
+ // removed card on the board as `archived`, the reconciliation pass
171
+ // first walks vanished completed rows to Done, and the session journal
172
+ // remains the durable record.
173
+ allowRequirementScopeShrink: true
134
174
  }
135
175
  );
136
- return result?.board ?? null;
176
+ if (!result) return null;
177
+ const compacted = await compactSessionMirrorBoard(projectRoot, board.id);
178
+ if (compacted?.removedTaskIds.length) {
179
+ return await getBoard(projectRoot, board.id) ?? result.board;
180
+ }
181
+ return result.board;
137
182
  });
138
183
  }
139
184
  function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
140
185
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return;
141
186
  const key = mirrorKey(projectRoot, sessionId, sourceSystem);
142
- pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });
187
+ const previous = pendingMirrors.get(key);
188
+ const reconciliationGraph = previous ? completedReconciliationGraph(
189
+ graph,
190
+ [previous.reconciliationGraph, previous.graph].filter(
191
+ (candidate) => candidate !== void 0
192
+ )
193
+ ) : void 0;
194
+ pendingMirrors.set(key, {
195
+ projectRoot,
196
+ sessionId,
197
+ graph,
198
+ ...reconciliationGraph ? { reconciliationGraph } : {},
199
+ sourceSystem
200
+ });
143
201
  if (activeMirrors.has(key)) return;
144
202
  activeMirrors.add(key);
145
203
  void (async () => {
@@ -149,20 +207,34 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
149
207
  if (!pending) break;
150
208
  pendingMirrors.delete(key);
151
209
  try {
210
+ if (pending.reconciliationGraph) {
211
+ await projectGraph(
212
+ pending.projectRoot,
213
+ pending.sessionId,
214
+ pending.reconciliationGraph,
215
+ pending.sourceSystem
216
+ );
217
+ }
152
218
  await projectGraph(
153
219
  pending.projectRoot,
154
220
  pending.sessionId,
155
221
  pending.graph,
156
222
  pending.sourceSystem
157
223
  );
224
+ mirrorFailures.delete(boardKey(pending.projectRoot, pending.sessionId));
158
225
  } catch (error) {
226
+ const message = error instanceof Error ? error.message : String(error);
227
+ mirrorFailures.set(boardKey(pending.projectRoot, pending.sessionId), {
228
+ message,
229
+ sourceSystem: pending.sourceSystem
230
+ });
159
231
  console.warn(
160
232
  JSON.stringify({
161
233
  level: "warn",
162
234
  event: "session-kanban.mirror-failed",
163
235
  sessionId: pending.sessionId,
164
236
  sourceSystem: pending.sourceSystem,
165
- message: error instanceof Error ? error.message : String(error),
237
+ message,
166
238
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
167
239
  })
168
240
  );
@@ -183,6 +255,14 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
183
255
  }
184
256
  })();
185
257
  }
258
+ function takeSessionMirrorFailure(projectRoot, sessionId) {
259
+ if (!projectRoot || !sessionId) return void 0;
260
+ const key = boardKey(projectRoot, sessionId);
261
+ const failure = mirrorFailures.get(key);
262
+ if (!failure) return void 0;
263
+ mirrorFailures.delete(key);
264
+ return `Kanban mirror (${failure.sourceSystem}) failed and the board may be stale: ${failure.message}`;
265
+ }
186
266
  function todoListToSerializedGraph(todos, sessionId) {
187
267
  const graphId = `todo:${sessionId}`;
188
268
  const nodes = todos.map((todo, index) => ({
@@ -314,11 +394,11 @@ function broadcastTodoUpdate(context, todos) {
314
394
  });
315
395
  }
316
396
  function notifyTodoUpdate(context, todos) {
317
- const summary = todos.length ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join("\n") : "- No active todos remain.";
397
+ const summary = formatTodosForModel(todos);
318
398
  const text = `[KANBAN TODO UPDATE]
319
399
  Another Kanban agent reassessed the shared board. The canonical todo list is now:
320
400
  ${summary}
321
- Reassess your current plan before continuing; do not rely on the initial todo snapshot.`;
401
+ 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.`;
322
402
  const state = context.state;
323
403
  if (typeof state.appendBlockToLastUserMessage === "function") {
324
404
  if (state.appendBlockToLastUserMessage({ type: "text", text })) return;
@@ -349,26 +429,68 @@ function todoStatus(task) {
349
429
  if (status === "in_progress" || status === "review") return "in_progress";
350
430
  return "pending";
351
431
  }
352
- function sessionTodoFromTask(task, boardId) {
432
+ function sessionTodoFromTask(task, board) {
433
+ const blockedBy = board ? blockingTitles(board, task) : [];
353
434
  return {
354
435
  id: task.origin?.taskId ?? task.id,
355
436
  content: task.title,
356
437
  status: todoStatus(task),
357
- kanbanBoardId: boardId,
358
- kanbanTaskId: task.id,
359
- ...task.description ? { activeForm: task.description } : {}
438
+ ...task.description ? { activeForm: task.description } : {},
439
+ ...blockedBy.length ? { blockedBy } : {}
360
440
  };
361
441
  }
362
- function managedTodoFromTask(task, boardId) {
442
+ function managedTodoFromTask(task, board) {
363
443
  return {
364
- ...sessionTodoFromTask(task, boardId),
365
- status: task.status === "completed" ? "completed" : task.status === "in_progress" ? "in_progress" : "pending"
444
+ ...sessionTodoFromTask(task, board),
445
+ kanbanBoardId: board.id,
446
+ kanbanTaskId: task.id
366
447
  };
367
448
  }
449
+ function blockingTitles(board, task) {
450
+ return getDependencyReadinessIssues(board, task).map((issue) => {
451
+ const dependency = board.tasks.find((candidate) => candidate.id === issue.dependencyId);
452
+ if (!dependency) return `${issue.dependencyId} (missing)`;
453
+ return dependency.title;
454
+ });
455
+ }
456
+ var PRIORITY_ORDER = {
457
+ critical: 0,
458
+ high: 1,
459
+ medium: 2,
460
+ low: 3
461
+ };
462
+ function orderTasksForTodos(board, tasks) {
463
+ const columnOrder = new Map(board.columns.map((column) => [column.id, column.order]));
464
+ const baseline = [...tasks].sort(
465
+ (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)
466
+ );
467
+ const included = new Set(baseline.map((task) => task.id));
468
+ const remaining = new Map(baseline.map((task) => [task.id, task]));
469
+ const emitted = [];
470
+ const done = /* @__PURE__ */ new Set();
471
+ while (remaining.size > 0) {
472
+ const ready = baseline.filter(
473
+ (task) => remaining.has(task.id) && (task.dependsOn ?? []).every(
474
+ (dependencyId) => !included.has(dependencyId) || done.has(dependencyId)
475
+ )
476
+ );
477
+ if (ready.length === 0) break;
478
+ for (const task of ready) {
479
+ remaining.delete(task.id);
480
+ done.add(task.id);
481
+ emitted.push(task);
482
+ }
483
+ }
484
+ for (const task of baseline) if (remaining.has(task.id)) emitted.push(task);
485
+ return emitted;
486
+ }
368
487
  function sameTodos(left, right) {
369
488
  return left.length === right.length && left.every((todo, index) => {
370
489
  const candidate = right[index];
371
- 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;
490
+ 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,
491
+ // the rows are otherwise identical and the unblocking would never
492
+ // reach the model.
493
+ (candidate.blockedBy ?? []).join("\0") === (todo.blockedBy ?? []).join("\0");
372
494
  });
373
495
  }
374
496
  function applyManagedKanbanBoardToTodos(context, board) {
@@ -378,11 +500,12 @@ function applyManagedKanbanBoardToTodos(context, board) {
378
500
  if (!activeBoardId2 || board.id !== activeBoardId2 || board.lifecycle?.mode !== "managed") {
379
501
  return [...context.todos];
380
502
  }
381
- const projectedTodos = board.tasks.filter(
382
- (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
383
- ).sort(
384
- (left, right) => left.createdAt.localeCompare(right.createdAt) || left.order - right.order
385
- ).map((task) => managedTodoFromTask(task, board.id));
503
+ const projectedTodos = orderTasksForTodos(
504
+ board,
505
+ board.tasks.filter(
506
+ (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
507
+ )
508
+ ).map((task) => managedTodoFromTask(task, board));
386
509
  if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
387
510
  suppressedTodoMirrors.add(context);
388
511
  try {
@@ -403,14 +526,13 @@ import {
403
526
  saveTasks,
404
527
  setPlanItemStatus
405
528
  } from "@wrongstack/core/storage";
406
- import { getBoard as getBoard4 } from "@wrongstack/kanban";
529
+ import { addTask as addTask2, getBoard as getBoard4 } from "@wrongstack/kanban";
407
530
 
408
531
  // src/kanban.ts
409
532
  import { randomUUID as randomUUID2 } from "node:crypto";
410
533
  import { loadTasks as loadTasks2 } from "@wrongstack/core/storage";
411
534
  import { deserializeTaskGraph as deserializeTaskGraph2, serializeTaskGraph } from "@wrongstack/core/tasking";
412
535
  import {
413
- addColumn,
414
536
  addTask,
415
537
  adoptManagedLifecycle,
416
538
  assignTask,
@@ -425,7 +547,7 @@ import {
425
547
  exportBoardToTaskGraph,
426
548
  finalizeTaskCompletion,
427
549
  getBoard as getBoard3,
428
- getKanbanOrchestrationSnapshot,
550
+ getKanbanOrchestrationSnapshot as getKanbanOrchestrationSnapshot2,
429
551
  getKanbanQueueHealth,
430
552
  getTask,
431
553
  getTaskChain,
@@ -439,16 +561,16 @@ import {
439
561
  recoverStaleTaskAssignments,
440
562
  releaseTaskClaim,
441
563
  removeBoard as removeBoard2,
442
- removeColumn,
443
564
  removeTask,
444
565
  repairManagedTaskProjection,
566
+ resolveAutoAccept,
445
567
  searchKanban,
446
568
  setTaskChain,
569
+ stripLifecycleIssues,
447
570
  syncBoardFromTaskGraph as syncBoardFromTaskGraph2,
448
571
  transferTaskToBoard,
449
572
  transitionTask,
450
573
  updateBoard as updateBoard2,
451
- updateColumn,
452
574
  updateTask as updateTask2,
453
575
  updateTaskAssignment,
454
576
  verifyTaskCompletion as verifyTaskCompletion2
@@ -498,6 +620,137 @@ function duplicateBoardOptions(input) {
498
620
  };
499
621
  }
500
622
 
623
+ // src/kanban-contract-actions.ts
624
+ import {
625
+ addContractEdge,
626
+ configureContractGraph,
627
+ evaluateTaskContractGraph,
628
+ getContractGraph,
629
+ removeContractEdge,
630
+ removeContractNode,
631
+ upsertContractNode
632
+ } from "@wrongstack/kanban";
633
+
634
+ // src/kanban-tool-results.ts
635
+ function atomicityNudge(task) {
636
+ if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
637
+ const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
638
+ return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
639
+ }
640
+ function readEnvGateEnforcement() {
641
+ const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
642
+ return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
643
+ }
644
+ function fail(message) {
645
+ return { ok: false, message };
646
+ }
647
+ function okBoard(board, message = "Board loaded.") {
648
+ return { ok: true, message, board };
649
+ }
650
+ function okTask(board, task, message) {
651
+ return { ok: true, message, board, task };
652
+ }
653
+
654
+ // src/kanban-contract-actions.ts
655
+ async function handleKanbanContractAction(projectRoot, input, actor) {
656
+ switch (input.action) {
657
+ case "get_contract_graph": {
658
+ if (!input.boardId) return fail("get_contract_graph requires boardId.");
659
+ const found = await getContractGraph(projectRoot, input.boardId);
660
+ if (!found) return fail("Board not found.");
661
+ const evaluated = input.taskId ? await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId) : null;
662
+ if (input.taskId && !evaluated) return fail("Task not found on this board.");
663
+ return {
664
+ ok: true,
665
+ 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.",
666
+ board: found.board,
667
+ contractGraph: found.graph,
668
+ ...evaluated ? { contractEvaluation: evaluated.evaluation } : {}
669
+ };
670
+ }
671
+ case "configure_contract_graph": {
672
+ if (!input.boardId) return fail("configure_contract_graph requires boardId.");
673
+ const enforcement = input.contractEnforcement ?? "advisory";
674
+ const board = await configureContractGraph(projectRoot, input.boardId, enforcement);
675
+ return board ? okBoard(board, `Contract map enforcement set to ${enforcement}.`) : fail("Board not found.");
676
+ }
677
+ case "upsert_contract_node": {
678
+ if (!input.boardId || !input.taskId) {
679
+ return fail("upsert_contract_node requires boardId and taskId.");
680
+ }
681
+ if (!input.contractNodeKind || !input.contractNodeTitle) {
682
+ return fail("upsert_contract_node requires contractNodeKind and contractNodeTitle.");
683
+ }
684
+ const waiver = input.contractNodeState === "waived" ? {
685
+ actor: actor ?? "agent",
686
+ reason: input.contractWaiverReason ?? "",
687
+ at: (/* @__PURE__ */ new Date()).toISOString()
688
+ } : void 0;
689
+ if (waiver && !waiver.reason.trim()) {
690
+ return fail("A waived contract node requires contractWaiverReason.");
691
+ }
692
+ const result = await upsertContractNode(projectRoot, input.boardId, {
693
+ taskId: input.taskId,
694
+ kind: input.contractNodeKind,
695
+ title: input.contractNodeTitle,
696
+ ...input.contractNodeId !== void 0 ? { id: input.contractNodeId } : {},
697
+ ...input.contractNodeDescription !== void 0 ? { description: input.contractNodeDescription } : {},
698
+ ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
699
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
700
+ ...input.contractCheckId !== void 0 ? { checkId: input.contractCheckId } : {},
701
+ ...input.contractMetricId !== void 0 ? { metricId: input.contractMetricId } : {},
702
+ ...waiver ? { waiver } : {},
703
+ ...actor !== void 0 ? { createdBy: actor } : {}
704
+ });
705
+ if (!result) return fail("Board or task not found.");
706
+ return {
707
+ ok: true,
708
+ message: `Contract node ${result.node.kind} "${result.node.title}" saved (${result.node.id}).`,
709
+ board: result.board,
710
+ contractGraph: result.board.contractGraph ?? null
711
+ };
712
+ }
713
+ case "remove_contract_node": {
714
+ if (!input.boardId || !input.contractNodeId) {
715
+ return fail("remove_contract_node requires boardId and contractNodeId.");
716
+ }
717
+ const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
718
+ return board ? okBoard(board, "Contract node removed, along with every edge that touched it.") : fail("Contract node not found.");
719
+ }
720
+ case "add_contract_edge": {
721
+ if (!input.boardId || !input.contractEdgeFrom || !input.contractEdgeTo) {
722
+ return fail("add_contract_edge requires boardId, contractEdgeFrom, and contractEdgeTo.");
723
+ }
724
+ if (!input.contractEdgeType) return fail("add_contract_edge requires contractEdgeType.");
725
+ const result = await addContractEdge(projectRoot, input.boardId, {
726
+ from: input.contractEdgeFrom,
727
+ to: input.contractEdgeTo,
728
+ type: input.contractEdgeType,
729
+ ...input.contractEdgeId !== void 0 ? { id: input.contractEdgeId } : {},
730
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
731
+ ...input.contractEdgeRationale !== void 0 ? { rationale: input.contractEdgeRationale } : {},
732
+ ...actor !== void 0 ? { createdBy: actor } : {}
733
+ });
734
+ if (!result) return fail("Board not found.");
735
+ return {
736
+ ok: true,
737
+ message: `Contract edge ${result.edge.type}: ${result.edge.from} \u2192 ${result.edge.to}.`,
738
+ board: result.board,
739
+ contractGraph: result.board.contractGraph ?? null
740
+ };
741
+ }
742
+ case "remove_contract_edge": {
743
+ if (!input.boardId || !input.contractEdgeId) {
744
+ return fail("remove_contract_edge requires boardId and contractEdgeId.");
745
+ }
746
+ const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
747
+ return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
748
+ }
749
+ default:
750
+ return void 0;
751
+ }
752
+ }
753
+
501
754
  // src/kanban-decomposition-actions.ts
502
755
  import {
503
756
  assessTaskAtomicity,
@@ -529,26 +782,6 @@ function recordKanbanVerificationEvidence(ctx, report) {
529
782
  }
530
783
  }
531
784
 
532
- // src/kanban-tool-results.ts
533
- function atomicityNudge(task) {
534
- if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
535
- const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
536
- return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
537
- }
538
- function readEnvGateEnforcement() {
539
- const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
540
- return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
541
- }
542
- function fail(message) {
543
- return { ok: false, message };
544
- }
545
- function okBoard(board, message = "Board loaded.") {
546
- return { ok: true, message, board };
547
- }
548
- function okTask(board, task, message) {
549
- return { ok: true, message, board, task };
550
- }
551
-
552
785
  // src/kanban-decomposition-actions.ts
553
786
  async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
554
787
  switch (input.action) {
@@ -633,20 +866,14 @@ async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
633
866
  // src/kanban-detail-actions.ts
634
867
  import {
635
868
  addCheckToTask,
636
- addContractEdge,
637
869
  addDependency,
638
870
  addGoalMetricToTask,
639
871
  addLinkToTask,
640
872
  addNoteToTask,
641
- configureContractGraph,
642
- evaluateTaskContractGraph,
643
- getContractGraph,
644
873
  getKanbanWorkbench,
645
- removeContractEdge,
646
- removeContractNode,
874
+ removeCheckFromTask,
647
875
  updateCheckOnTask,
648
- updateGoalMetricOnTask,
649
- upsertContractNode
876
+ updateGoalMetricOnTask
650
877
  } from "@wrongstack/kanban";
651
878
 
652
879
  // src/kanban-split-task-handler.ts
@@ -712,131 +939,6 @@ async function handleKanbanDetailAction(projectRoot, input) {
712
939
  workbench
713
940
  };
714
941
  }
715
- case "get_contract_graph": {
716
- if (!input.boardId) return fail("get_contract_graph requires boardId.");
717
- const result = await getContractGraph(projectRoot, input.boardId);
718
- return result ? {
719
- ok: true,
720
- message: result.graph ? `${result.graph.nodes.length} contract node(s), ${result.graph.edges.length} edge(s).` : "Contract graph is not configured.",
721
- board: result.board,
722
- ...result.graph ? { contractGraph: result.graph } : {}
723
- } : fail("Board not found.");
724
- }
725
- case "configure_contract_graph": {
726
- if (!input.boardId || !input.contractGraphEnforcement) {
727
- return fail("configure_contract_graph requires boardId and contractGraphEnforcement.");
728
- }
729
- const current = await getContractGraph(projectRoot, input.boardId);
730
- if (!current) return fail("Board not found.");
731
- if (input.contractGraphEnforcement === "strict" && current.graph?.enforcement !== "strict") {
732
- return fail(
733
- "Strict Contract Map enforcement is operator-owned. Autonomous agents may use advisory maps but may not turn them into an execution gate."
734
- );
735
- }
736
- if (current.graph?.enforcement === "strict" && input.contractGraphEnforcement !== "strict") {
737
- return fail("An autonomous agent may not loosen a strict contract graph.");
738
- }
739
- const board = await configureContractGraph(
740
- projectRoot,
741
- input.boardId,
742
- input.contractGraphEnforcement
743
- );
744
- return board ? okBoard(board, "Contract graph configured.") : fail("Board not found.");
745
- }
746
- case "upsert_contract_node": {
747
- if (!input.boardId || !input.taskId || !input.contractNodeKind || !input.title) {
748
- return fail("upsert_contract_node requires boardId, taskId, contractNodeKind, and title.");
749
- }
750
- if (input.contractNodeState === "waived") {
751
- return fail(
752
- "The autonomous kanban tool may not waive contract nodes; a human-owned review surface must record that exception."
753
- );
754
- }
755
- if (input.contractNodeId) {
756
- const current = await getContractGraph(projectRoot, input.boardId);
757
- const existing = current?.graph?.nodes.find((node) => node.id === input.contractNodeId);
758
- if (current?.graph?.enforcement === "strict" && existing && (existing.kind !== input.contractNodeKind || input.contractEnforcement !== void 0 && input.contractEnforcement !== existing.enforcement)) {
759
- return fail(
760
- "The autonomous kanban tool may not change the kind or enforcement of an existing strict contract node."
761
- );
762
- }
763
- }
764
- const result = await upsertContractNode(projectRoot, input.boardId, {
765
- ...input.contractNodeId ? { id: input.contractNodeId } : {},
766
- taskId: input.taskId,
767
- kind: input.contractNodeKind,
768
- title: input.title,
769
- ...input.description !== void 0 ? { description: input.description } : {},
770
- ...input.contractEnforcement !== void 0 ? { enforcement: input.contractEnforcement } : {},
771
- ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
772
- ...input.checkId !== void 0 ? { checkId: input.checkId } : {},
773
- ...input.metricId !== void 0 ? { metricId: input.metricId } : {},
774
- ...input.baseline !== void 0 ? { baseline: input.baseline } : {},
775
- ...input.threshold !== void 0 ? { threshold: input.threshold } : {},
776
- ...input.author !== void 0 ? { createdBy: input.author } : {}
777
- });
778
- return result ? {
779
- ...okBoard(result.board, "Contract node saved."),
780
- contractGraph: result.board.contractGraph
781
- } : fail("Task not found.");
782
- }
783
- case "link_contract_nodes": {
784
- if (!input.boardId || !input.fromNodeId || !input.toNodeId || !input.contractEdgeType) {
785
- return fail(
786
- "link_contract_nodes requires boardId, fromNodeId, toNodeId, and contractEdgeType."
787
- );
788
- }
789
- const result = await addContractEdge(projectRoot, input.boardId, {
790
- from: input.fromNodeId,
791
- to: input.toNodeId,
792
- type: input.contractEdgeType,
793
- ...input.contractEdgeId ? { id: input.contractEdgeId } : {},
794
- ...input.contractEnforcement ? { enforcement: input.contractEnforcement } : {},
795
- ...input.contractRationale ? { rationale: input.contractRationale } : {},
796
- ...input.author ? { createdBy: input.author } : {}
797
- });
798
- return result ? {
799
- ...okBoard(result.board, "Contract edge added."),
800
- contractGraph: result.board.contractGraph
801
- } : fail("Board not found.");
802
- }
803
- case "remove_contract_node": {
804
- if (!input.boardId || !input.contractNodeId) {
805
- return fail("remove_contract_node requires boardId and contractNodeId.");
806
- }
807
- const current = await getContractGraph(projectRoot, input.boardId);
808
- const node = current?.graph?.nodes.find((candidate) => candidate.id === input.contractNodeId);
809
- if (current?.graph?.enforcement === "strict" && node?.enforcement === "blocking") {
810
- return fail("The autonomous kanban tool may not remove a blocking strict contract node.");
811
- }
812
- const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
813
- return board ? okBoard(board, "Contract node removed.") : fail("Contract node not found.");
814
- }
815
- case "remove_contract_edge": {
816
- if (!input.boardId || !input.contractEdgeId) {
817
- return fail("remove_contract_edge requires boardId and contractEdgeId.");
818
- }
819
- const current = await getContractGraph(projectRoot, input.boardId);
820
- const edge = current?.graph?.edges.find((candidate) => candidate.id === input.contractEdgeId);
821
- if (current?.graph?.enforcement === "strict" && edge?.enforcement === "blocking") {
822
- return fail("The autonomous kanban tool may not remove a blocking strict contract edge.");
823
- }
824
- const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
825
- return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
826
- }
827
- case "evaluate_contract_graph": {
828
- if (!input.boardId || !input.taskId) {
829
- return fail("evaluate_contract_graph requires boardId and taskId.");
830
- }
831
- const result = await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId);
832
- return result ? {
833
- ok: result.evaluation.allowed,
834
- message: result.evaluation.allowed ? "Contract graph is closed." : `Contract graph has ${result.evaluation.issues.length} unresolved issue(s).`,
835
- board: result.board,
836
- contractGraph: result.board.contractGraph,
837
- contractEvaluation: result.evaluation
838
- } : fail("Task not found.");
839
- }
840
942
  case "add_dependency": {
841
943
  if (!input.boardId || !input.taskId || !input.dependencyTaskId) {
842
944
  return fail("add_dependency requires boardId, taskId, and dependencyTaskId.");
@@ -889,8 +991,9 @@ async function handleKanbanDetailAction(projectRoot, input) {
889
991
  }
890
992
  const board = await addCheckToTask(projectRoot, input.boardId, input.taskId, {
891
993
  description: input.checkDescription,
892
- type: "manual",
893
- status: input.checkStatus
994
+ type: input.checkType ?? "manual",
995
+ status: input.checkStatus,
996
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
894
997
  });
895
998
  return board ? okBoard(board, "Check added.") : fail("Task not found.");
896
999
  }
@@ -905,11 +1008,27 @@ async function handleKanbanDetailAction(projectRoot, input) {
905
1008
  input.checkId,
906
1009
  {
907
1010
  ...input.checkDescription !== void 0 ? { description: input.checkDescription } : {},
908
- ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {}
1011
+ ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {},
1012
+ // Promoting an existing manual criterion to an executable one is the
1013
+ // common repair: the card was written before anyone knew the command.
1014
+ ...input.checkType !== void 0 ? { type: input.checkType } : {},
1015
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
909
1016
  }
910
1017
  );
911
1018
  return board ? okBoard(board, "Check updated.") : fail("Check not found.");
912
1019
  }
1020
+ case "remove_check": {
1021
+ if (!input.boardId || !input.taskId || !input.checkId) {
1022
+ return fail("remove_check requires boardId, taskId, and checkId.");
1023
+ }
1024
+ const board = await removeCheckFromTask(
1025
+ projectRoot,
1026
+ input.boardId,
1027
+ input.taskId,
1028
+ input.checkId
1029
+ );
1030
+ return board ? okBoard(board, "Acceptance criterion removed.") : fail("Check not found on this task.");
1031
+ }
913
1032
  case "add_note": {
914
1033
  if (!input.boardId || !input.taskId || !input.note)
915
1034
  return fail("add_note requires boardId, taskId, and note.");
@@ -984,14 +1103,25 @@ function taskInput(input) {
984
1103
  ...input.order !== void 0 ? { order: input.order } : {},
985
1104
  ...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
986
1105
  ...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
1106
+ // The system prompt has always told the model it may "set atomic: true"
1107
+ // when creating a composite parent. It could not: the field reached
1108
+ // neither the create input nor the patch, so the instruction described a
1109
+ // capability that did not exist and the attempt was silently dropped.
1110
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
987
1111
  ...input.childTitles !== void 0 ? { childTaskIds: input.childTitles } : {},
988
1112
  ...input.checkDescription !== void 0 ? {
989
1113
  successCriteria: [
990
1114
  {
991
1115
  id: randomUUID(),
992
1116
  description: input.checkDescription,
993
- type: "manual",
994
- status: input.checkStatus ?? "pending"
1117
+ // `manual` only as the fallback. Hard-coding it here meant every
1118
+ // agent-authored criterion was unverifiable by construction: the
1119
+ // deterministic plugins never matched, the registry passed the
1120
+ // hand-set status straight through, and "verified" collapsed into
1121
+ // "the author ticked its own box".
1122
+ type: input.checkType ?? "manual",
1123
+ status: input.checkStatus ?? "pending",
1124
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
995
1125
  }
996
1126
  ]
997
1127
  } : {},
@@ -1039,11 +1169,11 @@ function taskInput(input) {
1039
1169
  };
1040
1170
  }
1041
1171
  function mergedDependsOn(input) {
1042
- const ids = [
1172
+ if (input.dependsOn === void 0 && input.dependencyTaskId === void 0) return void 0;
1173
+ return [
1043
1174
  ...input.dependsOn ?? [],
1044
1175
  ...input.dependencyTaskId !== void 0 ? [input.dependencyTaskId] : []
1045
1176
  ].filter((id, i, arr) => id && arr.indexOf(id) === i);
1046
- return ids.length > 0 ? ids : void 0;
1047
1177
  }
1048
1178
  function taskPatch(input) {
1049
1179
  return {
@@ -1057,7 +1187,15 @@ function taskPatch(input) {
1057
1187
  status: input.status,
1058
1188
  labels: input.labels,
1059
1189
  assignedAgent: input.agentId,
1060
- ...mergedDependsOn(input) ? { dependsOn: mergedDependsOn(input) } : {},
1190
+ ...mergedDependsOn(input) !== void 0 ? { dependsOn: mergedDependsOn(input) } : {},
1191
+ // `atomic` and `childTaskIds` are the composite-parent contract, and the
1192
+ // managed gate reads both: an `atomic` parent may not move forward without
1193
+ // children, and may not reach Done until every child is completed. The
1194
+ // manager has always accepted both on a patch; only this surface withheld
1195
+ // them, so `split_atomic` was a one-way door — delete the children and the
1196
+ // parent was stranded with no way to declare itself a leaf again.
1197
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
1198
+ ...input.childTaskIds !== void 0 ? { childTaskIds: input.childTaskIds } : {},
1061
1199
  ...input.estimatedHours !== void 0 ? { estimatedHours: input.estimatedHours } : {},
1062
1200
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
1063
1201
  };
@@ -1117,8 +1255,8 @@ function assignmentForTaskCreate(input) {
1117
1255
  }
1118
1256
 
1119
1257
  // src/kanban-tool-schema.ts
1120
- 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.";
1121
- 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.";
1258
+ 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.";
1259
+ 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.';
1122
1260
  var KANBAN_INPUT_SCHEMA = {
1123
1261
  type: "object",
1124
1262
  properties: {
@@ -1131,6 +1269,7 @@ var KANBAN_INPUT_SCHEMA = {
1131
1269
  "duplicate_board",
1132
1270
  "update_board",
1133
1271
  "adopt_managed_lifecycle",
1272
+ "release_managed_lifecycle",
1134
1273
  "delete_board",
1135
1274
  "generate_board",
1136
1275
  "export_markdown",
@@ -1142,9 +1281,6 @@ var KANBAN_INPUT_SCHEMA = {
1142
1281
  "ready_tasks",
1143
1282
  "snapshot",
1144
1283
  "workbench",
1145
- "add_column",
1146
- "update_column",
1147
- "delete_column",
1148
1284
  "add_task",
1149
1285
  "split_task",
1150
1286
  "merge_tasks",
@@ -1159,13 +1295,6 @@ var KANBAN_INPUT_SCHEMA = {
1159
1295
  "delete_task",
1160
1296
  "set_chain",
1161
1297
  "get_chain",
1162
- "get_contract_graph",
1163
- "configure_contract_graph",
1164
- "upsert_contract_node",
1165
- "link_contract_nodes",
1166
- "remove_contract_node",
1167
- "remove_contract_edge",
1168
- "evaluate_contract_graph",
1169
1298
  "claim_task",
1170
1299
  "release_task",
1171
1300
  "assign_task",
@@ -1179,49 +1308,27 @@ var KANBAN_INPUT_SCHEMA = {
1179
1308
  "update_goal_metric",
1180
1309
  "add_check",
1181
1310
  "update_check",
1311
+ "remove_check",
1182
1312
  "add_note",
1183
1313
  "add_link",
1184
1314
  "verify_completion",
1185
1315
  "split_atomic",
1186
1316
  "assess_atomicity",
1187
- "propose_decomposition"
1317
+ "propose_decomposition",
1318
+ "get_contract_graph",
1319
+ "configure_contract_graph",
1320
+ "upsert_contract_node",
1321
+ "remove_contract_node",
1322
+ "add_contract_edge",
1323
+ "remove_contract_edge"
1188
1324
  ]
1189
1325
  },
1190
1326
  boardId: { type: "string" },
1191
1327
  taskId: { type: "string" },
1192
1328
  taskIds: { type: "array", items: { type: "string" } },
1193
1329
  chainId: { type: "string" },
1194
- contractNodeId: { type: "string" },
1195
- contractNodeKind: {
1196
- type: "string",
1197
- enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"]
1198
- },
1199
- contractNodeState: {
1200
- type: "string",
1201
- enum: ["unknown", "active", "satisfied", "violated", "resolved"]
1202
- },
1203
- contractEnforcement: {
1204
- type: "string",
1205
- enum: ["blocking", "advisory", "informational"]
1206
- },
1207
- contractGraphEnforcement: { type: "string", enum: ["off", "advisory", "strict"] },
1208
- contractEdgeId: { type: "string" },
1209
- contractEdgeType: {
1210
- type: "string",
1211
- enum: [
1212
- "targets",
1213
- "affects",
1214
- "must_preserve",
1215
- "exposes",
1216
- "verified_by",
1217
- "conflicts_with",
1218
- "derived_from",
1219
- "relates_to"
1220
- ]
1221
- },
1222
1330
  fromNodeId: { type: "string" },
1223
1331
  toNodeId: { type: "string" },
1224
- contractRationale: { type: "string" },
1225
1332
  baseline: { oneOf: [{ type: "string" }, { type: "number" }] },
1226
1333
  threshold: { oneOf: [{ type: "string" }, { type: "number" }] },
1227
1334
  columnId: { type: "string" },
@@ -1305,7 +1412,20 @@ var KANBAN_INPUT_SCHEMA = {
1305
1412
  costCeilingUsd: { type: "number" },
1306
1413
  retryPolicy: { type: "string", enum: ["off", "incremental", "exponential"] },
1307
1414
  lastFailureKind: { type: "string" },
1308
- dependsOn: { type: "array", items: { type: "string" } },
1415
+ dependsOn: {
1416
+ type: "array",
1417
+ items: { type: "string" },
1418
+ 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."
1419
+ },
1420
+ atomic: {
1421
+ type: "boolean",
1422
+ description: "Composite parent (true) or executable leaf (false). Set false to make a stranded parent a leaf again after its children were dropped."
1423
+ },
1424
+ childTaskIds: {
1425
+ type: "array",
1426
+ items: { type: "string" },
1427
+ description: "Children of a composite parent. On update_task an explicit empty array detaches them all."
1428
+ },
1309
1429
  estimatedHours: { type: "number" },
1310
1430
  actualHours: { type: "number" },
1311
1431
  taskGraph: { type: "object" },
@@ -1339,6 +1459,74 @@ var KANBAN_INPUT_SCHEMA = {
1339
1459
  checkId: { type: "string" },
1340
1460
  checkDescription: { type: "string" },
1341
1461
  checkStatus: { type: "string", enum: ["pending", "passed", "failed", "skipped"] },
1462
+ checkType: {
1463
+ type: "string",
1464
+ // Only types a verifier can actually execute. `manual` is the default and
1465
+ // means a human or agent asserts the status by hand. The rest are run by
1466
+ // `verify_completion` against the default deterministic registry. Types
1467
+ // with no plugin in that registry (`auto`, `review`, `agent`, `council`)
1468
+ // are deliberately omitted: offering them would produce criteria that
1469
+ // silently report `skipped — no verifier plugin registered`.
1470
+ enum: ["manual", "command", "test", "file_exists", "file_matches", "git_diff", "metric"],
1471
+ 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.'
1472
+ },
1473
+ checkNotes: {
1474
+ type: "string",
1475
+ 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"}.'
1476
+ },
1477
+ // ── Contract map ───────────────────────────────────────────────────
1478
+ // The card contract: what this work targets, what it must not break, what
1479
+ // it risks, and what verifies it. Advisory by default — the readiness gate
1480
+ // deliberately does not require map structure, so a map is an operator
1481
+ // review aid, not work the model must complete before implementing.
1482
+ contractEnforcement: {
1483
+ type: "string",
1484
+ enum: ["off", "advisory", "strict"],
1485
+ description: "Board-level contract map enforcement. Default when first configured: advisory."
1486
+ },
1487
+ contractNodeId: { type: "string" },
1488
+ contractNodeKind: {
1489
+ type: "string",
1490
+ enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"],
1491
+ 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."
1492
+ },
1493
+ contractNodeTitle: { type: "string" },
1494
+ contractNodeDescription: { type: "string" },
1495
+ contractNodeState: {
1496
+ type: "string",
1497
+ enum: ["unknown", "active", "satisfied", "violated", "waived", "resolved"]
1498
+ },
1499
+ contractNodeEnforcement: {
1500
+ type: "string",
1501
+ enum: ["blocking", "advisory", "informational"]
1502
+ },
1503
+ /** Bind a node to an acceptance criterion or goal metric already on the task. */
1504
+ contractCheckId: { type: "string" },
1505
+ contractMetricId: { type: "string" },
1506
+ contractWaiverReason: {
1507
+ type: "string",
1508
+ description: 'Required, with an actor, when contractNodeState is "waived".'
1509
+ },
1510
+ contractEdgeId: { type: "string" },
1511
+ contractEdgeFrom: {
1512
+ type: "string",
1513
+ description: 'A contract node id, or a task id (bare or "task:<id>") for the card endpoint.'
1514
+ },
1515
+ contractEdgeTo: { type: "string" },
1516
+ contractEdgeType: {
1517
+ type: "string",
1518
+ enum: [
1519
+ "targets",
1520
+ "affects",
1521
+ "must_preserve",
1522
+ "exposes",
1523
+ "verified_by",
1524
+ "conflicts_with",
1525
+ "derived_from",
1526
+ "relates_to"
1527
+ ]
1528
+ },
1529
+ contractEdgeRationale: { type: "string" },
1342
1530
  note: { type: "string" },
1343
1531
  author: { type: "string" },
1344
1532
  url: { type: "string" },
@@ -1413,8 +1601,14 @@ var kanbanTool = {
1413
1601
  }
1414
1602
  case "create_board": {
1415
1603
  if (!input.title) return fail("create_board requires title.");
1604
+ const existing = (await listBoards2(projectRoot)).filter(
1605
+ (candidate) => (candidate.kind ?? "project") === "project"
1606
+ );
1416
1607
  const board = await createBoard2(projectRoot, boardCreateInput(input, input.title));
1417
- return { ok: true, message: `Board created: ${board.title}`, board };
1608
+ 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(
1609
+ ", "
1610
+ )}${existing.length > 3 ? ", \u2026" : ""}. If this work belongs to one of them, add_task there instead and delete this board.` : "";
1611
+ return { ok: true, message: `Board created: ${board.title}.${note}`, board };
1418
1612
  }
1419
1613
  case "update_board": {
1420
1614
  if (!input.boardId) return fail("update_board requires boardId.");
@@ -1443,6 +1637,20 @@ var kanbanTool = {
1443
1637
  });
1444
1638
  return board ? okBoard(board, "Managed lifecycle adopted without moving existing cards.") : fail("Board not found.");
1445
1639
  }
1640
+ // Adoption used to be a one-way door: the strict lifecycle carries
1641
+ // acceptance-criteria, verification-report, review-evidence and
1642
+ // one-stage-at-a-time gates, and nothing on the tool surface could
1643
+ // undo it, so a board adopted once kept its ceremony forever. The
1644
+ // gates are worth having where a fleet is supervised; they are not
1645
+ // worth being unable to leave. Cards and columns are untouched.
1646
+ case "release_managed_lifecycle": {
1647
+ if (!input.boardId) return fail("release_managed_lifecycle requires boardId.");
1648
+ const board = await updateBoard2(projectRoot, input.boardId, { lifecycle: null });
1649
+ return board ? okBoard(
1650
+ board,
1651
+ "Managed lifecycle released; the board now tracks work without strict gates."
1652
+ ) : fail("Board not found.");
1653
+ }
1446
1654
  case "duplicate_board": {
1447
1655
  if (!input.boardId) return fail("duplicate_board requires boardId.");
1448
1656
  const board = await duplicateBoard(
@@ -1462,8 +1670,7 @@ var kanbanTool = {
1462
1670
  const boardInput = createBoardFromText({
1463
1671
  description: input.description,
1464
1672
  ...input.title !== void 0 ? { title: input.title } : {},
1465
- ...input.context !== void 0 ? { context: input.context } : {},
1466
- ...input.columns !== void 0 ? { columns: input.columns } : {}
1673
+ ...input.context !== void 0 ? { context: input.context } : {}
1467
1674
  });
1468
1675
  const board = await createBoard2(projectRoot, boardInput);
1469
1676
  for (const taskInput2 of parseLinesIntoTasks(
@@ -1601,7 +1808,7 @@ var kanbanTool = {
1601
1808
  return { ok: true, message: `${tasks.length} ready task(s).`, tasks };
1602
1809
  }
1603
1810
  case "snapshot": {
1604
- const snapshot = await getKanbanOrchestrationSnapshot(projectRoot, {
1811
+ const snapshot = await getKanbanOrchestrationSnapshot2(projectRoot, {
1605
1812
  query: input.query,
1606
1813
  boardId: input.boardId,
1607
1814
  assignedAgent: input.agentId,
@@ -1616,33 +1823,6 @@ var kanbanTool = {
1616
1823
  snapshot
1617
1824
  };
1618
1825
  }
1619
- case "add_column": {
1620
- if (!input.boardId || !input.title)
1621
- return fail("add_column requires boardId and title.");
1622
- const result2 = await addColumn(projectRoot, input.boardId, {
1623
- title: input.title,
1624
- ...input.description !== void 0 ? { description: input.description } : {}
1625
- });
1626
- return result2 ? okBoard(result2.board, "Column added.") : fail("Board not found.");
1627
- }
1628
- case "update_column": {
1629
- if (!input.boardId || !input.columnId)
1630
- return fail("update_column requires boardId and columnId.");
1631
- const board = await updateColumn(projectRoot, input.boardId, input.columnId, {
1632
- ...input.title !== void 0 ? { title: input.title } : {},
1633
- ...input.description !== void 0 ? { description: input.description } : {},
1634
- ...input.order !== void 0 ? { order: input.order } : {}
1635
- });
1636
- return board ? okBoard(board, "Column updated.") : fail("Column not found.");
1637
- }
1638
- case "delete_column": {
1639
- if (!input.boardId || !input.columnId)
1640
- return fail("delete_column requires boardId and columnId.");
1641
- const board = await removeColumn(projectRoot, input.boardId, input.columnId, {
1642
- moveTasksToColumnId: input.moveTasksToColumnId
1643
- });
1644
- return board ? okBoard(board, "Column deleted.") : fail("Column not found.");
1645
- }
1646
1826
  case "add_task": {
1647
1827
  if (!input.boardId || !input.title) return fail("add_task requires boardId and title.");
1648
1828
  const result2 = await addTask(projectRoot, input.boardId, taskInput(input));
@@ -1724,6 +1904,32 @@ var kanbanTool = {
1724
1904
  `Task is not implementation-ready: ${readiness.issues.map((issue) => issue.message).join(" | ")}`
1725
1905
  );
1726
1906
  }
1907
+ if (board.lifecycle?.mode !== "managed") {
1908
+ const now = /* @__PURE__ */ new Date();
1909
+ const assigned = await updateTaskAssignment(projectRoot, board.id, task.id, {
1910
+ status: "running",
1911
+ agentId: input.agentId ?? input.author,
1912
+ leaseId: input.leaseId ?? randomUUID2(),
1913
+ claimedAt: input.claimedAt ?? now.toISOString(),
1914
+ heartbeatAt: input.heartbeatAt ?? now.toISOString(),
1915
+ leaseExpiresAt: input.leaseExpiresAt ?? new Date(now.getTime() + 15 * 6e4).toISOString(),
1916
+ attempt: input.attempt ?? 1,
1917
+ maxAttempts: input.maxAttempts ?? 3
1918
+ });
1919
+ if (!assigned) return fail("Task assignment could not be started.");
1920
+ const started = await updateTask2(projectRoot, board.id, task.id, {
1921
+ status: "in_progress"
1922
+ });
1923
+ const current = started ?? assigned;
1924
+ const claimed = task;
1925
+ const currentTask = current.tasks.find((candidate) => candidate.id === claimed.id) ?? claimed;
1926
+ ctx.setCurrentKanbanTask?.(currentTask.id, current.id);
1927
+ return okTask(
1928
+ current,
1929
+ currentTask,
1930
+ "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."
1931
+ );
1932
+ }
1727
1933
  let stage = task.lifecycle?.currentStage;
1728
1934
  if (stage === "backlog") {
1729
1935
  const moved = await transitionTask(projectRoot, board.id, task.id, {
@@ -1864,6 +2070,9 @@ var kanbanTool = {
1864
2070
  if (!input.boardId || !input.taskId)
1865
2071
  return fail("delete_task requires boardId and taskId.");
1866
2072
  const board = await removeTask(projectRoot, input.boardId, input.taskId);
2073
+ if (board && ctx.currentKanbanTaskId === input.taskId) {
2074
+ ctx.setCurrentKanbanTask?.(void 0, ctx.currentKanbanBoardId);
2075
+ }
1867
2076
  return board ? okBoard(board, "Task deleted.") : fail("Task not found.");
1868
2077
  }
1869
2078
  case "set_chain": {
@@ -1996,7 +2205,7 @@ var kanbanTool = {
1996
2205
  });
1997
2206
  } catch (err) {
1998
2207
  lifecycleWarnings.push(
1999
- `Lifecycle transition to Running deferred: ${err instanceof Error ? err.message : String(err)}`
2208
+ `Lifecycle transition to Running deferred: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
2000
2209
  );
2001
2210
  }
2002
2211
  }
@@ -2020,7 +2229,7 @@ var kanbanTool = {
2020
2229
  });
2021
2230
  } catch (err) {
2022
2231
  lifecycleWarnings.push(
2023
- `Lifecycle transition to Review failed: ${err instanceof Error ? err.message : String(err)}`
2232
+ `Lifecycle transition to Review failed: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
2024
2233
  );
2025
2234
  }
2026
2235
  if (transitionResult) {
@@ -2040,7 +2249,11 @@ var kanbanTool = {
2040
2249
  successCriteria: verResult.task.successCriteria
2041
2250
  });
2042
2251
  const verdict = verResult.report.verdict;
2043
- if (verdict === "passed") {
2252
+ if (verdict === "passed" && !resolveAutoAccept(board)) {
2253
+ lifecycleWarnings.push(
2254
+ "Verification passed, but this board does not auto-accept. The card is in Review awaiting an explicit transition_task to done."
2255
+ );
2256
+ } else if (verdict === "passed") {
2044
2257
  try {
2045
2258
  const doneResult = await transitionTask(
2046
2259
  projectRoot,
@@ -2158,11 +2371,34 @@ var kanbanTool = {
2158
2371
  });
2159
2372
  return {
2160
2373
  ok: true,
2161
- message: `Counts: ready=${health.counts.ready}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2374
+ message: `Counts: startable=${health.counts.startable}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2162
2375
  queueHealth: health
2163
2376
  };
2164
2377
  }
2378
+ // Not every action is handled above. These are dispatched from here,
2379
+ // and the split has already cost real time: an agent that read this
2380
+ // file concluded `add_check` / `update_check` did not exist, wrote
2381
+ // that on a card, and spent a session trying to satisfy a gate it
2382
+ // already had the tool to clear. Keep this index in step with the
2383
+ // handlers.
2384
+ //
2385
+ // kanban-detail-actions.ts workbench · add_dependency ·
2386
+ // add_goal_metric · update_goal_metric · add_check ·
2387
+ // update_check · add_note · add_link · split_atomic
2388
+ // kanban-decomposition-actions.ts verify_completion ·
2389
+ // assess_atomicity · propose_decomposition
2390
+ // kanban-contract-actions.ts get_contract_graph ·
2391
+ // configure_contract_graph · upsert_contract_node ·
2392
+ // remove_contract_node · add_contract_edge · remove_contract_edge
2165
2393
  default:
2394
+ {
2395
+ const contractResult = await handleKanbanContractAction(
2396
+ projectRoot,
2397
+ input,
2398
+ input.author ?? input.agentId
2399
+ );
2400
+ if (contractResult !== void 0) return contractResult;
2401
+ }
2166
2402
  {
2167
2403
  const detailResult = await handleKanbanDetailAction(projectRoot, input);
2168
2404
  if (detailResult !== void 0) return detailResult;
@@ -2172,7 +2408,7 @@ var kanbanTool = {
2172
2408
  })();
2173
2409
  return withPresence(result);
2174
2410
  } catch (err) {
2175
- return fail(err instanceof Error ? err.message : String(err));
2411
+ return fail(stripLifecycleIssues(err instanceof Error ? err.message : String(err)));
2176
2412
  }
2177
2413
  }
2178
2414
  };
@@ -2205,11 +2441,51 @@ function bindTodosToBoard(items, previous, board) {
2205
2441
  available.find((task2) => !used.has(task2.id) && normalizedTitle(task2.title) === title)
2206
2442
  ];
2207
2443
  const task = candidates.find((candidate) => candidate && !used.has(candidate.id));
2208
- if (!task) return { ...item };
2444
+ if (!task) {
2445
+ const { blockedBy: _discarded, ...rest } = item;
2446
+ return { ...rest };
2447
+ }
2209
2448
  used.add(task.id);
2210
- return { ...item, kanbanBoardId: board.id, kanbanTaskId: task.id };
2449
+ const blockedBy = blockingTitles(board, task);
2450
+ return {
2451
+ ...item,
2452
+ kanbanBoardId: board.id,
2453
+ kanbanTaskId: task.id,
2454
+ ...blockedBy.length ? { blockedBy } : { blockedBy: void 0 }
2455
+ };
2456
+ });
2457
+ }
2458
+ function demoteBlockedInProgress(items, warnings) {
2459
+ return items.map((item) => {
2460
+ if (item.status !== "in_progress" || !item.blockedBy?.length) return item;
2461
+ warnings.push(
2462
+ `"${item.content}" cannot start yet \u2014 it waits on: ${item.blockedBy.join("; ")}. Kept as pending; complete the blocking work first.`
2463
+ );
2464
+ return { ...item, status: "pending" };
2211
2465
  });
2212
2466
  }
2467
+ async function createMissingManagedCards(items, board, ctx, warnings) {
2468
+ const created = /* @__PURE__ */ new Map();
2469
+ for (const item of items) {
2470
+ if (item.kanbanBoardId === board.id && item.kanbanTaskId) continue;
2471
+ try {
2472
+ const result = await addTask2(ctx.projectRoot, board.id, {
2473
+ title: item.content,
2474
+ description: item.activeForm?.trim() || `Added from the session todo list: ${item.content}`
2475
+ });
2476
+ if (!result) {
2477
+ warnings.push(`Could not open a Kanban card for "${item.content}": board not found.`);
2478
+ continue;
2479
+ }
2480
+ created.set(item.id, result.task.id);
2481
+ } catch (error) {
2482
+ warnings.push(
2483
+ `Could not open a Kanban card for "${item.content}": ${error instanceof Error ? error.message : String(error)}`
2484
+ );
2485
+ }
2486
+ }
2487
+ return created;
2488
+ }
2213
2489
  async function synchronizeManagedKanban(items, board, ctx, signal) {
2214
2490
  let synced = 0;
2215
2491
  const warnings = [];
@@ -2247,6 +2523,16 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2247
2523
  transitionComment: `Todo returned to queue: ${item.content}`
2248
2524
  });
2249
2525
  }
2526
+ for (const item of items) {
2527
+ if (item.status === "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2528
+ continue;
2529
+ }
2530
+ const task = board.tasks.find((candidate) => candidate.id === item.kanbanTaskId);
2531
+ if (task?.status !== "completed") continue;
2532
+ warnings.push(
2533
+ `"${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.`
2534
+ );
2535
+ }
2250
2536
  for (const item of items) {
2251
2537
  if (item.status !== "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2252
2538
  continue;
@@ -2298,17 +2584,25 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2298
2584
  const active = items.find(
2299
2585
  (item) => item.status === "in_progress" && item.kanbanBoardId === board.id && Boolean(item.kanbanTaskId)
2300
2586
  );
2587
+ const activeStage = active?.kanbanTaskId ? afterCompletions?.tasks.find((task) => task.id === active.kanbanTaskId)?.lifecycle?.currentStage : void 0;
2301
2588
  if (active?.kanbanTaskId) {
2302
- await execute({
2303
- action: "start_task",
2304
- boardId: board.id,
2305
- taskId: active.kanbanTaskId,
2306
- author: actor,
2307
- agentId: actor,
2308
- transitionComment: `Todo activated: ${active.content}`
2309
- });
2589
+ if (activeStage === "review" || activeStage === "done") {
2590
+ warnings.push(
2591
+ `"${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.")
2592
+ );
2593
+ } else {
2594
+ await execute({
2595
+ action: "start_task",
2596
+ boardId: board.id,
2597
+ taskId: active.kanbanTaskId,
2598
+ author: actor,
2599
+ agentId: actor,
2600
+ transitionComment: `Todo activated: ${active.content}`
2601
+ });
2602
+ }
2310
2603
  }
2311
- if (active?.kanbanTaskId && completionPending) {
2604
+ if (active?.kanbanTaskId && activeStage === "review") {
2605
+ } else if (active?.kanbanTaskId && completionPending) {
2312
2606
  warnings.push(
2313
2607
  "A completed todo is still awaiting acceptance; the next independent Kanban task was started."
2314
2608
  );
@@ -2395,29 +2689,47 @@ var todoTool = {
2395
2689
  }
2396
2690
  }
2397
2691
  const boardId = activeBoardId(items, ctx);
2398
- const board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2399
- const boundItems = board?.lifecycle?.mode === "managed" ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2692
+ let board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2693
+ const managed = board?.lifecycle?.mode === "managed";
2694
+ let boundItems = managed && board ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2695
+ const creationWarnings = [];
2696
+ if (managed && board) {
2697
+ const managedBoardId = board.id;
2698
+ const created = await createMissingManagedCards(boundItems, board, ctx, creationWarnings);
2699
+ if (created.size > 0) {
2700
+ boundItems = boundItems.map((item) => {
2701
+ const taskId = created.get(item.id);
2702
+ return taskId ? { ...item, kanbanBoardId: managedBoardId, kanbanTaskId: taskId } : item;
2703
+ });
2704
+ board = await getBoard4(ctx.projectRoot, managedBoardId) ?? board;
2705
+ boundItems = bindTodosToBoard(boundItems, ctx.todos ?? [], board);
2706
+ }
2707
+ boundItems = demoteBlockedInProgress(boundItems, creationWarnings);
2708
+ }
2400
2709
  ctx.state.replaceTodos(boundItems);
2401
- const kanbanSync = board?.lifecycle?.mode === "managed" ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2402
- if (board?.lifecycle?.mode === "managed") {
2710
+ const kanbanSync = managed && board ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2711
+ kanbanSync.warnings.unshift(...creationWarnings);
2712
+ if (managed && board) {
2403
2713
  const unresolved = boundItems.filter(
2404
2714
  (item) => item.kanbanBoardId !== board.id || !item.kanbanTaskId
2405
2715
  );
2406
2716
  if (unresolved.length > 0) {
2407
2717
  kanbanSync.warnings.push(
2408
- `${unresolved.length} Todo row(s) did not match a real Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2718
+ `${unresolved.length} Todo row(s) could not be bound to a Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2409
2719
  );
2410
2720
  }
2411
2721
  }
2722
+ const mirrorFailure = takeSessionMirrorFailure(ctx.projectRoot, ctx.session?.id ?? "");
2723
+ if (mirrorFailure) kanbanSync.warnings.push(mirrorFailure);
2412
2724
  let projectedBoard = board;
2413
- if (board?.lifecycle?.mode === "managed") {
2725
+ if (managed && board) {
2414
2726
  const refreshed = await getBoard4(ctx.projectRoot, board.id);
2415
2727
  if (refreshed) {
2416
2728
  projectedBoard = refreshed;
2417
2729
  applyManagedKanbanBoardToTodos(ctx, refreshed);
2418
2730
  }
2419
2731
  }
2420
- if (board?.lifecycle?.mode !== "managed") {
2732
+ if (!managed) {
2421
2733
  mirrorSessionTodosToKanban(ctx.projectRoot, items, ctx.session?.id ?? "session");
2422
2734
  }
2423
2735
  const completedPlanIds = /* @__PURE__ */ new Set();