@wrongstack/tools 0.302.2 → 0.305.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/builtin.d.ts +6 -0
  2. package/dist/builtin.js +3213 -686
  3. package/dist/codebase-index/binary-frame.d.ts +43 -0
  4. package/dist/codebase-index/codebase-incoming-calls-tool.d.ts +1 -0
  5. package/dist/codebase-index/codebase-index-tool.d.ts +6 -0
  6. package/dist/codebase-index/codebase-outgoing-calls-tool.d.ts +1 -0
  7. package/dist/codebase-index/content-hash.d.ts +66 -0
  8. package/dist/codebase-index/index.js +1597 -153
  9. package/dist/codebase-index/indexer.d.ts +6 -0
  10. package/dist/codebase-index/parser-worker-pool.d.ts +63 -0
  11. package/dist/codebase-index/parser-worker-script.d.ts +42 -0
  12. package/dist/codebase-index/project-server-protocol.d.ts +2 -0
  13. package/dist/codebase-index/project-server.js +1483 -104
  14. package/dist/codebase-index/schema.d.ts +18 -0
  15. package/dist/codebase-index/tree-sitter/queries.d.ts +48 -0
  16. package/dist/codebase-index/tree-sitter/util.d.ts +31 -0
  17. package/dist/codebase-index/tree-sitter/visitor.d.ts +47 -0
  18. package/dist/codebase-index/tree-sitter-parser.d.ts +58 -0
  19. package/dist/codebase-index/vector-search.d.ts +62 -0
  20. package/dist/codebase-index/worker-protocol.d.ts +2 -0
  21. package/dist/codebase-index/worker.js +1452 -73
  22. package/dist/codebase-index/writer-bulk-insert.d.ts +5 -0
  23. package/dist/codebase-index/writer-graph-reader.d.ts +39 -0
  24. package/dist/codebase-index/writer-schema.d.ts +9 -2
  25. package/dist/codebase-index/writer.d.ts +36 -0
  26. package/dist/index.d.ts +4 -3
  27. package/dist/index.js +3258 -727
  28. package/dist/kanban-contract-actions.d.ts +7 -0
  29. package/dist/kanban-task-inputs.d.ts +16 -2
  30. package/dist/kanban-tool-schema.d.ts +2 -2
  31. package/dist/kanban-tool-types.d.ts +39 -2
  32. package/dist/kanban.js +580 -193
  33. package/dist/pack.js +3212 -686
  34. package/dist/plan.d.ts +4 -1
  35. package/dist/plan.js +2701 -18
  36. package/dist/read.js +1559 -103
  37. package/dist/session-kanban.d.ts +94 -1
  38. package/dist/session-kanban.js +308 -44
  39. package/dist/task.d.ts +5 -4
  40. package/dist/task.js +2825 -138
  41. package/dist/todo.d.ts +10 -1
  42. package/dist/todo.js +2474 -30
  43. package/dist/tool-tier.js +3212 -686
  44. package/package.json +8 -4
@@ -9,12 +9,17 @@ import {
9
9
  mutateTasks
10
10
  } from "@wrongstack/core/storage";
11
11
  import { deserializeTaskGraph } from "@wrongstack/core/tasking";
