@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/todo.js CHANGED
@@ -6,14 +6,13 @@ import {
6
6
  saveTasks,
7
7
  setPlanItemStatus
8
8
  } from "@wrongstack/core/storage";
9
- import { getBoard as getBoard4 } from "@wrongstack/kanban";
9
+ import { addTask as addTask2, getBoard as getBoard4 } from "@wrongstack/kanban";
10
10
 
11
11
  // src/kanban.ts
12
12
  import { randomUUID as randomUUID2 } from "node:crypto";
13
13
  import { loadTasks as loadTasks2 } from "@wrongstack/core/storage";
14
14
  import { deserializeTaskGraph as deserializeTaskGraph2, serializeTaskGraph } from "@wrongstack/core/tasking";
15
15
  import {
16
- addColumn,
17
16
  addTask,
18
17
  adoptManagedLifecycle,
19
18
  assignTask,
@@ -28,7 +27,7 @@ import {
28
27
  exportBoardToTaskGraph,
29
28
  finalizeTaskCompletion,
30
29
  getBoard as getBoard3,
31
- getKanbanOrchestrationSnapshot,
30
+ getKanbanOrchestrationSnapshot as getKanbanOrchestrationSnapshot2,
32
31
  getKanbanQueueHealth,
33
32
  getTask,
34
33
  getTaskChain,
@@ -42,16 +41,16 @@ import {
42
41
  recoverStaleTaskAssignments,
43
42
  releaseTaskClaim,
44
43
  removeBoard as removeBoard2,
45
- removeColumn,
46
44
  removeTask,
47
45
  repairManagedTaskProjection,
46
+ resolveAutoAccept,
48
47
  searchKanban,
49
48
  setTaskChain,
49
+ stripLifecycleIssues,
50
50
  syncBoardFromTaskGraph as syncBoardFromTaskGraph2,
51
51
  transferTaskToBoard,
52
52
  transitionTask,
53
53
  updateBoard as updateBoard2,
54
- updateColumn,
55
54
  updateTask as updateTask2,
56
55
  updateTaskAssignment,
57
56
  verifyTaskCompletion as verifyTaskCompletion2
@@ -101,6 +100,137 @@ function duplicateBoardOptions(input) {
101
100
  };
102
101
  }
103
102
 
103
+ // src/kanban-contract-actions.ts
104
+ import {
105
+ addContractEdge,
106
+ configureContractGraph,
107
+ evaluateTaskContractGraph,
108
+ getContractGraph,
109
+ removeContractEdge,
110
+ removeContractNode,
111
+ upsertContractNode
112
+ } from "@wrongstack/kanban";
113
+
114
+ // src/kanban-tool-results.ts
115
+ function atomicityNudge(task) {
116
+ if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
117
+ const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
118
+ return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
119
+ }
120
+ function readEnvGateEnforcement() {
121
+ const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
122
+ return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
123
+ }
124
+ function fail(message) {
125
+ return { ok: false, message };
126
+ }
127
+ function okBoard(board, message = "Board loaded.") {
128
+ return { ok: true, message, board };
129
+ }
130
+ function okTask(board, task, message) {
131
+ return { ok: true, message, board, task };
132
+ }
133
+
134
+ // src/kanban-contract-actions.ts
135
+ async function handleKanbanContractAction(projectRoot, input, actor) {
136
+ switch (input.action) {
137
+ case "get_contract_graph": {
138
+ if (!input.boardId) return fail("get_contract_graph requires boardId.");
139
+ const found = await getContractGraph(projectRoot, input.boardId);
140
+ if (!found) return fail("Board not found.");
141
+ const evaluated = input.taskId ? await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId) : null;
142
+ if (input.taskId && !evaluated) return fail("Task not found on this board.");
143
+ return {
144
+ ok: true,
145
+ 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.",
146
+ board: found.board,
147
+ contractGraph: found.graph,
148
+ ...evaluated ? { contractEvaluation: evaluated.evaluation } : {}
149
+ };
150
+ }
151
+ case "configure_contract_graph": {
152
+ if (!input.boardId) return fail("configure_contract_graph requires boardId.");
153
+ const enforcement = input.contractEnforcement ?? "advisory";
154
+ const board = await configureContractGraph(projectRoot, input.boardId, enforcement);
155
+ return board ? okBoard(board, `Contract map enforcement set to ${enforcement}.`) : fail("Board not found.");
156
+ }
157
+ case "upsert_contract_node": {
158
+ if (!input.boardId || !input.taskId) {
159
+ return fail("upsert_contract_node requires boardId and taskId.");
160
+ }
161
+ if (!input.contractNodeKind || !input.contractNodeTitle) {
162
+ return fail("upsert_contract_node requires contractNodeKind and contractNodeTitle.");
163
+ }
164
+ const waiver = input.contractNodeState === "waived" ? {
165
+ actor: actor ?? "agent",
166
+ reason: input.contractWaiverReason ?? "",
167
+ at: (/* @__PURE__ */ new Date()).toISOString()
168
+ } : void 0;
169
+ if (waiver && !waiver.reason.trim()) {
170
+ return fail("A waived contract node requires contractWaiverReason.");
171
+ }
172
+ const result = await upsertContractNode(projectRoot, input.boardId, {
173
+ taskId: input.taskId,
174
+ kind: input.contractNodeKind,
175
+ title: input.contractNodeTitle,
176
+ ...input.contractNodeId !== void 0 ? { id: input.contractNodeId } : {},
177
+ ...input.contractNodeDescription !== void 0 ? { description: input.contractNodeDescription } : {},
178
+ ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
179
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
180
+ ...input.contractCheckId !== void 0 ? { checkId: input.contractCheckId } : {},
181
+ ...input.contractMetricId !== void 0 ? { metricId: input.contractMetricId } : {},
182
+ ...waiver ? { waiver } : {},
183
+ ...actor !== void 0 ? { createdBy: actor } : {}
184
+ });
185
+ if (!result) return fail("Board or task not found.");
186
+ return {
187
+ ok: true,
188
+ message: `Contract node ${result.node.kind} "${result.node.title}" saved (${result.node.id}).`,
189
+ board: result.board,
190
+ contractGraph: result.board.contractGraph ?? null
191
+ };
192
+ }
193
+ case "remove_contract_node": {
194
+ if (!input.boardId || !input.contractNodeId) {
195
+ return fail("remove_contract_node requires boardId and contractNodeId.");
196
+ }
197
+ const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
198
+ return board ? okBoard(board, "Contract node removed, along with every edge that touched it.") : fail("Contract node not found.");
199
+ }
200
+ case "add_contract_edge": {
201
+ if (!input.boardId || !input.contractEdgeFrom || !input.contractEdgeTo) {
202
+ return fail("add_contract_edge requires boardId, contractEdgeFrom, and contractEdgeTo.");
203
+ }
204
+ if (!input.contractEdgeType) return fail("add_contract_edge requires contractEdgeType.");
205
+ const result = await addContractEdge(projectRoot, input.boardId, {
206
+ from: input.contractEdgeFrom,
207
+ to: input.contractEdgeTo,
208
+ type: input.contractEdgeType,
209
+ ...input.contractEdgeId !== void 0 ? { id: input.contractEdgeId } : {},
210
+ ...input.contractNodeEnforcement !== void 0 ? { enforcement: input.contractNodeEnforcement } : {},
211
+ ...input.contractEdgeRationale !== void 0 ? { rationale: input.contractEdgeRationale } : {},
212
+ ...actor !== void 0 ? { createdBy: actor } : {}
213
+ });
214
+ if (!result) return fail("Board not found.");
215
+ return {
216
+ ok: true,
217
+ message: `Contract edge ${result.edge.type}: ${result.edge.from} \u2192 ${result.edge.to}.`,
218
+ board: result.board,
219
+ contractGraph: result.board.contractGraph ?? null
220
+ };
221
+ }
222
+ case "remove_contract_edge": {
223
+ if (!input.boardId || !input.contractEdgeId) {
224
+ return fail("remove_contract_edge requires boardId and contractEdgeId.");
225
+ }
226
+ const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
227
+ return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
228
+ }
229
+ default:
230
+ return void 0;
231
+ }
232
+ }
233
+
104
234
  // src/kanban-decomposition-actions.ts
105
235
  import {
106
236
  assessTaskAtomicity,
@@ -132,26 +262,6 @@ function recordKanbanVerificationEvidence(ctx, report) {
132
262
  }
133
263
  }
134
264
 
135
- // src/kanban-tool-results.ts
136
- function atomicityNudge(task) {
137
- if (task.atomicityAssessment?.verdict !== "needs_decomposition") return "";
138
- const reasons = task.atomicityAssessment.criteria.filter((entry) => entry.score < 1).map((entry) => entry.reason).join(" | ");
139
- return ` Atomicity: needs_decomposition (score ${task.atomicityAssessment.score}) \u2014 call propose_decomposition with 2+ subtasks before dispatch. Reasons: ${reasons}`;
140
- }
141
- function readEnvGateEnforcement() {
142
- const raw = process.env["WRONGSTACK_KANBAN_GATE"]?.trim().toLowerCase();
143
- return raw === "strict" || raw === "soft" || raw === "off" ? raw : void 0;
144
- }
145
- function fail(message) {
146
- return { ok: false, message };
147
- }
148
- function okBoard(board, message = "Board loaded.") {
149
- return { ok: true, message, board };
150
- }
151
- function okTask(board, task, message) {
152
- return { ok: true, message, board, task };
153
- }
154
-
155
265
  // src/kanban-decomposition-actions.ts
156
266
  async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
157
267
  switch (input.action) {
@@ -236,20 +346,14 @@ async function handleKanbanDecompositionAction(projectRoot, input, ctx) {
236
346
  // src/kanban-detail-actions.ts
237
347
  import {
238
348
  addCheckToTask,
239
- addContractEdge,
240
349
  addDependency,
241
350
  addGoalMetricToTask,
242
351
  addLinkToTask,
243
352
  addNoteToTask,
244
- configureContractGraph,
245
- evaluateTaskContractGraph,
246
- getContractGraph,
247
353
  getKanbanWorkbench,
248
- removeContractEdge,
249
- removeContractNode,
354
+ removeCheckFromTask,
250
355
  updateCheckOnTask,
251
- updateGoalMetricOnTask,
252
- upsertContractNode
356
+ updateGoalMetricOnTask
253
357
  } from "@wrongstack/kanban";
254
358
 
255
359
  // src/kanban-split-task-handler.ts
@@ -315,131 +419,6 @@ async function handleKanbanDetailAction(projectRoot, input) {
315
419
  workbench
316
420
  };
317
421
  }
318
- case "get_contract_graph": {
319
- if (!input.boardId) return fail("get_contract_graph requires boardId.");
320
- const result = await getContractGraph(projectRoot, input.boardId);
321
- return result ? {
322
- ok: true,
323
- message: result.graph ? `${result.graph.nodes.length} contract node(s), ${result.graph.edges.length} edge(s).` : "Contract graph is not configured.",
324
- board: result.board,
325
- ...result.graph ? { contractGraph: result.graph } : {}
326
- } : fail("Board not found.");
327
- }
328
- case "configure_contract_graph": {
329
- if (!input.boardId || !input.contractGraphEnforcement) {
330
- return fail("configure_contract_graph requires boardId and contractGraphEnforcement.");
331
- }
332
- const current = await getContractGraph(projectRoot, input.boardId);
333
- if (!current) return fail("Board not found.");
334
- if (input.contractGraphEnforcement === "strict" && current.graph?.enforcement !== "strict") {
335
- return fail(
336
- "Strict Contract Map enforcement is operator-owned. Autonomous agents may use advisory maps but may not turn them into an execution gate."
337
- );
338
- }
339
- if (current.graph?.enforcement === "strict" && input.contractGraphEnforcement !== "strict") {
340
- return fail("An autonomous agent may not loosen a strict contract graph.");
341
- }
342
- const board = await configureContractGraph(
343
- projectRoot,
344
- input.boardId,
345
- input.contractGraphEnforcement
346
- );
347
- return board ? okBoard(board, "Contract graph configured.") : fail("Board not found.");
348
- }
349
- case "upsert_contract_node": {
350
- if (!input.boardId || !input.taskId || !input.contractNodeKind || !input.title) {
351
- return fail("upsert_contract_node requires boardId, taskId, contractNodeKind, and title.");
352
- }
353
- if (input.contractNodeState === "waived") {
354
- return fail(
355
- "The autonomous kanban tool may not waive contract nodes; a human-owned review surface must record that exception."
356
- );
357
- }
358
- if (input.contractNodeId) {
359
- const current = await getContractGraph(projectRoot, input.boardId);
360
- const existing = current?.graph?.nodes.find((node) => node.id === input.contractNodeId);
361
- if (current?.graph?.enforcement === "strict" && existing && (existing.kind !== input.contractNodeKind || input.contractEnforcement !== void 0 && input.contractEnforcement !== existing.enforcement)) {
362
- return fail(
363
- "The autonomous kanban tool may not change the kind or enforcement of an existing strict contract node."
364
- );
365
- }
366
- }
367
- const result = await upsertContractNode(projectRoot, input.boardId, {
368
- ...input.contractNodeId ? { id: input.contractNodeId } : {},
369
- taskId: input.taskId,
370
- kind: input.contractNodeKind,
371
- title: input.title,
372
- ...input.description !== void 0 ? { description: input.description } : {},
373
- ...input.contractEnforcement !== void 0 ? { enforcement: input.contractEnforcement } : {},
374
- ...input.contractNodeState !== void 0 ? { state: input.contractNodeState } : {},
375
- ...input.checkId !== void 0 ? { checkId: input.checkId } : {},
376
- ...input.metricId !== void 0 ? { metricId: input.metricId } : {},
377
- ...input.baseline !== void 0 ? { baseline: input.baseline } : {},
378
- ...input.threshold !== void 0 ? { threshold: input.threshold } : {},
379
- ...input.author !== void 0 ? { createdBy: input.author } : {}
380
- });
381
- return result ? {
382
- ...okBoard(result.board, "Contract node saved."),
383
- contractGraph: result.board.contractGraph
384
- } : fail("Task not found.");
385
- }
386
- case "link_contract_nodes": {
387
- if (!input.boardId || !input.fromNodeId || !input.toNodeId || !input.contractEdgeType) {
388
- return fail(
389
- "link_contract_nodes requires boardId, fromNodeId, toNodeId, and contractEdgeType."
390
- );
391
- }
392
- const result = await addContractEdge(projectRoot, input.boardId, {
393
- from: input.fromNodeId,
394
- to: input.toNodeId,
395
- type: input.contractEdgeType,
396
- ...input.contractEdgeId ? { id: input.contractEdgeId } : {},
397
- ...input.contractEnforcement ? { enforcement: input.contractEnforcement } : {},
398
- ...input.contractRationale ? { rationale: input.contractRationale } : {},
399
- ...input.author ? { createdBy: input.author } : {}
400
- });
401
- return result ? {
402
- ...okBoard(result.board, "Contract edge added."),
403
- contractGraph: result.board.contractGraph
404
- } : fail("Board not found.");
405
- }
406
- case "remove_contract_node": {
407
- if (!input.boardId || !input.contractNodeId) {
408
- return fail("remove_contract_node requires boardId and contractNodeId.");
409
- }
410
- const current = await getContractGraph(projectRoot, input.boardId);
411
- const node = current?.graph?.nodes.find((candidate) => candidate.id === input.contractNodeId);
412
- if (current?.graph?.enforcement === "strict" && node?.enforcement === "blocking") {
413
- return fail("The autonomous kanban tool may not remove a blocking strict contract node.");
414
- }
415
- const board = await removeContractNode(projectRoot, input.boardId, input.contractNodeId);
416
- return board ? okBoard(board, "Contract node removed.") : fail("Contract node not found.");
417
- }
418
- case "remove_contract_edge": {
419
- if (!input.boardId || !input.contractEdgeId) {
420
- return fail("remove_contract_edge requires boardId and contractEdgeId.");
421
- }
422
- const current = await getContractGraph(projectRoot, input.boardId);
423
- const edge = current?.graph?.edges.find((candidate) => candidate.id === input.contractEdgeId);
424
- if (current?.graph?.enforcement === "strict" && edge?.enforcement === "blocking") {
425
- return fail("The autonomous kanban tool may not remove a blocking strict contract edge.");
426
- }
427
- const board = await removeContractEdge(projectRoot, input.boardId, input.contractEdgeId);
428
- return board ? okBoard(board, "Contract edge removed.") : fail("Contract edge not found.");
429
- }
430
- case "evaluate_contract_graph": {
431
- if (!input.boardId || !input.taskId) {
432
- return fail("evaluate_contract_graph requires boardId and taskId.");
433
- }
434
- const result = await evaluateTaskContractGraph(projectRoot, input.boardId, input.taskId);
435
- return result ? {
436
- ok: result.evaluation.allowed,
437
- message: result.evaluation.allowed ? "Contract graph is closed." : `Contract graph has ${result.evaluation.issues.length} unresolved issue(s).`,
438
- board: result.board,
439
- contractGraph: result.board.contractGraph,
440
- contractEvaluation: result.evaluation
441
- } : fail("Task not found.");
442
- }
443
422
  case "add_dependency": {
444
423
  if (!input.boardId || !input.taskId || !input.dependencyTaskId) {
445
424
  return fail("add_dependency requires boardId, taskId, and dependencyTaskId.");
@@ -492,8 +471,9 @@ async function handleKanbanDetailAction(projectRoot, input) {
492
471
  }
493
472
  const board = await addCheckToTask(projectRoot, input.boardId, input.taskId, {
494
473
  description: input.checkDescription,
495
- type: "manual",
496
- status: input.checkStatus
474
+ type: input.checkType ?? "manual",
475
+ status: input.checkStatus,
476
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
497
477
  });
498
478
  return board ? okBoard(board, "Check added.") : fail("Task not found.");
499
479
  }
@@ -508,11 +488,27 @@ async function handleKanbanDetailAction(projectRoot, input) {
508
488
  input.checkId,
509
489
  {
510
490
  ...input.checkDescription !== void 0 ? { description: input.checkDescription } : {},
511
- ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {}
491
+ ...input.checkStatus !== void 0 ? { status: input.checkStatus } : {},
492
+ // Promoting an existing manual criterion to an executable one is the
493
+ // common repair: the card was written before anyone knew the command.
494
+ ...input.checkType !== void 0 ? { type: input.checkType } : {},
495
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
512
496
  }
513
497
  );
514
498
  return board ? okBoard(board, "Check updated.") : fail("Check not found.");
515
499
  }
500
+ case "remove_check": {
501
+ if (!input.boardId || !input.taskId || !input.checkId) {
502
+ return fail("remove_check requires boardId, taskId, and checkId.");
503
+ }
504
+ const board = await removeCheckFromTask(
505
+ projectRoot,
506
+ input.boardId,
507
+ input.taskId,
508
+ input.checkId
509
+ );
510
+ return board ? okBoard(board, "Acceptance criterion removed.") : fail("Check not found on this task.");
511
+ }
516
512
  case "add_note": {
517
513
  if (!input.boardId || !input.taskId || !input.note)
518
514
  return fail("add_note requires boardId, taskId, and note.");
@@ -587,14 +583,25 @@ function taskInput(input) {
587
583
  ...input.order !== void 0 ? { order: input.order } : {},
588
584
  ...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
589
585
  ...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
586
+ // The system prompt has always told the model it may "set atomic: true"
587
+ // when creating a composite parent. It could not: the field reached
588
+ // neither the create input nor the patch, so the instruction described a
589
+ // capability that did not exist and the attempt was silently dropped.
590
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
590
591
  ...input.childTitles !== void 0 ? { childTaskIds: input.childTitles } : {},
591
592
  ...input.checkDescription !== void 0 ? {
592
593
  successCriteria: [
593
594
  {
594
595
  id: randomUUID(),
595
596
  description: input.checkDescription,
596
- type: "manual",
597
- status: input.checkStatus ?? "pending"
597
+ // `manual` only as the fallback. Hard-coding it here meant every
598
+ // agent-authored criterion was unverifiable by construction: the
599
+ // deterministic plugins never matched, the registry passed the
600
+ // hand-set status straight through, and "verified" collapsed into
601
+ // "the author ticked its own box".
602
+ type: input.checkType ?? "manual",
603
+ status: input.checkStatus ?? "pending",
604
+ ...input.checkNotes !== void 0 ? { notes: input.checkNotes } : {}
598
605
  }
599
606
  ]
600
607
  } : {},
@@ -642,11 +649,11 @@ function taskInput(input) {
642
649
  };
643
650
  }
644
651
  function mergedDependsOn(input) {
645
- const ids = [
652
+ if (input.dependsOn === void 0 && input.dependencyTaskId === void 0) return void 0;
653
+ return [
646
654
  ...input.dependsOn ?? [],
647
655
  ...input.dependencyTaskId !== void 0 ? [input.dependencyTaskId] : []
648
656
  ].filter((id, i, arr) => id && arr.indexOf(id) === i);
649
- return ids.length > 0 ? ids : void 0;
650
657
  }
651
658
  function taskPatch(input) {
652
659
  return {
@@ -660,7 +667,15 @@ function taskPatch(input) {
660
667
  status: input.status,
661
668
  labels: input.labels,
662
669
  assignedAgent: input.agentId,
663
- ...mergedDependsOn(input) ? { dependsOn: mergedDependsOn(input) } : {},
670
+ ...mergedDependsOn(input) !== void 0 ? { dependsOn: mergedDependsOn(input) } : {},
671
+ // `atomic` and `childTaskIds` are the composite-parent contract, and the
672
+ // managed gate reads both: an `atomic` parent may not move forward without
673
+ // children, and may not reach Done until every child is completed. The
674
+ // manager has always accepted both on a patch; only this surface withheld
675
+ // them, so `split_atomic` was a one-way door — delete the children and the
676
+ // parent was stranded with no way to declare itself a leaf again.
677
+ ...input.atomic !== void 0 ? { atomic: input.atomic } : {},
678
+ ...input.childTaskIds !== void 0 ? { childTaskIds: input.childTaskIds } : {},
664
679
  ...input.estimatedHours !== void 0 ? { estimatedHours: input.estimatedHours } : {},
665
680
  ...input.actualHours !== void 0 ? { actualHours: input.actualHours } : {}
666
681
  };
@@ -720,8 +735,8 @@ function assignmentForTaskCreate(input) {
720
735
  }
721
736
 
722
737
  // src/kanban-tool-schema.ts
723
- 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.";
724
- 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.";
738
+ 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.";
739
+ 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.';
725
740
  var KANBAN_INPUT_SCHEMA = {
726
741
  type: "object",
727
742
  properties: {
@@ -734,6 +749,7 @@ var KANBAN_INPUT_SCHEMA = {
734
749
  "duplicate_board",
735
750
  "update_board",
736
751
  "adopt_managed_lifecycle",
752
+ "release_managed_lifecycle",
737
753
  "delete_board",
738
754
  "generate_board",
739
755
  "export_markdown",
@@ -745,9 +761,6 @@ var KANBAN_INPUT_SCHEMA = {
745
761
  "ready_tasks",
746
762
  "snapshot",
747
763
  "workbench",
748
- "add_column",
749
- "update_column",
750
- "delete_column",
751
764
  "add_task",
752
765
  "split_task",
753
766
  "merge_tasks",
@@ -762,13 +775,6 @@ var KANBAN_INPUT_SCHEMA = {
762
775
  "delete_task",
763
776
  "set_chain",
764
777
  "get_chain",
765
- "get_contract_graph",
766
- "configure_contract_graph",
767
- "upsert_contract_node",
768
- "link_contract_nodes",
769
- "remove_contract_node",
770
- "remove_contract_edge",
771
- "evaluate_contract_graph",
772
778
  "claim_task",
773
779
  "release_task",
774
780
  "assign_task",
@@ -782,49 +788,27 @@ var KANBAN_INPUT_SCHEMA = {
782
788
  "update_goal_metric",
783
789
  "add_check",
784
790
  "update_check",
791
+ "remove_check",
785
792
  "add_note",
786
793
  "add_link",
787
794
  "verify_completion",
788
795
  "split_atomic",
789
796
  "assess_atomicity",
790
- "propose_decomposition"
797
+ "propose_decomposition",
798
+ "get_contract_graph",
799
+ "configure_contract_graph",
800
+ "upsert_contract_node",
801
+ "remove_contract_node",
802
+ "add_contract_edge",
803
+ "remove_contract_edge"
791
804
  ]
792
805
  },
793
806
  boardId: { type: "string" },
794
807
  taskId: { type: "string" },
795
808
  taskIds: { type: "array", items: { type: "string" } },
796
809
  chainId: { type: "string" },
797
- contractNodeId: { type: "string" },
798
- contractNodeKind: {
799
- type: "string",
800
- enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"]
801
- },
802
- contractNodeState: {
803
- type: "string",
804
- enum: ["unknown", "active", "satisfied", "violated", "resolved"]
805
- },
806
- contractEnforcement: {
807
- type: "string",
808
- enum: ["blocking", "advisory", "informational"]
809
- },
810
- contractGraphEnforcement: { type: "string", enum: ["off", "advisory", "strict"] },
811
- contractEdgeId: { type: "string" },
812
- contractEdgeType: {
813
- type: "string",
814
- enum: [
815
- "targets",
816
- "affects",
817
- "must_preserve",
818
- "exposes",
819
- "verified_by",
820
- "conflicts_with",
821
- "derived_from",
822
- "relates_to"
823
- ]
824
- },
825
810
  fromNodeId: { type: "string" },
826
811
  toNodeId: { type: "string" },
827
- contractRationale: { type: "string" },
828
812
  baseline: { oneOf: [{ type: "string" }, { type: "number" }] },
829
813
  threshold: { oneOf: [{ type: "string" }, { type: "number" }] },
830
814
  columnId: { type: "string" },
@@ -908,7 +892,20 @@ var KANBAN_INPUT_SCHEMA = {
908
892
  costCeilingUsd: { type: "number" },
909
893
  retryPolicy: { type: "string", enum: ["off", "incremental", "exponential"] },
910
894
  lastFailureKind: { type: "string" },
911
- dependsOn: { type: "array", items: { type: "string" } },
895
+ dependsOn: {
896
+ type: "array",
897
+ items: { type: "string" },
898
+ 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."
899
+ },
900
+ atomic: {
901
+ type: "boolean",
902
+ description: "Composite parent (true) or executable leaf (false). Set false to make a stranded parent a leaf again after its children were dropped."
903
+ },
904
+ childTaskIds: {
905
+ type: "array",
906
+ items: { type: "string" },
907
+ description: "Children of a composite parent. On update_task an explicit empty array detaches them all."
908
+ },
912
909
  estimatedHours: { type: "number" },
913
910
  actualHours: { type: "number" },
914
911
  taskGraph: { type: "object" },
@@ -942,6 +939,74 @@ var KANBAN_INPUT_SCHEMA = {
942
939
  checkId: { type: "string" },
943
940
  checkDescription: { type: "string" },
944
941
  checkStatus: { type: "string", enum: ["pending", "passed", "failed", "skipped"] },
942
+ checkType: {
943
+ type: "string",
944
+ // Only types a verifier can actually execute. `manual` is the default and
945
+ // means a human or agent asserts the status by hand. The rest are run by
946
+ // `verify_completion` against the default deterministic registry. Types
947
+ // with no plugin in that registry (`auto`, `review`, `agent`, `council`)
948
+ // are deliberately omitted: offering them would produce criteria that
949
+ // silently report `skipped — no verifier plugin registered`.
950
+ enum: ["manual", "command", "test", "file_exists", "file_matches", "git_diff", "metric"],
951
+ 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.'
952
+ },
953
+ checkNotes: {
954
+ type: "string",
955
+ 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"}.'
956
+ },
957
+ // ── Contract map ───────────────────────────────────────────────────
958
+ // The card contract: what this work targets, what it must not break, what
959
+ // it risks, and what verifies it. Advisory by default — the readiness gate
960
+ // deliberately does not require map structure, so a map is an operator
961
+ // review aid, not work the model must complete before implementing.
962
+ contractEnforcement: {
963
+ type: "string",
964
+ enum: ["off", "advisory", "strict"],
965
+ description: "Board-level contract map enforcement. Default when first configured: advisory."
966
+ },
967
+ contractNodeId: { type: "string" },
968
+ contractNodeKind: {
969
+ type: "string",
970
+ enum: ["objective", "guardrail", "risk", "component", "artifact", "verification"],
971
+ 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."
972
+ },
973
+ contractNodeTitle: { type: "string" },
974
+ contractNodeDescription: { type: "string" },
975
+ contractNodeState: {
976
+ type: "string",
977
+ enum: ["unknown", "active", "satisfied", "violated", "waived", "resolved"]
978
+ },
979
+ contractNodeEnforcement: {
980
+ type: "string",
981
+ enum: ["blocking", "advisory", "informational"]
982
+ },
983
+ /** Bind a node to an acceptance criterion or goal metric already on the task. */
984
+ contractCheckId: { type: "string" },
985
+ contractMetricId: { type: "string" },
986
+ contractWaiverReason: {
987
+ type: "string",
988
+ description: 'Required, with an actor, when contractNodeState is "waived".'
989
+ },
990
+ contractEdgeId: { type: "string" },
991
+ contractEdgeFrom: {
992
+ type: "string",
993
+ description: 'A contract node id, or a task id (bare or "task:<id>") for the card endpoint.'
994
+ },
995
+ contractEdgeTo: { type: "string" },
996
+ contractEdgeType: {
997
+ type: "string",
998
+ enum: [
999
+ "targets",
1000
+ "affects",
1001
+ "must_preserve",
1002
+ "exposes",
1003
+ "verified_by",
1004
+ "conflicts_with",
1005
+ "derived_from",
1006
+ "relates_to"
1007
+ ]
1008
+ },
1009
+ contractEdgeRationale: { type: "string" },
945
1010
  note: { type: "string" },
946
1011
  author: { type: "string" },
947
1012
  url: { type: "string" },
@@ -994,12 +1059,17 @@ import {
994
1059
  mutateTasks
995
1060
  } from "@wrongstack/core/storage";
996
1061
  import { deserializeTaskGraph } from "@wrongstack/core/tasking";
997
- import { resolveWstackPaths } from "@wrongstack/core/utils";
1062
+ import { formatTodosForModel, resolveWstackPaths } from "@wrongstack/core/utils";
998
1063
  import {
999
1064
  bridgeKanbanSupervisor,
1065
+ compactSessionMirrorBoard,
1000
1066
  createBoard,
1067
+ DEFAULT_COLUMNS,
1001
1068
  getBoard as getBoard2,
1069
+ getDependencyReadinessIssues,
1070
+ getKanbanOrchestrationSnapshot,
1002
1071
  listBoards,
1072
+ pruneSessionBoards,
1003
1073
  removeBoard,
1004
1074
  syncBoardFromTaskGraph,
1005
1075
  touchKanbanPresence as touchKanbanPresence2,
@@ -1007,16 +1077,14 @@ import {
1007
1077
  } from "@wrongstack/kanban";
1008
1078
  var SESSION_BOARD_TAG = "session-work";
1009
1079
  var MIRROR_DISABLED_ENV = "WRONGSTACK_KANBAN_TASK_MIRROR";
1010
- var SESSION_KANBAN_COLUMNS = [
1011
- { id: "todo", title: "Todo", order: 0, wipLimit: 0, color: "#2563eb" },
1012
- { id: "in-progress", title: "Running", order: 1, wipLimit: 1, color: "#d97706" },
1013
- { id: "review", title: "Preview", order: 2, wipLimit: 0, color: "#7c3aed" },
1014
- { id: "done", title: "Done", order: 3, wipLimit: 0, color: "#16a34a" }
1015
- ];
1080
+ var SESSION_KANBAN_COLUMNS = DEFAULT_COLUMNS.map((column) => ({
1081
+ ...column
1082
+ }));
1016
1083
  var boardQueue = /* @__PURE__ */ new Map();
1017
1084
  var boardEnsures = /* @__PURE__ */ new Map();
1018
1085
  var pendingMirrors = /* @__PURE__ */ new Map();
1019
1086
  var activeMirrors = /* @__PURE__ */ new Set();
1087
+ var mirrorFailures = /* @__PURE__ */ new Map();
1020
1088
  var suppressedTodoMirrors = /* @__PURE__ */ new WeakSet();
1021
1089
  function boardKey(projectRoot, sessionId) {
1022
1090
  return `${projectRoot}\0${sessionId}`;
@@ -1024,6 +1092,33 @@ function boardKey(projectRoot, sessionId) {
1024
1092
  function mirrorKey(projectRoot, sessionId, sourceSystem) {
1025
1093
  return `${boardKey(projectRoot, sessionId)}\0${sourceSystem}`;
1026
1094
  }
1095
+ function completedReconciliationGraph(latest, candidates) {
1096
+ const latestNodeIds = new Set(latest.nodes.map((node) => node.id));
1097
+ const carriedNodeIds = /* @__PURE__ */ new Set();
1098
+ const completedNodes = candidates.flatMap(
1099
+ (candidate) => candidate.nodes.filter((node) => {
1100
+ if (node.status !== "completed" || latestNodeIds.has(node.id) || carriedNodeIds.has(node.id)) {
1101
+ return false;
1102
+ }
1103
+ carriedNodeIds.add(node.id);
1104
+ return true;
1105
+ })
1106
+ );
1107
+ if (completedNodes.length === 0) return void 0;
1108
+ const carriedRequirements = completedNodes.flatMap(
1109
+ (node) => node.specRequirementId ? [node.specRequirementId] : []
1110
+ );
1111
+ return {
1112
+ ...latest,
1113
+ nodes: [...latest.nodes, ...completedNodes],
1114
+ rootNodes: [.../* @__PURE__ */ new Set([...latest.rootNodes, ...completedNodes.map((node) => node.id)])],
1115
+ ...latest.requiredRequirementIds ? {
1116
+ requiredRequirementIds: [
1117
+ .../* @__PURE__ */ new Set([...latest.requiredRequirementIds, ...carriedRequirements])
1118
+ ]
1119
+ } : {}
1120
+ };
1121
+ }
1027
1122
  function sessionTag(sessionId) {
1028
1123
  return `session:${sessionId}`;
1029
1124
  }
@@ -1102,16 +1197,44 @@ async function projectGraph(projectRoot, sessionId, graph, sourceSystem) {
1102
1197
  sourceSystem,
1103
1198
  tags: [.../* @__PURE__ */ new Set([...board.tags ?? [], ...sessionBoardTags(sessionId)])],
1104
1199
  archiveMissingTasks: true,
1105
- includeCompletedTasks: true
1200
+ includeCompletedTasks: true,
1201
+ // The scope ledger stays declared and accurate, but it may not veto a
1202
+ // projection. A session mirror reflects a tactical list that shrinks by
1203
+ // design, and refusing the sync never protected the removed row — it
1204
+ // froze the entire board, permanently, because the stored scope then
1205
+ // outlived every later snapshot (`session-kanban.mirror-failed`).
1206
+ // Nothing is lost by shrinking here: `archiveMissingTasks` keeps the
1207
+ // removed card on the board as `archived`, the reconciliation pass
1208
+ // first walks vanished completed rows to Done, and the session journal
1209
+ // remains the durable record.
1210
+ allowRequirementScopeShrink: true
1106
1211
  }
1107
1212
  );
1108
- return result?.board ?? null;
1213
+ if (!result) return null;
1214
+ const compacted = await compactSessionMirrorBoard(projectRoot, board.id);
1215
+ if (compacted?.removedTaskIds.length) {
1216
+ return await getBoard2(projectRoot, board.id) ?? result.board;
1217
+ }
1218
+ return result.board;
1109
1219
  });
1110
1220
  }
1111
1221
  function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
1112
1222
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return;
1113
1223
  const key = mirrorKey(projectRoot, sessionId, sourceSystem);
1114
- pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });
1224
+ const previous = pendingMirrors.get(key);
1225
+ const reconciliationGraph = previous ? completedReconciliationGraph(
1226
+ graph,
1227
+ [previous.reconciliationGraph, previous.graph].filter(
1228
+ (candidate) => candidate !== void 0
1229
+ )
1230
+ ) : void 0;
1231
+ pendingMirrors.set(key, {
1232
+ projectRoot,
1233
+ sessionId,
1234
+ graph,
1235
+ ...reconciliationGraph ? { reconciliationGraph } : {},
1236
+ sourceSystem
1237
+ });
1115
1238
  if (activeMirrors.has(key)) return;
1116
1239
  activeMirrors.add(key);
1117
1240
  void (async () => {
@@ -1121,20 +1244,34 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
1121
1244
  if (!pending) break;
1122
1245
  pendingMirrors.delete(key);
1123
1246
  try {
1247
+ if (pending.reconciliationGraph) {
1248
+ await projectGraph(
1249
+ pending.projectRoot,
1250
+ pending.sessionId,
1251
+ pending.reconciliationGraph,
1252
+ pending.sourceSystem
1253
+ );
1254
+ }
1124
1255
  await projectGraph(
1125
1256
  pending.projectRoot,
1126
1257
  pending.sessionId,
1127
1258
  pending.graph,
1128
1259
  pending.sourceSystem
1129
1260
  );
1261
+ mirrorFailures.delete(boardKey(pending.projectRoot, pending.sessionId));
1130
1262
  } catch (error) {
1263
+ const message = error instanceof Error ? error.message : String(error);
1264
+ mirrorFailures.set(boardKey(pending.projectRoot, pending.sessionId), {
1265
+ message,
1266
+ sourceSystem: pending.sourceSystem
1267
+ });
1131
1268
  console.warn(
1132
1269
  JSON.stringify({
1133
1270
  level: "warn",
1134
1271
  event: "session-kanban.mirror-failed",
1135
1272
  sessionId: pending.sessionId,
1136
1273
  sourceSystem: pending.sourceSystem,
1137
- message: error instanceof Error ? error.message : String(error),
1274
+ message,
1138
1275
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1139
1276
  })
1140
1277
  );
@@ -1155,6 +1292,14 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
1155
1292
  }
1156
1293
  })();
1157
1294
  }
1295
+ function takeSessionMirrorFailure(projectRoot, sessionId) {
1296
+ if (!projectRoot || !sessionId) return void 0;
1297
+ const key = boardKey(projectRoot, sessionId);
1298
+ const failure = mirrorFailures.get(key);
1299
+ if (!failure) return void 0;
1300
+ mirrorFailures.delete(key);
1301
+ return `Kanban mirror (${failure.sourceSystem}) failed and the board may be stale: ${failure.message}`;
1302
+ }
1158
1303
  function todoListToSerializedGraph(todos, sessionId) {
1159
1304
  const graphId = `todo:${sessionId}`;
1160
1305
  const nodes = todos.map((todo, index) => ({
@@ -1248,11 +1393,11 @@ function broadcastTodoUpdate(context, todos) {
1248
1393
  });
1249
1394
  }
1250
1395
  function notifyTodoUpdate(context, todos) {
1251
- const summary = todos.length ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join("\n") : "- No active todos remain.";
1396
+ const summary = formatTodosForModel(todos);
1252
1397
  const text = `[KANBAN TODO UPDATE]
1253
1398
  Another Kanban agent reassessed the shared board. The canonical todo list is now:
1254
1399
  ${summary}
1255
- Reassess your current plan before continuing; do not rely on the initial todo snapshot.`;
1400
+ 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.`;
1256
1401
  const state = context.state;
1257
1402
  if (typeof state.appendBlockToLastUserMessage === "function") {
1258
1403
  if (state.appendBlockToLastUserMessage({ type: "text", text })) return;
@@ -1283,26 +1428,68 @@ function todoStatus(task) {
1283
1428
  if (status === "in_progress" || status === "review") return "in_progress";
1284
1429
  return "pending";
1285
1430
  }
1286
- function sessionTodoFromTask(task, boardId) {
1431
+ function sessionTodoFromTask(task, board) {
1432
+ const blockedBy = board ? blockingTitles(board, task) : [];
1287
1433
  return {
1288
1434
  id: task.origin?.taskId ?? task.id,
1289
1435
  content: task.title,
1290
1436
  status: todoStatus(task),
1291
- kanbanBoardId: boardId,
1292
- kanbanTaskId: task.id,
1293
- ...task.description ? { activeForm: task.description } : {}
1437
+ ...task.description ? { activeForm: task.description } : {},
1438
+ ...blockedBy.length ? { blockedBy } : {}
1294
1439
  };
1295
1440
  }
1296
- function managedTodoFromTask(task, boardId) {
1441
+ function managedTodoFromTask(task, board) {
1297
1442
  return {
1298
- ...sessionTodoFromTask(task, boardId),
1299
- status: task.status === "completed" ? "completed" : task.status === "in_progress" ? "in_progress" : "pending"
1443
+ ...sessionTodoFromTask(task, board),
1444
+ kanbanBoardId: board.id,
1445
+ kanbanTaskId: task.id
1300
1446
  };
1301
1447
  }
1448
+ function blockingTitles(board, task) {
1449
+ return getDependencyReadinessIssues(board, task).map((issue) => {
1450
+ const dependency = board.tasks.find((candidate) => candidate.id === issue.dependencyId);
1451
+ if (!dependency) return `${issue.dependencyId} (missing)`;
1452
+ return dependency.title;
1453
+ });
1454
+ }
1455
+ var PRIORITY_ORDER = {
1456
+ critical: 0,
1457
+ high: 1,
1458
+ medium: 2,
1459
+ low: 3
1460
+ };
1461
+ function orderTasksForTodos(board, tasks) {
1462
+ const columnOrder = new Map(board.columns.map((column) => [column.id, column.order]));
1463
+ const baseline = [...tasks].sort(
1464
+ (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)
1465
+ );
1466
+ const included = new Set(baseline.map((task) => task.id));
1467
+ const remaining = new Map(baseline.map((task) => [task.id, task]));
1468
+ const emitted = [];
1469
+ const done = /* @__PURE__ */ new Set();
1470
+ while (remaining.size > 0) {
1471
+ const ready = baseline.filter(
1472
+ (task) => remaining.has(task.id) && (task.dependsOn ?? []).every(
1473
+ (dependencyId) => !included.has(dependencyId) || done.has(dependencyId)
1474
+ )
1475
+ );
1476
+ if (ready.length === 0) break;
1477
+ for (const task of ready) {
1478
+ remaining.delete(task.id);
1479
+ done.add(task.id);
1480
+ emitted.push(task);
1481
+ }
1482
+ }
1483
+ for (const task of baseline) if (remaining.has(task.id)) emitted.push(task);
1484
+ return emitted;
1485
+ }
1302
1486
  function sameTodos(left, right) {
1303
1487
  return left.length === right.length && left.every((todo, index) => {
1304
1488
  const candidate = right[index];
1305
- 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;
1489
+ 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,
1490
+ // the rows are otherwise identical and the unblocking would never
1491
+ // reach the model.
1492
+ (candidate.blockedBy ?? []).join("\0") === (todo.blockedBy ?? []).join("\0");
1306
1493
  });
1307
1494
  }
1308
1495
  function applyManagedKanbanBoardToTodos(context, board) {
@@ -1312,11 +1499,12 @@ function applyManagedKanbanBoardToTodos(context, board) {
1312
1499
  if (!activeBoardId2 || board.id !== activeBoardId2 || board.lifecycle?.mode !== "managed") {
1313
1500
  return [...context.todos];
1314
1501
  }
1315
- const projectedTodos = board.tasks.filter(
1316
- (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
1317
- ).sort(
1318
- (left, right) => left.createdAt.localeCompare(right.createdAt) || left.order - right.order
1319
- ).map((task) => managedTodoFromTask(task, board.id));
1502
+ const projectedTodos = orderTasksForTodos(
1503
+ board,
1504
+ board.tasks.filter(
1505
+ (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
1506
+ )
1507
+ ).map((task) => managedTodoFromTask(task, board));
1320
1508
  if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
1321
1509
  suppressedTodoMirrors.add(context);
1322
1510
  try {
@@ -1360,8 +1548,14 @@ var kanbanTool = {
1360
1548
  }
1361
1549
  case "create_board": {
1362
1550
  if (!input.title) return fail("create_board requires title.");
1551
+ const existing = (await listBoards2(projectRoot)).filter(
1552
+ (candidate) => (candidate.kind ?? "project") === "project"
1553
+ );
1363
1554
  const board = await createBoard2(projectRoot, boardCreateInput(input, input.title));
1364
- return { ok: true, message: `Board created: ${board.title}`, board };
1555
+ 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(
1556
+ ", "
1557
+ )}${existing.length > 3 ? ", \u2026" : ""}. If this work belongs to one of them, add_task there instead and delete this board.` : "";
1558
+ return { ok: true, message: `Board created: ${board.title}.${note}`, board };
1365
1559
  }
1366
1560
  case "update_board": {
1367
1561
  if (!input.boardId) return fail("update_board requires boardId.");
@@ -1390,6 +1584,20 @@ var kanbanTool = {
1390
1584
  });
1391
1585
  return board ? okBoard(board, "Managed lifecycle adopted without moving existing cards.") : fail("Board not found.");
1392
1586
  }
1587
+ // Adoption used to be a one-way door: the strict lifecycle carries
1588
+ // acceptance-criteria, verification-report, review-evidence and
1589
+ // one-stage-at-a-time gates, and nothing on the tool surface could
1590
+ // undo it, so a board adopted once kept its ceremony forever. The
1591
+ // gates are worth having where a fleet is supervised; they are not
1592
+ // worth being unable to leave. Cards and columns are untouched.
1593
+ case "release_managed_lifecycle": {
1594
+ if (!input.boardId) return fail("release_managed_lifecycle requires boardId.");
1595
+ const board = await updateBoard2(projectRoot, input.boardId, { lifecycle: null });
1596
+ return board ? okBoard(
1597
+ board,
1598
+ "Managed lifecycle released; the board now tracks work without strict gates."
1599
+ ) : fail("Board not found.");
1600
+ }
1393
1601
  case "duplicate_board": {
1394
1602
  if (!input.boardId) return fail("duplicate_board requires boardId.");
1395
1603
  const board = await duplicateBoard(
@@ -1409,8 +1617,7 @@ var kanbanTool = {
1409
1617
  const boardInput = createBoardFromText({
1410
1618
  description: input.description,
1411
1619
  ...input.title !== void 0 ? { title: input.title } : {},
1412
- ...input.context !== void 0 ? { context: input.context } : {},
1413
- ...input.columns !== void 0 ? { columns: input.columns } : {}
1620
+ ...input.context !== void 0 ? { context: input.context } : {}
1414
1621
  });
1415
1622
  const board = await createBoard2(projectRoot, boardInput);
1416
1623
  for (const taskInput2 of parseLinesIntoTasks(
@@ -1548,7 +1755,7 @@ var kanbanTool = {
1548
1755
  return { ok: true, message: `${tasks.length} ready task(s).`, tasks };
1549
1756
  }
1550
1757
  case "snapshot": {
1551
- const snapshot = await getKanbanOrchestrationSnapshot(projectRoot, {
1758
+ const snapshot = await getKanbanOrchestrationSnapshot2(projectRoot, {
1552
1759
  query: input.query,
1553
1760
  boardId: input.boardId,
1554
1761
  assignedAgent: input.agentId,
@@ -1563,33 +1770,6 @@ var kanbanTool = {
1563
1770
  snapshot
1564
1771
  };
1565
1772
  }
1566
- case "add_column": {
1567
- if (!input.boardId || !input.title)
1568
- return fail("add_column requires boardId and title.");
1569
- const result2 = await addColumn(projectRoot, input.boardId, {
1570
- title: input.title,
1571
- ...input.description !== void 0 ? { description: input.description } : {}
1572
- });
1573
- return result2 ? okBoard(result2.board, "Column added.") : fail("Board not found.");
1574
- }
1575
- case "update_column": {
1576
- if (!input.boardId || !input.columnId)
1577
- return fail("update_column requires boardId and columnId.");
1578
- const board = await updateColumn(projectRoot, input.boardId, input.columnId, {
1579
- ...input.title !== void 0 ? { title: input.title } : {},
1580
- ...input.description !== void 0 ? { description: input.description } : {},
1581
- ...input.order !== void 0 ? { order: input.order } : {}
1582
- });
1583
- return board ? okBoard(board, "Column updated.") : fail("Column not found.");
1584
- }
1585
- case "delete_column": {
1586
- if (!input.boardId || !input.columnId)
1587
- return fail("delete_column requires boardId and columnId.");
1588
- const board = await removeColumn(projectRoot, input.boardId, input.columnId, {
1589
- moveTasksToColumnId: input.moveTasksToColumnId
1590
- });
1591
- return board ? okBoard(board, "Column deleted.") : fail("Column not found.");
1592
- }
1593
1773
  case "add_task": {
1594
1774
  if (!input.boardId || !input.title) return fail("add_task requires boardId and title.");
1595
1775
  const result2 = await addTask(projectRoot, input.boardId, taskInput(input));
@@ -1671,6 +1851,32 @@ var kanbanTool = {
1671
1851
  `Task is not implementation-ready: ${readiness.issues.map((issue) => issue.message).join(" | ")}`
1672
1852
  );
1673
1853
  }
1854
+ if (board.lifecycle?.mode !== "managed") {
1855
+ const now = /* @__PURE__ */ new Date();
1856
+ const assigned = await updateTaskAssignment(projectRoot, board.id, task.id, {
1857
+ status: "running",
1858
+ agentId: input.agentId ?? input.author,
1859
+ leaseId: input.leaseId ?? randomUUID2(),
1860
+ claimedAt: input.claimedAt ?? now.toISOString(),
1861
+ heartbeatAt: input.heartbeatAt ?? now.toISOString(),
1862
+ leaseExpiresAt: input.leaseExpiresAt ?? new Date(now.getTime() + 15 * 6e4).toISOString(),
1863
+ attempt: input.attempt ?? 1,
1864
+ maxAttempts: input.maxAttempts ?? 3
1865
+ });
1866
+ if (!assigned) return fail("Task assignment could not be started.");
1867
+ const started = await updateTask2(projectRoot, board.id, task.id, {
1868
+ status: "in_progress"
1869
+ });
1870
+ const current = started ?? assigned;
1871
+ const claimed = task;
1872
+ const currentTask = current.tasks.find((candidate) => candidate.id === claimed.id) ?? claimed;
1873
+ ctx.setCurrentKanbanTask?.(currentTask.id, current.id);
1874
+ return okTask(
1875
+ current,
1876
+ currentTask,
1877
+ "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."
1878
+ );
1879
+ }
1674
1880
  let stage = task.lifecycle?.currentStage;
1675
1881
  if (stage === "backlog") {
1676
1882
  const moved = await transitionTask(projectRoot, board.id, task.id, {
@@ -1811,6 +2017,9 @@ var kanbanTool = {
1811
2017
  if (!input.boardId || !input.taskId)
1812
2018
  return fail("delete_task requires boardId and taskId.");
1813
2019
  const board = await removeTask(projectRoot, input.boardId, input.taskId);
2020
+ if (board && ctx.currentKanbanTaskId === input.taskId) {
2021
+ ctx.setCurrentKanbanTask?.(void 0, ctx.currentKanbanBoardId);
2022
+ }
1814
2023
  return board ? okBoard(board, "Task deleted.") : fail("Task not found.");
1815
2024
  }
1816
2025
  case "set_chain": {
@@ -1943,7 +2152,7 @@ var kanbanTool = {
1943
2152
  });
1944
2153
  } catch (err) {
1945
2154
  lifecycleWarnings.push(
1946
- `Lifecycle transition to Running deferred: ${err instanceof Error ? err.message : String(err)}`
2155
+ `Lifecycle transition to Running deferred: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
1947
2156
  );
1948
2157
  }
1949
2158
  }
@@ -1967,7 +2176,7 @@ var kanbanTool = {
1967
2176
  });
1968
2177
  } catch (err) {
1969
2178
  lifecycleWarnings.push(
1970
- `Lifecycle transition to Review failed: ${err instanceof Error ? err.message : String(err)}`
2179
+ `Lifecycle transition to Review failed: ${stripLifecycleIssues(err instanceof Error ? err.message : String(err))}`
1971
2180
  );
1972
2181
  }
1973
2182
  if (transitionResult) {
@@ -1987,7 +2196,11 @@ var kanbanTool = {
1987
2196
  successCriteria: verResult.task.successCriteria
1988
2197
  });
1989
2198
  const verdict = verResult.report.verdict;
1990
- if (verdict === "passed") {
2199
+ if (verdict === "passed" && !resolveAutoAccept(board)) {
2200
+ lifecycleWarnings.push(
2201
+ "Verification passed, but this board does not auto-accept. The card is in Review awaiting an explicit transition_task to done."
2202
+ );
2203
+ } else if (verdict === "passed") {
1991
2204
  try {
1992
2205
  const doneResult = await transitionTask(
1993
2206
  projectRoot,
@@ -2105,11 +2318,34 @@ var kanbanTool = {
2105
2318
  });
2106
2319
  return {
2107
2320
  ok: true,
2108
- message: `Counts: ready=${health.counts.ready}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2321
+ message: `Counts: startable=${health.counts.startable}, running=${health.counts.running}, stale=${health.staleAssignments.count}.`,
2109
2322
  queueHealth: health
2110
2323
  };
2111
2324
  }
2325
+ // Not every action is handled above. These are dispatched from here,
2326
+ // and the split has already cost real time: an agent that read this
2327
+ // file concluded `add_check` / `update_check` did not exist, wrote
2328
+ // that on a card, and spent a session trying to satisfy a gate it
2329
+ // already had the tool to clear. Keep this index in step with the
2330
+ // handlers.
2331
+ //
2332
+ // kanban-detail-actions.ts workbench · add_dependency ·
2333
+ // add_goal_metric · update_goal_metric · add_check ·
2334
+ // update_check · add_note · add_link · split_atomic
2335
+ // kanban-decomposition-actions.ts verify_completion ·
2336
+ // assess_atomicity · propose_decomposition
2337
+ // kanban-contract-actions.ts get_contract_graph ·
2338
+ // configure_contract_graph · upsert_contract_node ·
2339
+ // remove_contract_node · add_contract_edge · remove_contract_edge
2112
2340
  default:
2341
+ {
2342
+ const contractResult = await handleKanbanContractAction(
2343
+ projectRoot,
2344
+ input,
2345
+ input.author ?? input.agentId
2346
+ );
2347
+ if (contractResult !== void 0) return contractResult;
2348
+ }
2113
2349
  {
2114
2350
  const detailResult = await handleKanbanDetailAction(projectRoot, input);
2115
2351
  if (detailResult !== void 0) return detailResult;
@@ -2119,7 +2355,7 @@ var kanbanTool = {
2119
2355
  })();
2120
2356
  return withPresence(result);
2121
2357
  } catch (err) {
2122
- return fail(err instanceof Error ? err.message : String(err));
2358
+ return fail(stripLifecycleIssues(err instanceof Error ? err.message : String(err)));
2123
2359
  }
2124
2360
  }
2125
2361
  };
@@ -2152,11 +2388,51 @@ function bindTodosToBoard(items, previous, board) {
2152
2388
  available.find((task2) => !used.has(task2.id) && normalizedTitle(task2.title) === title)
2153
2389
  ];
2154
2390
  const task = candidates.find((candidate) => candidate && !used.has(candidate.id));
2155
- if (!task) return { ...item };
2391
+ if (!task) {
2392
+ const { blockedBy: _discarded, ...rest } = item;
2393
+ return { ...rest };
2394
+ }
2156
2395
  used.add(task.id);
2157
- return { ...item, kanbanBoardId: board.id, kanbanTaskId: task.id };
2396
+ const blockedBy = blockingTitles(board, task);
2397
+ return {
2398
+ ...item,
2399
+ kanbanBoardId: board.id,
2400
+ kanbanTaskId: task.id,
2401
+ ...blockedBy.length ? { blockedBy } : { blockedBy: void 0 }
2402
+ };
2158
2403
  });
2159
2404
  }
2405
+ function demoteBlockedInProgress(items, warnings) {
2406
+ return items.map((item) => {
2407
+ if (item.status !== "in_progress" || !item.blockedBy?.length) return item;
2408
+ warnings.push(
2409
+ `"${item.content}" cannot start yet \u2014 it waits on: ${item.blockedBy.join("; ")}. Kept as pending; complete the blocking work first.`
2410
+ );
2411
+ return { ...item, status: "pending" };
2412
+ });
2413
+ }
2414
+ async function createMissingManagedCards(items, board, ctx, warnings) {
2415
+ const created = /* @__PURE__ */ new Map();
2416
+ for (const item of items) {
2417
+ if (item.kanbanBoardId === board.id && item.kanbanTaskId) continue;
2418
+ try {
2419
+ const result = await addTask2(ctx.projectRoot, board.id, {
2420
+ title: item.content,
2421
+ description: item.activeForm?.trim() || `Added from the session todo list: ${item.content}`
2422
+ });
2423
+ if (!result) {
2424
+ warnings.push(`Could not open a Kanban card for "${item.content}": board not found.`);
2425
+ continue;
2426
+ }
2427
+ created.set(item.id, result.task.id);
2428
+ } catch (error) {
2429
+ warnings.push(
2430
+ `Could not open a Kanban card for "${item.content}": ${error instanceof Error ? error.message : String(error)}`
2431
+ );
2432
+ }
2433
+ }
2434
+ return created;
2435
+ }
2160
2436
  async function synchronizeManagedKanban(items, board, ctx, signal) {
2161
2437
  let synced = 0;
2162
2438
  const warnings = [];
@@ -2194,6 +2470,16 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2194
2470
  transitionComment: `Todo returned to queue: ${item.content}`
2195
2471
  });
2196
2472
  }
2473
+ for (const item of items) {
2474
+ if (item.status === "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2475
+ continue;
2476
+ }
2477
+ const task = board.tasks.find((candidate) => candidate.id === item.kanbanTaskId);
2478
+ if (task?.status !== "completed") continue;
2479
+ warnings.push(
2480
+ `"${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.`
2481
+ );
2482
+ }
2197
2483
  for (const item of items) {
2198
2484
  if (item.status !== "completed" || item.kanbanBoardId !== board.id || !item.kanbanTaskId) {
2199
2485
  continue;
@@ -2245,17 +2531,25 @@ async function synchronizeManagedKanban(items, board, ctx, signal) {
2245
2531
  const active = items.find(
2246
2532
  (item) => item.status === "in_progress" && item.kanbanBoardId === board.id && Boolean(item.kanbanTaskId)
2247
2533
  );
2534
+ const activeStage = active?.kanbanTaskId ? afterCompletions?.tasks.find((task) => task.id === active.kanbanTaskId)?.lifecycle?.currentStage : void 0;
2248
2535
  if (active?.kanbanTaskId) {
2249
- await execute({
2250
- action: "start_task",
2251
- boardId: board.id,
2252
- taskId: active.kanbanTaskId,
2253
- author: actor,
2254
- agentId: actor,
2255
- transitionComment: `Todo activated: ${active.content}`
2256
- });
2536
+ if (activeStage === "review" || activeStage === "done") {
2537
+ warnings.push(
2538
+ `"${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.")
2539
+ );
2540
+ } else {
2541
+ await execute({
2542
+ action: "start_task",
2543
+ boardId: board.id,
2544
+ taskId: active.kanbanTaskId,
2545
+ author: actor,
2546
+ agentId: actor,
2547
+ transitionComment: `Todo activated: ${active.content}`
2548
+ });
2549
+ }
2257
2550
  }
2258
- if (active?.kanbanTaskId && completionPending) {
2551
+ if (active?.kanbanTaskId && activeStage === "review") {
2552
+ } else if (active?.kanbanTaskId && completionPending) {
2259
2553
  warnings.push(
2260
2554
  "A completed todo is still awaiting acceptance; the next independent Kanban task was started."
2261
2555
  );
@@ -2342,29 +2636,47 @@ var todoTool = {
2342
2636
  }
2343
2637
  }
2344
2638
  const boardId = activeBoardId(items, ctx);
2345
- const board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2346
- const boundItems = board?.lifecycle?.mode === "managed" ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2639
+ let board = boardId ? await getBoard4(ctx.projectRoot, boardId) : null;
2640
+ const managed = board?.lifecycle?.mode === "managed";
2641
+ let boundItems = managed && board ? bindTodosToBoard(items, ctx.todos ?? [], board) : items;
2642
+ const creationWarnings = [];
2643
+ if (managed && board) {
2644
+ const managedBoardId = board.id;
2645
+ const created = await createMissingManagedCards(boundItems, board, ctx, creationWarnings);
2646
+ if (created.size > 0) {
2647
+ boundItems = boundItems.map((item) => {
2648
+ const taskId = created.get(item.id);
2649
+ return taskId ? { ...item, kanbanBoardId: managedBoardId, kanbanTaskId: taskId } : item;
2650
+ });
2651
+ board = await getBoard4(ctx.projectRoot, managedBoardId) ?? board;
2652
+ boundItems = bindTodosToBoard(boundItems, ctx.todos ?? [], board);
2653
+ }
2654
+ boundItems = demoteBlockedInProgress(boundItems, creationWarnings);
2655
+ }
2347
2656
  ctx.state.replaceTodos(boundItems);
2348
- const kanbanSync = board?.lifecycle?.mode === "managed" ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2349
- if (board?.lifecycle?.mode === "managed") {
2657
+ const kanbanSync = managed && board ? await synchronizeManagedKanban(boundItems, board, ctx, call.signal) : { synced: 0, warnings: [] };
2658
+ kanbanSync.warnings.unshift(...creationWarnings);
2659
+ if (managed && board) {
2350
2660
  const unresolved = boundItems.filter(
2351
2661
  (item) => item.kanbanBoardId !== board.id || !item.kanbanTaskId
2352
2662
  );
2353
2663
  if (unresolved.length > 0) {
2354
2664
  kanbanSync.warnings.push(
2355
- `${unresolved.length} Todo row(s) did not match a real Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2665
+ `${unresolved.length} Todo row(s) could not be bound to a Kanban task and were not applied. Preserve kanbanBoardId/kanbanTaskId when updating the projection.`
2356
2666
  );
2357
2667
  }
2358
2668
  }
2669
+ const mirrorFailure = takeSessionMirrorFailure(ctx.projectRoot, ctx.session?.id ?? "");
2670
+ if (mirrorFailure) kanbanSync.warnings.push(mirrorFailure);
2359
2671
  let projectedBoard = board;
2360
- if (board?.lifecycle?.mode === "managed") {
2672
+ if (managed && board) {
2361
2673
  const refreshed = await getBoard4(ctx.projectRoot, board.id);
2362
2674
  if (refreshed) {
2363
2675
  projectedBoard = refreshed;
2364
2676
  applyManagedKanbanBoardToTodos(ctx, refreshed);
2365
2677
  }
2366
2678
  }
2367
- if (board?.lifecycle?.mode !== "managed") {
2679
+ if (!managed) {
2368
2680
  mirrorSessionTodosToKanban(ctx.projectRoot, items, ctx.session?.id ?? "session");
2369
2681
  }
2370
2682
  const completedPlanIds = /* @__PURE__ */ new Set();