@wrongstack/tools 0.303.0 → 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.
@@ -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
  }
@@ -192,16 +222,44 @@ async function projectGraph(projectRoot, sessionId, graph, sourceSystem) {
192
222
  sourceSystem,
193
223
  tags: [.../* @__PURE__ */ new Set([...board.tags ?? [], ...sessionBoardTags(sessionId)])],
194
224
  archiveMissingTasks: true,
195
- 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
196
236
  }
197
237
  );
198
- 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;
199
244
  });
200
245
  }
201
246
  function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
202
247
  if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === "0") return;
203
248
  const key = mirrorKey(projectRoot, sessionId, sourceSystem);
204
- 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
+ });
205
263
  if (activeMirrors.has(key)) return;
206
264
  activeMirrors.add(key);
207
265
  void (async () => {
@@ -211,20 +269,34 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
211
269
  if (!pending) break;
212
270
  pendingMirrors.delete(key);
213
271
  try {
272
+ if (pending.reconciliationGraph) {
273
+ await projectGraph(
274
+ pending.projectRoot,
275
+ pending.sessionId,
276
+ pending.reconciliationGraph,
277
+ pending.sourceSystem
278
+ );
279
+ }
214
280
  await projectGraph(
215
281
  pending.projectRoot,
216
282
  pending.sessionId,
217
283
  pending.graph,
218
284
  pending.sourceSystem
219
285
  );
286
+ mirrorFailures.delete(boardKey(pending.projectRoot, pending.sessionId));
220
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
+ });
221
293
  console.warn(
222
294
  JSON.stringify({
223
295
  level: "warn",
224
296
  event: "session-kanban.mirror-failed",
225
297
  sessionId: pending.sessionId,
226
298
  sourceSystem: pending.sourceSystem,
227
- message: error instanceof Error ? error.message : String(error),
299
+ message,
228
300
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
229
301
  })
230
302
  );
@@ -245,6 +317,19 @@ function queueLatestMirror(projectRoot, sessionId, graph, sourceSystem) {
245
317
  }
246
318
  })();
