SLOPSHOPPER

TaskPlanner

Task-first AI workflows over markdown tasks in your repo: MCP tools, skills, and a /taskplanner:board pane for the Claude Code terminal and the Claude Desktop…

panebandguardcommandtimer
★ 23v2.5.1MITupdated 2026-10-16smekai/taskplanner/plugins/taskplanner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taskplanner
│ ┃ TaskPlanner ✕ › fix the failing auth test and add an audit log call │ ┃ TaskPlanner r: Refresh │ ┃ No .tasks/ board in this folder or above it. ⏺ Read(src/auth.ts) │ ┃ Ask Claude to initialize TaskPlanner here, ⎿ Read 6 lines │ ┃ then run /taskboard again. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /taskboard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TaskPlanner
TaskPlanner r: Refresh No .tasks/ board in this folder or above it. Ask Claude to initialize TaskPlanner here, then run /taskboard again.
README

TaskPlanner — Cursor, Codex and Claude Code Plugin

Markdown-based task management for AI agents. This shared package gives Cursor, Codex and Claude Code the ability to read, create, move, plan, and implement tasks stored in .tasks/ files.

Use this plugin for agent workflows; use the TaskPlanner VS Code extension for persistent editor-side lists and kanban panels.

What's included

ComponentDescription
MCP Server8 tools: board overview, list, get, create, move, update, board-data (JSON), board-visual (inline UI)
Cursor commands/list-tasks, /next-task, /continue-task slash commands
SkillsTaskPlanner, initialize, update, list, next-task, continue-task, and board workflows for Codex and compatible hosts
RuleTask markdown format conventions (fires when editing .tasks/ files)
Claude Code board pane/taskplanner:board (or /taskboard) opens a live board beside the transcript in the Claude Code terminal and the Claude Desktop Code tab; a band above the prompt shows the task in progress
ManifestsHost-specific Cursor, Codex and Claude Code manifests backed by one package

Usage

Once installed, the agent can:

  • List tasks — use /list-tasks in Cursor, $taskplanner:list-tasks in Codex, or ask naturally
  • Pick next task — use /next-task in Cursor or $taskplanner:next-task in Codex
  • Continue current work — use /continue-task in Cursor or $taskplanner:continue-task in Codex
  • Initialize or update a project — ask to set up TaskPlanner or invoke the initialize/update skills; MCP tools are preferred when available and direct .tasks file operations provide the public skills-only fallback
  • Use MCP tools directly — e.g. "create a P1 task for fixing the login bug"
  • Show the board — /taskplanner:board in Claude Code opens the board pane; $taskplanner:board in Codex and the board skill in Cursor show it through the MCP tools
  • Show the visual board — ask "open the visual task board" (or invoke taskplanner_board_visual) to render an inline interactive kanban with drag-to-move and click-for-details (requires an MCP Apps host, such as Cursor 2.6+)
  • Get board JSON — invoke taskplanner_board_data for a machine-readable board view-model (states + cards), useful for hosts without MCP UI rendering

The MCP board is separate from the VS Code extension's TaskPlanner: Open Kanban Board panel. Inline rendering is experimental in Codex; text and structured JSON remain available when the UI is not rendered.

Requirements

  • A workspace with a .tasks/ directory (run TaskPlanner: Initialize Project from the VS Code extension)
  • Node.js (the MCP server runs as a Node process)
  • Built plugin artifacts (dist/mcp-server.js and ui/board/index.html) present in the plugin package

If your host does not support MCP Apps UI, taskplanner_board_visual may not render an iframe; use taskplanner_board_data and the standard task tools (taskplanner_list, taskplanner_get, taskplanner_move, etc.) as fallback.

All TaskPlanner tools accept workspace_root. Agent workflows pass the absolute active repository path explicitly because Codex starts bundled MCP servers from the installed plugin cache, not from the repository.

Installation

Claude Code (terminal and Claude Desktop Code tab)

The repository marketplace lives at .claude-plugin/marketplace.json. In a Claude Code terminal session:

/plugin install taskplanner --marketplace smekai/taskplanner

From a shell: claude plugin install taskplanner --marketplace smekai/taskplanner. In the Claude Desktop app, click + next to the prompt box, then Plugins, Add marketplace with smekai/taskplanner, then Add plugin. A plugin installed at user scope also loads in the Desktop Code tab's local sessions.

Then, in a project with .tasks/, run /taskplanner:board (or the shorter /taskboard):

  • The pane lists tasks per state (digits switch tabs, r refreshes). Open states are sorted by priority, while Done and Rejected keep file order, newest first. A long list shows 50 tasks and a Show all button. Pressing a task opens its description and plan. From there you can:
  • Move to another state: the row of state buttons (digits) changes the task's status, for example Backlog → Next.
  • Start with Claude (s): sends "Work on TASK-… following the TaskPlanner workflow" as your next prompt.
  • Edit with Claude (e): puts "Update TASK-…: " in the prompt box for you to finish. Editing title, description, plan, tags or priority goes through Claude.
  • New task (n) opens a short form: title, priority and state. Enter or Create creates it, and the server assigns the ID. The new task then opens, ready for Edit with Claude to add a description.
  • In the Desktop Code tab the list is richer: a titled header with counts per state, tasks as cards with a coloured priority, and a primary Start with Claude button. With Claude Code 2.1.299 or later it also uses the Desktop type scale. The terminal keeps a compact list.
  • The pane follows the board while it is open. It rereads .tasks/ after TaskPlanner tools or file edits there, and checks the files every 5 seconds for changes made elsewhere.
  • While a task is In Progress, a band above the prompt names it; its Board button opens the pane.
  • Like the MCP server, the pane finds .tasks/ in the session folder or in any folder above it, so it also works from a package subfolder. Where there is none, the plugin draws nothing.

Requirements and limits:

  • Claude Code 2.1.287 or later in the terminal (claude update), or 2.1.286 or later in the Desktop Code tab (/status shows it). The pane is a Claude Code mod, an API Anthropic marks as early access, and it may change between releases.
  • The pane is not drawn in Desktop WSL sessions, in the VS Code extension's chat panel, or under claude -p. There, the commands reply with a text digest of the board.
  • Plugins cannot choose where a pane sits, and the mods API has no way to add a launcher icon to the Desktop top bar. A wide fullscreen terminal docks the pane beside the transcript, and a narrower one opens it above the prompt. The Desktop app places it itself.
  • /taskplanner:board is this plugin's board skill, which the mod answers by opening the pane. In Cursor and Codex the same skill shows the board through the MCP tools. /taskboard is the short name; /tasks is built into Claude Code and /taskplanner belongs to the plugin's main skill.
  • The mod runs unsandboxed with your permissions, like any Claude Code mod. It reads the .tasks/ files itself with the same parser the MCP server uses, so reading the board never needs a permission. claude plugin validate plugins/taskplanner lists every call it makes.
  • Moving and creating a task go through this plugin's MCP server (taskplanner_move, taskplanner_create), so IDs, timestamps and file layout stay exactly as the server writes them. When you move or create a task in the pane and your permission rules would only ask, the mod approves its own call. So there is no prompt, and the call also passes in auto mode. A deny rule, or any other decision your rules reach, stands. When Claude calls the TaskPlanner tools itself, your normal permission rules apply. The mobile surface stays read-only.
  • If your project's .mcp.json also runs npx @smekai/taskplanner, Claude sees the TaskPlanner tools twice. That happens because Claude Code merges servers only when their commands match. Remove the project entry once the plugin is installed.