12
- import { resolveWstackPaths } from "@wrongstack/core/utils";
12
+ import { formatTodosForModel, resolveWstackPaths } from "@wrongstack/core/utils";
13
13
  import {
14
14
  bridgeKanbanSupervisor,
15
+ compactSessionMirrorBoard,
15
16
  createBoard,
17
+ DEFAULT_COLUMNS,
16
18
  getBoard,
19
+ getDependencyReadinessIssues,
20
+ getKanbanOrchestrationSnapshot,
17
21
  listBoards,
22
+ pruneSessionBoards,
18
23
  removeBoard,
19
24
  syncBoardFromTaskGraph,
20
25
  touchKanbanPresence,
@@ -22,16 +27,14 @@ import {
22
27
  } from "@wrongstack/kanban";
23
28
  var SESSION_BOARD_TAG = "session-work";
24
29
  var MIRROR_DISABLED_ENV = "WRONGSTACK_KANBAN_TASK_MIRROR";
25
- var SESSION_KANBAN_COLUMNS = [
26
- { id: "todo", title: "Todo", order: 0, wipLimit: 0, color: "#2563eb" },
27
- { id: "in-progress", title: "Running", order: 1, wipLimit: 1, color: "#d97706" },
28
- { id: "review", title: "Preview", order: 2, wipLimit: 0, color: "#7c3aed" },
29
- { id: "done", title: "Done", order: 3, wipLimit: 0, color: "#16a34a" }
30
- ];
30
+ var SESSION_KANBAN_COLUMNS = DEFAULT_COLUMNS.map((column) => ({
31
+ ...column
32
+ }));
31
33
  var boardQueue = /* @__PURE__ */ new Map();
32
34
  var boardEnsures = /* @__PURE__ */ new Map();
33
35
  var pendingMirrors = /* @__PURE__ */ new Map();
34
36
  var activeMirrors = /* @__PURE__ */ new Set();
37
+ var mirrorFailures = /* @__PURE__ */ new Map();
35
38
  var bindings = /* @__PURE__ */ new WeakMap();
36
39
  var suppressedTodoMirrors = /* @__PURE__ */ new WeakSet();
37
40
  var activeSessionBoards = /* @__PURE__ */ new Map();
@@ -41,6 +44,33 @@ function boardKey(projectRoot, sessionId) {
41
44
  function mirrorKey(projectRoot, sessionId, sourceSystem) {
42
45
  return `${boardKey(projectRoot, sessionId)}\0${sourceSystem}`;
43
46
  }
47
+ function completedReconciliationGraph(latest, candidates) {
48
+ const latestNodeIds = new Set(latest.nodes.map((node) => node.id));
49
+ const carriedNodeIds = /* @__PURE__ */ new Set();
50
+ const completedNodes = candidates.flatMap(
51
+ (candidate) => candidate.nodes.filter((node) => {
52
+ if (node.status !== "completed" || latestNodeIds.has(node.id) || carriedNodeIds.has(node.id)) {
53
+ return false;
54
+ }
55
+ carriedNodeIds.add(node.id);
56
+ return true;
57
+ })
58
+ );
59
+ if (completedNodes.length === 0) return void 0;
60
+ const carriedRequirements = completedNodes.flatMap(
61
+ (node) => node.specRequirementId ? [node.specRequirementId] : []
62
+ );
63
+ return {
64
+ ...latest,
65
+ nodes: [...latest.nodes, ...completedNodes],
66
+ rootNodes: [.../* @__PURE__ */ new Set([...latest.rootNodes, ...completedNodes.map((node) => node.id)])],
67
+ ...latest.requiredRequirementIds ? {
68
+ requiredRequirementIds: [
69
+ .../* @__PURE__ */ new Set([...latest.requiredRequirementIds, ...carriedRequirements])
70
+ ]
71
+ } : {}
72
+ };
73
+ }
44
74
  function sessionTag(sessionId) {
45
75
  return `session:${sessionId}`;
46
76
  }
@@ -135,6 +165,26 @@ async function removeEmptySessionBoard(projectRoot, boardId, sessionId) {
135
165
  return await removeBoard(projectRoot, board.id) ? board.id : null;
136
166
  });
137
167
  }
168
+ async function removeOwnedSessionBoard(projectRoot, boardId, sessionId) {
169
+ return enqueueBoardWork(projectRoot, sessionId, async () => {
170
+ if (isSessionBoardActive(projectRoot, sessionId)) return null;
171
+ const board = await getBoard(projectRoot, boardId);
172
+ if (!board || !isOwnedSessionBoard(board.tags)) return null;
173
+ if (sessionIdFromTags(board.tags) !== sessionId) return null;
174
+ return await removeBoard(projectRoot, board.id) ? board.id : null;
175
+ });
176
+ }
177
+ async function cleanupSessionKanbanBoard(projectRoot, sessionId) {
178
+ if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return [];
179
+ if (isSessionBoardActive(projectRoot, sessionId)) return [];
180
+ const candidates = (await listBoards(projectRoot)).filter(
181
+ (board) => isOwnedSessionBoard(board.tags) && sessionIdFromTags(board.tags) === sessionId
182
+ );
183
+ const removed = await Promise.all(
184
+ candidates.map((board) => removeOwnedSessionBoard(projectRoot, board.id, sessionId))
185
+ );
186
+ return removed.filter((boardId) => Boolean(boardId));
187
+ }
138
188
  async function cleanupSessionKanbanBoardIfEmpty(projectRoot, sessionId) {
139
189
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return [];
140
190
  if (isSessionBoardActive(projectRoot, sessionId)) return [];
@@ -172,16 +222,44 @@ async function projectGraph(projectRoot, sessionId, graph, sourceSystem) {
172
222
  sourceSystem,
173
223
  tags: [.../* @__PURE__ */ new Set([...board.tags ?? [], ...sessionBoardTags(sessionId)])],
174
224
  archiveMissingTasks: true,
175
- includeCompletedTasks: true
225
+ includeCompletedTasks: true,
226
+ // The scope ledger stays declared and accurate, but it may not veto a
227
+ // projection. A session mirror reflects a tactical list that shrinks by
228
+ // design, and refusing the sync never protected the removed row — it
229
+ // froze the entire board, permanently, because the stored scope then
230
+ // outlived every later snapshot (`session-kanban.mirror-failed`).
231
+ // Nothing is lost by shrinking here: `archiveMissingTasks` keeps the
232
+ // removed card on the board as `archived`, the reconciliation pass
233
+ // first walks vanished completed rows to Done, and the session journal
234
+ // remains the durable record.
235
+ allowRequirementScopeShrink: true
176
236
  }
177
237
  );
