taskplane 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -20
  3. package/bin/taskplane.mjs +706 -0
  4. package/dashboard/public/app.js +900 -0
  5. package/dashboard/public/index.html +92 -0
  6. package/dashboard/public/style.css +924 -0
  7. package/dashboard/server.cjs +531 -0
  8. package/extensions/task-orchestrator.ts +28 -0
  9. package/extensions/task-runner.ts +1923 -0
  10. package/extensions/taskplane/abort.ts +466 -0
  11. package/extensions/taskplane/config.ts +102 -0
  12. package/extensions/taskplane/discovery.ts +988 -0
  13. package/extensions/taskplane/engine.ts +758 -0
  14. package/extensions/taskplane/execution.ts +1752 -0
  15. package/extensions/taskplane/extension.ts +577 -0
  16. package/extensions/taskplane/formatting.ts +718 -0
  17. package/extensions/taskplane/git.ts +38 -0
  18. package/extensions/taskplane/index.ts +22 -0
  19. package/extensions/taskplane/merge.ts +795 -0
  20. package/extensions/taskplane/messages.ts +134 -0
  21. package/extensions/taskplane/persistence.ts +1121 -0
  22. package/extensions/taskplane/resume.ts +1092 -0
  23. package/extensions/taskplane/sessions.ts +92 -0
  24. package/extensions/taskplane/types.ts +1514 -0
  25. package/extensions/taskplane/waves.ts +900 -0
  26. package/extensions/taskplane/worktree.ts +1624 -0
  27. package/package.json +48 -3
  28. package/skills/create-taskplane-task/SKILL.md +326 -0
  29. package/skills/create-taskplane-task/references/context-template.md +78 -0
  30. package/skills/create-taskplane-task/references/prompt-template.md +246 -0
  31. package/templates/agents/task-merger.md +256 -0
  32. package/templates/agents/task-reviewer.md +81 -0
  33. package/templates/agents/task-worker.md +140 -0
  34. package/templates/config/task-orchestrator.yaml +89 -0
  35. package/templates/config/task-runner.yaml +99 -0
  36. package/templates/tasks/CONTEXT.md +31 -0
  37. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
  38. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
