@wrongstack/tools 0.295.1 → 0.296.2
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/auto-proceed-loop-guard.d.ts +67 -0
- package/dist/auto-proceed-loop-guard.d.ts.map +1 -1
- package/dist/auto-proceed-loop-guard.js +95 -0
- package/dist/auto-proceed-loop-guard.js.map +2 -2
- package/dist/bash.js +53 -18
- package/dist/bash.js.map +2 -2
- package/dist/browser/index.js +17 -7
- package/dist/browser/index.js.map +2 -2
- package/dist/browser/network-guard-proxy.d.ts.map +1 -1
- package/dist/builtin.d.ts +2 -2
- package/dist/builtin.d.ts.map +1 -1
- package/dist/builtin.js +8937 -7250
- package/dist/builtin.js.map +4 -4
- package/dist/codebase-index/background-indexer.d.ts +28 -2
- package/dist/codebase-index/background-indexer.d.ts.map +1 -1
- package/dist/codebase-index/codebase-index-tool.d.ts.map +1 -1
- package/dist/codebase-index/dead-code-scan.d.ts +77 -0
- package/dist/codebase-index/dead-code-scan.d.ts.map +1 -0
- package/dist/codebase-index/index-service.d.ts +4 -3
- package/dist/codebase-index/index-service.d.ts.map +1 -1
- package/dist/codebase-index/index.d.ts +11 -8
- package/dist/codebase-index/index.d.ts.map +1 -1
- package/dist/codebase-index/index.js +5038 -3573
- package/dist/codebase-index/index.js.map +4 -4
- package/dist/codebase-index/indexer.d.ts +2 -0
- package/dist/codebase-index/indexer.d.ts.map +1 -1
- package/dist/codebase-index/parser-dispatch.d.ts +8 -0
- package/dist/codebase-index/parser-dispatch.d.ts.map +1 -0
- package/dist/codebase-index/project-server-cache.d.ts +11 -0
- package/dist/codebase-index/project-server-cache.d.ts.map +1 -0
- package/dist/codebase-index/project-server-client.d.ts +74 -0
- package/dist/codebase-index/project-server-client.d.ts.map +1 -0
- package/dist/codebase-index/project-server-endpoint.d.ts +24 -0
- package/dist/codebase-index/project-server-endpoint.d.ts.map +1 -0
- package/dist/codebase-index/project-server-protocol.d.ts +88 -0
- package/dist/codebase-index/project-server-protocol.d.ts.map +1 -0
- package/dist/codebase-index/project-server.d.ts +11 -0
- package/dist/codebase-index/project-server.d.ts.map +1 -0
- package/dist/codebase-index/project-server.js +5061 -0
- package/dist/codebase-index/project-server.js.map +7 -0
- package/dist/codebase-index/refs-extractor.d.ts +1 -0
- package/dist/codebase-index/refs-extractor.d.ts.map +1 -1
- package/dist/codebase-index/schema.d.ts +11 -0
- package/dist/codebase-index/schema.d.ts.map +1 -1
- package/dist/codebase-index/sqlite-runtime.d.ts +9 -0
- package/dist/codebase-index/sqlite-runtime.d.ts.map +1 -0
- package/dist/codebase-index/ts-parser.d.ts +5 -1
- package/dist/codebase-index/ts-parser.d.ts.map +1 -1
- package/dist/codebase-index/worker-protocol.d.ts +19 -1
- package/dist/codebase-index/worker-protocol.d.ts.map +1 -1
- package/dist/codebase-index/worker.js +3860 -3415
- package/dist/codebase-index/worker.js.map +4 -4
- package/dist/codebase-index/writer-admin.d.ts +16 -0
- package/dist/codebase-index/writer-admin.d.ts.map +1 -0
- package/dist/codebase-index/writer-bulk-insert.d.ts +25 -0
- package/dist/codebase-index/writer-bulk-insert.d.ts.map +1 -0
- package/dist/codebase-index/writer-graph-helpers.d.ts +52 -0
- package/dist/codebase-index/writer-graph-helpers.d.ts.map +1 -0
- package/dist/codebase-index/writer-graph-reader.d.ts +11 -0
- package/dist/codebase-index/writer-graph-reader.d.ts.map +1 -0
- package/dist/codebase-index/writer-helpers.d.ts +15 -0
- package/dist/codebase-index/writer-helpers.d.ts.map +1 -0
- package/dist/codebase-index/writer-pragmas.d.ts +3 -0
- package/dist/codebase-index/writer-pragmas.d.ts.map +1 -0
- package/dist/codebase-index/writer-ref-mapper.d.ts +11 -0
- package/dist/codebase-index/writer-ref-mapper.d.ts.map +1 -0
- package/dist/codebase-index/writer-schema.d.ts +7 -0
- package/dist/codebase-index/writer-schema.d.ts.map +1 -0
- package/dist/codebase-index/writer-search-helpers.d.ts +28 -0
- package/dist/codebase-index/writer-search-helpers.d.ts.map +1 -0
- package/dist/codebase-index/writer-store-pool.d.ts +45 -0
- package/dist/codebase-index/writer-store-pool.d.ts.map +1 -0
- package/dist/codebase-index/writer.d.ts +53 -88
- package/dist/codebase-index/writer.d.ts.map +1 -1
- package/dist/exec.js +53 -18
- package/dist/exec.js.map +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9318 -7635
- package/dist/index.js.map +4 -4
- package/dist/kanban-board-inputs.d.ts +34 -0
- package/dist/kanban-board-inputs.d.ts.map +1 -0
- package/dist/kanban-decomposition-actions.d.ts +4 -0
- package/dist/kanban-decomposition-actions.d.ts.map +1 -0
- package/dist/kanban-detail-actions.d.ts +3 -0
- package/dist/kanban-detail-actions.d.ts.map +1 -0
- package/dist/kanban-presence.d.ts +10 -0
- package/dist/kanban-presence.d.ts.map +1 -0
- package/dist/kanban-split-task-handler.d.ts +6 -0
- package/dist/kanban-split-task-handler.d.ts.map +1 -0
- package/dist/kanban-task-inputs.d.ts +73 -0
- package/dist/kanban-task-inputs.d.ts.map +1 -0
- package/dist/kanban-tool-results.d.ts +14 -0
- package/dist/kanban-tool-results.d.ts.map +1 -0
- package/dist/kanban-tool-schema.d.ts +5 -0
- package/dist/kanban-tool-schema.d.ts.map +1 -0
- package/dist/kanban-tool-types.d.ts +135 -0
- package/dist/kanban-tool-types.d.ts.map +1 -0
- package/dist/kanban.d.ts +2 -146
- package/dist/kanban.d.ts.map +1 -1
- package/dist/kanban.js +1030 -825
- package/dist/kanban.js.map +4 -4
- package/dist/pack.js +8936 -7250
- package/dist/pack.js.map +4 -4
- package/dist/plan.js +2 -2
- package/dist/plan.js.map +2 -2
- package/dist/process-registry-persistent.d.ts +1 -0
- package/dist/process-registry-persistent.d.ts.map +1 -1
- package/dist/ps-slash.js +53 -18
- package/dist/ps-slash.js.map +2 -2
- package/dist/replace.js +8 -0
- package/dist/replace.js.map +2 -2
- package/dist/session-kanban.d.ts.map +1 -1
- package/dist/session-kanban.js +15 -20
- package/dist/session-kanban.js.map +2 -2
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +48 -6
- package/dist/task.js.map +2 -2
- package/dist/todo.d.ts.map +1 -1
- package/dist/todo.js +47 -5
- package/dist/todo.js.map +2 -2
- package/dist/tool-tier.js +8938 -7251
- package/dist/tool-tier.js.map +4 -4
- package/package.json +5 -5
- package/dist/shim/node-sqlite.d.ts +0 -3
- package/dist/shim/node-sqlite.d.ts.map +0 -1
package/dist/plan.js
CHANGED
|
@@ -16,7 +16,7 @@ import { formatTaskList } from "@wrongstack/core/utils";
|
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
|
|
18
18
|
// src/session-kanban.ts
|
|
19
|
-
import {
|
|
19
|
+
import { getSharedProjectMailbox } from "@wrongstack/core/coordination";
|
|
20
20
|
import {
|
|
21
21
|
loadPlan,
|
|
22
22
|
loadTasks,
|
|
@@ -26,9 +26,9 @@ import {
|
|
|
26
26
|
import { deserializeTaskGraph } from "@wrongstack/core/tasking";
|
|
27
27
|
import { resolveWstackPaths } from "@wrongstack/core/utils";
|
|
28
28
|
import {
|
|
29
|
+
bridgeKanbanSupervisor,
|
|
29
30
|
createBoard,
|
|
30
31
|
getBoard,
|
|
31
|
-
getKanbanDir,
|
|
32
32
|
listBoards,
|
|
33
33
|
removeBoard,
|
|
34
34
|
syncBoardFromTaskGraph,
|
package/dist/plan.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/plan.ts", "../src/session-kanban.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n type PlanFile,\n addPlanItem,\n clearPlan,\n deriveTodosFromPlanItem,\n formatPlan,\n getPlanTemplate,\n mutatePlan,\n removePlanItem,\n setPlanItemStatus,\n} from '@wrongstack/core/storage';\nimport {\n type TaskFile,\n mutateTasks,\n} from '@wrongstack/core/storage';\nimport { formatTaskList } from '@wrongstack/core/utils';\nimport { randomUUID } from 'node:crypto';\nimport type { Tool } from '@wrongstack/core/types';\nimport { projectSessionPlanToKanban } from './session-kanban.js';\n\n/**\n * `planTool` \u2014 the LLM-callable counterpart to the `/plan` slash command.\n *\n * Plans capture strategic, multi-step approaches that survive across\n * session resumes (unlike todos, which are tactical and per-turn).\n * Storage path comes from `ctx.meta['plan.path']` \u2014 the CLI seeds this\n * during startup so the tool always knows where to read/write.\n *\n * One tool, multiple actions, JSON in/out. The action discriminates the\n * operation so the LLM can do show / add / start / done / remove / promote /\n * derive / template_use / clear via a single tool registration instead of\n * bloating the surface with nine near-identical tools.\n */\ninterface PlanInput {\n action:\n | 'show'\n | 'add'\n | 'start'\n | 'done'\n | 'remove'\n | 'promote'\n | 'template_use'\n | 'clear'\n | 'taskify';\n /** Required for add. */\n title?: string | undefined;\n /** Optional detail line for add. */\n details?: string | undefined;\n /** Required for start/done/remove/promote \u2014 accepts plan item id OR 1-based index OR title substring. */\n target?: string | undefined;\n /** Optional subtasks for promote. If omitted, a single todo is created from the plan item title. */\n subtasks?: string[] | undefined;\n /** Required for template_use \u2014 the template name (e.g. \"new-feature\", \"bug-fix\"). */\n template?: string | undefined;\n /**\n * Storage scope. Default (unset): uses the session-scoped path \u2014 isolated to this\n * session, survives resume within the same session.\n * `scope: 'project'`: uses a shared project-level path, visible to all sessions\n * for this project. Useful for a shared roadmap that outlasts any single session.\n */\n scope?: 'session' | 'project';\n}\n\ninterface PlanOutput {\n ok: boolean;\n message: string;\n /** Formatted plan after the operation. Same string the user sees from `/plan show`. */\n plan: string;\n /** Total item count after the operation. */\n count: number;\n /** Number of items not in 'done' status. */\n open: number;\n /** When promote/derive succeed, the generated todo items so the caller can inspect them. */\n todos?: Array<{ id: string; content: string; status: string; activeForm?: string | undefined; promotedFromPlan?: string | undefined }>;\n}\n\nexport const planTool: Tool<PlanInput, PlanOutput> = {\n name: 'plan',\n category: 'Session',\n description:\n 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. ' +\n 'Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. ' +\n 'Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. ' +\n 'By default plans are isolated to this session; use `scope: \"project\"` to store the plan in a shared project-level file visible to all sessions.',\n usageHint:\n 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\\n\\n' +\n '- Start by creating a high-level plan with `action: \"add\"` or using templates (`template_use`).\\n' +\n '- Use `promote` to turn a plan item into actionable todos.\\n' +\n '- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\\n' +\n '- Keep plans at the \"why and what\" level, and todos at the \"how and next step\" level.\\n' +\n '- Common templates: \"new-feature\", \"bug-fix\", \"refactor\", \"release\", \"security-audit\".\\n\\n' +\n 'This tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\\n' +\n 'Use `scope: \"project\"` to use a shared project-level plan file.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n icon: 'plan',\n timeoutMs: 2_000,\n inputSchema: {\n type: 'object',\n properties: {\n action: {\n type: 'string',\n enum: [\n 'show',\n 'add',\n 'start',\n 'done',\n 'remove',\n 'promote',\n 'template_use',\n 'clear',\n 'taskify',\n ],\n description: 'The operation to perform on the plan board.',\n },\n title: {\n type: 'string',\n description: 'Title of the plan item. Required for action=add.',\n },\n details: {\n type: 'string',\n description: 'Additional details or description for a new plan item (action=add).',\n },\n target: {\n type: 'string',\n description:\n 'Identifier for the target plan item (id, 1-based index, or partial title). Required for most actions except add/show/clear.',\n },\n subtasks: {\n type: 'array',\n items: { type: 'string' },\n description:\n 'List of subtask titles. Used with promote to break a plan item into multiple todos.',\n },\n template: {\n type: 'string',\n description:\n 'Template identifier when using action=template_use. Common values: new-feature, bug-fix, refactor, release, security-audit.',\n },\n scope: {\n type: 'string',\n enum: ['session', 'project'],\n description: 'Storage scope: \"session\" (default, isolated to this session) or \"project\" (shared across all sessions for this project).',\n },\n },\n required: ['action'],\n },\n async execute(input, ctx) {\n const sessionPlanPath = (ctx.meta as Record<string, unknown>)['plan.path'] as string | undefined;\n let planPath: string | undefined;\n\n if (input.scope === 'project') {\n // Project-level: derive from the session path by replacing the filename with\n // 'backlog.plan.json' so all sessions share the same file.\n if (typeof sessionPlanPath === 'string') {\n // Handle BOTH separators \u2014 a Windows-native path uses '\\\\', and a\n // '/'-only search would miss it and fall back to a bare relative path\n // written into the process CWD instead of the sessions dir.\n const lastSep = Math.max(sessionPlanPath.lastIndexOf('/'), sessionPlanPath.lastIndexOf('\\\\'));\n planPath = lastSep >= 0\n ? sessionPlanPath.slice(0, lastSep + 1) + 'backlog.plan.json'\n : 'backlog.plan.json';\n }\n } else {\n planPath = sessionPlanPath;\n }\n if (typeof planPath !== 'string' || !planPath) {\n return {\n ok: false,\n message: 'Plan storage path is not configured for this session.',\n plan: '',\n count: 0,\n open: 0,\n };\n }\n const sessionId = ctx.session?.id ?? 'unknown';\n\n let early: PlanOutput | null = null;\n // Track taskify data \u2014 task write happens after the plan lock releases\n const taskifyMeta = { title: '', details: '' };\n let didTaskify = false;\n\n let plan: PlanFile;\n try {\n plan = await mutatePlan(planPath, sessionId, async (p) => {\n switch (input.action) {\n case 'show':\n break;\n\n case 'add': {\n const title = input.title?.trim();\n if (!title) {\n early = mkResult(p, false, 'add requires `title`.');\n return p;\n }\n const { plan: updated } = addPlanItem(p, title, input.details?.trim() || undefined);\n return updated;\n }\n\n case 'start':\n case 'done': {\n if (!input.target) {\n early = mkResult(p, false, `${input.action} requires \\`target\\` (id|index|substring).`);\n return p;\n }\n const next = setPlanItemStatus(\n p,\n input.target,\n input.action === 'start' ? 'in_progress' : 'done',\n );\n if (next === p) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n return next;\n }\n\n case 'remove': {\n if (!input.target) {\n early = mkResult(p, false, 'remove requires `target` (id|index|substring).');\n return p;\n }\n const next = removePlanItem(p, input.target);\n if (next === p) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n return next;\n }\n\n case 'promote': {\n if (!input.target) {\n early = mkResult(p, false, `${input.action} requires \\`target\\` (id|index|substring).`);\n return p;\n }\n const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);\n if (!derived) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n ctx.state.replaceTodos(derived.todos);\n early = mkResult(\n derived.plan,\n true,\n `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,\n derived.todos,\n );\n return derived.plan;\n }\n\n case 'template_use': {\n const templateName = input.template?.trim();\n if (!templateName) {\n early = mkResult(p, false, 'template_use requires `template` name.');\n return p;\n }\n const template = getPlanTemplate(templateName);\n if (!template) {\n early = mkResult(p, false, `Unknown template \"${templateName}\".`);\n return p;\n }\n let updated = p;\n for (const item of template.items) {\n ({ plan: updated } = addPlanItem(updated, item.title, item.details));\n }\n early = mkResult(\n updated,\n true,\n `Applied template \"${template.name}\" \u2014 ${template.items.length} items added.`,\n );\n return updated;\n }\n\n case 'clear':\n return clearPlan(p);\n\n case 'taskify': {\n if (!input.target) {\n early = mkResult(p, false, 'taskify requires `target` (plan item id|index|substring).');\n return p;\n }\n // Find plan item by 1-based index, exact id, or title substring\n let itemIdx = -1;\n const asNum = Number.parseInt(input.target, 10);\n if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {\n itemIdx = asNum - 1;\n } else {\n itemIdx = p.items.findIndex((it) => it.id === input.target);\n if (itemIdx === -1) {\n const lower = input.target.toLowerCase();\n itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));\n }\n }\n if (itemIdx === -1 || !p.items[itemIdx]) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n const item = p.items[itemIdx]!;\n // Extract data \u2014 task write happens after the plan lock releases\n taskifyMeta.title = item.title;\n taskifyMeta.details = item.details ?? '';\n didTaskify = true;\n break;\n }\n\n default:\n early = mkResult(p, false, `Unknown action \"${(input as { action: string }).action}\".`);\n return p;\n }\n\n return p;\n });\n } catch (err) {\n // Persist failed (mutatePlan throws on a failed save) \u2014 report ok:false\n // with the real reason instead of falsely claiming the plan was saved.\n return {\n ok: false,\n message: `Plan change not saved \u2014 ${err instanceof Error ? err.message : String(err)}`,\n plan: '',\n count: 0,\n open: 0,\n };\n }\n\n // A successful plan mutation includes its projection onto the unified\n // session board; callers never observe plan state ahead of Kanban state.\n await projectSessionPlanToKanban(ctx.projectRoot, plan.items, sessionId);\n\n // If the callback set an early-return result, use it\n if (early) return early;\n\n // If taskify copied plan item data, write it to the task file now\n if (didTaskify) {\n const taskPathRaw = (ctx.meta as Record<string, unknown>)['task.path'];\n if (typeof taskPathRaw !== 'string' || !taskPathRaw) {\n return mkResult(plan, false, 'Task storage path not configured \u2014 cannot taskify.');\n }\n let taskPath: string = taskPathRaw;\n // Honor project scope for the TASK file too: a project-scoped taskify must\n // append to the shared backlog.tasks.json, not the per-session task file\n // (mirrors the plan-path derivation above; handles both separators).\n if (input.scope === 'project') {\n const lastSep = Math.max(taskPath.lastIndexOf('/'), taskPath.lastIndexOf('\\\\'));\n taskPath = lastSep >= 0 ? taskPath.slice(0, lastSep + 1) + 'backlog.tasks.json' : 'backlog.tasks.json';\n }\n const now = new Date().toISOString();\n // Mutate the cross-file under ITS OWN lock \u2014 a raw loadTasks/push/saveTasks\n // can interleave with a concurrent task tool call in the same batch and\n // clobber writes. mutateTasks is the documented race-safe write path.\n try {\n const taskFile: TaskFile = await mutateTasks(taskPath, sessionId, (f) => {\n f.tasks.push({\n id: `task_${randomUUID()}`,\n title: taskifyMeta.title,\n description: taskifyMeta.details || undefined,\n type: 'feature',\n priority: 'medium',\n status: 'pending',\n createdAt: now,\n updatedAt: now,\n });\n return f;\n });\n return mkResult(\n plan,\n true,\n `taskify ok \u2014 added \"${taskifyMeta.title}\" to tasks.\\n${formatTaskList(taskFile.tasks)}`,\n );\n } catch (err) {\n // The plan item was saved, but copying it into the task file failed.\n return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);\n }\n }\n\n return mkResult(plan, true, `Plan ${input.action} ok.`);\n },\n};\n\nfunction mkResult(\n plan: PlanFile,\n ok: boolean,\n message: string,\n todos?: PlanOutput['todos'],\n): PlanOutput {\n const open = plan.items.filter((i) => i.status !== 'done').length;\n const result: PlanOutput = {\n ok,\n message,\n plan: formatPlan(plan),\n count: plan.items.length,\n open,\n };\n if (todos !== undefined) result.todos = todos;\n return result;\n}\n", "import { type FSWatcher, watch } from 'node:fs';\nimport { basename, dirname } from 'node:path';\nimport type { Context, TodoItem } from '@wrongstack/core/agent';\nimport { GlobalMailbox } from '@wrongstack/core/coordination';\nimport {\n loadPlan,\n loadTasks,\n mutatePlan,\n mutateTasks,\n type PlanFile,\n type PlanItem,\n type TaskFile,\n} from '@wrongstack/core/storage';\nimport { deserializeTaskGraph } from '@wrongstack/core/tasking';\nimport type { SerializedTaskGraph, TaskStatus } from '@wrongstack/core/types';\nimport { resolveWstackPaths, type TaskItem } from '@wrongstack/core/utils';\nimport {\n createBoard,\n getBoard,\n getKanbanDir,\n type KanbanBoard,\n type KanbanColumn,\n type KanbanTask,\n listBoards,\n removeBoard,\n syncBoardFromTaskGraph,\n touchKanbanPresence,\n updateBoard,\n} from '@wrongstack/kanban';\n\nconst SESSION_BOARD_TAG = 'session-work';\nconst MIRROR_DISABLED_ENV = 'WRONGSTACK_KANBAN_TASK_MIRROR';\n\n/** The canonical workflow shared by WebUI and TUI session boards. */\nexport const SESSION_KANBAN_COLUMNS: KanbanColumn[] = [\n { id: 'todo', title: 'Todo', order: 0, wipLimit: 0, color: '#2563eb' },\n { id: 'in-progress', title: 'Running', order: 1, wipLimit: 1, color: '#d97706' },\n { id: 'review', title: 'Preview', order: 2, wipLimit: 0, color: '#7c3aed' },\n { id: 'done', title: 'Done', order: 3, wipLimit: 0, color: '#16a34a' },\n];\n\nconst boardQueue = new Map<string, Promise<void>>();\nconst boardEnsures = new Map<string, Promise<KanbanBoard>>();\ntype PendingMirror = {\n projectRoot: string;\n sessionId: string;\n graph: SerializedTaskGraph;\n sourceSystem: 'session-todo' | 'session-task' | 'session-plan';\n};\nconst pendingMirrors = new Map<string, PendingMirror>();\nconst activeMirrors = new Set<string>();\nconst bindings = new WeakMap<Context, () => void>();\nconst suppressedTodoMirrors = new WeakSet<Context>();\nconst activeSessionBoards = new Map<string, number>();\n\nfunction boardKey(projectRoot: string, sessionId: string): string {\n return `${projectRoot}\\0${sessionId}`;\n}\n\nfunction mirrorKey(\n projectRoot: string,\n sessionId: string,\n sourceSystem: PendingMirror['sourceSystem'],\n): string {\n return `${boardKey(projectRoot, sessionId)}\\0${sourceSystem}`;\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction sessionBoardTitle(sessionId: string): string {\n const leaf = sessionId.split(/[\\\\/]/).filter(Boolean).pop() ?? sessionId;\n return `Session ${leaf.slice(0, 12)}`;\n}\n\nfunction sessionBoardTags(sessionId: string): string[] {\n return ['session', SESSION_BOARD_TAG, sessionTag(sessionId)];\n}\n\nfunction sessionIdFromTags(tags: readonly string[] | undefined): string | null {\n const tag = tags?.find((candidate) => candidate.startsWith('session:'));\n return tag?.slice('session:'.length) || null;\n}\n\nfunction isOwnedSessionBoard(tags: readonly string[] | undefined): boolean {\n return Boolean(tags?.includes(SESSION_BOARD_TAG) && sessionIdFromTags(tags));\n}\n\nfunction retainActiveSessionBoard(projectRoot: string, sessionId: string): void {\n const key = boardKey(projectRoot, sessionId);\n activeSessionBoards.set(key, (activeSessionBoards.get(key) ?? 0) + 1);\n}\n\nfunction releaseActiveSessionBoard(projectRoot: string, sessionId: string): void {\n const key = boardKey(projectRoot, sessionId);\n const remaining = (activeSessionBoards.get(key) ?? 0) - 1;\n if (remaining > 0) activeSessionBoards.set(key, remaining);\n else activeSessionBoards.delete(key);\n}\n\nfunction isSessionBoardActive(projectRoot: string, sessionId: string): boolean {\n return (activeSessionBoards.get(boardKey(projectRoot, sessionId)) ?? 0) > 0;\n}\n\nfunction sameColumns(columns: readonly KanbanColumn[]): boolean {\n return (\n columns.length === SESSION_KANBAN_COLUMNS.length &&\n columns.every((column, index) => column.id === SESSION_KANBAN_COLUMNS[index]?.id)\n );\n}\n\n/** Create (or migrate) the single Kanban board owned by a session. */\nexport async function ensureSessionKanbanBoard(\n projectRoot: string | undefined,\n sessionId: string,\n): Promise<KanbanBoard | null> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return null;\n const key = boardKey(projectRoot, sessionId);\n const inFlight = boardEnsures.get(key);\n if (inFlight) return inFlight;\n\n const promise = (async () => {\n const summary = (await listBoards(projectRoot)).find((board) =>\n board.tags?.includes(sessionTag(sessionId)),\n );\n let board = summary ? await getBoard(projectRoot, summary.id) : null;\n if (!board) {\n return createBoard(projectRoot, {\n title: sessionBoardTitle(sessionId),\n description: 'Live session work: todos, tasks, and plan items.',\n tags: sessionBoardTags(sessionId),\n columns: SESSION_KANBAN_COLUMNS,\n generatedBy: `session-kanban:${sessionId}`,\n });\n }\n\n // Boards produced by the older task/plan mirrors used the generic five\n // columns. Migrate only session-owned boards; updateBoard reconciles cards\n // from Backlog into Todo while preserving Running/Preview/Done cards.\n if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {\n board =\n (await updateBoard(projectRoot, board.id, {\n title: sessionBoardTitle(sessionId),\n description: 'Live session work: todos, tasks, and plan items.',\n tags: [...new Set([...(board.tags ?? []), ...sessionBoardTags(sessionId)])],\n columns: SESSION_KANBAN_COLUMNS,\n })) ?? board;\n }\n return board;\n })();\n\n boardEnsures.set(key, promise);\n try {\n return await promise;\n } finally {\n boardEnsures.delete(key);\n }\n}\n\nfunction enqueueBoardWork<T>(\n projectRoot: string,\n sessionId: string,\n work: () => Promise<T>,\n): Promise<T> {\n const key = boardKey(projectRoot, sessionId);\n const previous = boardQueue.get(key) ?? Promise.resolve();\n const result = previous.catch(() => undefined).then(work);\n const tail = result.then(\n () => undefined,\n () => undefined,\n );\n boardQueue.set(key, tail);\n void tail.then(() => {\n if (boardQueue.get(key) === tail) boardQueue.delete(key);\n });\n return result;\n}\n\nasync function removeEmptySessionBoard(\n projectRoot: string,\n boardId: string,\n sessionId: string,\n): Promise<string | null> {\n return enqueueBoardWork(projectRoot, sessionId, async () => {\n if (isSessionBoardActive(projectRoot, sessionId)) return null;\n const board = await getBoard(projectRoot, boardId);\n if (!board || board.tasks.length > 0 || !isOwnedSessionBoard(board.tags)) return null;\n if (sessionIdFromTags(board.tags) !== sessionId) return null;\n return (await removeBoard(projectRoot, board.id)) ? board.id : null;\n });\n}\n\n/** Remove a particular inactive session's system-owned board when it has no cards. */\nexport async function cleanupSessionKanbanBoardIfEmpty(\n projectRoot: string | undefined,\n sessionId: string,\n): Promise<string[]> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return [];\n if (isSessionBoardActive(projectRoot, sessionId)) return [];\n const candidates = (await listBoards(projectRoot)).filter(\n (board) =>\n board.taskCount === 0 &&\n isOwnedSessionBoard(board.tags) &&\n sessionIdFromTags(board.tags) === sessionId,\n );\n const removed = await Promise.all(\n candidates.map((board) => removeEmptySessionBoard(projectRoot, board.id, sessionId)),\n );\n return removed.filter((boardId): boardId is string => Boolean(boardId));\n}\n\n/** Prune stale empty session boards while preserving manual and live boards. */\nexport async function cleanupEmptySessionKanbanBoards(\n projectRoot: string | undefined,\n activeSessionId = '',\n): Promise<string[]> {\n if (!projectRoot || process.env[MIRROR_DISABLED_ENV] === '0') return [];\n const candidates = (await listBoards(projectRoot)).flatMap((board) => {\n const ownerSessionId = sessionIdFromTags(board.tags);\n return board.taskCount === 0 &&\n isOwnedSessionBoard(board.tags) &&\n ownerSessionId &&\n ownerSessionId !== activeSessionId &&\n !isSessionBoardActive(projectRoot, ownerSessionId)\n ? [{ boardId: board.id, sessionId: ownerSessionId }]\n : [];\n });\n const removed = await Promise.all(\n candidates.map(({ boardId, sessionId }) =>\n removeEmptySessionBoard(projectRoot, boardId, sessionId),\n ),\n );\n return removed.filter((boardId): boardId is string => Boolean(boardId));\n}\n\nasync function projectGraph(\n projectRoot: string | undefined,\n sessionId: string,\n graph: SerializedTaskGraph,\n sourceSystem: 'session-todo' | 'session-task' | 'session-plan',\n): Promise<KanbanBoard | null> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return null;\n return enqueueBoardWork(projectRoot, sessionId, async () => {\n const board = await ensureSessionKanbanBoard(projectRoot, sessionId);\n if (!board) return null;\n const result = await syncBoardFromTaskGraph(\n projectRoot,\n board.id,\n deserializeTaskGraph(graph),\n {\n sourceSystem,\n tags: [...new Set([...(board.tags ?? []), ...sessionBoardTags(sessionId)])],\n archiveMissingTasks: true,\n includeCompletedTasks: true,\n },\n );\n return result?.board ?? null;\n });\n}\n\n/**\n * Queue an observational mirror without retaining every intermediate state.\n * Todo/task/plan streams are independent, but within each stream only the\n * newest pending graph matters. This bounds a stalled file lock to one active\n * and one pending projection instead of an arbitrarily long Promise chain.\n */\nfunction queueLatestMirror(\n projectRoot: string | undefined,\n sessionId: string,\n graph: SerializedTaskGraph,\n sourceSystem: PendingMirror['sourceSystem'],\n): void {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return;\n const key = mirrorKey(projectRoot, sessionId, sourceSystem);\n pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });\n if (activeMirrors.has(key)) return;\n activeMirrors.add(key);\n void (async () => {\n try {\n for (;;) {\n const pending = pendingMirrors.get(key);\n if (!pending) break;\n pendingMirrors.delete(key);\n try {\n await projectGraph(\n pending.projectRoot,\n pending.sessionId,\n pending.graph,\n pending.sourceSystem,\n );\n } catch {\n // Mirrors are observational. A newer pending snapshot, if present,\n // still gets a chance after a transient lock or filesystem failure.\n }\n }\n } finally {\n activeMirrors.delete(key);\n // A mirror can arrive between the last Map read and deleting the active\n // marker. Re-arm once so that snapshot cannot be stranded.\n const pending = pendingMirrors.get(key);\n if (pending) {\n pendingMirrors.delete(key);\n queueLatestMirror(\n pending.projectRoot,\n pending.sessionId,\n pending.graph,\n pending.sourceSystem,\n );\n }\n }\n })();\n}\n\nexport function todoListToSerializedGraph(\n todos: readonly TodoItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const nodes = todos.map((todo, index) => ({\n id: todo.id,\n title: todo.content,\n description: todo.activeForm ?? '',\n type: 'chore' as const,\n priority: 'medium' as const,\n status: todo.status,\n createdAt: index,\n updatedAt: index,\n }));\n return {\n id: `todo:${sessionId}`,\n specId: `todo:${sessionId}`,\n title: 'Session todos',\n nodes,\n edges: [],\n rootNodes: nodes.map((node) => node.id),\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nexport function taskFileToSerializedGraph(\n tasks: readonly TaskItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const ids = new Set(tasks.map((task) => task.id));\n const nodes = tasks.map((task, index) => ({\n id: task.id,\n title: task.title,\n description: task.description ?? '',\n type: task.type,\n priority: task.priority,\n status: task.status,\n ...(task.assignee ? { assignee: task.assignee } : {}),\n ...(task.estimateHours !== undefined ? { estimateHours: task.estimateHours } : {}),\n createdAt: index,\n updatedAt: index,\n }));\n const edges = tasks.flatMap((task) =>\n (task.dependsOn ?? [])\n .filter((dependency) => ids.has(dependency))\n .map((dependency) => ({\n id: `${dependency}->${task.id}`,\n from: dependency,\n to: task.id,\n type: 'depends_on' as const,\n })),\n );\n const hasIncoming = new Set(edges.map((edge) => edge.to));\n const rootNodes = nodes.filter((node) => !hasIncoming.has(node.id)).map((node) => node.id);\n return {\n // Keep the historical graph id so existing mirrored task cards are reused.\n id: `session:${sessionId}`,\n specId: `session:${sessionId}`,\n title: 'Session tasks',\n nodes,\n edges,\n rootNodes: rootNodes.length ? rootNodes : nodes[0] ? [nodes[0].id] : [],\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nconst PLAN_STATUS_TO_TASK: Record<PlanItem['status'], TaskStatus> = {\n open: 'pending',\n in_progress: 'in_progress',\n done: 'completed',\n};\n\nexport function planFileToSerializedGraph(\n items: readonly PlanItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const nodes = items.map((item, index) => ({\n id: item.id,\n title: item.title,\n description: item.details ?? '',\n type: 'chore' as const,\n priority: 'medium' as const,\n status: PLAN_STATUS_TO_TASK[item.status],\n createdAt: index,\n updatedAt: index,\n }));\n return {\n id: `plan:${sessionId}`,\n specId: `plan:${sessionId}`,\n title: 'Session plan',\n nodes,\n edges: [],\n rootNodes: nodes.map((node) => node.id),\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nexport function projectSessionTodosToKanban(\n projectRoot: string | undefined,\n todos: readonly TodoItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n todoListToSerializedGraph(todos, sessionId),\n 'session-todo',\n );\n}\n\nexport function projectSessionTasksToKanban(\n projectRoot: string | undefined,\n tasks: readonly TaskItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n taskFileToSerializedGraph(tasks, sessionId),\n 'session-task',\n );\n}\n\nexport function projectSessionPlanToKanban(\n projectRoot: string | undefined,\n items: readonly PlanItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n planFileToSerializedGraph(items, sessionId),\n 'session-plan',\n );\n}\n\nfunction fireAndForget(context: string, work: Promise<unknown>): void {\n void work.catch((err: unknown) => {\n const message = err instanceof Error ? err.message : String(err);\n console.warn(JSON.stringify({ level: 'warn', event: 'session-kanban', context, message, timestamp: new Date().toISOString() }));\n });\n}\n\nfunction broadcastTodoUpdate(context: Context, todos: readonly TodoItem[]): void {\n const sessionId = context.session?.id ?? '';\n if (!context.agentId || !sessionId) return;\n const projectDir = resolveWstackPaths({ projectRoot: context.projectRoot }).projectDir;\n const mailbox = new GlobalMailbox(projectDir);\n void mailbox\n .send({\n from: context.agentId,\n to: '*',\n type: 'status',\n subject: `Kanban todo list updated (${todos.length} item${todos.length === 1 ? '' : 's'})`,\n body: JSON.stringify({\n kind: 'kanban.todos.updated',\n sessionId,\n revision: context.state.revision,\n todos,\n }),\n priority: 'normal',\n senderSessionId: sessionId,\n ttlMs: 6 * 60 * 60 * 1000,\n })\n .catch(() => {\n // Mailbox awareness is best-effort; canonical state is already updated.\n });\n}\n\nfunction notifyTodoUpdate(context: Context, todos: readonly TodoItem[]): void {\n const summary = todos.length\n ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join('\\n')\n : '- No active todos remain.';\n const text = `[KANBAN TODO UPDATE]\\nAnother Kanban agent reassessed the shared board. The canonical todo list is now:\\n${summary}\\nReassess your current plan before continuing; do not rely on the initial todo snapshot.`;\n const state = context.state as Partial<Context['state']>;\n if (typeof state.appendBlockToLastUserMessage === 'function') {\n if (state.appendBlockToLastUserMessage({ type: 'text', text })) return;\n }\n if (typeof state.appendMessage === 'function') {\n state.appendMessage({ role: 'user', content: [{ type: 'text', text }] });\n }\n}\n\nexport function mirrorSessionTodosToKanban(\n projectRoot: string | undefined,\n todos: readonly TodoItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n todoListToSerializedGraph(todos, sessionId),\n 'session-todo',\n );\n}\n\nexport function mirrorSessionTasksToKanban(\n projectRoot: string | undefined,\n tasks: readonly TaskItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n taskFileToSerializedGraph(tasks, sessionId),\n 'session-task',\n );\n}\n\nexport function mirrorSessionPlanToKanban(\n projectRoot: string | undefined,\n items: readonly PlanItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n planFileToSerializedGraph(items, sessionId),\n 'session-plan',\n );\n}\n\n/**\n * Bind all live session work paths to Kanban. Todo changes are observed from\n * ConversationState; plan/task sidecars are watched so slash commands, WebUI,\n * plugins, and tools all pass through the same board.\n */\nexport function attachSessionKanbanMirror(context: Context): () => void {\n const existing = bindings.get(context);\n if (existing) return existing;\n\n const attachedProjectRoot = context.projectRoot ?? '';\n let registeredSessionId = '';\n const syncActiveSessionRegistration = () => {\n if (!attachedProjectRoot) return;\n const currentSessionId = context.session?.id ?? '';\n if (currentSessionId === registeredSessionId) return;\n if (registeredSessionId) {\n releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n fireAndForget(\n 'cleanup-board',\n cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId),\n );\n }\n registeredSessionId = currentSessionId;\n if (registeredSessionId) {\n retainActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n }\n };\n syncActiveSessionRegistration();\n\n let watcher: FSWatcher | null = null;\n let watchedDir = '';\n let timer: NodeJS.Timeout | null = null;\n let boardWatcher: FSWatcher | null = null;\n let watchedBoardId = '';\n let boardTimer: NodeJS.Timeout | null = null;\n let presenceTimer: NodeJS.Timeout | null = null;\n\n const sessionId = () => context.session?.id ?? '';\n const refreshFiles = async () => {\n const id = sessionId();\n if (!id) return;\n const planPath = context.meta['plan.path'];\n if (typeof planPath === 'string' && planPath) {\n const plan = await loadPlan(planPath);\n if (plan) await projectSessionPlanToKanban(context.projectRoot, plan.items, id);\n }\n const taskPath = context.meta['task.path'];\n if (typeof taskPath === 'string' && taskPath) {\n const tasks = await loadTasks(taskPath);\n if (tasks) await projectSessionTasksToKanban(context.projectRoot, tasks.tasks, id);\n }\n };\n\n const refreshBoard = async () => {\n if (!watchedBoardId) return;\n const board = await getBoard(context.projectRoot, watchedBoardId);\n if (board) applySessionKanbanBoardToTodos(context, board);\n };\n\n const configureBoardWatcher = async () => {\n const id = sessionId();\n const board = id ? await ensureSessionKanbanBoard(context.projectRoot, id) : null;\n if (!board || board.id === watchedBoardId) return;\n boardWatcher?.close();\n boardWatcher = null;\n watchedBoardId = board.id;\n try {\n const boardFileName = `${board.id}.json`;\n boardWatcher = watch(\n getKanbanDir(context.projectRoot),\n { persistent: false },\n (_event, filename) => {\n if (filename?.toString() !== boardFileName) return;\n if (boardTimer) clearTimeout(boardTimer);\n boardTimer = setTimeout(() => fireAndForget('refresh-board', refreshBoard()), 60);\n },\n );\n const touchPresence = () =>\n touchKanbanPresence(context.projectRoot, board.id, {\n sessionId: id,\n agentId: context.agentId,\n agentName: context.agentName,\n });\n fireAndForget('touch-presence', touchPresence());\n if (presenceTimer) clearInterval(presenceTimer);\n presenceTimer = setInterval(() => fireAndForget('touch-presence', touchPresence()), 60_000);\n presenceTimer.unref?.();\n boardWatcher.on('error', () => {\n boardWatcher?.close();\n boardWatcher = null;\n watchedBoardId = '';\n });\n } catch {\n boardWatcher = null;\n watchedBoardId = '';\n }\n };\n\n const configureWatcher = () => {\n const planPath = context.meta['plan.path'];\n const taskPath = context.meta['task.path'];\n const candidate =\n typeof planPath === 'string' && planPath\n ? dirname(planPath)\n : typeof taskPath === 'string' && taskPath\n ? dirname(taskPath)\n : '';\n if (!candidate || candidate === watchedDir) return;\n watcher?.close();\n watcher = null;\n watchedDir = candidate;\n try {\n watcher = watch(candidate, { persistent: false }, (_event, filename) => {\n const name = filename?.toString();\n const currentPlanPath = context.meta['plan.path'];\n const currentTaskPath = context.meta['task.path'];\n const planName = typeof currentPlanPath === 'string' ? basename(currentPlanPath) : '';\n const taskName = typeof currentTaskPath === 'string' ? basename(currentTaskPath) : '';\n if (name && name !== planName && name !== taskName) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => fireAndForget('refresh-files', refreshFiles()), 60);\n });\n watcher.on('error', () => watcher?.close());\n } catch {\n // The session directory can briefly disappear during project/session\n // switches; the next meta update re-attempts the binding.\n watcher = null;\n watchedDir = '';\n }\n };\n\n const unsubscribe = context.state.onChange((change) => {\n if (change.kind === 'todos_replaced' && !suppressedTodoMirrors.has(context)) {\n // ConversationState auto-clears an all-done tactical list. Project the\n // pre-clear completion snapshot so every card reaches Done atomically.\n mirrorSessionTodosToKanban(\n context.projectRoot,\n change.completedSnapshot ?? change.todos,\n sessionId(),\n );\n return;\n }\n if (change.kind === 'meta_set' && (change.key === 'plan.path' || change.key === 'task.path')) {\n syncActiveSessionRegistration();\n configureWatcher();\n fireAndForget('ensure-board', ensureSessionKanbanBoard(context.projectRoot, sessionId()));\n fireAndForget('configure-watcher', configureBoardWatcher());\n fireAndForget('refresh-files', refreshFiles());\n }\n });\n\n configureWatcher();\n fireAndForget('configure-watcher', configureBoardWatcher());\n\n const detach = () => {\n unsubscribe();\n if (timer) clearTimeout(timer);\n if (boardTimer) clearTimeout(boardTimer);\n if (presenceTimer) clearInterval(presenceTimer);\n watcher?.close();\n boardWatcher?.close();\n bindings.delete(context);\n if (attachedProjectRoot && registeredSessionId) {\n releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n fireAndForget(\n 'cleanup-board',\n cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId),\n );\n registeredSessionId = '';\n }\n };\n bindings.set(context, detach);\n return detach;\n}\n\n/** Fully hydrate a session board before a host announces the session as ready. */\nexport async function hydrateSessionKanban(context: Context): Promise<KanbanBoard | null> {\n const id = context.session?.id ?? '';\n if (!id) return null;\n await cleanupEmptySessionKanbanBoards(context.projectRoot, id);\n let board = await ensureSessionKanbanBoard(context.projectRoot, id);\n if (context.todos.length) {\n board = await projectSessionTodosToKanban(context.projectRoot, context.todos, id);\n }\n const planPath = context.meta['plan.path'];\n if (typeof planPath === 'string' && planPath) {\n const plan = await loadPlan(planPath);\n if (plan) board = await projectSessionPlanToKanban(context.projectRoot, plan.items, id);\n }\n const taskPath = context.meta['task.path'];\n if (typeof taskPath === 'string' && taskPath) {\n const tasks = await loadTasks(taskPath);\n if (tasks) board = await projectSessionTasksToKanban(context.projectRoot, tasks.tasks, id);\n }\n return board;\n}\n\nexport interface SessionKanbanSourceUpdate {\n source: 'todo' | 'task' | 'plan' | null;\n todos?: TodoItem[] | undefined;\n tasks?: TaskFile | undefined;\n plan?: PlanFile | undefined;\n}\n\nfunction sourceStatus(task: KanbanTask): TaskStatus {\n if (task.status === 'completed') return 'completed';\n if (task.status === 'in_progress') return 'in_progress';\n if (task.status === 'review') return 'review';\n if (task.status === 'blocked') return 'blocked';\n if (task.status === 'failed') return 'failed';\n return 'pending';\n}\n\nfunction todoStatus(task: KanbanTask): TodoItem['status'] {\n const status = sourceStatus(task);\n if (status === 'completed') return 'completed';\n if (status === 'in_progress' || status === 'review') return 'in_progress';\n return 'pending';\n}\n\nfunction sessionTodoFromTask(task: KanbanTask): TodoItem {\n return {\n id: task.origin?.taskId ?? task.id,\n content: task.title,\n status: todoStatus(task),\n ...(task.description ? { activeForm: task.description } : {}),\n };\n}\n\nfunction sameTodos(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {\n return (\n left.length === right.length &&\n left.every((todo, index) => {\n const candidate = right[index];\n return (\n candidate?.id === todo.id &&\n candidate.content === todo.content &&\n candidate.status === todo.status &&\n candidate.activeForm === todo.activeForm &&\n candidate.promotedFromPlan === todo.promotedFromPlan &&\n candidate.promotedFromTask === todo.promotedFromTask\n );\n })\n );\n}\n\n/**\n * Replace the tactical todo list from the current cards on a session-owned board.\n *\n * Existing mirrored todo cards retain their stable source ids. New cards created\n * by a Kanban worker become todos under their card ids, so reassessment can add,\n * split, merge, reprioritize, or remove work without being overwritten by the\n * todo list that happened to exist when the run started.\n */\nexport function applySessionKanbanBoardToTodos(context: Context, board: KanbanBoard): TodoItem[] {\n const sessionId = context.session?.id ?? '';\n if (\n !sessionId ||\n sessionIdFromTags(board.tags) !== sessionId ||\n !isOwnedSessionBoard(board.tags)\n ) {\n return [...context.todos];\n }\n\n const projectedTodos = board.tasks\n .filter(\n (task) =>\n task.status !== 'archived' &&\n (!task.origin ||\n task.origin.system === 'session-todo' ||\n (task.origin.graphId ?? '').startsWith('todo:')),\n )\n .sort((left, right) => {\n const leftColumn = board.columns.find((column) => column.id === left.columnId)?.order ?? 0;\n const rightColumn = board.columns.find((column) => column.id === right.columnId)?.order ?? 0;\n return (\n leftColumn - rightColumn ||\n left.order - right.order ||\n left.createdAt.localeCompare(right.createdAt)\n );\n })\n .map(sessionTodoFromTask);\n\n const allCompleted =\n projectedTodos.length > 0 && projectedTodos.every((todo) => todo.status === 'completed');\n const effectiveTodos = allCompleted ? [] : projectedTodos;\n if (sameTodos(context.todos, effectiveTodos)) return [...context.todos];\n suppressedTodoMirrors.add(context);\n try {\n context.state.replaceTodos(projectedTodos);\n } finally {\n suppressedTodoMirrors.delete(context);\n }\n notifyTodoUpdate(context, context.todos);\n broadcastTodoUpdate(context, context.todos);\n return [...context.todos];\n}\n\n/** Reflect a TUI/WebUI Kanban card edit back to its originating work list. */\nexport async function applySessionKanbanTaskToSource(\n context: Context,\n task: KanbanTask,\n options: { remove?: boolean | undefined } = {},\n): Promise<SessionKanbanSourceUpdate> {\n const originId = task.origin?.taskId;\n const graphId = task.origin?.graphId ?? '';\n if (!originId) return { source: null };\n\n if (task.origin?.system === 'session-todo' || graphId.startsWith('todo:')) {\n const next = options.remove\n ? context.todos.filter((todo) => todo.id !== originId)\n : context.todos.map((todo) =>\n todo.id === originId\n ? {\n ...todo,\n content: task.title,\n status:\n sourceStatus(task) === 'completed'\n ? ('completed' as const)\n : sourceStatus(task) === 'in_progress' || sourceStatus(task) === 'review'\n ? ('in_progress' as const)\n : ('pending' as const),\n }\n : todo,\n );\n suppressedTodoMirrors.add(context);\n try {\n context.state.replaceTodos(next);\n } finally {\n suppressedTodoMirrors.delete(context);\n }\n return { source: 'todo', todos: [...context.todos] };\n }\n\n const id = context.session?.id ?? '';\n if (task.origin?.system === 'session-plan' || graphId.startsWith('plan:')) {\n const planPath = context.meta['plan.path'];\n if (typeof planPath !== 'string' || !planPath) return { source: 'plan' };\n const plan = await mutatePlan(planPath, id, (file) => ({\n ...file,\n updatedAt: new Date().toISOString(),\n items: options.remove\n ? file.items.filter((item) => item.id !== originId)\n : file.items.map((item) =>\n item.id === originId\n ? {\n ...item,\n title: task.title,\n details: task.description,\n status:\n task.status === 'completed'\n ? ('done' as const)\n : task.status === 'in_progress' || task.status === 'review'\n ? ('in_progress' as const)\n : ('open' as const),\n updatedAt: new Date().toISOString(),\n }\n : item,\n ),\n }));\n return { source: 'plan', plan };\n }\n\n if (\n task.origin?.system === 'session-task' ||\n task.origin?.system === 'session' ||\n graphId.startsWith('session:')\n ) {\n const taskPath = context.meta['task.path'];\n if (typeof taskPath !== 'string' || !taskPath) return { source: 'task' };\n const tasks = await mutateTasks(taskPath, id, (file) => ({\n ...file,\n tasks: options.remove\n ? file.tasks.filter((item) => item.id !== originId)\n : file.tasks.map((item) =>\n item.id === originId\n ? {\n ...item,\n title: task.title,\n description: task.description,\n status: sourceStatus(task),\n updatedAt: new Date().toISOString(),\n }\n : item,\n ),\n }));\n return { source: 'task', tasks };\n }\n\n return { source: null };\n}\n"],
|
|
5
|
-
"mappings": ";AAAA;AAAA,EAEE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAAA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EAEE,eAAAC;AAAA,OACK;AACP,SAAS,sBAAsB;AAC/B,SAAS,kBAAkB;;;ACb3B,SAAS
|
|
4
|
+
"sourcesContent": ["import {\n type PlanFile,\n addPlanItem,\n clearPlan,\n deriveTodosFromPlanItem,\n formatPlan,\n getPlanTemplate,\n mutatePlan,\n removePlanItem,\n setPlanItemStatus,\n} from '@wrongstack/core/storage';\nimport {\n type TaskFile,\n mutateTasks,\n} from '@wrongstack/core/storage';\nimport { formatTaskList } from '@wrongstack/core/utils';\nimport { randomUUID } from 'node:crypto';\nimport type { Tool } from '@wrongstack/core/types';\nimport { projectSessionPlanToKanban } from './session-kanban.js';\n\n/**\n * `planTool` \u2014 the LLM-callable counterpart to the `/plan` slash command.\n *\n * Plans capture strategic, multi-step approaches that survive across\n * session resumes (unlike todos, which are tactical and per-turn).\n * Storage path comes from `ctx.meta['plan.path']` \u2014 the CLI seeds this\n * during startup so the tool always knows where to read/write.\n *\n * One tool, multiple actions, JSON in/out. The action discriminates the\n * operation so the LLM can do show / add / start / done / remove / promote /\n * derive / template_use / clear via a single tool registration instead of\n * bloating the surface with nine near-identical tools.\n */\ninterface PlanInput {\n action:\n | 'show'\n | 'add'\n | 'start'\n | 'done'\n | 'remove'\n | 'promote'\n | 'template_use'\n | 'clear'\n | 'taskify';\n /** Required for add. */\n title?: string | undefined;\n /** Optional detail line for add. */\n details?: string | undefined;\n /** Required for start/done/remove/promote \u2014 accepts plan item id OR 1-based index OR title substring. */\n target?: string | undefined;\n /** Optional subtasks for promote. If omitted, a single todo is created from the plan item title. */\n subtasks?: string[] | undefined;\n /** Required for template_use \u2014 the template name (e.g. \"new-feature\", \"bug-fix\"). */\n template?: string | undefined;\n /**\n * Storage scope. Default (unset): uses the session-scoped path \u2014 isolated to this\n * session, survives resume within the same session.\n * `scope: 'project'`: uses a shared project-level path, visible to all sessions\n * for this project. Useful for a shared roadmap that outlasts any single session.\n */\n scope?: 'session' | 'project';\n}\n\ninterface PlanOutput {\n ok: boolean;\n message: string;\n /** Formatted plan after the operation. Same string the user sees from `/plan show`. */\n plan: string;\n /** Total item count after the operation. */\n count: number;\n /** Number of items not in 'done' status. */\n open: number;\n /** When promote/derive succeed, the generated todo items so the caller can inspect them. */\n todos?: Array<{ id: string; content: string; status: string; activeForm?: string | undefined; promotedFromPlan?: string | undefined }>;\n}\n\nexport const planTool: Tool<PlanInput, PlanOutput> = {\n name: 'plan',\n category: 'Session',\n description:\n 'Manage a session-persistent strategic plan. The plan is written to disk and survives conversation resumptions within the same session, but is isolated to this session \u2014 other sessions have their own separate plans. ' +\n 'Unlike todos (which are per-turn and lost on restart), a plan tracks high-level progress across multiple turns. ' +\n 'Use this to outline big-picture work, then promote concrete items into the todo list when ready to execute. ' +\n 'By default plans are isolated to this session; use `scope: \"project\"` to store the plan in a shared project-level file visible to all sessions.',\n usageHint:\n 'RECOMMENDED FOR COMPLEX, MULTI-PHASE WORK:\\n\\n' +\n '- Start by creating a high-level plan with `action: \"add\"` or using templates (`template_use`).\\n' +\n '- Use `promote` to turn a plan item into actionable todos.\\n' +\n '- Use `taskify` to convert a plan item into a structured task (with type/priority/deps).\\n' +\n '- Keep plans at the \"why and what\" level, and todos at the \"how and next step\" level.\\n' +\n '- Common templates: \"new-feature\", \"bug-fix\", \"refactor\", \"release\", \"security-audit\".\\n\\n' +\n 'This tool is excellent for maintaining long-term direction across many turns within a session. Plans survive resume but are not shared across separate sessions.\\n' +\n 'Use `scope: \"project\"` to use a shared project-level plan file.',\n permission: 'confirm',\n mutating: true,\n capabilities: ['fs.write'],\n icon: 'plan',\n timeoutMs: 2_000,\n inputSchema: {\n type: 'object',\n properties: {\n action: {\n type: 'string',\n enum: [\n 'show',\n 'add',\n 'start',\n 'done',\n 'remove',\n 'promote',\n 'template_use',\n 'clear',\n 'taskify',\n ],\n description: 'The operation to perform on the plan board.',\n },\n title: {\n type: 'string',\n description: 'Title of the plan item. Required for action=add.',\n },\n details: {\n type: 'string',\n description: 'Additional details or description for a new plan item (action=add).',\n },\n target: {\n type: 'string',\n description:\n 'Identifier for the target plan item (id, 1-based index, or partial title). Required for most actions except add/show/clear.',\n },\n subtasks: {\n type: 'array',\n items: { type: 'string' },\n description:\n 'List of subtask titles. Used with promote to break a plan item into multiple todos.',\n },\n template: {\n type: 'string',\n description:\n 'Template identifier when using action=template_use. Common values: new-feature, bug-fix, refactor, release, security-audit.',\n },\n scope: {\n type: 'string',\n enum: ['session', 'project'],\n description: 'Storage scope: \"session\" (default, isolated to this session) or \"project\" (shared across all sessions for this project).',\n },\n },\n required: ['action'],\n },\n async execute(input, ctx) {\n const sessionPlanPath = (ctx.meta as Record<string, unknown>)['plan.path'] as string | undefined;\n let planPath: string | undefined;\n\n if (input.scope === 'project') {\n // Project-level: derive from the session path by replacing the filename with\n // 'backlog.plan.json' so all sessions share the same file.\n if (typeof sessionPlanPath === 'string') {\n // Handle BOTH separators \u2014 a Windows-native path uses '\\\\', and a\n // '/'-only search would miss it and fall back to a bare relative path\n // written into the process CWD instead of the sessions dir.\n const lastSep = Math.max(sessionPlanPath.lastIndexOf('/'), sessionPlanPath.lastIndexOf('\\\\'));\n planPath = lastSep >= 0\n ? sessionPlanPath.slice(0, lastSep + 1) + 'backlog.plan.json'\n : 'backlog.plan.json';\n }\n } else {\n planPath = sessionPlanPath;\n }\n if (typeof planPath !== 'string' || !planPath) {\n return {\n ok: false,\n message: 'Plan storage path is not configured for this session.',\n plan: '',\n count: 0,\n open: 0,\n };\n }\n const sessionId = ctx.session?.id ?? 'unknown';\n\n let early: PlanOutput | null = null;\n // Track taskify data \u2014 task write happens after the plan lock releases\n const taskifyMeta = { title: '', details: '' };\n let didTaskify = false;\n\n let plan: PlanFile;\n try {\n plan = await mutatePlan(planPath, sessionId, async (p) => {\n switch (input.action) {\n case 'show':\n break;\n\n case 'add': {\n const title = input.title?.trim();\n if (!title) {\n early = mkResult(p, false, 'add requires `title`.');\n return p;\n }\n const { plan: updated } = addPlanItem(p, title, input.details?.trim() || undefined);\n return updated;\n }\n\n case 'start':\n case 'done': {\n if (!input.target) {\n early = mkResult(p, false, `${input.action} requires \\`target\\` (id|index|substring).`);\n return p;\n }\n const next = setPlanItemStatus(\n p,\n input.target,\n input.action === 'start' ? 'in_progress' : 'done',\n );\n if (next === p) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n return next;\n }\n\n case 'remove': {\n if (!input.target) {\n early = mkResult(p, false, 'remove requires `target` (id|index|substring).');\n return p;\n }\n const next = removePlanItem(p, input.target);\n if (next === p) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n return next;\n }\n\n case 'promote': {\n if (!input.target) {\n early = mkResult(p, false, `${input.action} requires \\`target\\` (id|index|substring).`);\n return p;\n }\n const derived = deriveTodosFromPlanItem(p, input.target, input.subtasks);\n if (!derived) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n ctx.state.replaceTodos(derived.todos);\n early = mkResult(\n derived.plan,\n true,\n `${input.action} ok \u2014 ${derived.todos.length} todo(s) created.`,\n derived.todos,\n );\n return derived.plan;\n }\n\n case 'template_use': {\n const templateName = input.template?.trim();\n if (!templateName) {\n early = mkResult(p, false, 'template_use requires `template` name.');\n return p;\n }\n const template = getPlanTemplate(templateName);\n if (!template) {\n early = mkResult(p, false, `Unknown template \"${templateName}\".`);\n return p;\n }\n let updated = p;\n for (const item of template.items) {\n ({ plan: updated } = addPlanItem(updated, item.title, item.details));\n }\n early = mkResult(\n updated,\n true,\n `Applied template \"${template.name}\" \u2014 ${template.items.length} items added.`,\n );\n return updated;\n }\n\n case 'clear':\n return clearPlan(p);\n\n case 'taskify': {\n if (!input.target) {\n early = mkResult(p, false, 'taskify requires `target` (plan item id|index|substring).');\n return p;\n }\n // Find plan item by 1-based index, exact id, or title substring\n let itemIdx = -1;\n const asNum = Number.parseInt(input.target, 10);\n if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= p.items.length) {\n itemIdx = asNum - 1;\n } else {\n itemIdx = p.items.findIndex((it) => it.id === input.target);\n if (itemIdx === -1) {\n const lower = input.target.toLowerCase();\n itemIdx = p.items.findIndex((it) => it.title.toLowerCase().includes(lower));\n }\n }\n if (itemIdx === -1 || !p.items[itemIdx]) {\n early = mkResult(p, false, `No plan item matched \"${input.target}\".`);\n return p;\n }\n const item = p.items[itemIdx]!;\n // Extract data \u2014 task write happens after the plan lock releases\n taskifyMeta.title = item.title;\n taskifyMeta.details = item.details ?? '';\n didTaskify = true;\n break;\n }\n\n default:\n early = mkResult(p, false, `Unknown action \"${(input as { action: string }).action}\".`);\n return p;\n }\n\n return p;\n });\n } catch (err) {\n // Persist failed (mutatePlan throws on a failed save) \u2014 report ok:false\n // with the real reason instead of falsely claiming the plan was saved.\n return {\n ok: false,\n message: `Plan change not saved \u2014 ${err instanceof Error ? err.message : String(err)}`,\n plan: '',\n count: 0,\n open: 0,\n };\n }\n\n // A successful plan mutation includes its projection onto the unified\n // session board; callers never observe plan state ahead of Kanban state.\n await projectSessionPlanToKanban(ctx.projectRoot, plan.items, sessionId);\n\n // If the callback set an early-return result, use it\n if (early) return early;\n\n // If taskify copied plan item data, write it to the task file now\n if (didTaskify) {\n const taskPathRaw = (ctx.meta as Record<string, unknown>)['task.path'];\n if (typeof taskPathRaw !== 'string' || !taskPathRaw) {\n return mkResult(plan, false, 'Task storage path not configured \u2014 cannot taskify.');\n }\n let taskPath: string = taskPathRaw;\n // Honor project scope for the TASK file too: a project-scoped taskify must\n // append to the shared backlog.tasks.json, not the per-session task file\n // (mirrors the plan-path derivation above; handles both separators).\n if (input.scope === 'project') {\n const lastSep = Math.max(taskPath.lastIndexOf('/'), taskPath.lastIndexOf('\\\\'));\n taskPath = lastSep >= 0 ? taskPath.slice(0, lastSep + 1) + 'backlog.tasks.json' : 'backlog.tasks.json';\n }\n const now = new Date().toISOString();\n // Mutate the cross-file under ITS OWN lock \u2014 a raw loadTasks/push/saveTasks\n // can interleave with a concurrent task tool call in the same batch and\n // clobber writes. mutateTasks is the documented race-safe write path.\n try {\n const taskFile: TaskFile = await mutateTasks(taskPath, sessionId, (f) => {\n f.tasks.push({\n id: `task_${randomUUID()}`,\n title: taskifyMeta.title,\n description: taskifyMeta.details || undefined,\n type: 'feature',\n priority: 'medium',\n status: 'pending',\n createdAt: now,\n updatedAt: now,\n });\n return f;\n });\n return mkResult(\n plan,\n true,\n `taskify ok \u2014 added \"${taskifyMeta.title}\" to tasks.\\n${formatTaskList(taskFile.tasks)}`,\n );\n } catch (err) {\n // The plan item was saved, but copying it into the task file failed.\n return mkResult(plan, false, `taskify: task not saved \u2014 ${err instanceof Error ? err.message : String(err)}`);\n }\n }\n\n return mkResult(plan, true, `Plan ${input.action} ok.`);\n },\n};\n\nfunction mkResult(\n plan: PlanFile,\n ok: boolean,\n message: string,\n todos?: PlanOutput['todos'],\n): PlanOutput {\n const open = plan.items.filter((i) => i.status !== 'done').length;\n const result: PlanOutput = {\n ok,\n message,\n plan: formatPlan(plan),\n count: plan.items.length,\n open,\n };\n if (todos !== undefined) result.todos = todos;\n return result;\n}\n", "import { type FSWatcher, watch } from 'node:fs';\nimport { basename, dirname } from 'node:path';\nimport type { Context, TodoItem } from '@wrongstack/core/agent';\nimport { getSharedProjectMailbox } from '@wrongstack/core/coordination';\nimport {\n loadPlan,\n loadTasks,\n mutatePlan,\n mutateTasks,\n type PlanFile,\n type PlanItem,\n type TaskFile,\n} from '@wrongstack/core/storage';\nimport { deserializeTaskGraph } from '@wrongstack/core/tasking';\nimport type { SerializedTaskGraph, TaskStatus } from '@wrongstack/core/types';\nimport { resolveWstackPaths, type TaskItem } from '@wrongstack/core/utils';\nimport {\n bridgeKanbanSupervisor,\n createBoard,\n getBoard,\n type KanbanBoard,\n type KanbanColumn,\n type KanbanTask,\n listBoards,\n removeBoard,\n syncBoardFromTaskGraph,\n touchKanbanPresence,\n updateBoard,\n} from '@wrongstack/kanban';\n\nconst SESSION_BOARD_TAG = 'session-work';\nconst MIRROR_DISABLED_ENV = 'WRONGSTACK_KANBAN_TASK_MIRROR';\n\n/** The canonical workflow shared by WebUI and TUI session boards. */\nexport const SESSION_KANBAN_COLUMNS: KanbanColumn[] = [\n { id: 'todo', title: 'Todo', order: 0, wipLimit: 0, color: '#2563eb' },\n { id: 'in-progress', title: 'Running', order: 1, wipLimit: 1, color: '#d97706' },\n { id: 'review', title: 'Preview', order: 2, wipLimit: 0, color: '#7c3aed' },\n { id: 'done', title: 'Done', order: 3, wipLimit: 0, color: '#16a34a' },\n];\n\nconst boardQueue = new Map<string, Promise<void>>();\nconst boardEnsures = new Map<string, Promise<KanbanBoard>>();\ntype PendingMirror = {\n projectRoot: string;\n sessionId: string;\n graph: SerializedTaskGraph;\n sourceSystem: 'session-todo' | 'session-task' | 'session-plan';\n};\nconst pendingMirrors = new Map<string, PendingMirror>();\nconst activeMirrors = new Set<string>();\nconst bindings = new WeakMap<Context, () => void>();\nconst suppressedTodoMirrors = new WeakSet<Context>();\nconst activeSessionBoards = new Map<string, number>();\n\nfunction boardKey(projectRoot: string, sessionId: string): string {\n return `${projectRoot}\\0${sessionId}`;\n}\n\nfunction mirrorKey(\n projectRoot: string,\n sessionId: string,\n sourceSystem: PendingMirror['sourceSystem'],\n): string {\n return `${boardKey(projectRoot, sessionId)}\\0${sourceSystem}`;\n}\n\nfunction sessionTag(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\nfunction sessionBoardTitle(sessionId: string): string {\n const leaf = sessionId.split(/[\\\\/]/).filter(Boolean).pop() ?? sessionId;\n return `Session ${leaf.slice(0, 12)}`;\n}\n\nfunction sessionBoardTags(sessionId: string): string[] {\n return ['session', SESSION_BOARD_TAG, sessionTag(sessionId)];\n}\n\nfunction sessionIdFromTags(tags: readonly string[] | undefined): string | null {\n const tag = tags?.find((candidate) => candidate.startsWith('session:'));\n return tag?.slice('session:'.length) || null;\n}\n\nfunction isOwnedSessionBoard(tags: readonly string[] | undefined): boolean {\n return Boolean(tags?.includes(SESSION_BOARD_TAG) && sessionIdFromTags(tags));\n}\n\nfunction retainActiveSessionBoard(projectRoot: string, sessionId: string): void {\n const key = boardKey(projectRoot, sessionId);\n activeSessionBoards.set(key, (activeSessionBoards.get(key) ?? 0) + 1);\n}\n\nfunction releaseActiveSessionBoard(projectRoot: string, sessionId: string): void {\n const key = boardKey(projectRoot, sessionId);\n const remaining = (activeSessionBoards.get(key) ?? 0) - 1;\n if (remaining > 0) activeSessionBoards.set(key, remaining);\n else activeSessionBoards.delete(key);\n}\n\nfunction isSessionBoardActive(projectRoot: string, sessionId: string): boolean {\n return (activeSessionBoards.get(boardKey(projectRoot, sessionId)) ?? 0) > 0;\n}\n\nfunction sameColumns(columns: readonly KanbanColumn[]): boolean {\n return (\n columns.length === SESSION_KANBAN_COLUMNS.length &&\n columns.every((column, index) => column.id === SESSION_KANBAN_COLUMNS[index]?.id)\n );\n}\n\n/** Create (or migrate) the single Kanban board owned by a session. */\nexport async function ensureSessionKanbanBoard(\n projectRoot: string | undefined,\n sessionId: string,\n): Promise<KanbanBoard | null> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return null;\n const key = boardKey(projectRoot, sessionId);\n const inFlight = boardEnsures.get(key);\n if (inFlight) return inFlight;\n\n const promise = (async () => {\n const summary = (await listBoards(projectRoot)).find((board) =>\n board.tags?.includes(sessionTag(sessionId)),\n );\n let board = summary ? await getBoard(projectRoot, summary.id) : null;\n if (!board) {\n return createBoard(projectRoot, {\n title: sessionBoardTitle(sessionId),\n description: 'Live session work: todos, tasks, and plan items.',\n tags: sessionBoardTags(sessionId),\n columns: SESSION_KANBAN_COLUMNS,\n generatedBy: `session-kanban:${sessionId}`,\n });\n }\n\n // Boards produced by the older task/plan mirrors used the generic five\n // columns. Migrate only session-owned boards; updateBoard reconciles cards\n // from Backlog into Todo while preserving Running/Preview/Done cards.\n if (!sameColumns(board.columns) || !board.tags?.includes(SESSION_BOARD_TAG)) {\n board =\n (await updateBoard(projectRoot, board.id, {\n title: sessionBoardTitle(sessionId),\n description: 'Live session work: todos, tasks, and plan items.',\n tags: [...new Set([...(board.tags ?? []), ...sessionBoardTags(sessionId)])],\n columns: SESSION_KANBAN_COLUMNS,\n })) ?? board;\n }\n return board;\n })();\n\n boardEnsures.set(key, promise);\n try {\n return await promise;\n } finally {\n boardEnsures.delete(key);\n }\n}\n\nfunction enqueueBoardWork<T>(\n projectRoot: string,\n sessionId: string,\n work: () => Promise<T>,\n): Promise<T> {\n const key = boardKey(projectRoot, sessionId);\n const previous = boardQueue.get(key) ?? Promise.resolve();\n const result = previous.catch(() => undefined).then(work);\n const tail = result.then(\n () => undefined,\n () => undefined,\n );\n boardQueue.set(key, tail);\n void tail.then(() => {\n if (boardQueue.get(key) === tail) boardQueue.delete(key);\n });\n return result;\n}\n\nasync function removeEmptySessionBoard(\n projectRoot: string,\n boardId: string,\n sessionId: string,\n): Promise<string | null> {\n return enqueueBoardWork(projectRoot, sessionId, async () => {\n if (isSessionBoardActive(projectRoot, sessionId)) return null;\n const board = await getBoard(projectRoot, boardId);\n if (!board || board.tasks.length > 0 || !isOwnedSessionBoard(board.tags)) return null;\n if (sessionIdFromTags(board.tags) !== sessionId) return null;\n return (await removeBoard(projectRoot, board.id)) ? board.id : null;\n });\n}\n\n/** Remove a particular inactive session's system-owned board when it has no cards. */\nexport async function cleanupSessionKanbanBoardIfEmpty(\n projectRoot: string | undefined,\n sessionId: string,\n): Promise<string[]> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return [];\n if (isSessionBoardActive(projectRoot, sessionId)) return [];\n const candidates = (await listBoards(projectRoot)).filter(\n (board) =>\n board.taskCount === 0 &&\n isOwnedSessionBoard(board.tags) &&\n sessionIdFromTags(board.tags) === sessionId,\n );\n const removed = await Promise.all(\n candidates.map((board) => removeEmptySessionBoard(projectRoot, board.id, sessionId)),\n );\n return removed.filter((boardId): boardId is string => Boolean(boardId));\n}\n\n/** Prune stale empty session boards while preserving manual and live boards. */\nexport async function cleanupEmptySessionKanbanBoards(\n projectRoot: string | undefined,\n activeSessionId = '',\n): Promise<string[]> {\n if (!projectRoot || process.env[MIRROR_DISABLED_ENV] === '0') return [];\n const candidates = (await listBoards(projectRoot)).flatMap((board) => {\n const ownerSessionId = sessionIdFromTags(board.tags);\n return board.taskCount === 0 &&\n isOwnedSessionBoard(board.tags) &&\n ownerSessionId &&\n ownerSessionId !== activeSessionId &&\n !isSessionBoardActive(projectRoot, ownerSessionId)\n ? [{ boardId: board.id, sessionId: ownerSessionId }]\n : [];\n });\n const removed = await Promise.all(\n candidates.map(({ boardId, sessionId }) =>\n removeEmptySessionBoard(projectRoot, boardId, sessionId),\n ),\n );\n return removed.filter((boardId): boardId is string => Boolean(boardId));\n}\n\nasync function projectGraph(\n projectRoot: string | undefined,\n sessionId: string,\n graph: SerializedTaskGraph,\n sourceSystem: 'session-todo' | 'session-task' | 'session-plan',\n): Promise<KanbanBoard | null> {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return null;\n return enqueueBoardWork(projectRoot, sessionId, async () => {\n const board = await ensureSessionKanbanBoard(projectRoot, sessionId);\n if (!board) return null;\n const result = await syncBoardFromTaskGraph(\n projectRoot,\n board.id,\n deserializeTaskGraph(graph),\n {\n sourceSystem,\n tags: [...new Set([...(board.tags ?? []), ...sessionBoardTags(sessionId)])],\n archiveMissingTasks: true,\n includeCompletedTasks: true,\n },\n );\n return result?.board ?? null;\n });\n}\n\n/**\n * Queue an observational mirror without retaining every intermediate state.\n * Todo/task/plan streams are independent, but within each stream only the\n * newest pending graph matters. This bounds a stalled file lock to one active\n * and one pending projection instead of an arbitrarily long Promise chain.\n */\nfunction queueLatestMirror(\n projectRoot: string | undefined,\n sessionId: string,\n graph: SerializedTaskGraph,\n sourceSystem: PendingMirror['sourceSystem'],\n): void {\n if (!projectRoot || !sessionId || process.env[MIRROR_DISABLED_ENV] === '0') return;\n const key = mirrorKey(projectRoot, sessionId, sourceSystem);\n pendingMirrors.set(key, { projectRoot, sessionId, graph, sourceSystem });\n if (activeMirrors.has(key)) return;\n activeMirrors.add(key);\n void (async () => {\n try {\n for (;;) {\n const pending = pendingMirrors.get(key);\n if (!pending) break;\n pendingMirrors.delete(key);\n try {\n await projectGraph(\n pending.projectRoot,\n pending.sessionId,\n pending.graph,\n pending.sourceSystem,\n );\n } catch {\n // Mirrors are observational. A newer pending snapshot, if present,\n // still gets a chance after a transient lock or filesystem failure.\n }\n }\n } finally {\n activeMirrors.delete(key);\n // A mirror can arrive between the last Map read and deleting the active\n // marker. Re-arm once so that snapshot cannot be stranded.\n const pending = pendingMirrors.get(key);\n if (pending) {\n pendingMirrors.delete(key);\n queueLatestMirror(\n pending.projectRoot,\n pending.sessionId,\n pending.graph,\n pending.sourceSystem,\n );\n }\n }\n })();\n}\n\nexport function todoListToSerializedGraph(\n todos: readonly TodoItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const nodes = todos.map((todo, index) => ({\n id: todo.id,\n title: todo.content,\n description: todo.activeForm ?? '',\n type: 'chore' as const,\n priority: 'medium' as const,\n status: todo.status,\n createdAt: index,\n updatedAt: index,\n }));\n return {\n id: `todo:${sessionId}`,\n specId: `todo:${sessionId}`,\n title: 'Session todos',\n nodes,\n edges: [],\n rootNodes: nodes.map((node) => node.id),\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nexport function taskFileToSerializedGraph(\n tasks: readonly TaskItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const ids = new Set(tasks.map((task) => task.id));\n const nodes = tasks.map((task, index) => ({\n id: task.id,\n title: task.title,\n description: task.description ?? '',\n type: task.type,\n priority: task.priority,\n status: task.status,\n ...(task.assignee ? { assignee: task.assignee } : {}),\n ...(task.estimateHours !== undefined ? { estimateHours: task.estimateHours } : {}),\n createdAt: index,\n updatedAt: index,\n }));\n const edges = tasks.flatMap((task) =>\n (task.dependsOn ?? [])\n .filter((dependency) => ids.has(dependency))\n .map((dependency) => ({\n id: `${dependency}->${task.id}`,\n from: dependency,\n to: task.id,\n type: 'depends_on' as const,\n })),\n );\n const hasIncoming = new Set(edges.map((edge) => edge.to));\n const rootNodes = nodes.filter((node) => !hasIncoming.has(node.id)).map((node) => node.id);\n return {\n // Keep the historical graph id so existing mirrored task cards are reused.\n id: `session:${sessionId}`,\n specId: `session:${sessionId}`,\n title: 'Session tasks',\n nodes,\n edges,\n rootNodes: rootNodes.length ? rootNodes : nodes[0] ? [nodes[0].id] : [],\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nconst PLAN_STATUS_TO_TASK: Record<PlanItem['status'], TaskStatus> = {\n open: 'pending',\n in_progress: 'in_progress',\n done: 'completed',\n};\n\nexport function planFileToSerializedGraph(\n items: readonly PlanItem[],\n sessionId: string,\n): SerializedTaskGraph {\n const nodes = items.map((item, index) => ({\n id: item.id,\n title: item.title,\n description: item.details ?? '',\n type: 'chore' as const,\n priority: 'medium' as const,\n status: PLAN_STATUS_TO_TASK[item.status],\n createdAt: index,\n updatedAt: index,\n }));\n return {\n id: `plan:${sessionId}`,\n specId: `plan:${sessionId}`,\n title: 'Session plan',\n nodes,\n edges: [],\n rootNodes: nodes.map((node) => node.id),\n createdAt: 0,\n updatedAt: 0,\n };\n}\n\nexport function projectSessionTodosToKanban(\n projectRoot: string | undefined,\n todos: readonly TodoItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n todoListToSerializedGraph(todos, sessionId),\n 'session-todo',\n );\n}\n\nexport function projectSessionTasksToKanban(\n projectRoot: string | undefined,\n tasks: readonly TaskItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n taskFileToSerializedGraph(tasks, sessionId),\n 'session-task',\n );\n}\n\nexport function projectSessionPlanToKanban(\n projectRoot: string | undefined,\n items: readonly PlanItem[],\n sessionId: string,\n): Promise<KanbanBoard | null> {\n return projectGraph(\n projectRoot,\n sessionId,\n planFileToSerializedGraph(items, sessionId),\n 'session-plan',\n );\n}\n\nfunction fireAndForget(context: string, work: Promise<unknown>): void {\n void work.catch((err: unknown) => {\n const message = err instanceof Error ? err.message : String(err);\n console.warn(JSON.stringify({ level: 'warn', event: 'session-kanban', context, message, timestamp: new Date().toISOString() }));\n });\n}\n\nfunction broadcastTodoUpdate(context: Context, todos: readonly TodoItem[]): void {\n const sessionId = context.session?.id ?? '';\n if (!context.agentId || !sessionId) return;\n const projectDir = resolveWstackPaths({ projectRoot: context.projectRoot }).projectDir;\n const mailbox = getSharedProjectMailbox(projectDir);\n void mailbox\n .send({\n from: context.agentId,\n to: '*',\n type: 'status',\n subject: `Kanban todo list updated (${todos.length} item${todos.length === 1 ? '' : 's'})`,\n body: JSON.stringify({\n kind: 'kanban.todos.updated',\n sessionId,\n revision: context.state.revision,\n todos,\n }),\n priority: 'normal',\n senderSessionId: sessionId,\n ttlMs: 6 * 60 * 60 * 1000,\n })\n .catch(() => {\n // Mailbox awareness is best-effort; canonical state is already updated.\n });\n}\n\nfunction notifyTodoUpdate(context: Context, todos: readonly TodoItem[]): void {\n const summary = todos.length\n ? todos.map((todo) => `- [${todo.status}] ${todo.content} (${todo.id})`).join('\\n')\n : '- No active todos remain.';\n const text = `[KANBAN TODO UPDATE]\\nAnother Kanban agent reassessed the shared board. The canonical todo list is now:\\n${summary}\\nReassess your current plan before continuing; do not rely on the initial todo snapshot.`;\n const state = context.state as Partial<Context['state']>;\n if (typeof state.appendBlockToLastUserMessage === 'function') {\n if (state.appendBlockToLastUserMessage({ type: 'text', text })) return;\n }\n if (typeof state.appendMessage === 'function') {\n state.appendMessage({ role: 'user', content: [{ type: 'text', text }] });\n }\n}\n\nexport function mirrorSessionTodosToKanban(\n projectRoot: string | undefined,\n todos: readonly TodoItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n todoListToSerializedGraph(todos, sessionId),\n 'session-todo',\n );\n}\n\nexport function mirrorSessionTasksToKanban(\n projectRoot: string | undefined,\n tasks: readonly TaskItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n taskFileToSerializedGraph(tasks, sessionId),\n 'session-task',\n );\n}\n\nexport function mirrorSessionPlanToKanban(\n projectRoot: string | undefined,\n items: readonly PlanItem[],\n sessionId: string,\n): void {\n queueLatestMirror(\n projectRoot,\n sessionId,\n planFileToSerializedGraph(items, sessionId),\n 'session-plan',\n );\n}\n\n/**\n * Bind all live session work paths to Kanban. Todo changes are observed from\n * ConversationState; plan/task sidecars are watched so slash commands, WebUI,\n * plugins, and tools all pass through the same board.\n */\nexport function attachSessionKanbanMirror(context: Context): () => void {\n const existing = bindings.get(context);\n if (existing) return existing;\n\n const attachedProjectRoot = context.projectRoot ?? '';\n let registeredSessionId = '';\n const syncActiveSessionRegistration = () => {\n if (!attachedProjectRoot) return;\n const currentSessionId = context.session?.id ?? '';\n if (currentSessionId === registeredSessionId) return;\n if (registeredSessionId) {\n releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n fireAndForget(\n 'cleanup-board',\n cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId),\n );\n }\n registeredSessionId = currentSessionId;\n if (registeredSessionId) {\n retainActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n }\n };\n syncActiveSessionRegistration();\n\n let watcher: FSWatcher | null = null;\n let watchedDir = '';\n let timer: NodeJS.Timeout | null = null;\n let unsubscribeBoardEvents: (() => void) | null = null;\n let watchedBoardId = '';\n let boardTimer: NodeJS.Timeout | null = null;\n let presenceTimer: NodeJS.Timeout | null = null;\n\n const sessionId = () => context.session?.id ?? '';\n const refreshFiles = async () => {\n const id = sessionId();\n if (!id) return;\n const planPath = context.meta['plan.path'];\n if (typeof planPath === 'string' && planPath) {\n const plan = await loadPlan(planPath);\n if (plan) await projectSessionPlanToKanban(context.projectRoot, plan.items, id);\n }\n const taskPath = context.meta['task.path'];\n if (typeof taskPath === 'string' && taskPath) {\n const tasks = await loadTasks(taskPath);\n if (tasks) await projectSessionTasksToKanban(context.projectRoot, tasks.tasks, id);\n }\n };\n\n const refreshBoard = async () => {\n if (!watchedBoardId) return;\n const board = await getBoard(context.projectRoot, watchedBoardId);\n if (board) applySessionKanbanBoardToTodos(context, board);\n };\n\n const configureBoardWatcher = async () => {\n const id = sessionId();\n const board = id ? await ensureSessionKanbanBoard(context.projectRoot, id) : null;\n if (!board || board.id === watchedBoardId) return;\n unsubscribeBoardEvents?.();\n unsubscribeBoardEvents = null;\n watchedBoardId = board.id;\n try {\n unsubscribeBoardEvents = bridgeKanbanSupervisor(\n context.projectRoot,\n (event) => {\n const data = event.data as { boardId?: string } | undefined;\n if (data?.boardId !== board.id) return;\n if (boardTimer) clearTimeout(boardTimer);\n boardTimer = setTimeout(() => fireAndForget('refresh-board', refreshBoard()), 60);\n },\n { autoReconnect: true, reconnectDelayMs: 1_000 },\n );\n const touchPresence = () =>\n touchKanbanPresence(context.projectRoot, board.id, {\n sessionId: id,\n agentId: context.agentId,\n agentName: context.agentName,\n });\n fireAndForget('touch-presence', touchPresence());\n if (presenceTimer) clearInterval(presenceTimer);\n presenceTimer = setInterval(() => fireAndForget('touch-presence', touchPresence()), 60_000);\n presenceTimer.unref?.();\n } catch {\n unsubscribeBoardEvents = null;\n watchedBoardId = '';\n }\n };\n\n const configureWatcher = () => {\n const planPath = context.meta['plan.path'];\n const taskPath = context.meta['task.path'];\n const candidate =\n typeof planPath === 'string' && planPath\n ? dirname(planPath)\n : typeof taskPath === 'string' && taskPath\n ? dirname(taskPath)\n : '';\n if (!candidate || candidate === watchedDir) return;\n watcher?.close();\n watcher = null;\n watchedDir = candidate;\n try {\n watcher = watch(candidate, { persistent: false }, (_event, filename) => {\n const name = filename?.toString();\n const currentPlanPath = context.meta['plan.path'];\n const currentTaskPath = context.meta['task.path'];\n const planName = typeof currentPlanPath === 'string' ? basename(currentPlanPath) : '';\n const taskName = typeof currentTaskPath === 'string' ? basename(currentTaskPath) : '';\n if (name && name !== planName && name !== taskName) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => fireAndForget('refresh-files', refreshFiles()), 60);\n });\n watcher.on('error', () => watcher?.close());\n } catch {\n // The session directory can briefly disappear during project/session\n // switches; the next meta update re-attempts the binding.\n watcher = null;\n watchedDir = '';\n }\n };\n\n const unsubscribe = context.state.onChange((change) => {\n if (change.kind === 'todos_replaced' && !suppressedTodoMirrors.has(context)) {\n // ConversationState auto-clears an all-done tactical list. Project the\n // pre-clear completion snapshot so every card reaches Done atomically.\n mirrorSessionTodosToKanban(\n context.projectRoot,\n change.completedSnapshot ?? change.todos,\n sessionId(),\n );\n return;\n }\n if (change.kind === 'meta_set' && (change.key === 'plan.path' || change.key === 'task.path')) {\n syncActiveSessionRegistration();\n configureWatcher();\n fireAndForget('ensure-board', ensureSessionKanbanBoard(context.projectRoot, sessionId()));\n fireAndForget('configure-watcher', configureBoardWatcher());\n fireAndForget('refresh-files', refreshFiles());\n }\n });\n\n configureWatcher();\n fireAndForget('configure-watcher', configureBoardWatcher());\n\n const detach = () => {\n unsubscribe();\n if (timer) clearTimeout(timer);\n if (boardTimer) clearTimeout(boardTimer);\n if (presenceTimer) clearInterval(presenceTimer);\n watcher?.close();\n unsubscribeBoardEvents?.();\n bindings.delete(context);\n if (attachedProjectRoot && registeredSessionId) {\n releaseActiveSessionBoard(attachedProjectRoot, registeredSessionId);\n fireAndForget(\n 'cleanup-board',\n cleanupSessionKanbanBoardIfEmpty(attachedProjectRoot, registeredSessionId),\n );\n registeredSessionId = '';\n }\n };\n bindings.set(context, detach);\n return detach;\n}\n\n/** Fully hydrate a session board before a host announces the session as ready. */\nexport async function hydrateSessionKanban(context: Context): Promise<KanbanBoard | null> {\n const id = context.session?.id ?? '';\n if (!id) return null;\n await cleanupEmptySessionKanbanBoards(context.projectRoot, id);\n let board = await ensureSessionKanbanBoard(context.projectRoot, id);\n if (context.todos.length) {\n board = await projectSessionTodosToKanban(context.projectRoot, context.todos, id);\n }\n const planPath = context.meta['plan.path'];\n if (typeof planPath === 'string' && planPath) {\n const plan = await loadPlan(planPath);\n if (plan) board = await projectSessionPlanToKanban(context.projectRoot, plan.items, id);\n }\n const taskPath = context.meta['task.path'];\n if (typeof taskPath === 'string' && taskPath) {\n const tasks = await loadTasks(taskPath);\n if (tasks) board = await projectSessionTasksToKanban(context.projectRoot, tasks.tasks, id);\n }\n return board;\n}\n\nexport interface SessionKanbanSourceUpdate {\n source: 'todo' | 'task' | 'plan' | null;\n todos?: TodoItem[] | undefined;\n tasks?: TaskFile | undefined;\n plan?: PlanFile | undefined;\n}\n\nfunction sourceStatus(task: KanbanTask): TaskStatus {\n if (task.status === 'completed') return 'completed';\n if (task.status === 'in_progress') return 'in_progress';\n if (task.status === 'review') return 'review';\n if (task.status === 'blocked') return 'blocked';\n if (task.status === 'failed') return 'failed';\n return 'pending';\n}\n\nfunction todoStatus(task: KanbanTask): TodoItem['status'] {\n const status = sourceStatus(task);\n if (status === 'completed') return 'completed';\n if (status === 'in_progress' || status === 'review') return 'in_progress';\n return 'pending';\n}\n\nfunction sessionTodoFromTask(task: KanbanTask): TodoItem {\n return {\n id: task.origin?.taskId ?? task.id,\n content: task.title,\n status: todoStatus(task),\n ...(task.description ? { activeForm: task.description } : {}),\n };\n}\n\nfunction sameTodos(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {\n return (\n left.length === right.length &&\n left.every((todo, index) => {\n const candidate = right[index];\n return (\n candidate?.id === todo.id &&\n candidate.content === todo.content &&\n candidate.status === todo.status &&\n candidate.activeForm === todo.activeForm &&\n candidate.promotedFromPlan === todo.promotedFromPlan &&\n candidate.promotedFromTask === todo.promotedFromTask\n );\n })\n );\n}\n\n/**\n * Replace the tactical todo list from the current cards on a session-owned board.\n *\n * Existing mirrored todo cards retain their stable source ids. New cards created\n * by a Kanban worker become todos under their card ids, so reassessment can add,\n * split, merge, reprioritize, or remove work without being overwritten by the\n * todo list that happened to exist when the run started.\n */\nexport function applySessionKanbanBoardToTodos(context: Context, board: KanbanBoard): TodoItem[] {\n const sessionId = context.session?.id ?? '';\n if (\n !sessionId ||\n sessionIdFromTags(board.tags) !== sessionId ||\n !isOwnedSessionBoard(board.tags)\n ) {\n return [...context.todos];\n }\n\n const projectedTodos = board.tasks\n .filter(\n (task) =>\n task.status !== 'archived' &&\n (!task.origin ||\n task.origin.system === 'session-todo' ||\n (task.origin.graphId ?? '').startsWith('todo:')),\n )\n .sort((left, right) => {\n const leftColumn = board.columns.find((column) => column.id === left.columnId)?.order ?? 0;\n const rightColumn = board.columns.find((column) => column.id === right.columnId)?.order ?? 0;\n return (\n leftColumn - rightColumn ||\n left.order - right.order ||\n left.createdAt.localeCompare(right.createdAt)\n );\n })\n .map(sessionTodoFromTask);\n\n const allCompleted =\n projectedTodos.length > 0 && projectedTodos.every((todo) => todo.status === 'completed');\n const effectiveTodos = allCompleted ? [] : projectedTodos;\n if (sameTodos(context.todos, effectiveTodos)) return [...context.todos];\n suppressedTodoMirrors.add(context);\n try {\n context.state.replaceTodos(projectedTodos);\n } finally {\n suppressedTodoMirrors.delete(context);\n }\n notifyTodoUpdate(context, context.todos);\n broadcastTodoUpdate(context, context.todos);\n return [...context.todos];\n}\n\n/** Reflect a TUI/WebUI Kanban card edit back to its originating work list. */\nexport async function applySessionKanbanTaskToSource(\n context: Context,\n task: KanbanTask,\n options: { remove?: boolean | undefined } = {},\n): Promise<SessionKanbanSourceUpdate> {\n const originId = task.origin?.taskId;\n const graphId = task.origin?.graphId ?? '';\n if (!originId) return { source: null };\n\n if (task.origin?.system === 'session-todo' || graphId.startsWith('todo:')) {\n const next = options.remove\n ? context.todos.filter((todo) => todo.id !== originId)\n : context.todos.map((todo) =>\n todo.id === originId\n ? {\n ...todo,\n content: task.title,\n status:\n sourceStatus(task) === 'completed'\n ? ('completed' as const)\n : sourceStatus(task) === 'in_progress' || sourceStatus(task) === 'review'\n ? ('in_progress' as const)\n : ('pending' as const),\n }\n : todo,\n );\n suppressedTodoMirrors.add(context);\n try {\n context.state.replaceTodos(next);\n } finally {\n suppressedTodoMirrors.delete(context);\n }\n return { source: 'todo', todos: [...context.todos] };\n }\n\n const id = context.session?.id ?? '';\n if (task.origin?.system === 'session-plan' || graphId.startsWith('plan:')) {\n const planPath = context.meta['plan.path'];\n if (typeof planPath !== 'string' || !planPath) return { source: 'plan' };\n const plan = await mutatePlan(planPath, id, (file) => ({\n ...file,\n updatedAt: new Date().toISOString(),\n items: options.remove\n ? file.items.filter((item) => item.id !== originId)\n : file.items.map((item) =>\n item.id === originId\n ? {\n ...item,\n title: task.title,\n details: task.description,\n status:\n task.status === 'completed'\n ? ('done' as const)\n : task.status === 'in_progress' || task.status === 'review'\n ? ('in_progress' as const)\n : ('open' as const),\n updatedAt: new Date().toISOString(),\n }\n : item,\n ),\n }));\n return { source: 'plan', plan };\n }\n\n if (\n task.origin?.system === 'session-task' ||\n task.origin?.system === 'session' ||\n graphId.startsWith('session:')\n ) {\n const taskPath = context.meta['task.path'];\n if (typeof taskPath !== 'string' || !taskPath) return { source: 'task' };\n const tasks = await mutateTasks(taskPath, id, (file) => ({\n ...file,\n tasks: options.remove\n ? file.tasks.filter((item) => item.id !== originId)\n : file.tasks.map((item) =>\n item.id === originId\n ? {\n ...item,\n title: task.title,\n description: task.description,\n status: sourceStatus(task),\n updatedAt: new Date().toISOString(),\n }\n : item,\n ),\n }));\n return { source: 'task', tasks };\n }\n\n return { source: null };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["mutatePlan", "mutateTasks", "board", "mutatePlan", "mutateTasks"]
|
|
7
7
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-registry-persistent.d.ts","sourceRoot":"","sources":["../src/process-registry-persistent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AA8BrF,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,4IAA4I;IAC5I,SAAS,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACrC,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,CAAC,CAAC;IACX,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;IAC/C,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;CACrB;AAkJD;;;GAGG;AACH,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,OAAO,CAAC,iBAAiB,CAA+C;IACxE,OAAO,CAAC,eAAe,CAA+C;IACtE,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa,
|
|
1
|
+
{"version":3,"file":"process-registry-persistent.d.ts","sourceRoot":"","sources":["../src/process-registry-persistent.ts"],"names":[],"mappings":"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"}
|
package/dist/ps-slash.js
CHANGED
|
@@ -706,7 +706,11 @@ var PersistentProcessRegistry = class {
|
|
|
706
706
|
cleanupRunning = false;
|
|
707
707
|
isShuttingDown = false;
|
|
708
708
|
onProcessExit = () => {
|
|
709
|
-
|
|
709
|
+
this.runInBackground(
|
|
710
|
+
this.syncToPersistent(),
|
|
711
|
+
"process_registry.exit_sync_failed",
|
|
712
|
+
"PersistentProcessRegistry: exit sync failed"
|
|
713
|
+
);
|
|
710
714
|
};
|
|
711
715
|
constructor(baseRegistry) {
|
|
712
716
|
this.instanceId = generateInstanceId();
|
|
@@ -726,6 +730,11 @@ var PersistentProcessRegistry = class {
|
|
|
726
730
|
if (!isNodeError(err) || err.code !== "EEXIST") throw err;
|
|
727
731
|
}
|
|
728
732
|
}
|
|
733
|
+
runInBackground(operation, event, message) {
|
|
734
|
+
void operation.catch((err) => {
|
|
735
|
+
emitStructuredLog("warn", event, message, err);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
729
738
|
/**
|
|
730
739
|
* Start the heartbeat and periodic cleanup tasks.
|
|
731
740
|
*/
|
|
@@ -758,26 +767,34 @@ var PersistentProcessRegistry = class {
|
|
|
758
767
|
this.cleanupInterval = null;
|
|
759
768
|
}
|
|
760
769
|
process.off("exit", this.onProcessExit);
|
|
761
|
-
|
|
770
|
+
this.runInBackground(
|
|
771
|
+
this.syncToPersistent(),
|
|
772
|
+
"process_registry.stop_sync_failed",
|
|
773
|
+
"PersistentProcessRegistry: stop sync failed"
|
|
774
|
+
);
|
|
762
775
|
}
|
|
763
776
|
/**
|
|
764
777
|
* Register the main WrongStack process as protected.
|
|
765
778
|
*/
|
|
766
779
|
registerMainProcess() {
|
|
767
780
|
const mainPid = process.pid;
|
|
768
|
-
this.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
+
this.runInBackground(
|
|
782
|
+
this.updatePersistentEntry({
|
|
783
|
+
pid: mainPid,
|
|
784
|
+
name: "wrongstack-main",
|
|
785
|
+
command: process.argv.slice(0, 3).join(" "),
|
|
786
|
+
startedAt: Date.now(),
|
|
787
|
+
lastHeartbeat: Date.now(),
|
|
788
|
+
instanceId: this.instanceId,
|
|
789
|
+
hostname: os2.hostname(),
|
|
790
|
+
protected: true,
|
|
791
|
+
spawnMode: "main",
|
|
792
|
+
parentPid: process.ppid,
|
|
793
|
+
platform: process.platform
|
|
794
|
+
}),
|
|
795
|
+
"process_registry.main_register_failed",
|
|
796
|
+
"PersistentProcessRegistry: failed to register main process"
|
|
797
|
+
);
|
|
781
798
|
}
|
|
782
799
|
/**
|
|
783
800
|
* Register a spawned child process with the persistent registry.
|
|
@@ -800,7 +817,11 @@ var PersistentProcessRegistry = class {
|
|
|
800
817
|
if (sessionId) {
|
|
801
818
|
entry.sessionId = sessionId;
|
|
802
819
|
}
|
|
803
|
-
this.
|
|
820
|
+
this.runInBackground(
|
|
821
|
+
this.updatePersistentEntry(entry),
|
|
822
|
+
"process_registry.child_register_failed",
|
|
823
|
+
"PersistentProcessRegistry: failed to register child process"
|
|
824
|
+
);
|
|
804
825
|
}
|
|
805
826
|
/**
|
|
806
827
|
* Update or add an entry in the persistent registry.
|
|
@@ -844,14 +865,28 @@ var PersistentProcessRegistry = class {
|
|
|
844
865
|
heartbeat() {
|
|
845
866
|
if (this.isShuttingDown || this.heartbeatRunning) return;
|
|
846
867
|
this.heartbeatRunning = true;
|
|
847
|
-
void this.syncToPersistent().
|
|
868
|
+
void this.syncToPersistent().catch((err) => {
|
|
869
|
+
emitStructuredLog(
|
|
870
|
+
"warn",
|
|
871
|
+
"process_registry.heartbeat_failed",
|
|
872
|
+
"PersistentProcessRegistry: heartbeat failed",
|
|
873
|
+
err
|
|
874
|
+
);
|
|
875
|
+
}).finally(() => {
|
|
848
876
|
this.heartbeatRunning = false;
|
|
849
877
|
});
|
|
850
878
|
}
|
|
851
879
|
cleanup() {
|
|
852
880
|
if (this.isShuttingDown || this.cleanupRunning) return;
|
|
853
881
|
this.cleanupRunning = true;
|
|
854
|
-
void this.cleanupStaleEntries().
|
|
882
|
+
void this.cleanupStaleEntries().catch((err) => {
|
|
883
|
+
emitStructuredLog(
|
|
884
|
+
"warn",
|
|
885
|
+
"process_registry.periodic_cleanup_failed",
|
|
886
|
+
"PersistentProcessRegistry: periodic cleanup failed",
|
|
887
|
+
err
|
|
888
|
+
);
|
|
889
|
+
}).finally(() => {
|
|
855
890
|
this.cleanupRunning = false;
|
|
856
891
|
});
|
|
857
892
|
}
|