178
- return result?.board ?? null;
238
+ if (!result) return null;
239
+ const compacted = await compactSessionMirrorBoard(projectRoot, board.id);
240
+ if (compacted?.removedTaskIds.length) {
241
+ return await getBoard(projectRoot, board.id) ?? result.board;
242
+ }
243
+ return result.board;
179
244
  });
180
245
  }
181
246
  function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
182
247
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return;
183
248
  const key = mirrorKey(projectRoot, sessionId, sourceSystem);
184
- pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });
249
+ const previous = pendingMirrors.get(key);
250
+ const reconciliationGraph = previous ? completedReconciliationGraph(
251
+ graph,
252
+ [previous.reconciliationGraph, previous.graph].filter(
253
+ (candidate) => candidate !== void 0
254
+ )
255
+ ) : void 0;
256
+ pendingMirrors.set(key, {
257
+ projectRoot,
258
+ sessionId,
259
+ graph,
260
+ ...reconciliationGraph ? { reconciliationGraph } : {},
261
+ sourceSystem
262
+ });
185
263
  if (activeMirrors.has(key)) return;
186
264
  activeMirrors.add(key);
187
265
  void (async () => {
@@ -191,13 +269,37 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
191
269
  if (!pending) break;
192
270
  pendingMirrors.delete(key);
193
271
  try {
272
+ if (pending.reconciliationGraph) {
273
+ await projectGraph(
274
+ pending.projectRoot,
275
+ pending.sessionId,
276
+ pending.reconciliationGraph,
277
+ pending.sourceSystem
278
+ );
279
+ }
194
280
  await projectGraph(
195
281
  pending.projectRoot,
196
282
  pending.sessionId,
197
283
  pending.graph,
198
284
  pending.sourceSystem
199
285
  );
200
- } catch {
286
+ mirrorFailures.delete(boardKey(pending.projectRoot, pending.sessionId));
287
+ } catch (error) {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ mirrorFailures.set(boardKey(pending.projectRoot, pending.sessionId), {
290
+ message,
291
+ sourceSystem: pending.sourceSystem
292
+ });
293
+ console.warn(
294
+ JSON.stringify({
295
+ level: "warn",
296
+ event: "session-kanban.mirror-failed",
297
+ sessionId: pending.sessionId,
298
+ sourceSystem: pending.sourceSystem,
299
+ message,
300
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
301
+ })
302
+ );
201
303
  }
202
304
  }
203
305
  } finally {
@@ -215,7 +317,21 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
215
317
  }
216
318
  })();
217
319
  }
