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…

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.
| Component | Description |
|---|---|
| MCP Server | 8 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 |
| Skills | TaskPlanner, initialize, update, list, next-task, continue-task, and board workflows for Codex and compatible hosts |
| Rule | Task 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 |
| Manifests | Host-specific Cursor, Codex and Claude Code manifests backed by one package |
Once installed, the agent can:
/list-tasks in Cursor, $taskplanner:list-tasks in Codex, or ask naturally/next-task in Cursor or $taskplanner:next-task in Codex/continue-task in Cursor or $taskplanner:continue-task in Codex.tasks file operations provide the public skills-only fallback/taskplanner:board in Claude Code opens the board pane; $taskplanner:board in Codex and the board skill in Cursor show it through the MCP toolstaskplanner_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+)taskplanner_board_data for a machine-readable board view-model (states + cards), useful for hosts without MCP UI renderingThe 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.
.tasks/ directory (run TaskPlanner: Initialize Project from the VS Code extension)dist/mcp-server.js and ui/board/index.html) present in the plugin packageIf 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.
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):
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:s): sends "Work on TASK-… following the TaskPlanner workflow" as your next prompt.e): puts "Update TASK-…: " in the prompt box for you to finish. Editing title, description, plan, tags or priority goes through Claude.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..tasks/ after TaskPlanner tools or file edits there, and checks the files every 5 seconds for changes made elsewhere..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 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.claude -p. There, the commands reply with a text digest of the board./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..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.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..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.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
Search for "taskplanner" in the Cursor marketplace panel.
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:
taskplanner (toggle ON)taskplannertaskplanner-workflow/list-tasks, /next-task, /continue-task.tasks/), ask: open the visual task boardHeadless checks from repo root:
npm run validate:cursor-plugin
npm run smoke:mcp-server
The local plugin path is:
%USERPROFILE%\.cursor\plugins\local\taskplanner
Install the Task → Plan → AI VS Code extension — the plugin is auto-registered.
ln -s /path/to/taskplanner/plugins/taskplanner ~/.cursor/plugins/local/taskplanner
Tasks live in .tasks/*.md as ## TASK-###: Title sections separated by ---:
## TASK-001: Example task
**Priority:** P1
**Tags:** feature, core
Description in markdown.
---
MIT. The optional README attribution added during initialization is voluntary and removable; it is not a license condition.
For maintainers preparing a publish/update:
npm run buildnpm run validate:cursor-pluginnpm run validate:codex-pluginnpm run validate:claude-plugin (needs Claude Code 2.1.287+)plugins/taskplanner/.plugins/taskplanner.hooks/register.js 1474 lines1// 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;