Codex app and CLI — repository marketplace

The repository marketplace lives at .agents/plugins/marketplace.json. From the repository root, register the non-default local marketplace and install the plugin:

codex plugin marketplace add .
codex plugin add taskplanner@refined-taskplanner

The local development flow applies a Codex cachebuster before reinstalling. Always start a new Codex task after reinstalling so the updated skills are loaded.

Restart the ChatGPT desktop app or start a new Codex task after installation. Open Plugins, choose TaskPlanner, and verify the taskplanner skills and MCP tools are enabled. Node.js must be available because the bundled MCP server runs locally over stdio.

For local changes, rebuild and reinstall the plugin before opening a new task:

npm run build
npm run validate:codex-plugin
codex plugin add taskplanner@refined-taskplanner

Via Cursor Marketplace

Search for "taskplanner" in the Cursor marketplace panel.

Local test on Windows (recommended before publish)

Build and validate from the repository root, then copy the plugin into Cursor's supported local-development directory:

npm.cmd run build
npm.cmd run validate:cursor-plugin

$cursorPluginTarget = Join-Path $env:USERPROFILE '.cursor\plugins\local\taskplanner'
if (Test-Path -LiteralPath $cursorPluginTarget) {
  Remove-Item -LiteralPath $cursorPluginTarget -Recurse -Force
}
Copy-Item -LiteralPath 'plugins\taskplanner' -Destination $cursorPluginTarget -Recurse

Then:

  1. Fully restart Cursor or run Developer: Reload Window.
  2. Open Customize and locate the local TaskPlanner plugin.
  3. Verify these entries appear:
  4. MCP: taskplanner (toggle ON)
  5. Skills: taskplanner
  6. Rules: taskplanner-workflow
  7. Commands: /list-tasks, /next-task, /continue-task
  8. In Agent chat (workspace with .tasks/), ask: open the visual task board

Headless checks from repo root:

npm run validate:cursor-plugin
npm run smoke:mcp-server

The local plugin path is:

%USERPROFILE%\.cursor\plugins\local\taskplanner

Via the VS Code extension

Install the Task → Plan → AI VS Code extension — the plugin is auto-registered.

Manual (macOS/Linux)

ln -s /path/to/taskplanner/plugins/taskplanner ~/.cursor/plugins/local/taskplanner

Task format