320
+ function takeSessionMirrorFailure(projectRoot, sessionId) {
321
+ if (!projectRoot || !sessionId) return void 0;
322
+ const key = boardKey(projectRoot, sessionId);
323
+ const failure = mirrorFailures.get(key);
324
+ if (!failure) return void 0;
325
+ mirrorFailures.delete(key);
326
+ return `Kanban mirror (${failure.sourceSystem}) failed and the board may be stale: ${failure.message}`;
327
+ }
328
+ function hasInFlightTodoMirror(projectRoot, sessionId) {
329
+ if (!projectRoot || !sessionId) return false;
330
+ const key = mirrorKey(projectRoot, sessionId, "session-todo");
331
+ return pendingMirrors.has(key) || activeMirrors.has(key);
332
+ }
218
333
  function todoListToSerializedGraph(todos, sessionId) {
334
+ const graphId = `todo:${sessionId}`;
219
335
  const nodes = todos.map((todo, index) => ({
220
336
  id: todo.id,
221
337
  title: todo.content,
@@ -223,12 +339,14 @@ function todoListToSerializedGraph(todos, sessionId) {
223
339
  type: "chore",
224
340
  priority: "medium",
225
341
  status: todo.status,
342
+ specRequirementId: `${graphId}:${todo.id}`,
226
343
  createdAt: index,
227
344
  updatedAt: index
228
345
  }));
229
346
  return {
230
- id: `todo:${sessionId}`,
231
- specId: `todo:${sessionId}`,
347
+ id: graphId,
348
+ specId: graphId,
349
+ requiredRequirementIds: nodes.map((node) => node.specRequirementId),
232
350
  title: "Session todos",
233
351
  nodes,
234
352
  edges: [],
@@ -238,6 +356,7 @@ function todoListToSerializedGraph(todos, sessionId) {
238
356
  };
239
357
  }
240
358
  function taskFileToSerializedGraph(tasks, sessionId) {
359
+ const graphId = `session:${sessionId}`;
241
360
  const ids = new Set(tasks.map((task) => task.id));
242
361
  const nodes = tasks.map((task, index) => ({
243
362
  id: task.id,
@@ -246,6 +365,7 @@ function taskFileToSerializedGraph(tasks, sessionId) {
246
365
  type: task.type,
247
366
  priority: task.priority,
248
367
  status: task.status,
368
+ specRequirementId: `${graphId}:${task.id}`,
249
369
  ...task.assignee ? { assignee: task.assignee } : {},
250
370
  ...task.estimateHours !== void 0 ? { estimateHours: task.estimateHours } : {},
251
371
  createdAt: index,
@@ -263,8 +383,9 @@ function taskFileToSerializedGraph(tasks, sessionId) {
263
383
  const rootNodes = nodes.filter((node) => !hasIncoming.has(node.id)).map((node) => node.id);
264
384
  return {
265
385
  // Keep the historical graph id so existing mirrored task cards are reused.
266
- id: `session:${sessionId}`,
267
- specId: `session:${sessionId}`,
386
+ id: graphId,
387
+ specId: graphId,
388
+ requiredRequirementIds: nodes.map((node) => node.specRequirementId),
268
389
  title: "Session tasks",
269
390
  nodes,
270
391
  edges,
@@ -279,6 +400,7 @@ var PLAN_STATUS_TO_TASK = {
279
400
  done: "completed"
280
401
  };
281
402
  function planFileToSerializedGraph(items, sessionId) {
403
+ const graphId = `plan:${sessionId}`;
282
404
  const nodes = items.map((item, index) => ({
283
405
  id: item.id,
284
406
  title: item.title,
@@ -286,12 +408,14 @@ function planFileToSerializedGraph(items, sessionId) {
286
408
  type: "chore",
287
409
  priority: "medium",
288
410
  status: PLAN_STATUS_TO_TASK[item.status],
411
+ specRequirementId: `${graphId}:${item.id}`,
289
412
  createdAt: index,
290
413
  updatedAt: index
291
414
  }));
292
415
  return {
293
- id: `plan:${sessionId}`,
294
- specId: `plan:${sessionId}`,
416
+ id: graphId,
417
+ specId: graphId,
418
+ requiredRequirementIds: nodes.map((node) => node.specRequirementId),
295
419
  title: "Session plan",
296
420
  nodes,
297
421
  edges: [],
@@ -367,11 +491,11 @@ function broadcastTodoUpdate(context, todos) {
367
491
  });
368
492
  }
369
493
  function notifyTodoUpdate(context, todos) {
370
- const summary = todos.length ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join("\n") : "- No active todos remain.";
494
+ const summary = formatTodosForModel(todos);
371
495
  const text = `[KANBAN TODO UPDATE]
372
496
  Another Kanban agent reassessed the shared board. The canonical todo list is now:
373
497
  ${summary}
374
- Reassess your current plan before continuing; do not rely on the initial todo snapshot.`;
498
+ 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.`;
375
499
  const state = context.state;
376
500
  if (typeof state.appendBlockToLastUserMessage === "function") {
377
501
  if (state.appendBlockToLastUserMessage({ type: "text", text })) return;
@@ -380,6 +504,10 @@ Reassess your current plan before continuing; do not rely on the initial todo sn
380
504
  state.appendMessage({ role: "user", content: [{ type: "text", text }] });
381
505
  }
382
506
  }
507
+ function todosNeedingSessionMirror(todos, activeBoardId) {
508
+ if (!activeBoardId) return todos;
509
+ return todos.filter((todo) => todo.kanbanBoardId !== activeBoardId || !todo.kanbanTaskId);
510
+ }
383
511
  function mirrorSessionTodosToKanban(projectRoot, todos, sessionId) {
384
512
  queueLatestMirror(
385
513
  projectRoot,
@@ -417,7 +545,7 @@ function attachSessionKanbanMirror(context) {
417
545
  releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);
418
546
  fireAndForget(
419
547
  "cleanup-board",
420
- cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId)
548
+ cleanupSessionKanbanBoard(attachedProjectRoot, registeredSessionId)
421
549
  );
422
550
  }
423
551
  registeredSessionId = currentSessionId;
@@ -434,6 +562,11 @@ function attachSessionKanbanMirror(context) {
434
562
  let boardTimer = null;
435
563
  let presenceTimer = null;
436
564
  const sessionId = () => context.session?.id ?? "";
565
+ const activeManagedBoardId = () => {
566
+ const metaKanban = context.meta["kanban"];
567
+ const metaBoardId = metaKanban && typeof metaKanban === "object" ? metaKanban["boardId"] : void 0;
568
+ return context.currentKanbanBoardId ?? (typeof metaBoardId === "string" && metaBoardId ? metaBoardId : "");
569
+ };
437
570
  const refreshFiles = async () => {
438
571
  const id = sessionId();
439
572
  if (!id) return;
@@ -451,12 +584,19 @@ function attachSessionKanbanMirror(context) {
451
584
  const refreshBoard = async () => {
452
585
  if (!watchedBoardId) return;
453
586
  const board = await getBoard(context.projectRoot, watchedBoardId);
454
- if (board) applySessionKanbanBoardToTodos(context, board);
587
+ if (!board) return;
588
+ if (board.lifecycle?.mode === "managed") applyManagedKanbanBoardToTodos(context, board);
589
+ else applySessionKanbanBoardToTodos(context, board);
455
590
  };
456
591
  const configureBoardWatcher = async () => {
457
592
  const id = sessionId();
458
- const board = id ? await ensureSessionKanbanBoard(context.projectRoot, id) : null;
459
- if (!board || board.id === watchedBoardId) return;
593
+ const managedBoardId = activeManagedBoardId();
594
+ const board = managedBoardId ? await getBoard(context.projectRoot, managedBoardId) : id ? await ensureSessionKanbanBoard(context.projectRoot, id) : null;
595
+ if (!board) return;
596
+ if (board.id === watchedBoardId) {
597
+ await refreshBoard();
598
+ return;
599
+ }
460
600
  unsubscribeBoardEvents?.();
461
601
  unsubscribeBoardEvents = null;
462
602
  watchedBoardId = board.id;
@@ -480,6 +620,7 @@ function attachSessionKanbanMirror(context) {
480
620
  if (presenceTimer) clearInterval(presenceTimer);
481
621
  presenceTimer = setInterval(() => fireAndForget("touch-presence", touchPresence()), 6e4);
482
622
  presenceTimer.unref?.();
623
+ if (board.lifecycle?.mode === "managed") applyManagedKanbanBoardToTodos(context, board);
483
624
  } catch {
484
625
  unsubscribeBoardEvents = null;
485
626
  watchedBoardId = "";
@@ -512,18 +653,20 @@ function attachSessionKanbanMirror(context) {
512
653
  };
513
654
  const unsubscribe = context.state.onChange((change) => {
514
655
  if (change.kind === "todos_replaced" && !suppressedTodoMirrors.has(context)) {
515
- mirrorSessionTodosToKanban(
516
- context.projectRoot,
517
- change.completedSnapshot ?? change.todos,
518
- sessionId()
519
- );
656
+ const snapshot = change.completedSnapshot ?? change.todos;
657
+ const unbound = todosNeedingSessionMirror(snapshot, activeManagedBoardId());
658
+ if (snapshot.length > 0 && unbound.length === 0) return;
659
+ mirrorSessionTodosToKanban(context.projectRoot, unbound, sessionId());
520
660
  return;
521
661
  }
522
- if (change.kind === "meta_set" && (change.key === "plan.path" || change.key === "task.path")) {
662
+ if (change.kind === "meta_set" && (change.key === "plan.path" || change.key === "task.path" || change.key === "kanban")) {
523
663
  syncActiveSessionRegistration();
524
664
  configureWatcher();
525
- fireAndForget("ensure-board", ensureSessionKanbanBoard(context.projectRoot, sessionId()));
665
+ if (!activeManagedBoardId()) {
666
+ fireAndForget("ensure-board", ensureSessionKanbanBoard(context.projectRoot, sessionId()));
667
+ }
526
668
  fireAndForget("configure-watcher", configureBoardWatcher());
669
+ fireAndForget("refresh-board", refreshBoard());
527
670
  fireAndForget("refresh-files", refreshFiles());
528
671
  }
529
672
  });
@@ -541,7 +684,7 @@ function attachSessionKanbanMirror(context) {
541
684
  releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);
542
685
  fireAndForget(
543
686
  "cleanup-board",
544
- cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId)
687
+ cleanupSessionKanbanBoard(attachedProjectRoot, registeredSessionId)
545
688
  );
546
689
  registeredSessionId = "";
547
690
  }
@@ -549,10 +692,42 @@ function attachSessionKanbanMirror(context) {
549
692
  bindings.set(context, detach);
550
693
  return detach;
551
694
  }
695
+ async function rebindSessionKanbanTask(context) {
696
+ const sessionId = context.session?.id;
697
+ if (!sessionId || !context.projectRoot) return null;
698
+ if (context.currentKanbanTaskId) return null;
699
+ let best;
700
+ try {
701
+ const snapshot = await getKanbanOrchestrationSnapshot(context.projectRoot);
702
+ const nowMs = Date.now();
703
+ for (const result of snapshot.running) {
704
+ const assignment = result.task.assignment;
705
+ if (assignment?.status !== "running") continue;
706
+ const expiresAt = assignment.leaseExpiresAt ? Date.parse(assignment.leaseExpiresAt) : Number.NaN;
707
+ if (Number.isFinite(expiresAt) && expiresAt <= nowMs) continue;
708
+ const entry = result.board.presence?.find(
709
+ (candidate) => candidate.sessionId === sessionId && candidate.taskId === result.task.id
710
+ );
711
+ if (!entry) continue;
712
+ if (!best || entry.lastSeenAt > best.lastSeenAt) {
713
+ best = { boardId: result.board.id, taskId: result.task.id, lastSeenAt: entry.lastSeenAt };
714
+ }
715
+ }
716
+ } catch {
717
+ return null;
718
+ }
719
+ if (!best) return null;
720
+ context.setCurrentKanbanTask(best.taskId, best.boardId);
721
+ return { boardId: best.boardId, taskId: best.taskId };
722
+ }
552
723
  async function hydrateSessionKanban(context) {
553
724
  const id = context.session?.id ?? "";
554
725
  if (!id) return null;
726
+ await rebindSessionKanbanTask(context);
555
727
  await cleanupEmptySessionKanbanBoards(context.projectRoot, id);
728
+ if (context.projectRoot) {
729
+ fireAndForget("prune-session-boards", pruneSessionBoards(context.projectRoot));
730
+ }
556
731
  let board = await ensureSessionKanbanBoard(context.projectRoot, id);
557
732
  if (context.todos.length) {
558
733
  board = await projectSessionTodosToKanban(context.projectRoot, context.todos, id);
@@ -583,18 +758,68 @@ function todoStatus(task) {
583
758
  if (status === "in_progress" || status === "review") return "in_progress";
584
759
  return "pending";
585
760
  }
586
- function sessionTodoFromTask(task) {
761
+ function sessionTodoFromTask(task, board) {
762
+ const blockedBy = board ? blockingTitles(board, task) : [];
587
763
  return {
588
764
  id: task.origin?.taskId ?? task.id,
589
765
  content: task.title,
590
766
  status: todoStatus(task),
591
- ...task.description ? { activeForm: task.description } : {}
767
+ ...task.description ? { activeForm: task.description } : {},
768
+ ...blockedBy.length ? { blockedBy } : {}
769
+ };
770
+ }
771
+ function managedTodoFromTask(task, board) {
772
+ return {
773
+ ...sessionTodoFromTask(task, board),
774
+ kanbanBoardId: board.id,
775
+ kanbanTaskId: task.id
592
776
  };
593
777
  }
778
+ function blockingTitles(board, task) {
779
+ return getDependencyReadinessIssues(board, task).map((issue) => {
780
+ const dependency = board.tasks.find((candidate) => candidate.id === issue.dependencyId);
781
+ if (!dependency) return `${issue.dependencyId} (missing)`;
782
+ return dependency.title;
783
+ });
784
+ }
785
+ var PRIORITY_ORDER = {
786
+ critical: 0,
787
+ high: 1,
788
+ medium: 2,
789
+ low: 3
790
+ };
791
+ function orderTasksForTodos(board, tasks) {
792
+ const columnOrder = new Map(board.columns.map((column) => [column.id, column.order]));
793
+ const baseline = [...tasks].sort(
794
+ (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)
795
+ );
796
+ const included = new Set(baseline.map((task) => task.id));
797
+ const remaining = new Map(baseline.map((task) => [task.id, task]));
798
+ const emitted = [];
799
+ const done = /* @__PURE__ */ new Set();
800
+ while (remaining.size > 0) {
801
+ const ready = baseline.filter(
802
+ (task) => remaining.has(task.id) && (task.dependsOn ?? []).every(
803
+ (dependencyId) => !included.has(dependencyId) || done.has(dependencyId)
804
+ )
805
+ );
806
+ if (ready.length === 0) break;
807
+ for (const task of ready) {
808
+ remaining.delete(task.id);
809
+ done.add(task.id);
810
+ emitted.push(task);
811
+ }
812
+ }
813
+ for (const task of baseline) if (remaining.has(task.id)) emitted.push(task);
814
+ return emitted;
815
+ }
594
816
  function sameTodos(left, right) {
595
817
  return left.length === right.length && left.every((todo, index) => {
596
818
  const candidate = right[index];
597
- 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;
819
+ 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,
820
+ // the rows are otherwise identical and the unblocking would never
821
+ // reach the model.
822
+ (candidate.blockedBy ?? []).join("\0") === (todo.blockedBy ?? []).join("\0");
598
823
  });
599
824
  }
600
825
  function applySessionKanbanBoardToTodos(context, board) {
@@ -602,13 +827,13 @@ function applySessionKanbanBoardToTodos(context, board) {
602
827
  if (!sessionId || sessionIdFromTags(board.tags) !== sessionId || !isOwnedSessionBoard(board.tags)) {
603
828
  return [...context.todos];
604
829
  }
605
- const projectedTodos = board.tasks.filter(
606
- (task) => task.status !== "archived" && (!task.origin || task.origin.system === "session-todo" || (task.origin.graphId ?? "").startsWith("todo:"))
607
- ).sort((left, right) => {
608
- const leftColumn = board.columns.find((column) => column.id === left.columnId)?.order ?? 0;
609
- const rightColumn = board.columns.find((column) => column.id === right.columnId)?.order ?? 0;
610
- return leftColumn - rightColumn || left.order - right.order || left.createdAt.localeCompare(right.createdAt);
611
- }).map(sessionTodoFromTask);
830
+ if (hasInFlightTodoMirror(context.projectRoot, sessionId)) return [...context.todos];
831
+ const projectedTodos = orderTasksForTodos(
832
+ board,
833
+ board.tasks.filter(
834
+ (task) => task.status !== "archived" && (!task.origin || task.origin.system === "session-todo" || (task.origin.graphId ?? "").startsWith("todo:"))
835
+ )
836
+ ).map((task) => sessionTodoFromTask(task, board));
612
837
  const allCompleted = projectedTodos.length > 0 && projectedTodos.every((todo) => todo.status === "completed");
613
838
  const effectiveTodos = allCompleted ? [] : projectedTodos;
614
839
  if (sameTodos(context.todos, effectiveTodos)) return [...context.todos];
@@ -622,6 +847,30 @@ function applySessionKanbanBoardToTodos(context, board) {
622
847
  broadcastTodoUpdate(context, context.todos);
623
848
  return [...context.todos];
624
849
  }
850
+ function applyManagedKanbanBoardToTodos(context, board) {
851
+ const metaKanban = context.meta["kanban"];
852
+ const metaBoardId = metaKanban && typeof metaKanban === "object" ? metaKanban["boardId"] : void 0;
853
+ const activeBoardId = context.currentKanbanBoardId ?? (typeof metaBoardId === "string" ? metaBoardId : void 0);
854
+ if (!activeBoardId || board.id !== activeBoardId || board.lifecycle?.mode !== "managed") {
855
+ return [...context.todos];
856
+ }
857
+ const projectedTodos = orderTasksForTodos(
858
+ board,
859
+ board.tasks.filter(
860
+ (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
861
+ )
862
+ ).map((task) => managedTodoFromTask(task, board));
863
+ if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
864
+ suppressedTodoMirrors.add(context);
865
+ try {
866
+ context.state.replaceTodos(projectedTodos);
867
+ } finally {
868
+ suppressedTodoMirrors.delete(context);
869
+ }
870
+ notifyTodoUpdate(context, context.todos);
871
+ broadcastTodoUpdate(context, context.todos);
872
+ return [...context.todos];
873
+ }
625
874
  async function applySessionKanbanTaskToSource(context, task, options = {}) {
626
875
  const originId = task.origin?.taskId;
627
876
  const graphId = task.origin?.graphId ?? "";
@@ -645,7 +894,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
645
894
  const id = context.session?.id ?? "";
646
895
  if (task.origin?.system === "session-plan" || graphId.startsWith("plan:")) {
647
896
  const planPath = context.meta["plan.path"];
648
- if (typeof planPath !== "string" || !planPath) return { source: "plan" };
897
+ if (typeof planPath !== "string" || !planPath) {
898
+ throw new Error(
899
+ "Cannot reflect this Kanban edit back to its plan source: the session has no plan.path configured. The board mutation already succeeded; the plan file is now out of sync."
900
+ );
901
+ }
649
902
  const plan = await mutatePlan(planPath, id, (file) => ({
650
903
  ...file,
651
904
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -663,7 +916,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
663
916
  }
664
917
  if (task.origin?.system === "session-task" || task.origin?.system === "session" || graphId.startsWith("session:")) {
665
918
  const taskPath = context.meta["task.path"];
666
- if (typeof taskPath !== "string" || !taskPath) return { source: "task" };
919
+ if (typeof taskPath !== "string" || !taskPath) {
920
+ throw new Error(
921
+ "Cannot reflect this Kanban edit back to its task source: the session has no task.path configured. The board mutation already succeeded; the task file is now out of sync."
922
+ );
923
+ }
667
924
  const tasks = await mutateTasks(taskPath, id, (file) => ({
668
925
  ...file,
669
926
  tasks: options.remove ? file.tasks.filter((item) => item.id !== originId) : file.tasks.map(
@@ -682,21 +939,28 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
682
939
  }
683
940
  export {
684
941
  SESSION_KANBAN_COLUMNS,
942
+ applyManagedKanbanBoardToTodos,
685
943
  applySessionKanbanBoardToTodos,
686
944
  applySessionKanbanTaskToSource,
687
945
  attachSessionKanbanMirror,
946
+ blockingTitles,
688
947
  cleanupEmptySessionKanbanBoards,
948
+ cleanupSessionKanbanBoard,
689
949
  cleanupSessionKanbanBoardIfEmpty,
690
950
  ensureSessionKanbanBoard,
691
951
  hydrateSessionKanban,
692
952
  mirrorSessionPlanToKanban,
693
953
  mirrorSessionTasksToKanban,
694
954
  mirrorSessionTodosToKanban,
955
+ orderTasksForTodos,
695
956
  planFileToSerializedGraph,
696
957
  projectSessionPlanToKanban,
697
958
  projectSessionTasksToKanban,
698
959
  projectSessionTodosToKanban,
960
+ rebindSessionKanbanTask,
961
+ takeSessionMirrorFailure,
699
962
  taskFileToSerializedGraph,
700
- todoListToSerializedGraph
963
+ todoListToSerializedGraph,
964
+ todosNeedingSessionMirror
701
965
  };
702
966
  //# sourceMappingURL=session-kanban.js.map
package/dist/task.d.ts CHANGED
@@ -1,13 +1,14 @@
1
- import type { TaskStatus } from '@wrongstack/core/types';
1
+ import type { TaskStatus, Tool } from '@wrongstack/core/types';
2
2
  import { type TaskItem } from '@wrongstack/core/utils';
3
- import type { Tool } from '@wrongstack/core/types';
3
+ type TaskReplacementItem = Omit<TaskItem, 'createdAt' | 'updatedAt'> & Partial<Pick<TaskItem, 'createdAt' | 'updatedAt'>>;
4
+ type TaskAdditionItem = Omit<TaskItem, 'id' | 'createdAt' | 'updatedAt' | 'status'> & Partial<Pick<TaskItem, 'status'>>;
4
5
  interface TaskInput {
5
6
  /** Replace: set new task list. Add: append a task. Status: update task status. Promote: convert a task to todo items. */
6
7
  action: 'replace' | 'add' | 'status' | 'show' | 'promote' | 'planify';
7
8
  /** Full task list for action=replace. */
8
- tasks?: TaskItem[] | undefined;
9
+ tasks?: TaskReplacementItem[] | undefined;
9
10
  /** Single task for action=add. id, createdAt, updatedAt are auto-generated. */
10
- task?: Omit<TaskItem, 'id' | 'createdAt' | 'updatedAt'> | undefined;
11
+ task?: TaskAdditionItem | undefined;
11
12
  /** Task id for action=status or target for action=promote. */
12
13
  id?: string | undefined;
13
14
  /** New status for action=status. */