247
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
+ }
248
333
  function todoListToSerializedGraph(todos, sessionId) {
249
334
  const graphId = `todo:${sessionId}`;
250
335
  const nodes = todos.map((todo, index) => ({
@@ -406,11 +491,11 @@ function broadcastTodoUpdate(context, todos) {
406
491
  });
407
492
  }
408
493
  function notifyTodoUpdate(context, todos) {
409
- const summary = todos.length ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join("\n") : "- No active todos remain.";
494
+ const summary = formatTodosForModel(todos);
410
495
  const text = `[KANBAN TODO UPDATE]
411
496
  Another Kanban agent reassessed the shared board. The canonical todo list is now:
412
497
  ${summary}
413
- 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.`;
414
499
  const state = context.state;
415
500
  if (typeof state.appendBlockToLastUserMessage === "function") {
416
501
  if (state.appendBlockToLastUserMessage({ type: "text", text })) return;
@@ -419,6 +504,10 @@ Reassess your current plan before continuing; do not rely on the initial todo sn
419
504
  state.appendMessage({ role: "user", content: [{ type: "text", text }] });
420
505
  }
421
506
  }
507
+ function todosNeedingSessionMirror(todos, activeBoardId) {
508
+ if (!activeBoardId) return todos;
509
+ return todos.filter((todo) => todo.kanbanBoardId !== activeBoardId || !todo.kanbanTaskId);
510
+ }
422
511
  function mirrorSessionTodosToKanban(projectRoot, todos, sessionId) {
423
512
  queueLatestMirror(
424
513
  projectRoot,
@@ -565,16 +654,9 @@ function attachSessionKanbanMirror(context) {
565
654
  const unsubscribe = context.state.onChange((change) => {
566
655
  if (change.kind === "todos_replaced" && !suppressedTodoMirrors.has(context)) {
567
656
  const snapshot = change.completedSnapshot ?? change.todos;
568
- if (snapshot.length > 0 && snapshot.every(
569
- (todo) => todo.kanbanBoardId === activeManagedBoardId() && Boolean(todo.kanbanTaskId)
570
- )) {
571
- return;
572
- }
573
- mirrorSessionTodosToKanban(
574
- context.projectRoot,
575
- change.completedSnapshot ?? change.todos,
576
- sessionId()
577
- );
657
+ const unbound = todosNeedingSessionMirror(snapshot, activeManagedBoardId());
658
+ if (snapshot.length > 0 && unbound.length === 0) return;
659
+ mirrorSessionTodosToKanban(context.projectRoot, unbound, sessionId());
578
660
  return;
579
661
  }
580
662
  if (change.kind === "meta_set" && (change.key === "plan.path" || change.key === "task.path" || change.key === "kanban")) {
@@ -610,10 +692,42 @@ function attachSessionKanbanMirror(context) {
610
692
  bindings.set(context, detach);
611
693
  return detach;
612
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
+ }
613
723
  async function hydrateSessionKanban(context) {
614
724
  const id = context.session?.id ?? "";
615
725
  if (!id) return null;
726
+ await rebindSessionKanbanTask(context);
616
727
  await cleanupEmptySessionKanbanBoards(context.projectRoot, id);
728
+ if (context.projectRoot) {
729
+ fireAndForget("prune-session-boards", pruneSessionBoards(context.projectRoot));
730
+ }
617
731
  let board = await ensureSessionKanbanBoard(context.projectRoot, id);
618
732
  if (context.todos.length) {
619
733
  board = await projectSessionTodosToKanban(context.projectRoot, context.todos, id);
@@ -644,26 +758,68 @@ function todoStatus(task) {
644
758
  if (status === "in_progress" || status === "review") return "in_progress";
645
759
  return "pending";
646
760
  }
647
- function sessionTodoFromTask(task, boardId) {
761
+ function sessionTodoFromTask(task, board) {
762
+ const blockedBy = board ? blockingTitles(board, task) : [];
648
763
  return {
649
764
  id: task.origin?.taskId ?? task.id,
650
765
  content: task.title,
651
766
  status: todoStatus(task),
652
- kanbanBoardId: boardId,
653
- kanbanTaskId: task.id,
654
- ...task.description ? { activeForm: task.description } : {}
767
+ ...task.description ? { activeForm: task.description } : {},
768
+ ...blockedBy.length ? { blockedBy } : {}
655
769
  };
656
770
  }
657
- function managedTodoFromTask(task, boardId) {
771
+ function managedTodoFromTask(task, board) {
658
772
  return {
659
- ...sessionTodoFromTask(task, boardId),
660
- status: task.status === "completed" ? "completed" : task.status === "in_progress" ? "in_progress" : "pending"
773
+ ...sessionTodoFromTask(task, board),
774
+ kanbanBoardId: board.id,
775
+ kanbanTaskId: task.id
661
776
  };
662
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
+ }
663
816
  function sameTodos(left, right) {
664
817
  return left.length === right.length && left.every((todo, index) => {
665
818
  const candidate = right[index];
666
- 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;
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");
667
823
  });
668
824
  }
669
825
  function applySessionKanbanBoardToTodos(context, board) {
@@ -671,13 +827,13 @@ function applySessionKanbanBoardToTodos(context, board) {
671
827
  if (!sessionId || sessionIdFromTags(board.tags) !== sessionId || !isOwnedSessionBoard(board.tags)) {
672
828
  return [...context.todos];
673
829
  }
674
- const projectedTodos = board.tasks.filter(
675
- (task) => task.status !== "archived" && (!task.origin || task.origin.system === "session-todo" || (task.origin.graphId ?? "").startsWith("todo:"))
676
- ).sort((left, right) => {
677
- const leftColumn = board.columns.find((column) => column.id === left.columnId)?.order ?? 0;
678
- const rightColumn = board.columns.find((column) => column.id === right.columnId)?.order ?? 0;
679
- return leftColumn - rightColumn || left.order - right.order || left.createdAt.localeCompare(right.createdAt);
680
- }).map((task) => sessionTodoFromTask(task, board.id));
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));
681
837
  const allCompleted = projectedTodos.length > 0 && projectedTodos.every((todo) => todo.status === "completed");
682
838
  const effectiveTodos = allCompleted ? [] : projectedTodos;
683
839
  if (sameTodos(context.todos, effectiveTodos)) return [...context.todos];
@@ -698,11 +854,12 @@ function applyManagedKanbanBoardToTodos(context, board) {
698
854
  if (!activeBoardId || board.id !== activeBoardId || board.lifecycle?.mode !== "managed") {
699
855
  return [...context.todos];
700
856
  }
701
- const projectedTodos = board.tasks.filter(
702
- (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
703
- ).sort(
704
- (left, right) => left.createdAt.localeCompare(right.createdAt) || left.order - right.order
705
- ).map((task) => managedTodoFromTask(task, board.id));
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));
706
863
  if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
707
864
  suppressedTodoMirrors.add(context);
708
865
  try {
@@ -737,7 +894,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
737
894
  const id = context.session?.id ?? "";
738
895
  if (task.origin?.system === "session-plan" || graphId.startsWith("plan:")) {
739
896
  const planPath = context.meta["plan.path"];
740
- 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
+ }
741
902
  const plan = await mutatePlan(planPath, id, (file) => ({
742
903
  ...file,
743
904
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -755,7 +916,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
755
916
  }
756
917
  if (task.origin?.system === "session-task" || task.origin?.system === "session" || graphId.startsWith("session:")) {
757
918
  const taskPath = context.meta["task.path"];
758
- 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
+ }
759
924
  const tasks = await mutateTasks(taskPath, id, (file) => ({
760
925
  ...file,
761
926
  tasks: options.remove ? file.tasks.filter((item) => item.id !== originId) : file.tasks.map(
@@ -778,6 +943,7 @@ export {
778
943
  applySessionKanbanBoardToTodos,
779
944
  applySessionKanbanTaskToSource,
780
945
  attachSessionKanbanMirror,
946
+ blockingTitles,
781
947
  cleanupEmptySessionKanbanBoards,
782
948
  cleanupSessionKanbanBoard,
783
949
  cleanupSessionKanbanBoardIfEmpty,
@@ -786,11 +952,15 @@ export {
786
952
  mirrorSessionPlanToKanban,
787
953
  mirrorSessionTasksToKanban,
788
954
  mirrorSessionTodosToKanban,
955
+ orderTasksForTodos,
789
956
  planFileToSerializedGraph,
790
957
  projectSessionPlanToKanban,
791
958
  projectSessionTasksToKanban,
792
959
  projectSessionTodosToKanban,
960
+ rebindSessionKanbanTask,
961
+ takeSessionMirrorFailure,
793
962
  taskFileToSerializedGraph,
794
- todoListToSerializedGraph
963
+ todoListToSerializedGraph,
964
+ todosNeedingSessionMirror
795
965
  };
796
966
  //# sourceMappingURL=session-kanban.js.map