Tasks live in .tasks/*.md as ## TASK-###: Title sections separated by ---:

## TASK-001: Example task
**Priority:** P1
**Tags:** feature, core

Description in markdown.

---

License

MIT. The optional README attribution added during initialization is voluntary and removable; it is not a license condition.

Marketplace submission checklist

For maintainers preparing a publish/update:

  1. Build the repo root artifacts:
  2. npm run build
  3. Validate plugin packaging from repo root:
  4. npm run validate:cursor-plugin
  5. npm run validate:codex-plugin
  6. npm run validate:claude-plugin (needs Claude Code 2.1.287+)
  7. Confirm public source repo + open-source license metadata in both manifests under plugins/taskplanner/.
  8. Ensure the Cursor and Codex marketplace files point to plugins/taskplanner.
  9. Submit/update at cursor.com/marketplace/publish.
Source 1 files
hooks/register.js 1474 lines
1// plugins/taskplanner/mod/src/boardModel.ts
2var IN_PROGRESS_STATE = "In Progress";
3var COMPLETED_STATES = ["Done", "Rejected"];
4var BOARD_FILE_PATTERN = /^(.+\.md|config\.json)$/;
5var DEFAULT_TAB_ORDER = [IN_PROGRESS_STATE, "Next", "Backlog"];
6var TASKPLANNER_WRITE_TOOL = /__taskplanner_(create|move|update)$/;
7var PANE_WRITE_TOOL = /^mcp__.+__taskplanner_(move|create)$/;
8var TASK_ID_IN_TEXT = /\b([A-Z][A-Z0-9]*-\d+)\b/;
9var PRIORITIES = ["P0", "P1", "P2", "P3", "P4"];
10var DEFAULT_NEW_PRIORITY = "P2";
11var OWN_PLUGIN_IDS = /* @__PURE__ */ new Set([
12  "taskplanner",
13  "taskplanner@refined-taskplanner"
14]);
15var FILE_EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
16var TASKS_DIR_SEGMENT = /(^|[\\/])\.tasks[\\/]/;
17var PERMISSION_REFUSAL = /refused|permission|classifier|haven't granted/i;
18function resultText(result) {
19  return result.content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n").trim();
20}
21function slug(name) {
22  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
23}
24function stringField(record, key) {
25  const value = record[key];
26  return typeof value === "string" ? value.trim() : "";
27}
28function tagsOf(task) {
29  return Array.isArray(task.tags) ? task.tags.filter((tag) => typeof tag === "string") : [];
30}
31function taskBody(task) {
32  const sections = [];
33  const description = stringField(task, "description");
34  if (description) sections.push(description);
35  const plan = stringField(task, "plan");
36  if (plan) sections.push(`### Plan
37
38${plan}`);
39  return sections.join("\n\n");
40}
41function taskMarkdown(task) {
42  const meta = [
43    `**Priority:** ${stringField(task, "priority")}`,
44    `**State:** ${stringField(task, "state")}`
45  ];
46  const tags = tagsOf(task);
47  if (tags.length > 0) meta.push(`**Tags:** ${tags.join(", ")}`);
48  for (const [key, label] of [
49    ["epic", "Epic"],
50    ["assignee", "Assignee"],
51    ["waitingUntil", "Waiting until"],
52    ["updatedAt", "Updated"]
53  ]) {
54    const value = stringField(task, key);
55    if (value) meta.push(`**${label}:** ${value}`);
56  }
57  const sections = [
58    `## ${stringField(task, "id")}: ${stringField(task, "title")}`,
59    meta.join(" \xB7 ")
60  ];
61  const body = taskBody(task);
62  if (body) sections.push(body);
63  return sections.join("\n\n");
64}
65function boardDigestText(board2) {
66  const counts = board2.states.map((state) => `${state.name} ${state.totalCount}`).join(" \xB7 ");
67  const lines = [counts];
68  for (const name of [IN_PROGRESS_STATE, "Next"]) {
69    for (const task of stateNamed(board2, name)?.tasks ?? []) {
70      lines.push(`- ${name}: ${task.id} ${task.priority} ${task.title}`);
71    }
72  }
73  return lines.join("\n");
74}
75function stateNamed(board2, name) {
76  return board2.states.find((state) => state.name === name);
77}
78function pickTab(board2, current) {
79  if (current && stateNamed(board2, current)) return current;
80  const withTasks = DEFAULT_TAB_ORDER.find(
81    (name) => (stateNamed(board2, name)?.totalCount ?? 0) > 0
82  );
83  if (withTasks) return withTasks;
84  const present = DEFAULT_TAB_ORDER.find((name) => stateNamed(board2, name));
85  return present ?? board2.states[0]?.name ?? null;
86}
87function isCompletedState(name) {
88  return name !== null && COMPLETED_STATES.includes(name);
89}
90function moveTargets(board2, currentState) {
91  return board2.states.map((state) => state.name).filter((name) => name !== currentState);
92}
93function moveKey(target) {
94  return `move-${slug(target)}`;
95}
96function moveTargetForKey(board2, currentState, key) {
97  return moveTargets(board2, currentState).find((target) => moveKey(target) === key) ?? null;
98}
99function inProgressTask(board2) {
100  return board2 && stateNamed(board2, IN_PROGRESS_STATE)?.tasks[0] || null;
101}
102function shouldRefreshAfterTool(tool, input) {
103  if (TASKPLANNER_WRITE_TOOL.test(tool)) return true;
104  if (!FILE_EDIT_TOOLS.has(tool)) return false;
105  const target = input.file_path ?? input.notebook_path;
106  return typeof target === "string" && TASKS_DIR_SEGMENT.test(target);
107}
108var TEXT_SCALE_VERSION = [2, 1, 299];
109function supportsTextScale(version) {
110  const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
111  if (!match) return false;
112  const parts = match.slice(1).map(Number);
113  for (let index = 0; index < TEXT_SCALE_VERSION.length; index += 1) {
114    if (parts[index] !== TEXT_SCALE_VERSION[index]) return parts[index] > TEXT_SCALE_VERSION[index];
115  }
116  return true;
117}
118function isOwnPlugin(name) {
119  return OWN_PLUGIN_IDS.has(name);
120}
121function isPaneWriteByOwnPlugin(origin, tool) {
122  return isOwnPlugin(origin) && PANE_WRITE_TOOL.test(tool);
123}
124function paneCheckDecision(origin, tool, verdict) {
125  if (verdict.decision !== "ask" || !isPaneWriteByOwnPlugin(origin, tool)) return verdict;
126  return { decision: "allow", reason: "Requested from the TaskPlanner board pane." };
127}
128function ancestorDirs(start) {
129  const dirs = [];
130  let current = start.replace(/[\\/]+$/, "") || start;
131  for (; ; ) {
132    dirs.push(current);
133    const cut = current.search(/[\\/][^\\/]*$/);
134    if (cut < 0) break;
135    const parent = cut === 0 ? current[0] : current.slice(0, cut);
136    if (parent === current) break;
137    current = parent;
138  }
139  return dirs;
140}
141function describeError(message) {
142  if (PERMISSION_REFUSAL.test(message)) {
143    return {
144      title: "Claude Code did not allow the change.",
145      detail: `Allow the TaskPlanner move and create tools for this project, then try again. ${message}`
146    };
147  }
148  return { title: "Something went wrong with the board.", detail: message };
149}
150function newTaskStates(board2) {
151  const names = board2?.states.map((state) => state.name) ?? [];
152  return names.filter((name) => !isCompletedState(name));
153}
154function startDraft(board2) {
155  const states = newTaskStates(board2);
156  const state = states.includes("Backlog") ? "Backlog" : states[0] ?? "Backlog";
157  return { title: "", priority: DEFAULT_NEW_PRIORITY, state };
158}
159function createdTaskId(result) {
160  const structured = result.structuredContent;
161  if (typeof structured?.task?.id === "string") return structured.task.id;
162  const text = resultText(result);
163  try {
164    const parsed = JSON.parse(text);
165    if (typeof parsed.task?.id === "string") return parsed.task.id;
166  } catch {
167    return TASK_ID_IN_TEXT.exec(text)?.[1] ?? null;
168  }
169  return TASK_ID_IN_TEXT.exec(text)?.[1] ?? null;
170}
171function editPrompt(task) {
172  return `Update ${task.id} (${task.title}): `;
173}
174function boardSignature(files) {
175  return [...files].sort((a, b) => a.name.localeCompare(b.name)).map((file) => `${file.name}:${file.size}:${file.mtimeMs}`).join("|");
176}
177function joinPath(root, ...parts) {
178  return [root.replace(/[\\/]+$/, ""), ...parts].join("/");
179}
180function startPrompt(task) {
181  const verb = task.state === IN_PROGRESS_STATE ? "Continue" : "Work on";
182  return `${verb} ${task.id} (${task.title}) following the TaskPlanner workflow.`;
183}
184function truncate(text, width) {
185  if (width <= 1) return text.slice(0, Math.max(0, width));
186  return text.length <= width ? text : `${text.slice(0, width - 1)}\u2026`;
187}
188function errorMessage(error) {
189  return error instanceof Error ? error.message : String(error);
190}
191
192// src/core/model/state.ts
193var DEFAULT_STATES = [
194  { name: "Backlog", fileName: "BACKLOG.md", order: 0 },
195  { name: "Next", fileName: "NEXT.md", order: 1 },
196  { name: "In Progress", fileName: "IN_PROGRESS.md", order: 2 },
197  { name: "Done", fileName: "DONE.md", order: 3 },
198  { name: "Rejected", fileName: "REJECTED.md", order: 4 }
199];
200
201// src/core/config/configStates.ts
202function isUsableState(value) {
203  if (!value || typeof value !== "object") return false;
204  const state = value;
205  return typeof state.name === "string" && state.name.length > 0 && typeof state.fileName === "string" && state.fileName.length > 0 && typeof state.order === "number" && Number.isFinite(state.order);
206}
207function normalizeStates(value, report) {
208  if (!Array.isArray(value)) {
209    report(`"states" must be an array of {name, fileName, order}; using the default board.`);
210    return [...DEFAULT_STATES];
211  }
212  const normalized = [];
213  for (const entry of value) {
214    if (isUsableState(entry)) {
215      normalized.push({ name: entry.name, fileName: entry.fileName, order: entry.order });
216      continue;
217    }
218    const name = typeof entry === "string" ? entry : entry?.name;
219    const known = DEFAULT_STATES.find((s) => s.name === name);
220    if (known) {
221      report(`State "${known.name}" was malformed in config.json; restored from the defaults.`);
222      normalized.push({ ...known });
223      continue;
224    }
225    report(
226      `Unusable entry in "states": ${JSON.stringify(entry)}. Using the default board instead.`
227    );
228    return [...DEFAULT_STATES];
229  }
230  if (normalized.length === 0) {
231    report(`"states" was empty; using the default board.`);
232    return [...DEFAULT_STATES];
233  }
234  return normalized;
235}
236
237// src/core/filter/taskFilter.ts
238var DEFAULT_LIMIT = 50;
239function applyLimit(items, totalCount, limit) {
240  if (limit === null) {
241    return { sliced: items, hasMore: false };
242  }
243  return { sliced: items.slice(0, limit), hasMore: totalCount > limit };
244}
245function taskToViewItem(task) {
246  return {
247    id: task.id,
248    title: task.title,
249    priority: task.priority,
250    tags: [...task.tags],
251    epic: task.epic,
252    assignee: task.assignee,
253    updatedAt: task.updatedAt,
254    description: task.description
255  };
256}
257function matchesQuery(task, query) {
258  const q = query.toLowerCase();
259  return task.id.toLowerCase().includes(q) || task.title.toLowerCase().includes(q) || (task.assignee?.toLowerCase().includes(q) ?? false) || (task.updatedAt?.toLowerCase().includes(q) ?? false) || task.tags.some((tag) => tag.toLowerCase().includes(q));
260}
261function matchesTag(task, tag) {
262  return task.tags.includes(tag);
263}
264var PRIORITY_ORDER = { P0: 0, P1: 1, P2: 2, P3: 3, P4: 4 };
265function compareById(a, b) {
266  return a.id.localeCompare(b.id);
267}
268function sortTasks(tasks, sortBy) {
269  if (sortBy === "file") {
270    return [...tasks];
271  }
272  const sorted = [...tasks];
273  sorted.sort((a, b) => {
274    switch (sortBy) {
275      case "priority": {
276        const diff = (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9);
277        return diff !== 0 ? diff : compareById(a, b);
278      }
279      case "name": {
280        const diff = a.title.localeCompare(b.title);
281        return diff !== 0 ? diff : compareById(a, b);
282      }
283      case "id":
284        return compareById(a, b);
285      default:
286        return 0;
287    }
288  });
289  return sorted;
290}
291function filterAndPaginate(tasksByState, states, filter, limit = DEFAULT_LIMIT, sortBy = "priority", stateDisplayCounts) {
292  const result = [];
293  for (const state of states) {
294    if (filter?.status && filter.status !== state.name) {
295      continue;
296    }
297    let tasks = tasksByState.get(state.name) ?? [];
298    if (filter?.query) {
299      tasks = tasks.filter((t) => matchesQuery(t, filter.query));
300    }
301    if (filter?.tag) {
302      tasks = tasks.filter((t) => matchesTag(t, filter.tag));
303    }
304    tasks = sortTasks(tasks, sortBy);
305    const hasContentFilter = Boolean(filter?.query?.trim()) || Boolean(filter?.tag);
306    const totalCount = hasContentFilter ? tasks.length : stateDisplayCounts?.get(state.name) ?? tasks.length;
307    const { sliced, hasMore } = applyLimit(tasks, totalCount, limit);
308    result.push({
309      name: state.name,
310      tasks: sliced.map(taskToViewItem),
311      totalCount,
312      hasMore
313    });
314  }
315  return { states: result, filter };
316}
317
318// src/core/model/task.ts
319var Priority = /* @__PURE__ */ ((Priority2) => {
320  Priority2["P0"] = "P0";
321  Priority2["P1"] = "P1";
322  Priority2["P2"] = "P2";
323  Priority2["P3"] = "P3";
324  Priority2["P4"] = "P4";
325  return Priority2;
326})(Priority || {});
327
328// src/core/parser/grammar.ts
329var LINE_BREAK = /\r?\n/;
330var TASK_HEADING_RE = /^## ([A-Z]+-\d+):\s*(\S.*)$/;
331var SEPARATOR_RE = /^---\s*$/;
332var PLAN_HEADING_RE = /^### Plan\s*$/;
333var ATTRIBUTE_RE = /^\*\*(.+?):\*\*\s*(.*)$/;
334var SEPARATOR_BEFORE_NEXT_FIELD = /\s\|\s(?=\*\*)/;
335var BUILT_IN_FIELD_RES = {
336  priority: /^\*\*Priority:\*\*\s*(\S*)/,
337  tags: /^\*\*Tags?:\*\*\s*(.+)/,
338  epic: /^\*\*Epic:\*\*\s*(.+)/,
339  assignee: /^\*\*Assignee:\*\*\s*(.+)/,
340  updatedAt: /^\*\*Updated:\*\*\s*(.+)/,
341  waitingUntil: /^\*\*Waiting until:\*\*\s*(.+)/
342};
343function taskHeadingOf(line) {
344  const match = line.match(TASK_HEADING_RE);
345  return match ? { id: match[1], title: match[2].trim() } : void 0;
346}
347function taskHeadingIdOf(line) {
348  return taskHeadingOf(line)?.id;
349}
350function looksLikeTaskHeading(line) {
351  return /^##\s/.test(line) && !/^###/.test(line);
352}
353function isSectionSeparatorLine(line) {
354  return SEPARATOR_RE.test(line);
355}
356function isPlanHeadingLine(line) {
357  return PLAN_HEADING_RE.test(line);
358}
359function builtInFieldOf(segment) {
360  for (const [field, pattern] of Object.entries(BUILT_IN_FIELD_RES)) {
361    const match = segment.match(pattern);
362    if (match) return { field, value: match[1].trim() };
363  }
364  return void 0;
365}
366function attributeOf(segment) {
367  const match = segment.match(ATTRIBUTE_RE);
368  return match ? { key: match[1].trim(), value: match[2].trim() } : void 0;
369}
370function splitFieldGroup(line) {
371  return line.split(SEPARATOR_BEFORE_NEXT_FIELD).map((segment) => segment.trim());
372}
373var PRIORITY_VALUES = Object.values(Priority);
374function parsePriority(value) {
375  const normalized = value.trim().toUpperCase();
376  return PRIORITY_VALUES.find((priority) => priority === normalized);
377}
378
379// src/core/parser/fileSections.ts
380function lineSpans(content) {
381  const spans = [];
382  let start = 0;
383  while (start < content.length) {
384    const brk = content.indexOf("\n", start);
385    const stop = brk === -1 ? content.length : brk;
386    spans.push({
387      text: content.slice(start, stop).replace(/\r$/, ""),
388      start,
389      end: brk === -1 ? content.length : brk + 1
390    });
391    if (brk === -1) break;
392    start = brk + 1;
393  }
394  return spans;
395}
396function sectionEnd(spans, from) {
397  for (let i = from; i < spans.length; i++) {
398    if (isSectionSeparatorLine(spans[i].text)) return i + 1;
399    if (i > from && taskHeadingIdOf(spans[i].text) !== void 0) return i;
400  }
401  return spans.length;
402}
403function splitSections(content) {
404  const spans = lineSpans(content);
405  const sections = [];
406  const sliceOf = (from, to) => content.slice(spans[from].start, spans[to - 1].end);
407  let at = 0;
408  let textFrom = 0;
409  const flushText = (to) => {
410    if (to > textFrom) {
411      sections.push({ kind: "text", raw: sliceOf(textFrom, to), line: textFrom + 1 });
412    }
413  };
414  while (at < spans.length) {
415    const id = taskHeadingIdOf(spans[at].text);
416    if (id === void 0) {
417      at++;
418      continue;
419    }
420    flushText(at);
421    const end = sectionEnd(spans, at);
422    sections.push({ kind: "task", id, raw: sliceOf(at, end), line: at + 1 });
423    at = end;
424    textFrom = end;
425  }
426  flushText(spans.length);
427  return sections;
428}
429
430// src/core/parser/taskSection.ts
431var MISSING_PRIORITY = "P4" /* P4 */;
432function assign(collected, segment) {
433  const builtIn = builtInFieldOf(segment);
434  if (builtIn) {
435    collected.fields[builtIn.field] = builtIn.value;
436    return true;
437  }
438  const attribute = attributeOf(segment);
439  if (attribute) {
440    collected.attributes[attribute.key] = attribute.value;
441    return true;
442  }
443  return false;
444}
445function readFieldOrFieldGroup(collected, line) {
446  const segments = splitFieldGroup(line);
447  let matched = false;
448  for (const segment of segments) {
449    if (assign(collected, segment)) matched = true;
450  }
451  return matched;
452}
453function tagsOf2(raw) {
454  if (raw === void 0) return [];
455  return raw.split(",").map((tag) => tag.trim()).filter(Boolean);
456}
457function priorityOf(raw, line, errors, warnings) {
458  if (raw === void 0) {
459    warnings.push({ line, message: `No **Priority:** line; read as ${MISSING_PRIORITY}.` });
460    return MISSING_PRIORITY;
461  }
462  const parsed = parsePriority(raw);
463  if (parsed) return parsed;
464  const valid = PRIORITY_VALUES.join(", ");
465  const message = raw.length === 0 ? `**Priority:** has no value; expected one of ${valid}.` : `**Priority:** "${raw}" is not a priority; expected one of ${valid}.`;
466  errors.push({ line, message });
467  return MISSING_PRIORITY;
468}
469function parseTaskSection(raw, startLine) {
470  const lines = raw.split(LINE_BREAK);
471  const errors = [];
472  const warnings = [];
473  const heading = taskHeadingOf(lines[0]);
474  if (!heading) {
475    return { errors, warnings };
476  }
477  const collected = { fields: {}, attributes: {}, description: [], plan: [] };
478  let inMetadata = true;
479  let inPlan = false;
480  let priorityLine = startLine;
481  for (let i = 1; i < lines.length; i++) {
482    const line = lines[i];
483    if (isSectionSeparatorLine(line)) break;
484    if (inMetadata) {
485      if (readFieldOrFieldGroup(collected, line)) {
486        if (collected.fields.priority !== void 0 && priorityLine === startLine) {
487          priorityLine = startLine + i;
488        }
489        continue;
490      }
491      inMetadata = false;
492      if (line.trim() === "") continue;
493    }
494    if (isPlanHeadingLine(line)) {
495      inPlan = true;
496      continue;
497    }
498    (inPlan ? collected.plan : collected.description).push(line);
499  }
500  const plan = collected.plan.join("\n").trim();
501  const task = {
502    id: heading.id,
503    title: heading.title,
504    description: collected.description.join("\n").trim(),
505    priority: priorityOf(collected.fields.priority, priorityLine, errors, warnings),
506    tags: tagsOf2(collected.fields.tags),
507    epic: collected.fields.epic,
508    assignee: collected.fields.assignee,
509    updatedAt: collected.fields.updatedAt,
510    waitingUntil: collected.fields.waitingUntil,
511    ...plan ? { plan } : {},
512    ...Object.keys(collected.attributes).length > 0 ? { attributes: collected.attributes } : {}
513  };
514  return { task, errors, warnings };
515}
516
517// src/core/parser/taskParser.ts
518var BOM = "\uFEFF";
519function stripBom(content) {
520  return content.startsWith(BOM) ? content.slice(1) : content;
521}
522function brokenHeadings(section) {
523  const lines = section.raw.split(LINE_BREAK);
524  const starts = lines.map((line, index) => looksLikeTaskHeading(line) ? index : -1).filter((index) => index !== -1);
525  return starts.map((start, nth) => ({
526    line: section.line + start,
527    message: `"${lines[start].trim()}" is not a task heading, so this section was not read as a task. Use "## PREFIX-000: Title" with an uppercase prefix, digits, and a title.`,
528    raw: lines.slice(start, starts[nth + 1] ?? lines.length).join("\n")
529  }));
530}
531function parseTasks(rawContent) {
532  const content = stripBom(rawContent);
533  const tasks = [];
534  const errors = [];
535  const warnings = [];
536  const segments = [];
537  if (rawContent.startsWith(BOM)) {
538    segments.push({ kind: "text", raw: BOM });
539  }
540  for (const section of splitSections(content)) {
541    if (section.kind === "text") {
542      errors.push(...brokenHeadings(section));
543      segments.push({ kind: "text", raw: section.raw });
544      continue;
545    }
546    const parsed = parseTaskSection(section.raw, section.line);
547    errors.push(...parsed.errors);
548    warnings.push(...parsed.warnings);
549    if (parsed.task) {
550      tasks.push(parsed.task);
551      segments.push({ kind: "task", task: parsed.task, raw: section.raw });
552      continue;
553    }
554    errors.push(...brokenHeadings(section));
555    segments.push({ kind: "text", raw: section.raw });
556  }
557  return { tasks, errors, warnings, segments };
558}
559function headingIds(rawContent) {
560  return stripBom(rawContent).split(LINE_BREAK).map(taskHeadingIdOf).filter((id) => id !== void 0);
561}
562function countTaskHeadings(rawContent) {
563  return headingIds(rawContent).length;
564}
565
566// plugins/taskplanner/mod/src/boardReader.ts
567var BOARD_TASK_LIMIT = 50;
568function withoutBom(text) {
569  return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
570}
571function readConfigStates(configText) {
572  let parsed;
573  try {
574    parsed = JSON.parse(withoutBom(configText));
575  } catch {
576    throw new Error(".tasks/config.json is not valid JSON.");
577  }
578  const states = parsed?.states;
579  return states === void 0 ? [...DEFAULT_STATES] : normalizeStates(states, () => void 0);
580}
581function indexTask(index, entry) {
582  const copies = index.get(entry.task.id);
583  if (copies) copies.push(entry);
584  else index.set(entry.task.id, [entry]);
585}
586function buildBoard(states, fileTexts, options, workspaceRoot) {
587  const tasksByState = /* @__PURE__ */ new Map();
588  const displayCounts = /* @__PURE__ */ new Map();
589  const tasks = /* @__PURE__ */ new Map();
590  const deferredStates = [];
591  let issueCount2 = 0;
592  for (const state of states) {
593    const text = fileTexts.get(state.fileName) ?? "";
594    if (isCompletedState(state.name) && !options.loadCompleted) {
595      tasksByState.set(state.name, []);
596      displayCounts.set(state.name, countTaskHeadings(text));
597      deferredStates.push(state.name);
598      continue;
599    }
600    const parsed = parseTasks(text);
601    issueCount2 += parsed.errors.length;
602    tasksByState.set(state.name, parsed.tasks);
603    displayCounts.set(state.name, parsed.tasks.length);
604    for (const task of parsed.tasks) indexTask(tasks, { task, state: state.name });
605  }
606  const viewStates = states.map(
607    (state) => filterAndPaginate(
608      tasksByState,
609      [state],
610      void 0,
611      options.expandedStates.has(state.name) ? null : BOARD_TASK_LIMIT,
612      isCompletedState(state.name) ? "file" : "priority",
613      displayCounts
614    ).states[0]
615  );
616  return {
617    snapshot: { states: viewStates, workspaceRoot, deferredStates },
618    tasks,
619    issueCount: issueCount2
620  };
621}
622function findTask(index, id, state) {
623  const copies = index.get(id) ?? [];
624  if (state === void 0) return copies[0] ?? null;
625  return copies.find((copy) => copy.state === state) ?? null;
626}
627function taskDetailOf({ task, state }) {
628  const record = { ...task, state };
629  return {
630    id: task.id,
631    title: task.title,
632    state,
633    priority: task.priority,
634    tags: [...task.tags],
635    text: taskMarkdown(record),
636    body: taskBody(record)
637  };
638}
639
640// plugins/taskplanner/mod/src/paneView.ts
641var MAX_DIGIT_HOTKEYS = 9;
642var MIN_LABEL_WIDTH = 12;
643var BAND_BUTTON_ROOM = 8;
644var PRIORITY_COLORS = {
645  P0: "error",
646  P1: "warning",
647  P2: "suggestion",
648  P3: "subtle",
649  P4: "inactive"
650};
651function textStyle(style, role) {
652  if (!style.hasTextScale) {
653    if (role === "title" || role === "heading" || role === "chip") return { bold: true };
654    return { dimColor: true };
655  }
656  switch (role) {
657    case "title":
658      return { size: "title", weight: "semibold" };
659    case "heading":
660      return { size: "heading", weight: "semibold" };
661    case "chip":
662      return { size: "caption", weight: "semibold" };
663    case "id":
664      return { size: "caption", mono: true, dimColor: true };
665    default:
666      return { size: "caption", dimColor: true };
667  }
668}
669function digitHotkey(index) {
670  return index < MAX_DIGIT_HOTKEYS ? { hotkey: String(index + 1) } : {};
671}
672function chrome(style) {
673  return style.isRich ? {} : { plain: true };
674}
675function priorityChip(style, priority) {
676  return style.el.Text({
677    ...textStyle(style, "chip"),
678    color: PRIORITY_COLORS[priority] ?? "subtle",
679    children: [priority]
680  });
681}
682function caption(style, text) {
683  return style.el.Text({ ...textStyle(style, "caption"), wrap: "wrap", children: [text] });
684}
685function countsLine(board2) {
686  return board2.states.map((state) => `${state.totalCount} ${state.name.toLowerCase()}`).join(" \xB7 ");
687}
688function canWrite(model) {
689  return model.surface !== "mobile" && model.board !== null && !model.isMissingBoard;
690}
691function headerButtons(style, model, actions) {
692  const buttons = [
693    style.el.Button({
694      key: "refresh",
695      label: "Refresh",
696      hotkey: "r",
697      plain: true,
698      onPress: actions.refresh
699    })
700  ];
701  if (canWrite(model) && !model.draft) {
702    buttons.unshift(
703      style.el.Button({
704        key: "new-task",
705        label: style.isRich ? "+ New task" : "New task",
706        hotkey: "n",
707        plain: true,
708        onPress: actions.newTask
709      })
710    );
711  }
712  return buttons;
713}
714function header(style, model, actions) {
715  const buttons = headerButtons(style, model, actions);
716  if (!style.isRich) {
717    const status = model.isLoading ? " \xB7 loading\u2026" : "";
718    return style.el.Box({
719      flexDirection: "row",
720      columnGap: 2,
721      children: [style.el.Text({ bold: true, children: [`TaskPlanner${status}`] }), ...buttons]
722    });
723  }
724  const summary = model.isLoading ? "Refreshing\u2026" : model.board ? countsLine(model.board) : "Markdown tasks in .tasks/";
725  return style.el.Box({
726    flexDirection: "column",
727    children: [
728      style.el.Box({
729        flexDirection: "row",
730        columnGap: 2,
731        children: [
732          style.el.Text({ ...textStyle(style, "title"), children: ["TaskPlanner"] }),
733          ...buttons
734        ]
735      }),
736      caption(style, summary)
737    ]
738  });
739}
740function errorBlock(style, message, actions) {
741  const summary = describeError(message);
742  return style.el.Box({
743    flexDirection: "column",
744    marginTop: 1,
745    children: [
746      style.el.Text({ color: "error", bold: true, children: [summary.title] }),
747      caption(style, summary.detail),
748      style.el.Button({ key: "retry", label: "Retry", ...chrome(style), onPress: actions.refresh })
749    ]
750  });
751}
752function tabs(style, board2, active, actions) {
753  return style.el.Box({
754    flexDirection: "row",
755    flexWrap: "wrap",
756    columnGap: style.isRich ? 1 : 2,
757    rowGap: style.isRich ? 1 : 0,
758    marginTop: 1,
759    children: board2.states.map(
760      (state, index) => style.el.Button({
761        key: `tab-${slug(state.name)}`,
762        label: `${state.name} ${state.totalCount}`,
763        ...digitHotkey(index),
764        ...chrome(style),
765        ...style.isRich ? { variant: state.name === active ? "primary" : "secondary" } : {},
766        dimColor: state.name !== active,
767        onPress: () => actions.selectTab(state.name)
768      })
769    )
770  });
771}
772function plainRow(style, task, state, columns, actions) {
773  const label = `${task.id} ${task.priority} ${task.title}`;
774  return style.el.Button({
775    key: `task-${task.id}`,
776    label: truncate(label, Math.max(MIN_LABEL_WIDTH, columns)),
777    plain: true,
778    onPress: () => actions.openTask(task.id, state)
779  });
780}
781function card(style, task, state, actions) {
782  const meta = [
783    style.el.Text({ ...textStyle(style, "id"), children: [task.id] }),
784    priorityChip(style, task.priority)
785  ];
786  if (task.tags.length > 0) meta.push(caption(style, task.tags.join(", ")));
787  if (task.assignee) meta.push(caption(style, `@${task.assignee}`));
788  return style.el.Box({
789    key: `card-${task.id}`,
790    flexDirection: "column",
791    borderStyle: "round",
792    paddingX: 1,
793    children: [
794      style.el.Box({ flexDirection: "row", columnGap: 1, children: meta }),
795      style.el.Button({
796        key: `task-${task.id}`,
797        label: task.title,
798        plain: true,
799        onPress: () => actions.openTask(task.id, state)
800      })
801    ]
802  });
803}
804function listView(style, model, board2, actions) {
805  const state = stateNamed(board2, model.tab);
806  const rows = [];
807  if (!state) {
808    rows.push(caption(style, "This board has no states."));
809  } else if (state.tasks.length === 0 && board2.deferredStates?.includes(state.name)) {
810    rows.push(caption(style, `Loading ${state.name}\u2026`));
811  } else if (state.tasks.length === 0) {
812    rows.push(caption(style, `No tasks in ${state.name}.`));
813  } else {
814    rows.push(
815      ...state.tasks.map(
816        (task) => style.isRich ? card(style, task, state.name, actions) : plainRow(style, task, state.name, model.columns, actions)
817      )
818    );
819    if (state.hasMore) {
820      rows.push(
821        style.el.Box({
822          flexDirection: "row",
823          columnGap: 2,
824          children: [
825            caption(style, `Showing ${state.tasks.length} of ${state.totalCount}.`),
826            style.el.Button({
827              key: "show-all",
828              label: `Show all ${state.totalCount}`,
829              ...chrome(style),
830              onPress: () => actions.showAll(state.name)
831            })
832          ]
833        })
834      );
835    }
836  }
837  return [
838    tabs(style, board2, model.tab, actions),
839    style.el.Box({
840      flexDirection: "column",
841      rowGap: style.isRich ? 1 : 0,
842      marginTop: 1,
843      children: rows
844    })
845  ];
846}
847function detailHeading(style, detail2) {
848  if (!style.isRich) return [];
849  const chips = [
850    style.el.Text({ ...textStyle(style, "id"), children: [detail2.id] }),
851    priorityChip(style, detail2.priority),
852    caption(style, detail2.state)
853  ];
854  if (detail2.tags.length > 0) chips.push(caption(style, detail2.tags.join(", ")));
855  return [
856    style.el.Text({ ...textStyle(style, "heading"), wrap: "wrap", children: [detail2.title] }),
857    style.el.Box({ flexDirection: "row", columnGap: 1, children: chips })
858  ];
859}
860function detailView(style, board2, detail2, canAct, actions) {
861  const commands = [
862    style.el.Button({
863      key: "back",
864      label: "Back",
865      hotkey: "b",
866      plain: true,
867      onPress: actions.back
868    })
869  ];
870  if (canAct && !isCompletedState(detail2.state)) {
871    commands.push(
872      style.el.Button({
873        key: "start",
874        label: "Start with Claude",
875        hotkey: "s",
876        ...chrome(style),
877        ...style.isRich ? { variant: "primary" } : {},
878        onPress: () => actions.start(detail2)
879      })
880    );
881  }
882  if (canAct) {
883    commands.push(
884      style.el.Button({
885        key: "edit",
886        label: "Edit with Claude",
887        hotkey: "e",
888        ...chrome(style),
889        onPress: () => actions.edit(detail2)
890      })
891    );
892  }
893  const targets = board2 && canAct ? moveTargets(board2, detail2.state) : [];
894  const moves = targets.map(
895    (target, index) => style.el.Button({
896      key: moveKey(target),
897      label: target,
898      ...digitHotkey(index),
899      ...chrome(style),
900      onPress: () => actions.move(detail2.id, target)
901    })
902  );
903  const moveSection = moves.length === 0 ? [] : [
904    style.el.Box({
905      flexDirection: "column",
906      children: [
907        caption(style, `Move to another state (now ${detail2.state}):`),
908        style.el.Box({
909          flexDirection: "row",
910          flexWrap: "wrap",
911          columnGap: style.isRich ? 1 : 2,
912          rowGap: style.isRich ? 1 : 0,
913          children: moves
914        })
915      ]
916    })
917  ];
918  const body = style.isRich ? detail2.body : detail2.text;
919  return [
920    style.el.Box({
921      flexDirection: "column",
922      rowGap: style.isRich ? 1 : 0,
923      marginTop: 1,
924      children: [
925        style.el.Box({ flexDirection: "row", flexWrap: "wrap", columnGap: 2, children: commands }),
926        ...detailHeading(style, detail2),
927        ...moveSection
928      ]
929    }),
930    style.el.Box({
931      flexDirection: "column",
932      marginTop: 1,
933      children: body ? [style.el.Markdown({ key: "task-markdown", text: body })] : [caption(style, "No description yet.")]
934    })
935  ];
936}
937function bodyOf(style, model, actions) {
938  if (model.isMissingBoard) {
939    return [
940      style.el.Text({ children: ["No .tasks/ board in this folder or above it."] }),
941      caption(style, "Ask Claude to initialize TaskPlanner here, then run /taskboard again.")
942    ];
943  }
944  if (model.draft && model.board && canWrite(model)) {
945    return draftView(style, model.board, model.draft, actions);
946  }
947  if (model.detail) {
948    return detailView(style, model.board, model.detail, canWrite(model), actions);
949  }
950  if (!model.board) return [caption(style, "Loading the board\u2026")];
951  return listView(style, model, model.board, actions);
952}
953function draftView(style, board2, draft2, actions) {
954  const states = newTaskStates(board2);
955  return [
956    style.el.Box({
957      flexDirection: "column",
958      rowGap: style.isRich ? 1 : 0,
959      marginTop: 1,
960      children: [
961        style.el.Text({ ...textStyle(style, "heading"), children: ["New task"] }),
962        style.el.Input({
963          key: "new-title",
964          label: "Title",
965          placeholder: "What needs doing?",
966          value: draft2.title,
967          submitLabel: "create",
968          autoFocus: true,
969          onInput: actions.setDraftTitle,
970          onSubmit: (value) => {
971            actions.setDraftTitle(value);
972            actions.create();
973          }
974        }),
975        style.el.Select({
976          key: "new-priority",
977          label: "Priority",
978          options: PRIORITIES.map((priority) => ({ value: priority, label: priority })),
979          value: draft2.priority,
980          onSelect: actions.setDraftPriority
981        }),
982        style.el.Select({
983          key: "new-state",
984          label: "State",
985          options: states.map((state) => ({ value: state, label: state })),
986          value: draft2.state,
987          onSelect: actions.setDraftState
988        }),
989        caption(
990          style,
991          "The server assigns the ID. Add the description afterwards with Edit with Claude."
992        ),
993        style.el.Box({
994          flexDirection: "row",
995          columnGap: 2,
996          children: [
997            style.el.Button({
998              key: "create-task",
999              label: "Create",
1000              ...chrome(style),
1001              ...style.isRich ? { variant: "primary" } : {},
1002              onPress: actions.create
1003            }),
1004            style.el.Button({
1005              key: "cancel-new",
1006              label: "Cancel",
1007              ...chrome(style),
1008              onPress: actions.cancelNew
1009            })
1010          ]
1011        })
1012      ]
1013    })
1014  ];
1015}
1016function renderPane(el, model, actions) {
1017  const style = {
1018    el,
1019    isRich: model.surface === "desktop",
1020    hasTextScale: model.surface === "desktop" && model.hasTextScale
1021  };
1022  const children = [header(style, model, actions)];
1023  if (model.notice) children.push(caption(style, model.notice));
1024  if (model.error) children.push(errorBlock(style, model.error, actions));
1025  if (model.issueCount > 0) {
1026    children.push(
1027      caption(
1028        style,
1029        `${model.issueCount} part(s) of the board did not parse; ask Claude to check .tasks/.`
1030      )
1031    );
1032  }
1033  children.push(...bodyOf(style, model, actions));
1034  return el.Box({ flexDirection: "column", children });
1035}
1036function renderBand(el, task, columns, below, onOpen) {
1037  const label = truncate(
1038    `\u25B6 ${task.id} \xB7 ${task.title}`,
1039    Math.max(MIN_LABEL_WIDTH, columns - BAND_BUTTON_ROOM)
1040  );
1041  return el.Box({
1042    flexDirection: "column",
1043    children: [
1044      below,
1045      el.Box({
1046        flexDirection: "row",
1047        columnGap: 1,
1048        children: [
1049          el.Text({ dimColor: true, children: [label] }),
1050          el.Button({ key: "taskplanner-board", label: "Board", plain: true, onPress: onOpen })
1051        ]
1052      })
1053    ]
1054  });
1055}
1056
1057// plugins/taskplanner/mod/src/register.ts
1058var PANE_ID = "taskplanner";
1059var PANE_TITLE = "TaskPlanner";
1060var SERVER_KEY = "taskplanner";
1061var POLL_INTERVAL_MS = 5e3;
1062var MISSING_BOARD_TEXT = "No .tasks/ board in this folder or above it.";
1063var serverName = null;
1064var boardRoot = null;
1065var boardSearchFrom = null;
1066var board = null;
1067var taskIndex = /* @__PURE__ */ new Map();
1068var issueCount = 0;
1069var activeTab = null;
1070var detail = null;
1071var pendingDetail = null;
1072var errorText = null;
1073var isLoading = false;
1074var isMissingBoard = false;
1075var lastSignature = null;
1076var pollTimer = null;
1077var refreshRun = null;
1078var needsRefresh = false;
1079var hasTextScale = false;
1080var draft = null;
1081var notice = null;
1082var expandedStates = /* @__PURE__ */ new Set();
1083async function connectServer($) {
1084  if (serverName) return serverName;
1085  const connection = await $.mcp.connect(SERVER_KEY);
1086  if (!connection.isConnected) throw new Error(connection.message);
1087  serverName = connection.server;
1088  return connection.server;
1089}
1090async function hasBoardAt($, dir) {
1091  return $.fs.exists(joinPath(dir, ".tasks", "config.json"));
1092}
1093async function findBoardRoot($, starts) {
1094  for (const start of starts) {
1095    for (const dir of ancestorDirs(start)) {
1096      if (await hasBoardAt($, dir)) return dir;
1097    }
1098  }
1099  return null;
1100}
1101async function locateBoard($) {
1102  const starts = [await $.session.cwd(), await $.session.root()];
1103  const searchFrom = starts.join("|");
1104  if (searchFrom === boardSearchFrom && boardRoot && await hasBoardAt($, boardRoot)) {
1105    return boardRoot;
1106  }
1107  boardSearchFrom = searchFrom;
1108  const found = await findBoardRoot($, starts);
1109  if (found !== boardRoot) {
1110    forgetBoard();
1111    expandedStates.clear();
1112  }
1113  boardRoot = found;
1114  return found;
1115}
1116async function callTaskplanner($, tool, args) {
1117  const server = await connectServer($);
1118  const root = board?.workspaceRoot ?? boardRoot ?? await $.session.root();
1119  return $.mcp.call(server, tool, { workspace_root: root, ...args });
1120}
1121async function readTextOrEmpty($, path) {
1122  try {
1123    return await $.fs.read(path);
1124  } catch {
1125    return "";
1126  }
1127}
1128async function readSignature($, tasksDir) {
1129  const entries = await $.fs.list(tasksDir);
1130  const files = [];
1131  for (const entry of entries) {
1132    if (entry.kind !== "file" || !BOARD_FILE_PATTERN.test(entry.name)) continue;
1133    const stat = await $.fs.stat(joinPath(tasksDir, entry.name));
1134    files.push({ name: entry.name, size: stat.size, mtimeMs: stat.mtimeMs });
1135  }
1136  return boardSignature(files);
1137}
1138function forgetBoard() {
1139  board = null;
1140  taskIndex = /* @__PURE__ */ new Map();
1141  issueCount = 0;
1142  detail = null;
1143  pendingDetail = null;
1144  lastSignature = null;
1145}
1146function detailFor(id, state) {
1147  const indexed = findTask(taskIndex, id, state);
1148  return indexed ? taskDetailOf(indexed) : null;
1149}
1150function reopenDetail() {
1151  if (pendingDetail) {
1152    const created = detailFor(pendingDetail.id, pendingDetail.state);
1153    if (created) {
1154      detail = created;
1155      pendingDetail = null;
1156    }
1157  } else if (detail) {
1158    detail = detailFor(detail.id, detail.state);
1159  }
1160}
1161async function loadBoard($) {
1162  const root = await locateBoard($);
1163  if (!root) {
1164    isMissingBoard = true;
1165    forgetBoard();
1166    return;
1167  }
1168  isMissingBoard = false;
1169  const tasksDir = joinPath(root, ".tasks");
1170  const signature = await readSignature($, tasksDir);
1171  const states = readConfigStates(await $.fs.read(joinPath(tasksDir, "config.json")));
1172  const texts = /* @__PURE__ */ new Map();
1173  for (const state of states) {
1174    texts.set(state.fileName, await readTextOrEmpty($, joinPath(tasksDir, state.fileName)));
1175  }
1176  const loadCompleted = isCompletedState(activeTab) || detail !== null || pendingDetail !== null;
1177  const read = buildBoard(states, texts, { loadCompleted, expandedStates }, root);
1178  board = read.snapshot;
1179  taskIndex = read.tasks;
1180  issueCount = read.issueCount;
1181  activeTab = pickTab(board, activeTab);
1182  lastSignature = signature;
1183  reopenDetail();
1184}
1185async function runRefresh($) {
1186  isLoading = true;
1187  $.ui.invalidate("ui.render");
1188  try {
1189    do {
1190      needsRefresh = false;
1191      try {
1192        await loadBoard($);
1193        errorText = null;
1194      } catch (error) {
1195        errorText = errorMessage(error);
1196      }
1197    } while (needsRefresh);
1198  } finally {
1199    isLoading = false;
1200    refreshRun = null;