@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.
@@ -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,57 @@ 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
+ }
723
+ var degradationReason;
724
+ function sessionKanbanDegradation() {
725
+ return degradationReason;
726
+ }
613
727
  async function hydrateSessionKanban(context) {
614
728
  const id = context.session?.id ?? "";
615
729
  if (!id) return null;
730
+ try {
731
+ const board = await hydrateSessionKanbanBoard(context, id);
732
+ degradationReason = void 0;
733
+ return board;
734
+ } catch (error) {
735
+ degradationReason = error instanceof Error ? error.message : String(error);
736
+ fireAndForget("hydrate", Promise.reject(error));
737
+ return null;
738
+ }
739
+ }
740
+ async function hydrateSessionKanbanBoard(context, id) {
741
+ await rebindSessionKanbanTask(context);
616
742
  await cleanupEmptySessionKanbanBoards(context.projectRoot, id);
743
+ if (context.projectRoot) {
744
+ fireAndForget("prune-session-boards", pruneSessionBoards(context.projectRoot));
745
+ }
617
746
  let board = await ensureSessionKanbanBoard(context.projectRoot, id);
618
747
  if (context.todos.length) {
619
748
  board = await projectSessionTodosToKanban(context.projectRoot, context.todos, id);
@@ -644,26 +773,68 @@ function todoStatus(task) {
644
773
  if (status === "in_progress" || status === "review") return "in_progress";
645
774
  return "pending";
646
775
  }
647
- function sessionTodoFromTask(task, boardId) {
776
+ function sessionTodoFromTask(task, board) {
777
+ const blockedBy = board ? blockingTitles(board, task) : [];
648
778
  return {
649
779
  id: task.origin?.taskId ?? task.id,
650
780
  content: task.title,
651
781
  status: todoStatus(task),
652
- kanbanBoardId: boardId,
653
- kanbanTaskId: task.id,
654
- ...task.description ? { activeForm: task.description } : {}
782
+ ...task.description ? { activeForm: task.description } : {},
783
+ ...blockedBy.length ? { blockedBy } : {}
655
784
  };
656
785
  }
657
- function managedTodoFromTask(task, boardId) {
786
+ function managedTodoFromTask(task, board) {
658
787
  return {
659
- ...sessionTodoFromTask(task, boardId),
660
- status: task.status === "completed" ? "completed" : task.status === "in_progress" ? "in_progress" : "pending"
788
+ ...sessionTodoFromTask(task, board),
789
+ kanbanBoardId: board.id,
790
+ kanbanTaskId: task.id
661
791
  };
662
792
  }
793
+ function blockingTitles(board, task) {
794
+ return getDependencyReadinessIssues(board, task).map((issue) => {
795
+ const dependency = board.tasks.find((candidate) => candidate.id === issue.dependencyId);
796
+ if (!dependency) return `${issue.dependencyId} (missing)`;
797
+ return dependency.title;
798
+ });
799
+ }
800
+ var PRIORITY_ORDER = {
801
+ critical: 0,
802
+ high: 1,
803
+ medium: 2,
804
+ low: 3
805
+ };
806
+ function orderTasksForTodos(board, tasks) {
807
+ const columnOrder = new Map(board.columns.map((column) => [column.id, column.order]));
808
+ const baseline = [...tasks].sort(
809
+ (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)
810
+ );
811
+ const included = new Set(baseline.map((task) => task.id));
812
+ const remaining = new Map(baseline.map((task) => [task.id, task]));
813
+ const emitted = [];
814
+ const done = /* @__PURE__ */ new Set();
815
+ while (remaining.size > 0) {
816
+ const ready = baseline.filter(
817
+ (task) => remaining.has(task.id) && (task.dependsOn ?? []).every(
818
+ (dependencyId) => !included.has(dependencyId) || done.has(dependencyId)
819
+ )
820
+ );
821
+ if (ready.length === 0) break;
822
+ for (const task of ready) {
823
+ remaining.delete(task.id);
824
+ done.add(task.id);
825
+ emitted.push(task);
826
+ }
827
+ }
828
+ for (const task of baseline) if (remaining.has(task.id)) emitted.push(task);
829
+ return emitted;
830
+ }
663
831
  function sameTodos(left, right) {
664
832
  return left.length === right.length && left.every((todo, index) => {
665
833
  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;
834
+ 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,
835
+ // the rows are otherwise identical and the unblocking would never
836
+ // reach the model.
837
+ (candidate.blockedBy ?? []).join("\0") === (todo.blockedBy ?? []).join("\0");
667
838
  });
668
839
  }
669
840
  function applySessionKanbanBoardToTodos(context, board) {
@@ -671,13 +842,13 @@ function applySessionKanbanBoardToTodos(context, board) {
671
842
  if (!sessionId || sessionIdFromTags(board.tags) !== sessionId || !isOwnedSessionBoard(board.tags)) {
672
843
  return [...context.todos];
673
844
  }
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));
845
+ if (hasInFlightTodoMirror(context.projectRoot, sessionId)) return [...context.todos];
846
+ const projectedTodos = orderTasksForTodos(
847
+ board,
848
+ board.tasks.filter(
849
+ (task) => task.status !== "archived" && (!task.origin || task.origin.system === "session-todo" || (task.origin.graphId ?? "").startsWith("todo:"))
850
+ )
851
+ ).map((task) => sessionTodoFromTask(task, board));
681
852
  const allCompleted = projectedTodos.length > 0 && projectedTodos.every((todo) => todo.status === "completed");
682
853
  const effectiveTodos = allCompleted ? [] : projectedTodos;
683
854
  if (sameTodos(context.todos, effectiveTodos)) return [...context.todos];
@@ -698,11 +869,12 @@ function applyManagedKanbanBoardToTodos(context, board) {
698
869
  if (!activeBoardId || board.id !== activeBoardId || board.lifecycle?.mode !== "managed") {
699
870
  return [...context.todos];
700
871
  }
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));
872
+ const projectedTodos = orderTasksForTodos(
873
+ board,
874
+ board.tasks.filter(
875
+ (task) => task.status !== "archived" && task.mergedIntoTaskId === void 0 && (!task.childTaskIds || task.childTaskIds.length === 0)
876
+ )
877
+ ).map((task) => managedTodoFromTask(task, board));
706
878
  if (sameTodos(context.todos, projectedTodos)) return [...context.todos];
707
879
  suppressedTodoMirrors.add(context);
708
880
  try {
@@ -737,7 +909,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
737
909
  const id = context.session?.id ?? "";
738
910
  if (task.origin?.system === "session-plan" || graphId.startsWith("plan:")) {
739
911
  const planPath = context.meta["plan.path"];
740
- if (typeof planPath !== "string" || !planPath) return { source: "plan" };
912
+ if (typeof planPath !== "string" || !planPath) {
913
+ throw new Error(
914
+ "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."
915
+ );
916
+ }
741
917
  const plan = await mutatePlan(planPath, id, (file) => ({
742
918
  ...file,
743
919
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -755,7 +931,11 @@ async function applySessionKanbanTaskToSource(context, task, options = {}) {
755
931
  }
756
932
  if (task.origin?.system === "session-task" || task.origin?.system === "session" || graphId.startsWith("session:")) {
757
933
  const taskPath = context.meta["task.path"];
758
- if (typeof taskPath !== "string" || !taskPath) return { source: "task" };
934
+ if (typeof taskPath !== "string" || !taskPath) {
935
+ throw new Error(
936
+ "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."
937
+ );
938
+ }
759
939
  const tasks = await mutateTasks(taskPath, id, (file) => ({
760
940
  ...file,
761
941
  tasks: options.remove ? file.tasks.filter((item) => item.id !== originId) : file.tasks.map(
@@ -778,6 +958,7 @@ export {
778
958
  applySessionKanbanBoardToTodos,
779
959
  applySessionKanbanTaskToSource,
780
960
  attachSessionKanbanMirror,
961
+ blockingTitles,
781
962
  cleanupEmptySessionKanbanBoards,
782
963
  cleanupSessionKanbanBoard,
783
964
  cleanupSessionKanbanBoardIfEmpty,
@@ -786,11 +967,16 @@ export {
786
967
  mirrorSessionPlanToKanban,
787
968
  mirrorSessionTasksToKanban,
788
969
  mirrorSessionTodosToKanban,
970
+ orderTasksForTodos,
789
971
  planFileToSerializedGraph,
790
972
  projectSessionPlanToKanban,
791
973
  projectSessionTasksToKanban,
792
974
  projectSessionTodosToKanban,
975
+ rebindSessionKanbanTask,
976
+ sessionKanbanDegradation,
977
+ takeSessionMirrorFailure,
793
978
  taskFileToSerializedGraph,
794
- todoListToSerializedGraph
979
+ todoListToSerializedGraph,
980
+ todosNeedingSessionMirror
795
981
  };
796
982
  //# sourceMappingURL=session-kanban.js.map