4
+ *
5
+ * Reads .pi/batch-state.json + STATUS.md files and streams state to the
6
+ * browser via Server-Sent Events. Zero external dependencies.
7
+ *
8
+ * Usage:
9
+ * node dashboard/server.cjs [--port 8099] [--root /path/to/project]
10
+ */
11
+
12
+ const http = require("http");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { execFileSync, exec } = require("child_process");
16
+ // url module not needed — we parse with new URL() below
17
+
18
+ // ─── Configuration ──────────────────────────────────────────────────────────
19
+
20
+ const PUBLIC_DIR = path.join(__dirname, "public");
21
+ const DEFAULT_PORT = 8099;
22
+ const POLL_INTERVAL = 2000; // ms between state checks
23
+
24
+ // REPO_ROOT is resolved after parseArgs() — see initialization below.
25
+ let REPO_ROOT;
26
+ let BATCH_STATE_PATH;
27
+
28
+ // ─── CLI Args ───────────────────────────────────────────────────────────────
29
+
30
+ function parseArgs() {
31
+ const args = process.argv.slice(2);
32
+ const opts = { port: DEFAULT_PORT, open: true, root: "" };
33
+ for (let i = 0; i < args.length; i++) {
34
+ if (args[i] === "--port" && args[i + 1]) {
35
+ opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
36
+ i++;
37
+ } else if (args[i] === "--root" && args[i + 1]) {
38
+ opts.root = args[i + 1];
39
+ i++;
40
+ } else if (args[i] === "--no-open") {
41
+ opts.open = false;
42
+ } else if (args[i] === "--help" || args[i] === "-h") {
43
+ console.log(`
44
+ Orchestrator Web Dashboard
45
+
46
+ Usage:
47
+ node dashboard/server.cjs [options]
48
+
49
+ Options:
50
+ --port <number> Port to listen on (default: ${DEFAULT_PORT})
51
+ --root <path> Project root directory (default: current directory)
52
+ --no-open Don't auto-open browser
53
+ -h, --help Show this help
54
+ `);
55
+ process.exit(0);
56
+ }
57
+ }
58
+ return opts;
59
+ }
60
+
61
+ // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
62
+
63
+ function loadBatchState() {
64
+ try {
65
+ const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
66
+ return JSON.parse(raw);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function resolveTaskFolder(task, state) {
73
+ if (!task || !task.taskFolder) return null;
74
+ const laneNum = task.laneNumber;
75
+ const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
76
+ if (!lane || !lane.worktreePath) return task.taskFolder;
77
+ const taskFolderAbs = path.resolve(task.taskFolder);
78
+ const repoRootAbs = path.resolve(REPO_ROOT);
79
+ const rel = path.relative(repoRootAbs, taskFolderAbs);
80
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
81
+ return path.join(lane.worktreePath, rel);
82
+ }
83
+
84
+ function parseStatusMd(taskFolder) {
85
+ const candidates = [taskFolder];
86
+ const taskId = path.basename(taskFolder);
87
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
88
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
89
+
90
+ for (const folder of candidates) {
91
+ const statusPath = path.join(folder, "STATUS.md");
92
+ try {
93
+ const content = fs.readFileSync(statusPath, "utf-8");
94
+ const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
95
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
96
+ const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
97
+ const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
98
+ const checked = (content.match(/- \[x\]/gi) || []).length;
99
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
100
+ const total = checked + unchecked;
101
+ return {
102
+ currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
103
+ status: statusMatch ? statusMatch[1].trim() : "Unknown",
104
+ iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
105
+ reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
106
+ checked,
107
+ total,
108
+ progress: total > 0 ? Math.round((checked / total) * 100) : 0,
109
+ };
110
+ } catch {
111
+ continue;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function getTmuxSessions() {
118
+ try {
119
+ const output = execFileSync('tmux list-sessions -F "#{session_name}"', {
120
+ encoding: "utf-8",
121
+ timeout: 5000,
122
+ shell: true,
123
+ stdio: ["ignore", "pipe", "ignore"],
124
+ }).trim();
125
+ return output ? output.split("\n").map((s) => s.trim()).filter(Boolean) : [];
126
+ } catch {
127
+ return [];
128
+ }
129
+ }
130
+
131
+ function checkDoneFile(taskFolder) {
132
+ const candidates = [taskFolder];
133
+ const taskId = path.basename(taskFolder);
134
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
135
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
136
+ for (const folder of candidates) {
137
+ if (fs.existsSync(path.join(folder, ".DONE"))) return true;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /** Read lane state sidecar JSON files written by the task-runner. */
143
+ function loadLaneStates() {
144
+ const piDir = path.join(REPO_ROOT, ".pi");
145
+ const states = {};
146
+ try {
147
+ const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
148
+ for (const file of files) {
149
+ try {
150
+ const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
151
+ if (!raw) continue;
152
+ const data = JSON.parse(raw);
153
+ if (data.prefix) states[data.prefix] = data;
154
+ } catch { continue; }
155
+ }
156
+ } catch { /* .pi dir may not exist */ }
157
+ return states;
158
+ }
159
+
160
+ /** Build full dashboard state object for the frontend. */
161
+ function buildDashboardState() {
162
+ const state = loadBatchState();
163
+ const tmuxSessions = getTmuxSessions();
164
+ const laneStates = loadLaneStates();
165
+
166
+ if (!state) {
167
+ return { batch: null, tmuxSessions, laneStates: {}, timestamp: Date.now() };
168
+ }
169
+
170
+ const tasks = (state.tasks || []).map((task) => {
171
+ const effectiveFolder = resolveTaskFolder(task, state);
172
+ let statusData = null;
173
+ if (effectiveFolder) {
174
+ statusData = parseStatusMd(effectiveFolder);
175
+ }
176
+ if (!task.doneFileFound && effectiveFolder) {
177
+ task.doneFileFound = checkDoneFile(effectiveFolder);
178
+ }
179
+ return { ...task, statusData };
180
+ });
181
+
182
+ return {
183
+ laneStates,
184
+ batch: {
185
+ batchId: state.batchId,
186
+ phase: state.phase,
187
+ startedAt: state.startedAt,
188
+ updatedAt: state.updatedAt,
189
+ currentWaveIndex: state.currentWaveIndex || 0,
190
+ totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
191
+ wavePlan: state.wavePlan || [],
192
+ lanes: state.lanes || [],
193
+ tasks,
194
+ mergeResults: state.mergeResults || [],
195
+ errors: state.errors || [],
196
+ lastError: state.lastError || null,
197
+ },
198
+ tmuxSessions,
199
+ timestamp: Date.now(),
200
+ };
201
+ }
202
+
203
+ // ─── Static File Serving ────────────────────────────────────────────────────
204
+
205
+ const MIME_TYPES = {
206
+ ".html": "text/html; charset=utf-8",
207
+ ".css": "text/css; charset=utf-8",
208
+ ".js": "application/javascript; charset=utf-8",
209
+ ".json": "application/json; charset=utf-8",
210
+ ".svg": "image/svg+xml",
211
+ ".png": "image/png",
212
+ ".ico": "image/x-icon",
213
+ };
214
+
215
+ function serveStatic(req, res) {
216
+ let filePath = new URL(req.url, "http://localhost").pathname;
217
+ if (filePath === "/") filePath = "/index.html";
218
+
219
+ const fullPath = path.join(PUBLIC_DIR, filePath);
220
+ // Prevent directory traversal
221
+ if (!fullPath.startsWith(PUBLIC_DIR)) {
222
+ res.writeHead(403);
223
+ res.end("Forbidden");
224
+ return;
225
+ }
226
+
227
+ const ext = path.extname(fullPath);
228
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
229
+
230
+ try {
231
+ const content = fs.readFileSync(fullPath);
232
+ res.writeHead(200, {
233
+ "Content-Type": contentType,
234
+ "Cache-Control": "no-cache, no-store, must-revalidate",
235
+ });
236
+ res.end(content);
237
+ } catch {
238
+ res.writeHead(404, { "Content-Type": "text/plain" });
239
+ res.end("Not Found");
240
+ }
241
+ }
242
+
243
+ // ─── SSE Stream ─────────────────────────────────────────────────────────────
244
+
245
+ const sseClients = new Set();
246
+
247
+ // ─── Pane Capture SSE ───────────────────────────────────────────────────
248
+
249
+ const paneClients = new Map(); // sessionName → Set<res>
250
+
251
+ function handlePaneSSE(req, res, sessionName) {
252
+ // Validate session name (alphanumeric, dashes, underscores only)
253
+ if (!/^[\w-]+$/.test(sessionName)) {
254
+ res.writeHead(400, { "Content-Type": "text/plain" });
255
+ res.end("Invalid session name");
256
+ return;
257
+ }
258
+
259
+ res.writeHead(200, {
260
+ "Content-Type": "text/event-stream",
261
+ "Cache-Control": "no-cache",
262
+ Connection: "keep-alive",
263
+ "Access-Control-Allow-Origin": "*",
264
+ });
265
+
266
+ if (!paneClients.has(sessionName)) {
267
+ paneClients.set(sessionName, new Set());
268
+ }
269
+ paneClients.get(sessionName).add(res);
270
+
271
+ // Send initial capture immediately
272
+ const initial = captureTmuxPane(sessionName);
273
+ if (initial !== null) {
274
+ res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
275
+ } else {
276
+ res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
277
+ }
278
+
279
+ req.on("close", () => {
280
+ const clients = paneClients.get(sessionName);
281
+ if (clients) {
282
+ clients.delete(res);
283
+ if (clients.size === 0) paneClients.delete(sessionName);
284
+ }
285
+ });
286
+ }
287
+
288
+ function captureTmuxPane(sessionName) {
289
+ try {
290
+ // Capture with ANSI escape sequences (-e), full scrollback visible area (-p)
291
+ const output = execFileSync(
292
+ `tmux capture-pane -t "${sessionName}" -p -e`,
293
+ { encoding: "utf-8", timeout: 3000, shell: true, stdio: ["ignore", "pipe", "ignore"] }
294
+ );
295
+ return output;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ function broadcastPaneCaptures() {
302
+ for (const [sessionName, clients] of paneClients) {
303
+ if (clients.size === 0) continue;
304
+ const output = captureTmuxPane(sessionName);
305
+ if (output === null) continue;
306
+ const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
307
+ for (const client of clients) {
308
+ try {
309
+ client.write(payload);
310
+ } catch {
311
+ clients.delete(client);
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // ─── Conversation JSONL ─────────────────────────────────────────────────
318
+
319
+ function serveConversation(req, res, prefix) {
320
+ if (!/^[\w-]+$/.test(prefix)) {
321
+ res.writeHead(400, { "Content-Type": "text/plain" });
322
+ res.end("Invalid prefix");
323
+ return;
324
+ }
325
+
326
+ const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
327
+ try {
328
+ const content = fs.readFileSync(filePath, "utf-8");
329
+ res.writeHead(200, {
330
+ "Content-Type": "application/x-ndjson",
331
+ "Access-Control-Allow-Origin": "*",
332
+ });
333
+ res.end(content);
334
+ } catch {
335
+ res.writeHead(200, {
336
+ "Content-Type": "application/x-ndjson",
337
+ "Access-Control-Allow-Origin": "*",
338
+ });
339
+ res.end(""); // empty — no conversation yet
340
+ }
341
+ }
342
+
343
+ // ─── Dashboard SSE ──────────────────────────────────────────────────────
344
+
345
+ function handleSSE(req, res) {
346
+ res.writeHead(200, {
347
+ "Content-Type": "text/event-stream",
348
+ "Cache-Control": "no-cache",
349
+ Connection: "keep-alive",
350
+ "Access-Control-Allow-Origin": "*",
351
+ });
352
+
353
+ // Send initial state immediately
354
+ const state = buildDashboardState();
355
+ res.write(`data: ${JSON.stringify(state)}\n\n`);
356
+
357
+ sseClients.add(res);
358
+ req.on("close", () => sseClients.delete(res));
359
+ }
360
+
361
+ function broadcastState() {
362
+ if (sseClients.size === 0) return;
363
+ const state = buildDashboardState();
364
+ const payload = `data: ${JSON.stringify(state)}\n\n`;
365
+ for (const client of sseClients) {
366
+ try {
367
+ client.write(payload);
368
+ } catch {
369
+ sseClients.delete(client);
370
+ }
371
+ }
372
+ }
373
+
374
+ // ─── Batch History API ──────────────────────────────────────────────────────
375
+
376
+ const BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
377
+
378
+ function loadHistory() {
379
+ try {
380
+ if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
381
+ const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
382
+ return JSON.parse(raw);
383
+ } catch {
384
+ return [];
385
+ }
386
+ }
387
+
388
+ /** GET /api/history — return list of batch summaries (compact: no per-task detail). */
389
+ function serveHistory(req, res) {
390
+ const history = loadHistory();
391
+ // Return compact list for the dropdown (no per-task details)
392
+ const compact = history.map(h => ({
393
+ batchId: h.batchId,
394
+ status: h.status,
395
+ startedAt: h.startedAt,
396
+ endedAt: h.endedAt,
397
+ durationMs: h.durationMs,
398
+ totalWaves: h.totalWaves,
399
+ totalTasks: h.totalTasks,
400
+ succeededTasks: h.succeededTasks,
401
+ failedTasks: h.failedTasks,
402
+ tokens: h.tokens,
403
+ }));
404
+ res.writeHead(200, {
405
+ "Content-Type": "application/json",
406
+ "Access-Control-Allow-Origin": "*",
407
+ });
408
+ res.end(JSON.stringify(compact));
409
+ }
410
+
411
+ /** GET /api/history/:batchId — return full detail for one batch. */
412
+ function serveHistoryEntry(req, res, batchId) {
413
+ const history = loadHistory();
414
+ const entry = history.find(h => h.batchId === batchId);
415
+ if (!entry) {
416
+ res.writeHead(404, { "Content-Type": "application/json" });
417
+ res.end(JSON.stringify({ error: "Batch not found" }));
418
+ return;
419
+ }
420
+ res.writeHead(200, {
421
+ "Content-Type": "application/json",
422
+ "Access-Control-Allow-Origin": "*",
423
+ });
424
+ res.end(JSON.stringify(entry));
425
+ }
426
+
427
+ // ─── HTTP Server ────────────────────────────────────────────────────────────
428
+
429
+ function createServer(port) {
430
+ const server = http.createServer((req, res) => {
431
+ const pathname = new URL(req.url, "http://localhost").pathname;
432
+
433
+ if (pathname === "/api/stream" && req.method === "GET") {
434
+ handleSSE(req, res);
435
+ } else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
436
+ const sessionName = pathname.slice("/api/pane/".length);
437
+ handlePaneSSE(req, res, sessionName);
438
+ } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
439
+ const prefix = pathname.slice("/api/conversation/".length);
440
+ serveConversation(req, res, prefix);
441
+ } else if (pathname === "/api/state" && req.method === "GET") {
442
+ const state = buildDashboardState();
443
+ res.writeHead(200, {
444
+ "Content-Type": "application/json",
445
+ "Access-Control-Allow-Origin": "*",
446
+ });
447
+ res.end(JSON.stringify(state));
448
+ } else if (pathname === "/api/history" && req.method === "GET") {
449
+ serveHistory(req, res);
450
+ } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
451
+ const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
452
+ serveHistoryEntry(req, res, batchId);
453
+ } else {
454
+ serveStatic(req, res);
455
+ }
456
+ });
457
+
458
+ server.listen(port, () => {
459
+ console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
460
+ });
461
+
462
+ return server;
463
+ }
464
+
465
+ // ─── Browser Auto-Open ─────────────────────────────────────────────────────
466
+
467
+ function openBrowser(url) {
468
+ const cmd = process.platform === "win32" ? "start"
469
+ : process.platform === "darwin" ? "open" : "xdg-open";
470
+ exec(`${cmd} ${url}`, () => {}); // fire-and-forget
471
+ }
472
+
473
+ // ─── Main ───────────────────────────────────────────────────────────────────
474
+
475
+ function main() {
476
+ const opts = parseArgs();
477
+
478
+ // Resolve project root: --root flag > cwd
479
+ REPO_ROOT = path.resolve(opts.root || process.cwd());
480
+ BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
481
+
482
+ const server = createServer(opts.port);
483
+
484
+ // Broadcast state to all SSE clients on interval
485
+ const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
486
+
487
+ // Broadcast pane captures more frequently (1s) for smooth terminal viewing
488
+ const paneTimer = setInterval(broadcastPaneCaptures, 1000);
489
+
490
+ // Also watch batch-state.json for immediate push on change
491
+ try {
492
+ const batchDir = path.dirname(BATCH_STATE_PATH);
493
+ if (fs.existsSync(batchDir)) {
494
+ let debounce = null;
495
+ fs.watch(batchDir, (eventType, filename) => {
496
+ if (filename === "batch-state.json") {
497
+ clearTimeout(debounce);
498
+ debounce = setTimeout(broadcastState, 200);
499
+ }
500
+ });
501
+ }
502
+ } catch {
503
+ // fs.watch not supported — polling is sufficient
504
+ }
505
+
506
+ // Auto-open browser
507
+ if (opts.open) {
508
+ setTimeout(() => openBrowser(`http://localhost:${opts.port}`), 500);
509
+ }
510
+
511
+ // Graceful shutdown
512
+ function cleanup() {
513
+ clearInterval(pollTimer);
514
+ clearInterval(paneTimer);
515
+ for (const [, clients] of paneClients) {
516
+ for (const client of clients) {
517
+ try { client.end(); } catch {}
518
+ }
519
+ }
520
+ for (const client of sseClients) {
521
+ try { client.end(); } catch {}
522
+ }
523
+ server.close();
524
+ process.exit(0);
525
+ }
526
+
527
+ process.on("SIGINT", cleanup);
528
+ process.on("SIGTERM", cleanup);
529
+ }
530
+
531
+ main();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Task Orchestrator — Parallel task execution with git worktrees
3
+ *
4
+ * This is a thin facade that re-exports everything from the taskplane/ modules.
5
+ * The actual implementation lives in extensions/taskplane/*.ts.
6
+ *
7
+ * Commands:
8
+ * /orch <areas|paths|all> — Start batch execution
9
+ * /orch-plan <areas|paths|all> — Preview execution plan (no execution)
10
+ * /orch-status — Show current batch progress
11
+ * /orch-pause — Pause after current tasks finish
12
+ * /orch-resume — Resume a paused batch
13
+ * /orch-abort [--hard] — Abort batch (graceful or immediate)
14
+ * /orch-deps <areas|paths|all> — Show dependency graph
15
+ * /orch-sessions — List active TMUX sessions
16
+ *
17
+ * Configuration:
18
+ * .pi/task-orchestrator.yaml — orchestrator-specific settings
19
+ * .pi/task-runner.yaml — task areas, worker/reviewer config (shared)
20
+ *
21
+ * Usage: pi -e extensions/task-orchestrator.ts -e extensions/task-runner.ts
22
+ */
23
+
24
+ // Re-export all named exports for tests and other consumers
25
+ export * from "./taskplane/index.ts";
26
+
27
+ // Re-export the default activate function for the pi extension system
28
+ export { default } from "./taskplane/extension.ts";