taskplane 0.1.12 → 0.1.13

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 (3) hide show
  1. package/README.md +188 -180
  2. package/bin/taskplane.mjs +1047 -1007
  3. package/package.json +1 -1
package/bin/taskplane.mjs CHANGED
@@ -1,1007 +1,1047 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Taskplane CLI — Project scaffolding, diagnostics, uninstall, and dashboard launcher.
5
- *
6
- * This CLI handles what the pi package system cannot: project-local config
7
- * scaffolding, installation health checks, and dashboard management.
8
- *
9
- * Extensions, skills, and themes are delivered via `pi install npm:taskplane`
10
- * and auto-discovered by pi. This CLI is for everything else.
11
- */
12
-
13
- import fs from "node:fs";
14
- import path from "node:path";
15
- import readline from "node:readline";
16
- import { fileURLToPath } from "node:url";
17
- import { execSync, spawn } from "node:child_process";
18
-
19
- // ─── Paths ──────────────────────────────────────────────────────────────────
20
-
21
- const __filename = fileURLToPath(import.meta.url);
22
- const __dirname = path.dirname(__filename);
23
- const PACKAGE_ROOT = path.resolve(__dirname, "..");
24
- const TEMPLATES_DIR = path.join(PACKAGE_ROOT, "templates");
25
- const DASHBOARD_SERVER = path.join(PACKAGE_ROOT, "dashboard", "server.cjs");
26
-
27
- // ─── ANSI Colors ────────────────────────────────────────────────────────────
28
-
29
- const c = {
30
- reset: "\x1b[0m",
31
- bold: "\x1b[1m",
32
- dim: "\x1b[2m",
33
- red: "\x1b[31m",
34
- green: "\x1b[32m",
35
- yellow: "\x1b[33m",
36
- blue: "\x1b[34m",
37
- cyan: "\x1b[36m",
38
- };
39
-
40
- const OK = `${c.green}✅${c.reset}`;
41
- const WARN = `${c.yellow}⚠️${c.reset}`;
42
- const FAIL = `${c.red}❌${c.reset}`;
43
- const INFO = `${c.cyan}ℹ${c.reset}`;
44
-
45
- // ─── Utilities ──────────────────────────────────────────────────────────────
46
-
47
- function die(msg) {
48
- console.error(`${FAIL} ${msg}`);
49
- process.exit(1);
50
- }
51
-
52
- function today() {
53
- return new Date().toISOString().slice(0, 10);
54
- }
55
-
56
- function slugify(str) {
57
- return str
58
- .toLowerCase()
59
- .replace(/[^a-z0-9]+/g, "-")
60
- .replace(/^-|-$/g, "");
61
- }
62
-
63
- /** Prompt the user for input. Returns the answer or defaultValue. */
64
- function ask(question, defaultValue) {
65
- const rl = readline.createInterface({
66
- input: process.stdin,
67
- output: process.stdout,
68
- });
69
- const suffix = defaultValue != null ? ` ${c.dim}(${defaultValue})${c.reset}` : "";
70
- return new Promise((resolve) => {
71
- rl.question(` ${question}${suffix}: `, (answer) => {
72
- rl.close();
73
- resolve(answer.trim() || defaultValue || "");
74
- });
75
- });
76
- }
77
-
78
- /** Prompt yes/no. Returns boolean. */
79
- async function confirm(question, defaultYes = true) {
80
- const hint = defaultYes ? "Y/n" : "y/N";
81
- const answer = await ask(`${question} [${hint}]`);
82
- if (!answer) return defaultYes;
83
- return answer.toLowerCase().startsWith("y");
84
- }
85
-
86
- /** Read a YAML file if it exists, return null otherwise. */
87
- function readYaml(filePath) {
88
- try {
89
- // Dynamic import of yaml — it's a dependency of the package
90
- const raw = fs.readFileSync(filePath, "utf-8");
91
- // Simple YAML value extraction (avoids requiring yaml at top level for fast startup)
92
- return raw;
93
- } catch {
94
- return null;
95
- }
96
- }
97
-
98
- /** Check if a command exists on PATH. */
99
- function commandExists(cmd) {
100
- try {
101
- const which = process.platform === "win32" ? "where" : "which";
102
- execSync(`${which} ${cmd}`, { stdio: "pipe" });
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
- /** Get command version string. */
110
- function getVersion(cmd, flag = "--version") {
111
- try {
112
- return execSync(`${cmd} ${flag}`, { stdio: "pipe" }).toString().trim();
113
- } catch {
114
- return null;
115
- }
116
- }
117
-
118
- /** Write a file, creating parent directories as needed. Optionally skip if exists. */
119
- function writeFile(dest, content, { skipIfExists = false, label = "" } = {}) {
120
- if (skipIfExists && fs.existsSync(dest)) {
121
- if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
122
- return false;
123
- }
124
- fs.mkdirSync(path.dirname(dest), { recursive: true });
125
- fs.writeFileSync(dest, content, "utf-8");
126
- if (label) console.log(` ${c.green}create${c.reset} ${label}`);
127
- return true;
128
- }
129
-
130
- /** Copy a file from templates, creating parent dirs. */
131
- function copyTemplate(src, dest, { skipIfExists = false, label = "" } = {}) {
132
- if (skipIfExists && fs.existsSync(dest)) {
133
- if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
134
- return false;
135
- }
136
- const content = fs.readFileSync(src, "utf-8");
137
- return writeFile(dest, content, { label });
138
- }
139
-
140
- /** Replace {{variables}} in template content. */
141
- function interpolate(content, vars) {
142
- return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
143
- }
144
-
145
- // ─── Stack Detection ────────────────────────────────────────────────────────
146
-
147
- function detectStack(projectRoot) {
148
- const checks = [
149
- { file: "package.json", stack: "node", test: "npm test", build: "npm run build" },
150
- { file: "go.mod", stack: "go", test: "go test ./...", build: "go build ./..." },
151
- { file: "Cargo.toml", stack: "rust", test: "cargo test", build: "cargo build" },
152
- { file: "pyproject.toml", stack: "python", test: "pytest", build: "" },
153
- { file: "pom.xml", stack: "java-maven", test: "mvn test", build: "mvn package" },
154
- { file: "build.gradle", stack: "java-gradle", test: "gradle test", build: "gradle build" },
155
- ];
156
- for (const { file, stack, test, build } of checks) {
157
- if (fs.existsSync(path.join(projectRoot, file))) {
158
- return { stack, test, build };
159
- }
160
- }
161
- return { stack: "unknown", test: "", build: "" };
162
- }
163
-
164
- // ─── YAML Generation ────────────────────────────────────────────────────────
165
-
166
- function generateTaskRunnerYaml(vars) {
167
- return `# ═══════════════════════════════════════════════════════════════════════
168
- # Task Runner Configuration — ${vars.project_name}
169
- # ═══════════════════════════════════════════════════════════════════════
170
- #
171
- # This file configures the /task command (task-runner extension).
172
- # Edit freely — this file is owned by you, not the package.
173
-
174
- # ── Task Areas ────────────────────────────────────────────────────────
175
- # Define where tasks live. Each area has a folder path, ID prefix, and
176
- # a CONTEXT.md file that provides domain context to agents.
177
-
178
- task_areas:
179
- ${vars.default_area}:
180
- path: "${vars.tasks_root}"
181
- prefix: "${vars.default_prefix}"
182
- context: "${vars.tasks_root}/CONTEXT.md"
183
-
184
- # ── Reference Docs ────────────────────────────────────────────────────
185
- # Docs that tasks can reference in their "Context to Read First" section.
186
- # Add your project's architecture docs, API specs, etc.
187
-
188
- reference_docs: {}
189
-
190
- # ── Standards ─────────────────────────────────────────────────────────
191
- # Coding standards and rules. Agents follow these during implementation.
192
-
193
- standards: {}
194
-
195
- # ── Testing ───────────────────────────────────────────────────────────
196
- # Commands that agents run to verify their work.
197
-
198
- testing:
199
- commands:${vars.test_cmd ? `\n unit: "${vars.test_cmd}"` : ""}${vars.build_cmd ? `\n build: "${vars.build_cmd}"` : ""}
200
- `;
201
- }
202
-
203
- function generateOrchestratorYaml(vars) {
204
- return `# ═══════════════════════════════════════════════════════════════════════
205
- # Parallel Task Orchestrator Configuration — ${vars.project_name}
206
- # ═══════════════════════════════════════════════════════════════════════
207
- #
208
- # This file configures the /orch commands (task-orchestrator extension).
209
- # Edit freely — this file is owned by you, not the package.
210
-
211
- orchestrator:
212
- max_lanes: ${vars.max_lanes}
213
- worktree_location: "subdirectory"
214
- worktree_prefix: "${vars.worktree_prefix}"
215
- integration_branch: "${vars.integration_branch}"
216
- batch_id_format: "timestamp"
217
- spawn_mode: "subprocess"
218
- tmux_prefix: "${vars.tmux_prefix}"
219
-
220
- dependencies:
221
- source: "prompt"
222
- cache: true
223
-
224
- assignment:
225
- strategy: "affinity-first"
226
- size_weights:
227
- S: 1
228
- M: 2
229
- L: 4
230
-
231
- pre_warm:
232
- auto_detect: false
233
- commands: {}
234
- always: []
235
-
236
- merge:
237
- model: ""
238
- tools: "read,write,edit,bash,grep,find,ls"
239
- verify: []
240
- order: "fewest-files-first"
241
-
242
- failure:
243
- on_task_failure: "skip-dependents"
244
- on_merge_failure: "pause"
245
- stall_timeout: 30
246
- max_worker_minutes: 30
247
- abort_grace_period: 60
248
-
249
- monitoring:
250
- poll_interval: 5
251
- `;
252
- }
253
-
254
- // ═════════════════════════════════════════════════════════════════════════════
255
- // COMMANDS
256
- // ═════════════════════════════════════════════════════════════════════════════
257
-
258
- /** Auto-commit task files to git so they're available in orchestrator worktrees. */
259
- async function autoCommitTaskFiles(projectRoot, tasksRoot) {
260
- // Check if we're in a git repo
261
- try {
262
- execSync("git rev-parse --is-inside-work-tree", { cwd: projectRoot, stdio: "pipe" });
263
- } catch {
264
- // Not a git repo — skip silently
265
- return;
266
- }
267
-
268
- // Stage the tasks directory (not .pi/ — that's gitignored)
269
- const tasksDir = path.join(projectRoot, tasksRoot);
270
- if (!fs.existsSync(tasksDir)) return;
271
-
272
- try {
273
- // Check if there's anything new to commit
274
- execSync(`git add "${tasksRoot}"`, { cwd: projectRoot, stdio: "pipe" });
275
- const status = execSync("git diff --cached --name-only", { cwd: projectRoot, stdio: "pipe" })
276
- .toString()
277
- .trim();
278
-
279
- if (!status) return; // nothing staged
280
-
281
- execSync('git commit -m "chore: initialize taskplane tasks"', {
282
- cwd: projectRoot,
283
- stdio: "pipe",
284
- });
285
- console.log(`\n ${c.green}git${c.reset} committed ${tasksRoot}/ to git`);
286
- console.log(` ${c.dim}(orchestrator worktrees require committed files)${c.reset}`);
287
- } catch (err) {
288
- // Git commit failed — warn but don't block init
289
- console.log(`\n ${WARN} Could not auto-commit task files to git.`);
290
- console.log(` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`);
291
- }
292
- }
293
-
294
- function discoverTaskAreaPaths(projectRoot) {
295
- const runnerPath = path.join(projectRoot, ".pi", "task-runner.yaml");
296
- if (!fs.existsSync(runnerPath)) return [];
297
-
298
- const raw = readYaml(runnerPath);
299
- if (!raw) return [];
300
-
301
- const lines = raw.split(/\r?\n/);
302
- let inTaskAreas = false;
303
- const paths = new Set();
304
-
305
- for (const line of lines) {
306
- const trimmed = line.trim();
307
-
308
- if (!inTaskAreas) {
309
- if (/^task_areas:\s*$/.test(trimmed)) {
310
- inTaskAreas = true;
311
- }
312
- continue;
313
- }
314
-
315
- // End of task_areas block when we hit next top-level key
316
- if (/^[A-Za-z0-9_]+\s*:\s*$/.test(line)) {
317
- break;
318
- }
319
-
320
- const m = line.match(/^\s{4}path:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
321
- if (m?.[1]) {
322
- paths.add(m[1].trim());
323
- }
324
- }
325
-
326
- return [...paths];
327
- }
328
-
329
- function pruneEmptyDir(dirPath) {
330
- try {
331
- if (!fs.existsSync(dirPath)) return false;
332
- if (fs.readdirSync(dirPath).length !== 0) return false;
333
- fs.rmdirSync(dirPath);
334
- return true;
335
- } catch {
336
- return false;
337
- }
338
- }
339
-
340
- function listExampleTaskTemplates() {
341
- const tasksTemplatesDir = path.join(TEMPLATES_DIR, "tasks");
342
- try {
343
- return fs.readdirSync(tasksTemplatesDir, { withFileTypes: true })
344
- .filter((entry) => entry.isDirectory() && /^EXAMPLE-\d+/i.test(entry.name))
345
- .map((entry) => entry.name)
346
- .sort();
347
- } catch {
348
- return [];
349
- }
350
- }
351
-
352
- async function cmdUninstall(args) {
353
- const projectRoot = process.cwd();
354
- const dryRun = args.includes("--dry-run");
355
- const yes = args.includes("--yes") || args.includes("-y");
356
- const removePackage = args.includes("--package") || args.includes("--all") || args.includes("--package-only");
357
- const packageOnly = args.includes("--package-only");
358
- const removeProject = !packageOnly;
359
- const removeTasks = removeProject && (args.includes("--remove-tasks") || args.includes("--all"));
360
- const local = args.includes("--local");
361
- const global = args.includes("--global");
362
-
363
- if (local && global) {
364
- die("Choose either --local or --global, not both.");
365
- }
366
-
367
- console.log(`\n${c.bold}Taskplane Uninstall${c.reset}\n`);
368
-
369
- const managedFiles = [
370
- ".pi/task-runner.yaml",
371
- ".pi/task-orchestrator.yaml",
372
- ".pi/taskplane.json",
373
- ".pi/agents/task-worker.md",
374
- ".pi/agents/task-reviewer.md",
375
- ".pi/agents/task-merger.md",
376
- ".pi/batch-state.json",
377
- ".pi/batch-history.json",
378
- ".pi/orch-abort-signal",
379
- ];
380
-
381
- const sidecarPrefixes = [
382
- "lane-state-",
383
- "worker-conversation-",
384
- "merge-result-",
385
- "merge-request-",
386
- ];
387
-
388
- const filesToDelete = managedFiles
389
- .map(rel => ({ rel, abs: path.join(projectRoot, rel) }))
390
- .filter(({ abs }) => fs.existsSync(abs));
391
-
392
- const piDir = path.join(projectRoot, ".pi");
393
- const sidecarsToDelete = fs.existsSync(piDir)
394
- ? fs.readdirSync(piDir)
395
- .filter(name => sidecarPrefixes.some(prefix => name.startsWith(prefix)))
396
- .map(name => ({ rel: path.join(".pi", name), abs: path.join(piDir, name) }))
397
- : [];
398
-
399
- let taskDirsToDelete = [];
400
- if (removeTasks) {
401
- const areaPaths = discoverTaskAreaPaths(projectRoot);
402
- const rootPrefix = path.resolve(projectRoot) + path.sep;
403
- taskDirsToDelete = areaPaths
404
- .map(rel => ({ rel, abs: path.resolve(projectRoot, rel) }))
405
- .filter(({ abs }) => abs.startsWith(rootPrefix) && fs.existsSync(abs));
406
- }
407
-
408
- const inferredInstallType = /[\\/]\.pi[\\/]/.test(PACKAGE_ROOT) ? "local" : "global";
409
- const packageScope = local ? "local" : global ? "global" : inferredInstallType;
410
- const piRemoveCmd = packageScope === "local"
411
- ? "pi remove -l npm:taskplane"
412
- : "pi remove npm:taskplane";
413
-
414
- if (!removeProject && !removePackage) {
415
- console.log(` ${WARN} Nothing to do. Use one of:`);
416
- console.log(` ${c.cyan}taskplane uninstall${c.reset} # remove project-scaffolded files`);
417
- console.log(` ${c.cyan}taskplane uninstall --package${c.reset} # remove installed package via pi`);
418
- console.log();
419
- return;
420
- }
421
-
422
- if (removeProject) {
423
- console.log(`${c.bold}Project cleanup:${c.reset}`);
424
- if (filesToDelete.length === 0 && sidecarsToDelete.length === 0 && taskDirsToDelete.length === 0) {
425
- console.log(` ${c.dim}No Taskplane-managed project files found.${c.reset}`);
426
- }
427
- for (const f of filesToDelete) console.log(` - remove ${f.rel}`);
428
- for (const f of sidecarsToDelete) console.log(` - remove ${f.rel}`);
429
- for (const d of taskDirsToDelete) console.log(` - remove dir ${d.rel}`);
430
- if (removeTasks && taskDirsToDelete.length === 0) {
431
- console.log(` ${c.dim}No task area directories found from .pi/task-runner.yaml.${c.reset}`);
432
- }
433
- if (!removeTasks) {
434
- console.log(` ${c.dim}Task directories are preserved by default (use --remove-tasks to delete them).${c.reset}`);
435
- }
436
- console.log();
437
- }
438
-
439
- if (removePackage) {
440
- console.log(`${c.bold}Package cleanup:${c.reset}`);
441
- console.log(` - run ${piRemoveCmd}`);
442
- console.log(` ${c.dim}(removes extensions, skills, and dashboard files from this install scope)${c.reset}`);
443
- console.log();
444
- }
445
-
446
- if (dryRun) {
447
- console.log(`${INFO} Dry run complete. No files were changed.\n`);
448
- return;
449
- }
450
-
451
- if (!yes) {
452
- const proceed = await confirm("Proceed with uninstall?", false);
453
- if (!proceed) {
454
- console.log(" Aborted.");
455
- return;
456
- }
457
- if (removeTasks) {
458
- const taskConfirm = await confirm("This will delete task area directories recursively. Continue?", false);
459
- if (!taskConfirm) {
460
- console.log(" Aborted.");
461
- return;
462
- }
463
- }
464
- }
465
-
466
- let removedCount = 0;
467
- let failedCount = 0;
468
-
469
- if (removeProject) {
470
- for (const item of [...filesToDelete, ...sidecarsToDelete]) {
471
- try {
472
- fs.unlinkSync(item.abs);
473
- removedCount++;
474
- } catch (err) {
475
- failedCount++;
476
- console.log(` ${WARN} Failed to remove ${item.rel}: ${err.message}`);
477
- }
478
- }
479
-
480
- for (const dir of taskDirsToDelete) {
481
- try {
482
- fs.rmSync(dir.abs, { recursive: true, force: true });
483
- removedCount++;
484
- } catch (err) {
485
- failedCount++;
486
- console.log(` ${WARN} Failed to remove directory ${dir.rel}: ${err.message}`);
487
- }
488
- }
489
-
490
- // Best-effort cleanup of empty folders
491
- pruneEmptyDir(path.join(projectRoot, ".pi", "agents"));
492
- pruneEmptyDir(path.join(projectRoot, ".pi"));
493
- }
494
-
495
- if (removePackage) {
496
- if (!commandExists("pi")) {
497
- failedCount++;
498
- console.log(` ${FAIL} pi is not on PATH; could not run: ${piRemoveCmd}`);
499
- } else {
500
- try {
501
- execSync(piRemoveCmd, { cwd: projectRoot, stdio: "inherit" });
502
- } catch {
503
- failedCount++;
504
- console.log(` ${FAIL} Package uninstall failed: ${piRemoveCmd}`);
505
- }
506
- }
507
- }
508
-
509
- console.log();
510
- if (failedCount === 0) {
511
- console.log(`${OK} ${c.bold}Uninstall complete.${c.reset}`);
512
- if (removeProject) {
513
- console.log(` Removed ${removedCount} project artifact(s).`);
514
- }
515
- console.log();
516
- } else {
517
- console.log(`${FAIL} Uninstall completed with ${failedCount} error(s).`);
518
- if (removeProject) {
519
- console.log(` Removed ${removedCount} project artifact(s).`);
520
- }
521
- console.log();
522
- process.exit(1);
523
- }
524
- }
525
-
526
- // ─── init ───────────────────────────────────────────────────────────────────
527
-
528
- async function cmdInit(args) {
529
- const projectRoot = process.cwd();
530
- const force = args.includes("--force");
531
- const dryRun = args.includes("--dry-run");
532
- const noExamples = args.includes("--no-examples");
533
- const presetIdx = args.indexOf("--preset");
534
- const preset = presetIdx !== -1 ? args[presetIdx + 1] : null;
535
-
536
- console.log(`\n${c.bold}Taskplane Init${c.reset}\n`);
537
-
538
- // Check for existing config
539
- const hasConfig =
540
- fs.existsSync(path.join(projectRoot, ".pi", "task-runner.yaml")) ||
541
- fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml"));
542
-
543
- if (hasConfig && !force) {
544
- console.log(`${WARN} Taskplane config already exists in this project.`);
545
- const proceed = await confirm(" Overwrite existing files?", false);
546
- if (!proceed) {
547
- console.log(" Aborted.");
548
- return;
549
- }
550
- }
551
-
552
- // Gather config values
553
- let vars;
554
- if (preset === "minimal" || preset === "full" || preset === "runner-only") {
555
- vars = getPresetVars(preset, projectRoot);
556
- console.log(` Using preset: ${c.cyan}${preset}${c.reset}\n`);
557
- } else {
558
- vars = await getInteractiveVars(projectRoot);
559
- }
560
-
561
- const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
562
-
563
- if (dryRun) {
564
- console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
565
- printFileList(vars, noExamples, preset, exampleTemplateDirs);
566
- return;
567
- }
568
-
569
- // Scaffold files
570
- console.log(`\n${c.bold}Creating files...${c.reset}\n`);
571
- const skipIfExists = !force;
572
-
573
- // Agent prompts
574
- for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
575
- copyTemplate(
576
- path.join(TEMPLATES_DIR, "agents", agent),
577
- path.join(projectRoot, ".pi", "agents", agent),
578
- { skipIfExists, label: `.pi/agents/${agent}` }
579
- );
580
- }
581
-
582
- // Task runner config
583
- writeFile(
584
- path.join(projectRoot, ".pi", "task-runner.yaml"),
585
- generateTaskRunnerYaml(vars),
586
- { skipIfExists, label: ".pi/task-runner.yaml" }
587
- );
588
-
589
- // Orchestrator config (skip for runner-only preset)
590
- if (preset !== "runner-only") {
591
- writeFile(
592
- path.join(projectRoot, ".pi", "task-orchestrator.yaml"),
593
- generateOrchestratorYaml(vars),
594
- { skipIfExists, label: ".pi/task-orchestrator.yaml" }
595
- );
596
- }
597
-
598
- // Version tracker (always overwrite)
599
- const versionInfo = {
600
- version: getPackageVersion(),
601
- installedAt: new Date().toISOString(),
602
- lastUpgraded: new Date().toISOString(),
603
- components: { agents: getPackageVersion(), config: getPackageVersion() },
604
- };
605
- writeFile(
606
- path.join(projectRoot, ".pi", "taskplane.json"),
607
- JSON.stringify(versionInfo, null, 2) + "\n",
608
- { label: ".pi/taskplane.json" }
609
- );
610
-
611
- // CONTEXT.md
612
- const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8");
613
- writeFile(
614
- path.join(projectRoot, vars.tasks_root, "CONTEXT.md"),
615
- interpolate(contextSrc, vars),
616
- { skipIfExists, label: `${vars.tasks_root}/CONTEXT.md` }
617
- );
618
-
619
- // Example tasks
620
- if (!noExamples) {
621
- for (const exampleName of exampleTemplateDirs) {
622
- const exampleDir = path.join(TEMPLATES_DIR, "tasks", exampleName);
623
- const destDir = path.join(projectRoot, vars.tasks_root, exampleName);
624
- for (const file of ["PROMPT.md", "STATUS.md"]) {
625
- const srcPath = path.join(exampleDir, file);
626
- if (!fs.existsSync(srcPath)) continue;
627
- const src = fs.readFileSync(srcPath, "utf-8");
628
- writeFile(path.join(destDir, file), interpolate(src, vars), {
629
- skipIfExists,
630
- label: `${vars.tasks_root}/${exampleName}/${file}`,
631
- });
632
- }
633
- }
634
- if (exampleTemplateDirs.length === 0) {
635
- console.log(` ${WARN} No example task templates found under templates/tasks/EXAMPLE-*`);
636
- }
637
- }
638
-
639
- // Auto-commit task files to git so they're available in worktrees
640
- await autoCommitTaskFiles(projectRoot, vars.tasks_root);
641
-
642
- // Report
643
- console.log(`\n${OK} ${c.bold}Taskplane initialized!${c.reset}\n`);
644
- console.log(`${c.bold}Quick start:${c.reset}`);
645
- console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
646
- if (preset !== "runner-only") {
647
- console.log(` ${c.cyan}/orch-plan all${c.reset} # preview waves/lanes/dependencies`);
648
- console.log(` ${c.cyan}/orch all${c.reset} # run examples via orchestrator`);
649
- }
650
- if (!noExamples && exampleTemplateDirs.length > 0) {
651
- const firstExample = exampleTemplateDirs[0];
652
- console.log(` ${c.dim}optional single-task mode:${c.reset}`);
653
- console.log(` ${c.cyan}/task ${vars.tasks_root}/${firstExample}/PROMPT.md${c.reset}`);
654
- }
655
- console.log();
656
- }
657
-
658
- function getPresetVars(preset, projectRoot) {
659
- const dirName = path.basename(projectRoot);
660
- const slug = slugify(dirName);
661
- const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
662
- return {
663
- project_name: dirName,
664
- integration_branch: "main",
665
- max_lanes: 3,
666
- worktree_prefix: `${slug}-wt`,
667
- tmux_prefix: `${slug}-orch`,
668
- tasks_root: "taskplane-tasks",
669
- default_area: "general",
670
- default_prefix: "TP",
671
- test_cmd,
672
- build_cmd,
673
- date: today(),
674
- };
675
- }
676
-
677
- async function getInteractiveVars(projectRoot) {
678
- const dirName = path.basename(projectRoot);
679
- const detected = detectStack(projectRoot);
680
-
681
- const project_name = await ask("Project name", dirName);
682
- const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
683
- const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
684
- const tasks_root = await ask("Tasks directory", "taskplane-tasks");
685
- const default_area = await ask("Default area name", "general");
686
- const default_prefix = await ask("Task ID prefix", "TP");
687
- const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || "");
688
- const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || "");
689
-
690
- const slug = slugify(project_name);
691
- return {
692
- project_name,
693
- integration_branch,
694
- max_lanes,
695
- worktree_prefix: `${slug}-wt`,
696
- tmux_prefix: `${slug}-orch`,
697
- tasks_root,
698
- default_area,
699
- default_prefix,
700
- test_cmd,
701
- build_cmd,
702
- date: today(),
703
- };
704
- }
705
-
706
- function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
707
- const files = [
708
- ".pi/agents/task-worker.md",
709
- ".pi/agents/task-reviewer.md",
710
- ".pi/agents/task-merger.md",
711
- ".pi/task-runner.yaml",
712
- ];
713
- if (preset !== "runner-only") files.push(".pi/task-orchestrator.yaml");
714
- files.push(".pi/taskplane.json");
715
- files.push(`${vars.tasks_root}/CONTEXT.md`);
716
- if (!noExamples) {
717
- for (const exampleName of exampleTemplateDirs) {
718
- files.push(`${vars.tasks_root}/${exampleName}/PROMPT.md`);
719
- files.push(`${vars.tasks_root}/${exampleName}/STATUS.md`);
720
- }
721
- }
722
- for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
723
- console.log();
724
- }
725
-
726
- // ─── doctor ─────────────────────────────────────────────────────────────────
727
-
728
- function cmdDoctor() {
729
- const projectRoot = process.cwd();
730
- let issues = 0;
731
-
732
- console.log(`\n${c.bold}Taskplane Doctor${c.reset}\n`);
733
-
734
- // Check prerequisites
735
- const checks = [
736
- { label: "pi installed", check: () => commandExists("pi"), detail: () => getVersion("pi") },
737
- {
738
- label: "Node.js >= 20.0.0",
739
- check: () => {
740
- const v = process.versions.node;
741
- return parseInt(v.split(".")[0]) >= 20;
742
- },
743
- detail: () => `v${process.versions.node}`,
744
- },
745
- { label: "git installed", check: () => commandExists("git"), detail: () => getVersion("git") },
746
- ];
747
-
748
- for (const { label, check, detail } of checks) {
749
- const ok = check();
750
- const info = ok && detail ? ` ${c.dim}(${detail()})${c.reset}` : "";
751
- console.log(` ${ok ? OK : FAIL} ${label}${info}`);
752
- if (!ok) issues++;
753
- }
754
-
755
- // Check tmux (optional)
756
- const hasTmux = commandExists("tmux");
757
- console.log(
758
- ` ${hasTmux ? OK : `${WARN}`} tmux installed${hasTmux ? ` ${c.dim}(${getVersion("tmux", "-V")})${c.reset}` : ` ${c.dim}(optional — needed for spawn_mode: tmux)${c.reset}`}`
759
- );
760
-
761
- // Check package installation
762
- const pkgJson = path.join(PACKAGE_ROOT, "package.json");
763
- const pkgVersion = getPackageVersion();
764
- const isProjectLocal = PACKAGE_ROOT.includes(".pi");
765
- const installType = isProjectLocal ? "project-local" : "global";
766
- console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
767
-
768
- // Check project config
769
- console.log();
770
- const configFiles = [
771
- { path: ".pi/task-runner.yaml", required: true },
772
- { path: ".pi/task-orchestrator.yaml", required: true },
773
- { path: ".pi/agents/task-worker.md", required: true },
774
- { path: ".pi/agents/task-reviewer.md", required: true },
775
- { path: ".pi/agents/task-merger.md", required: true },
776
- { path: ".pi/taskplane.json", required: false },
777
- ];
778
-
779
- for (const { path: relPath, required } of configFiles) {
780
- const exists = fs.existsSync(path.join(projectRoot, relPath));
781
- if (exists) {
782
- console.log(` ${OK} ${relPath} exists`);
783
- } else if (required) {
784
- console.log(` ${FAIL} ${relPath} missing`);
785
- issues++;
786
- } else {
787
- console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
788
- }
789
- }
790
-
791
- // Check task areas from config
792
- const runnerYaml = readYaml(path.join(projectRoot, ".pi", "task-runner.yaml"));
793
- if (runnerYaml) {
794
- // Simple regex extraction of task area paths
795
- const pathMatches = [...runnerYaml.matchAll(/^\s+path:\s*"?([^"\n]+)"?/gm)];
796
- const contextMatches = [...runnerYaml.matchAll(/^\s+context:\s*"?([^"\n]+)"?/gm)];
797
-
798
- if (pathMatches.length > 0) {
799
- console.log();
800
- for (const match of pathMatches) {
801
- const areaPath = match[1].trim();
802
- const exists = fs.existsSync(path.join(projectRoot, areaPath));
803
- if (exists) {
804
- console.log(` ${OK} task area path: ${areaPath}`);
805
- } else {
806
- console.log(` ${FAIL} task area path: ${areaPath} ${c.dim}(directory not found)${c.reset}`);
807
- console.log(` ${c.dim}→ Run: mkdir -p ${areaPath}${c.reset}`);
808
- issues++;
809
- }
810
- }
811
- for (const match of contextMatches) {
812
- const ctxPath = match[1].trim();
813
- const exists = fs.existsSync(path.join(projectRoot, ctxPath));
814
- if (exists) {
815
- console.log(` ${OK} CONTEXT.md: ${ctxPath}`);
816
- } else {
817
- console.log(` ${WARN} CONTEXT.md: ${ctxPath} ${c.dim}(not found)${c.reset}`);
818
- }
819
- }
820
- }
821
- }
822
-
823
- console.log();
824
- if (issues === 0) {
825
- console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
826
- } else {
827
- console.log(`${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`);
828
- process.exit(1);
829
- }
830
- }
831
-
832
- // ─── version ────────────────────────────────────────────────────────────────
833
-
834
- function cmdVersion() {
835
- const pkgVersion = getPackageVersion();
836
- const isProjectLocal = PACKAGE_ROOT.includes(".pi");
837
- const installType = isProjectLocal ? `project-local: ${PACKAGE_ROOT}` : `global: ${PACKAGE_ROOT}`;
838
-
839
- console.log(`\ntaskplane ${c.bold}v${pkgVersion}${c.reset}`);
840
- console.log(` Package: ${installType}`);
841
-
842
- // Check for project config
843
- const projectRoot = process.cwd();
844
- const tpJson = path.join(projectRoot, ".pi", "taskplane.json");
845
- if (fs.existsSync(tpJson)) {
846
- try {
847
- const info = JSON.parse(fs.readFileSync(tpJson, "utf-8"));
848
- console.log(` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`);
849
- } catch {
850
- console.log(` Config: .pi/taskplane.json (unreadable)`);
851
- }
852
- } else {
853
- console.log(` Config: ${c.dim}not initialized (run taskplane init)${c.reset}`);
854
- }
855
-
856
- // Pi version
857
- const piVersion = getVersion("pi");
858
- if (piVersion) console.log(` Pi: ${piVersion}`);
859
-
860
- // Node version
861
- console.log(` Node: v${process.versions.node}`);
862
- console.log();
863
- }
864
-
865
- function getPackageVersion() {
866
- try {
867
- const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
868
- return pkg.version || "unknown";
869
- } catch {
870
- return "unknown";
871
- }
872
- }
873
-
874
- // ─── dashboard ──────────────────────────────────────────────────────────────
875
-
876
- function cmdDashboard(args) {
877
- const projectRoot = process.cwd();
878
-
879
- if (!fs.existsSync(DASHBOARD_SERVER)) {
880
- die(`Dashboard server not found at ${DASHBOARD_SERVER}`);
881
- }
882
-
883
- // Pass through args to server.cjs, adding --root
884
- const serverArgs = ["--root", projectRoot];
885
-
886
- // Forward --port and --no-open if provided
887
- for (let i = 0; i < args.length; i++) {
888
- if (args[i] === "--port" && args[i + 1]) {
889
- serverArgs.push("--port", args[i + 1]);
890
- i++;
891
- } else if (args[i] === "--no-open") {
892
- serverArgs.push("--no-open");
893
- }
894
- }
895
-
896
- console.log(`\n${c.bold}Taskplane Dashboard${c.reset}`);
897
- console.log(` Project: ${projectRoot}`);
898
- console.log(` Server: ${DASHBOARD_SERVER}\n`);
899
-
900
- const child = spawn("node", [DASHBOARD_SERVER, ...serverArgs], {
901
- stdio: "inherit",
902
- cwd: projectRoot,
903
- });
904
-
905
- child.on("error", (err) => {
906
- die(`Failed to start dashboard: ${err.message}`);
907
- });
908
-
909
- // Forward signals
910
- process.on("SIGINT", () => child.kill("SIGINT"));
911
- process.on("SIGTERM", () => child.kill("SIGTERM"));
912
-
913
- child.on("exit", (code) => {
914
- process.exit(code ?? 0);
915
- });
916
- }
917
-
918
- // ─── help ───────────────────────────────────────────────────────────────────
919
-
920
- function showHelp() {
921
- const version = getPackageVersion();
922
- console.log(`
923
- ${c.bold}taskplane${c.reset} v${version} AI agent orchestration for pi
924
-
925
- ${c.bold}Usage:${c.reset}
926
- taskplane <command> [options]
927
-
928
- ${c.bold}Commands:${c.reset}
929
- ${c.cyan}init${c.reset} Scaffold Taskplane config in the current project
930
- ${c.cyan}doctor${c.reset} Validate installation and project configuration
931
- ${c.cyan}version${c.reset} Show version information
932
- ${c.cyan}dashboard${c.reset} Launch the web-based orchestrator dashboard
933
- ${c.cyan}uninstall${c.reset} Remove Taskplane project files and/or package install
934
- ${c.cyan}help${c.reset} Show this help message
935
-
936
- ${c.bold}Init options:${c.reset}
937
- --preset <name> Use a preset: minimal, full, runner-only
938
- --no-examples Skip example tasks scaffolding
939
- --force Overwrite existing files without prompting
940
- --dry-run Show what would be created without writing
941
-
942
- ${c.bold}Dashboard options:${c.reset}
943
- --port <number> Port to listen on (default: 8099)
944
- --no-open Don't auto-open browser
945
-
946
- ${c.bold}Uninstall options:${c.reset}
947
- --dry-run Show what would be removed
948
- --yes, -y Skip confirmation prompts
949
- --package Also remove installed package via pi remove
950
- --package-only Only remove installed package (skip project cleanup)
951
- --local Force package uninstall from project-local scope
952
- --global Force package uninstall from global scope
953
- --remove-tasks Also remove task area directories from task-runner.yaml
954
- --all Equivalent to --package + --remove-tasks
955
-
956
- ${c.bold}Examples:${c.reset}
957
- taskplane init # Interactive project setup
958
- taskplane init --preset full # Quick setup with defaults
959
- taskplane init --dry-run # Preview what would be created
960
- taskplane doctor # Check installation health
961
- taskplane dashboard # Launch web dashboard
962
- taskplane dashboard --port 3000 # Dashboard on custom port
963
- taskplane uninstall --dry-run # Preview uninstall actions
964
- taskplane uninstall --package --yes # Remove project files + package install
965
-
966
- ${c.bold}Getting started:${c.reset}
967
- 1. pi install npm:taskplane # Install the pi package
968
- 2. cd my-project && taskplane init # Scaffold project config
969
- 3. pi # Start pi — /task and /orch are ready
970
- `);
971
- }
972
-
973
- // ═════════════════════════════════════════════════════════════════════════════
974
- // MAIN
975
- // ═════════════════════════════════════════════════════════════════════════════
976
-
977
- const [command, ...args] = process.argv.slice(2);
978
-
979
- switch (command) {
980
- case "init":
981
- await cmdInit(args);
982
- break;
983
- case "doctor":
984
- cmdDoctor();
985
- break;
986
- case "version":
987
- case "--version":
988
- case "-v":
989
- cmdVersion();
990
- break;
991
- case "dashboard":
992
- cmdDashboard(args);
993
- break;
994
- case "uninstall":
995
- await cmdUninstall(args);
996
- break;
997
- case "help":
998
- case "--help":
999
- case "-h":
1000
- case undefined:
1001
- showHelp();
1002
- break;
1003
- default:
1004
- console.error(`${FAIL} Unknown command: ${command}`);
1005
- showHelp();
1006
- process.exit(1);
1007
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Taskplane CLI — Project scaffolding, diagnostics, uninstall, and dashboard launcher.
5
+ *
6
+ * This CLI handles what the pi package system cannot: project-local config
7
+ * scaffolding, installation health checks, and dashboard management.
8
+ *
9
+ * Extensions, skills, and themes are delivered via `pi install npm:taskplane`
10
+ * and auto-discovered by pi. This CLI is for everything else.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import readline from "node:readline";
16
+ import { fileURLToPath } from "node:url";
17
+ import { execSync, spawn } from "node:child_process";
18
+
19
+ // ─── Paths ──────────────────────────────────────────────────────────────────
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
24
+ const TEMPLATES_DIR = path.join(PACKAGE_ROOT, "templates");
25
+ const DASHBOARD_SERVER = path.join(PACKAGE_ROOT, "dashboard", "server.cjs");
26
+
27
+ // ─── ANSI Colors ────────────────────────────────────────────────────────────
28
+
29
+ const c = {
30
+ reset: "\x1b[0m",
31
+ bold: "\x1b[1m",
32
+ dim: "\x1b[2m",
33
+ red: "\x1b[31m",
34
+ green: "\x1b[32m",
35
+ yellow: "\x1b[33m",
36
+ blue: "\x1b[34m",
37
+ cyan: "\x1b[36m",
38
+ };
39
+
40
+ const OK = `${c.green}✅${c.reset}`;
41
+ const WARN = `${c.yellow}⚠️${c.reset}`;
42
+ const FAIL = `${c.red}❌${c.reset}`;
43
+ const INFO = `${c.cyan}ℹ${c.reset}`;
44
+
45
+ // ─── Utilities ──────────────────────────────────────────────────────────────
46
+
47
+ function die(msg) {
48
+ console.error(`${FAIL} ${msg}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ function today() {
53
+ return new Date().toISOString().slice(0, 10);
54
+ }
55
+
56
+ function slugify(str) {
57
+ return str
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-|-$/g, "");
61
+ }
62
+
63
+ /** Prompt the user for input. Returns the answer or defaultValue. */
64
+ function ask(question, defaultValue) {
65
+ const rl = readline.createInterface({
66
+ input: process.stdin,
67
+ output: process.stdout,
68
+ });
69
+ const suffix = defaultValue != null ? ` ${c.dim}(${defaultValue})${c.reset}` : "";
70
+ return new Promise((resolve) => {
71
+ rl.question(` ${question}${suffix}: `, (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim() || defaultValue || "");
74
+ });
75
+ });
76
+ }
77
+
78
+ /** Prompt yes/no. Returns boolean. */
79
+ async function confirm(question, defaultYes = true) {
80
+ const hint = defaultYes ? "Y/n" : "y/N";
81
+ const answer = await ask(`${question} [${hint}]`);
82
+ if (!answer) return defaultYes;
83
+ return answer.toLowerCase().startsWith("y");
84
+ }
85
+
86
+ /** Read a YAML file if it exists, return null otherwise. */
87
+ function readYaml(filePath) {
88
+ try {
89
+ // Dynamic import of yaml — it's a dependency of the package
90
+ const raw = fs.readFileSync(filePath, "utf-8");
91
+ // Simple YAML value extraction (avoids requiring yaml at top level for fast startup)
92
+ return raw;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** Check if a command exists on PATH. */
99
+ function commandExists(cmd) {
100
+ try {
101
+ const which = process.platform === "win32" ? "where" : "which";
102
+ execSync(`${which} ${cmd}`, { stdio: "pipe" });
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** Get command version string. */
110
+ function getVersion(cmd, flag = "--version") {
111
+ try {
112
+ return execSync(`${cmd} ${flag}`, { stdio: "pipe" }).toString().trim();
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Write a file, creating parent directories as needed. Optionally skip if exists. */
119
+ function writeFile(dest, content, { skipIfExists = false, label = "" } = {}) {
120
+ if (skipIfExists && fs.existsSync(dest)) {
121
+ if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
122
+ return false;
123
+ }
124
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
125
+ fs.writeFileSync(dest, content, "utf-8");
126
+ if (label) console.log(` ${c.green}create${c.reset} ${label}`);
127
+ return true;
128
+ }
129
+
130
+ /** Copy a file from templates, creating parent dirs. */
131
+ function copyTemplate(src, dest, { skipIfExists = false, label = "" } = {}) {
132
+ if (skipIfExists && fs.existsSync(dest)) {
133
+ if (label) console.log(` ${c.dim}skip${c.reset} ${label} (already exists)`);
134
+ return false;
135
+ }
136
+ const content = fs.readFileSync(src, "utf-8");
137
+ return writeFile(dest, content, { label });
138
+ }
139
+
140
+ /** Replace {{variables}} in template content. */
141
+ function interpolate(content, vars) {
142
+ return content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
143
+ }
144
+
145
+ // ─── Stack Detection ────────────────────────────────────────────────────────
146
+
147
+ function detectStack(projectRoot) {
148
+ const checks = [
149
+ { file: "package.json", stack: "node", test: "npm test", build: "npm run build" },
150
+ { file: "go.mod", stack: "go", test: "go test ./...", build: "go build ./..." },
151
+ { file: "Cargo.toml", stack: "rust", test: "cargo test", build: "cargo build" },
152
+ { file: "pyproject.toml", stack: "python", test: "pytest", build: "" },
153
+ { file: "pom.xml", stack: "java-maven", test: "mvn test", build: "mvn package" },
154
+ { file: "build.gradle", stack: "java-gradle", test: "gradle test", build: "gradle build" },
155
+ ];
156
+ for (const { file, stack, test, build } of checks) {
157
+ if (fs.existsSync(path.join(projectRoot, file))) {
158
+ return { stack, test, build };
159
+ }
160
+ }
161
+ return { stack: "unknown", test: "", build: "" };
162
+ }
163
+
164
+ // ─── YAML Generation ────────────────────────────────────────────────────────
165
+
166
+ function generateTaskRunnerYaml(vars) {
167
+ return `# ═══════════════════════════════════════════════════════════════════════
168
+ # Task Runner Configuration — ${vars.project_name}
169
+ # ═══════════════════════════════════════════════════════════════════════
170
+ #
171
+ # This file configures the /task command (task-runner extension).
172
+ # Edit freely — this file is owned by you, not the package.
173
+
174
+ # ── Task Areas ────────────────────────────────────────────────────────
175
+ # Define where tasks live. Each area has a folder path, ID prefix, and
176
+ # a CONTEXT.md file that provides domain context to agents.
177
+
178
+ task_areas:
179
+ ${vars.default_area}:
180
+ path: "${vars.tasks_root}"
181
+ prefix: "${vars.default_prefix}"
182
+ context: "${vars.tasks_root}/CONTEXT.md"
183
+
184
+ # ── Reference Docs ────────────────────────────────────────────────────
185
+ # Docs that tasks can reference in their "Context to Read First" section.
186
+ # Add your project's architecture docs, API specs, etc.
187
+
188
+ reference_docs: {}
189
+
190
+ # ── Standards ─────────────────────────────────────────────────────────
191
+ # Coding standards and rules. Agents follow these during implementation.
192
+
193
+ standards: {}
194
+
195
+ # ── Testing ───────────────────────────────────────────────────────────
196
+ # Commands that agents run to verify their work.
197
+
198
+ testing:
199
+ commands:${vars.test_cmd ? `\n unit: "${vars.test_cmd}"` : ""}${vars.build_cmd ? `\n build: "${vars.build_cmd}"` : ""}
200
+ `;
201
+ }
202
+
203
+ function generateOrchestratorYaml(vars) {
204
+ return `# ═══════════════════════════════════════════════════════════════════════
205
+ # Parallel Task Orchestrator Configuration — ${vars.project_name}
206
+ # ═══════════════════════════════════════════════════════════════════════
207
+ #
208
+ # This file configures the /orch commands (task-orchestrator extension).
209
+ # Edit freely — this file is owned by you, not the package.
210
+
211
+ orchestrator:
212
+ max_lanes: ${vars.max_lanes}
213
+ worktree_location: "subdirectory"
214
+ worktree_prefix: "${vars.worktree_prefix}"
215
+ integration_branch: "${vars.integration_branch}"
216
+ batch_id_format: "timestamp"
217
+ spawn_mode: "subprocess"
218
+ tmux_prefix: "${vars.tmux_prefix}"
219
+
220
+ dependencies:
221
+ source: "prompt"
222
+ cache: true
223
+
224
+ assignment:
225
+ strategy: "affinity-first"
226
+ size_weights:
227
+ S: 1
228
+ M: 2
229
+ L: 4
230
+
231
+ pre_warm:
232
+ auto_detect: false
233
+ commands: {}
234
+ always: []
235
+
236
+ merge:
237
+ model: ""
238
+ tools: "read,write,edit,bash,grep,find,ls"
239
+ verify: []
240
+ order: "fewest-files-first"
241
+
242
+ failure:
243
+ on_task_failure: "skip-dependents"
244
+ on_merge_failure: "pause"
245
+ stall_timeout: 30
246
+ max_worker_minutes: 30
247
+ abort_grace_period: 60
248
+
249
+ monitoring:
250
+ poll_interval: 5
251
+ `;
252
+ }
253
+
254
+ // ═════════════════════════════════════════════════════════════════════════════
255
+ // COMMANDS
256
+ // ═════════════════════════════════════════════════════════════════════════════
257
+
258
+ /** Auto-commit task files to git so they're available in orchestrator worktrees. */
259
+ async function autoCommitTaskFiles(projectRoot, tasksRoot) {
260
+ // Check if we're in a git repo
261
+ try {
262
+ execSync("git rev-parse --is-inside-work-tree", { cwd: projectRoot, stdio: "pipe" });
263
+ } catch {
264
+ // Not a git repo — skip silently
265
+ return;
266
+ }
267
+
268
+ // Stage the tasks directory (not .pi/ — that's gitignored)
269
+ const tasksDir = path.join(projectRoot, tasksRoot);
270
+ if (!fs.existsSync(tasksDir)) return;
271
+
272
+ try {
273
+ // Check if there's anything new to commit
274
+ execSync(`git add "${tasksRoot}"`, { cwd: projectRoot, stdio: "pipe" });
275
+ const status = execSync("git diff --cached --name-only", { cwd: projectRoot, stdio: "pipe" })
276
+ .toString()
277
+ .trim();
278
+
279
+ if (!status) return; // nothing staged
280
+
281
+ execSync('git commit -m "chore: initialize taskplane tasks"', {
282
+ cwd: projectRoot,
283
+ stdio: "pipe",
284
+ });
285
+ console.log(`\n ${c.green}git${c.reset} committed ${tasksRoot}/ to git`);
286
+ console.log(` ${c.dim}(orchestrator worktrees require committed files)${c.reset}`);
287
+ } catch (err) {
288
+ // Git commit failed — warn but don't block init
289
+ console.log(`\n ${WARN} Could not auto-commit task files to git.`);
290
+ console.log(` ${c.dim}Run manually before using /orch: git add ${tasksRoot} && git commit -m "add taskplane tasks"${c.reset}`);
291
+ }
292
+ }
293
+
294
+ function discoverTaskAreaPaths(projectRoot) {
295
+ const runnerPath = path.join(projectRoot, ".pi", "task-runner.yaml");
296
+ if (!fs.existsSync(runnerPath)) return [];
297
+
298
+ const raw = readYaml(runnerPath);
299
+ if (!raw) return [];
300
+
301
+ const lines = raw.split(/\r?\n/);
302
+ let inTaskAreas = false;
303
+ const paths = new Set();
304
+
305
+ for (const line of lines) {
306
+ const trimmed = line.trim();
307
+
308
+ if (!inTaskAreas) {
309
+ if (/^task_areas:\s*$/.test(trimmed)) {
310
+ inTaskAreas = true;
311
+ }
312
+ continue;
313
+ }
314
+
315
+ // End of task_areas block when we hit next top-level key
316
+ if (/^[A-Za-z0-9_]+\s*:\s*$/.test(line)) {
317
+ break;
318
+ }
319
+
320
+ const m = line.match(/^\s{4}path:\s*["']?([^"'\n#]+)["']?\s*(?:#.*)?$/);
321
+ if (m?.[1]) {
322
+ paths.add(m[1].trim());
323
+ }
324
+ }
325
+
326
+ return [...paths];
327
+ }
328
+
329
+ function pruneEmptyDir(dirPath) {
330
+ try {
331
+ if (!fs.existsSync(dirPath)) return false;
332
+ if (fs.readdirSync(dirPath).length !== 0) return false;
333
+ fs.rmdirSync(dirPath);
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function listExampleTaskTemplates() {
341
+ const tasksTemplatesDir = path.join(TEMPLATES_DIR, "tasks");
342
+ try {
343
+ return fs.readdirSync(tasksTemplatesDir, { withFileTypes: true })
344
+ .filter((entry) => entry.isDirectory() && /^EXAMPLE-\d+/i.test(entry.name))
345
+ .map((entry) => entry.name)
346
+ .sort();
347
+ } catch {
348
+ return [];
349
+ }
350
+ }
351
+
352
+ async function cmdUninstall(args) {
353
+ const projectRoot = process.cwd();
354
+ const dryRun = args.includes("--dry-run");
355
+ const yes = args.includes("--yes") || args.includes("-y");
356
+ const removePackage = args.includes("--package") || args.includes("--all") || args.includes("--package-only");
357
+ const packageOnly = args.includes("--package-only");
358
+ const removeProject = !packageOnly;
359
+ const removeTasks = removeProject && (args.includes("--remove-tasks") || args.includes("--all"));
360
+ const local = args.includes("--local");
361
+ const global = args.includes("--global");
362
+
363
+ if (local && global) {
364
+ die("Choose either --local or --global, not both.");
365
+ }
366
+
367
+ console.log(`\n${c.bold}Taskplane Uninstall${c.reset}\n`);
368
+
369
+ const managedFiles = [
370
+ ".pi/task-runner.yaml",
371
+ ".pi/task-orchestrator.yaml",
372
+ ".pi/taskplane.json",
373
+ ".pi/agents/task-worker.md",
374
+ ".pi/agents/task-reviewer.md",
375
+ ".pi/agents/task-merger.md",
376
+ ".pi/batch-state.json",
377
+ ".pi/batch-history.json",
378
+ ".pi/orch-abort-signal",
379
+ ];
380
+
381
+ const sidecarPrefixes = [
382
+ "lane-state-",
383
+ "worker-conversation-",
384
+ "merge-result-",
385
+ "merge-request-",
386
+ ];
387
+
388
+ const filesToDelete = managedFiles
389
+ .map(rel => ({ rel, abs: path.join(projectRoot, rel) }))
390
+ .filter(({ abs }) => fs.existsSync(abs));
391
+
392
+ const piDir = path.join(projectRoot, ".pi");
393
+ const sidecarsToDelete = fs.existsSync(piDir)
394
+ ? fs.readdirSync(piDir)
395
+ .filter(name => sidecarPrefixes.some(prefix => name.startsWith(prefix)))
396
+ .map(name => ({ rel: path.join(".pi", name), abs: path.join(piDir, name) }))
397
+ : [];
398
+
399
+ let taskDirsToDelete = [];
400
+ if (removeTasks) {
401
+ const areaPaths = discoverTaskAreaPaths(projectRoot);
402
+ const rootPrefix = path.resolve(projectRoot) + path.sep;
403
+ taskDirsToDelete = areaPaths
404
+ .map(rel => ({ rel, abs: path.resolve(projectRoot, rel) }))
405
+ .filter(({ abs }) => abs.startsWith(rootPrefix) && fs.existsSync(abs));
406
+ }
407
+
408
+ const inferredInstallType = /[\\/]\.pi[\\/]/.test(PACKAGE_ROOT) ? "local" : "global";
409
+ const packageScope = local ? "local" : global ? "global" : inferredInstallType;
410
+ const piRemoveCmd = packageScope === "local"
411
+ ? "pi remove -l npm:taskplane"
412
+ : "pi remove npm:taskplane";
413
+
414
+ if (!removeProject && !removePackage) {
415
+ console.log(` ${WARN} Nothing to do. Use one of:`);
416
+ console.log(` ${c.cyan}taskplane uninstall${c.reset} # remove project-scaffolded files`);
417
+ console.log(` ${c.cyan}taskplane uninstall --package${c.reset} # remove installed package via pi`);
418
+ console.log();
419
+ return;
420
+ }
421
+
422
+ if (removeProject) {
423
+ console.log(`${c.bold}Project cleanup:${c.reset}`);
424
+ if (filesToDelete.length === 0 && sidecarsToDelete.length === 0 && taskDirsToDelete.length === 0) {
425
+ console.log(` ${c.dim}No Taskplane-managed project files found.${c.reset}`);
426
+ }
427
+ for (const f of filesToDelete) console.log(` - remove ${f.rel}`);
428
+ for (const f of sidecarsToDelete) console.log(` - remove ${f.rel}`);
429
+ for (const d of taskDirsToDelete) console.log(` - remove dir ${d.rel}`);
430
+ if (removeTasks && taskDirsToDelete.length === 0) {
431
+ console.log(` ${c.dim}No task area directories found from .pi/task-runner.yaml.${c.reset}`);
432
+ }
433
+ if (!removeTasks) {
434
+ console.log(` ${c.dim}Task directories are preserved by default (use --remove-tasks to delete them).${c.reset}`);
435
+ }
436
+ console.log();
437
+ }
438
+
439
+ if (removePackage) {
440
+ console.log(`${c.bold}Package cleanup:${c.reset}`);
441
+ console.log(` - run ${piRemoveCmd}`);
442
+ console.log(` ${c.dim}(removes extensions, skills, and dashboard files from this install scope)${c.reset}`);
443
+ console.log();
444
+ }
445
+
446
+ if (dryRun) {
447
+ console.log(`${INFO} Dry run complete. No files were changed.\n`);
448
+ return;
449
+ }
450
+
451
+ if (!yes) {
452
+ const proceed = await confirm("Proceed with uninstall?", false);
453
+ if (!proceed) {
454
+ console.log(" Aborted.");
455
+ return;
456
+ }
457
+ if (removeTasks) {
458
+ const taskConfirm = await confirm("This will delete task area directories recursively. Continue?", false);
459
+ if (!taskConfirm) {
460
+ console.log(" Aborted.");
461
+ return;
462
+ }
463
+ }
464
+ }
465
+
466
+ let removedCount = 0;
467
+ let failedCount = 0;
468
+
469
+ if (removeProject) {
470
+ for (const item of [...filesToDelete, ...sidecarsToDelete]) {
471
+ try {
472
+ fs.unlinkSync(item.abs);
473
+ removedCount++;
474
+ } catch (err) {
475
+ failedCount++;
476
+ console.log(` ${WARN} Failed to remove ${item.rel}: ${err.message}`);
477
+ }
478
+ }
479
+
480
+ for (const dir of taskDirsToDelete) {
481
+ try {
482
+ fs.rmSync(dir.abs, { recursive: true, force: true });
483
+ removedCount++;
484
+ } catch (err) {
485
+ failedCount++;
486
+ console.log(` ${WARN} Failed to remove directory ${dir.rel}: ${err.message}`);
487
+ }
488
+ }
489
+
490
+ // Best-effort cleanup of empty folders
491
+ pruneEmptyDir(path.join(projectRoot, ".pi", "agents"));
492
+ pruneEmptyDir(path.join(projectRoot, ".pi"));
493
+ }
494
+
495
+ if (removePackage) {
496
+ if (!commandExists("pi")) {
497
+ failedCount++;
498
+ console.log(` ${FAIL} pi is not on PATH; could not run: ${piRemoveCmd}`);
499
+ } else {
500
+ try {
501
+ execSync(piRemoveCmd, { cwd: projectRoot, stdio: "inherit" });
502
+ } catch {
503
+ failedCount++;
504
+ console.log(` ${FAIL} Package uninstall failed: ${piRemoveCmd}`);
505
+ }
506
+ }
507
+ }
508
+
509
+ console.log();
510
+ if (failedCount === 0) {
511
+ console.log(`${OK} ${c.bold}Uninstall complete.${c.reset}`);
512
+ if (removeProject) {
513
+ console.log(` Removed ${removedCount} project artifact(s).`);
514
+ }
515
+ console.log();
516
+ } else {
517
+ console.log(`${FAIL} Uninstall completed with ${failedCount} error(s).`);
518
+ if (removeProject) {
519
+ console.log(` Removed ${removedCount} project artifact(s).`);
520
+ }
521
+ console.log();
522
+ process.exit(1);
523
+ }
524
+ }
525
+
526
+ // ─── init ───────────────────────────────────────────────────────────────────
527
+
528
+ async function cmdInit(args) {
529
+ const projectRoot = process.cwd();
530
+ const force = args.includes("--force");
531
+ const dryRun = args.includes("--dry-run");
532
+ const noExamplesFlag = args.includes("--no-examples");
533
+ const includeExamples = args.includes("--include-examples");
534
+ const presetIdx = args.indexOf("--preset");
535
+ const preset = presetIdx !== -1 ? args[presetIdx + 1] : null;
536
+ const tasksRootIdx = args.indexOf("--tasks-root");
537
+ const tasksRootRaw = tasksRootIdx !== -1 ? args[tasksRootIdx + 1] : null;
538
+
539
+ if (noExamplesFlag && includeExamples) {
540
+ die("Choose either --no-examples or --include-examples, not both.");
541
+ }
542
+
543
+ if (tasksRootIdx !== -1 && (!tasksRootRaw || tasksRootRaw.startsWith("--"))) {
544
+ die("Missing value for --tasks-root <relative-path>.");
545
+ }
546
+
547
+ let tasksRootOverride = null;
548
+ if (tasksRootRaw) {
549
+ if (path.isAbsolute(tasksRootRaw)) {
550
+ die("--tasks-root must be relative to the project root (absolute paths are not allowed).");
551
+ }
552
+ tasksRootOverride = tasksRootRaw.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
553
+ if (!tasksRootOverride || tasksRootOverride === ".") {
554
+ die("--tasks-root must not be empty.");
555
+ }
556
+ if (tasksRootOverride === ".." || tasksRootOverride.startsWith("../")) {
557
+ die("--tasks-root must stay within the project root (paths starting with .. are not allowed).");
558
+ }
559
+ }
560
+
561
+ const noExamples = noExamplesFlag || (!!tasksRootOverride && !includeExamples);
562
+
563
+ console.log(`\n${c.bold}Taskplane Init${c.reset}\n`);
564
+
565
+ if (tasksRootOverride && !noExamplesFlag && !includeExamples) {
566
+ console.log(` ${INFO} Using custom --tasks-root (${tasksRootOverride}); skipping example tasks by default.`);
567
+ console.log(` Use --include-examples to scaffold examples into that directory.\n`);
568
+ }
569
+
570
+ // Check for existing config
571
+ const hasConfig =
572
+ fs.existsSync(path.join(projectRoot, ".pi", "task-runner.yaml")) ||
573
+ fs.existsSync(path.join(projectRoot, ".pi", "task-orchestrator.yaml"));
574
+
575
+ if (hasConfig && !force) {
576
+ console.log(`${WARN} Taskplane config already exists in this project.`);
577
+ const proceed = await confirm(" Overwrite existing files?", false);
578
+ if (!proceed) {
579
+ console.log(" Aborted.");
580
+ return;
581
+ }
582
+ }
583
+
584
+ // Gather config values
585
+ let vars;
586
+ if (preset === "minimal" || preset === "full" || preset === "runner-only") {
587
+ vars = getPresetVars(preset, projectRoot, tasksRootOverride);
588
+ console.log(` Using preset: ${c.cyan}${preset}${c.reset}`);
589
+ if (tasksRootOverride) {
590
+ console.log(` Task directory: ${c.cyan}${tasksRootOverride}${c.reset}`);
591
+ }
592
+ console.log();
593
+ } else {
594
+ vars = await getInteractiveVars(projectRoot, tasksRootOverride);
595
+ }
596
+
597
+ const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
598
+
599
+ if (dryRun) {
600
+ console.log(`\n${c.bold}Dry run — files that would be created:${c.reset}\n`);
601
+ printFileList(vars, noExamples, preset, exampleTemplateDirs);
602
+ return;
603
+ }
604
+
605
+ // Scaffold files
606
+ console.log(`\n${c.bold}Creating files...${c.reset}\n`);
607
+ const skipIfExists = !force;
608
+
609
+ // Agent prompts
610
+ for (const agent of ["task-worker.md", "task-reviewer.md", "task-merger.md"]) {
611
+ copyTemplate(
612
+ path.join(TEMPLATES_DIR, "agents", agent),
613
+ path.join(projectRoot, ".pi", "agents", agent),
614
+ { skipIfExists, label: `.pi/agents/${agent}` }
615
+ );
616
+ }
617
+
618
+ // Task runner config
619
+ writeFile(
620
+ path.join(projectRoot, ".pi", "task-runner.yaml"),
621
+ generateTaskRunnerYaml(vars),
622
+ { skipIfExists, label: ".pi/task-runner.yaml" }
623
+ );
624
+
625
+ // Orchestrator config (skip for runner-only preset)
626
+ if (preset !== "runner-only") {
627
+ writeFile(
628
+ path.join(projectRoot, ".pi", "task-orchestrator.yaml"),
629
+ generateOrchestratorYaml(vars),
630
+ { skipIfExists, label: ".pi/task-orchestrator.yaml" }
631
+ );
632
+ }
633
+
634
+ // Version tracker (always overwrite)
635
+ const versionInfo = {
636
+ version: getPackageVersion(),
637
+ installedAt: new Date().toISOString(),
638
+ lastUpgraded: new Date().toISOString(),
639
+ components: { agents: getPackageVersion(), config: getPackageVersion() },
640
+ };
641
+ writeFile(
642
+ path.join(projectRoot, ".pi", "taskplane.json"),
643
+ JSON.stringify(versionInfo, null, 2) + "\n",
644
+ { label: ".pi/taskplane.json" }
645
+ );
646
+
647
+ // CONTEXT.md
648
+ const contextSrc = fs.readFileSync(path.join(TEMPLATES_DIR, "tasks", "CONTEXT.md"), "utf-8");
649
+ writeFile(
650
+ path.join(projectRoot, vars.tasks_root, "CONTEXT.md"),
651
+ interpolate(contextSrc, vars),
652
+ { skipIfExists, label: `${vars.tasks_root}/CONTEXT.md` }
653
+ );
654
+
655
+ // Example tasks
656
+ if (!noExamples) {
657
+ for (const exampleName of exampleTemplateDirs) {
658
+ const exampleDir = path.join(TEMPLATES_DIR, "tasks", exampleName);
659
+ const destDir = path.join(projectRoot, vars.tasks_root, exampleName);
660
+ for (const file of ["PROMPT.md", "STATUS.md"]) {
661
+ const srcPath = path.join(exampleDir, file);
662
+ if (!fs.existsSync(srcPath)) continue;
663
+ const src = fs.readFileSync(srcPath, "utf-8");
664
+ writeFile(path.join(destDir, file), interpolate(src, vars), {
665
+ skipIfExists,
666
+ label: `${vars.tasks_root}/${exampleName}/${file}`,
667
+ });
668
+ }
669
+ }
670
+ if (exampleTemplateDirs.length === 0) {
671
+ console.log(` ${WARN} No example task templates found under templates/tasks/EXAMPLE-*`);
672
+ }
673
+ }
674
+
675
+ // Auto-commit task files to git so they're available in worktrees
676
+ await autoCommitTaskFiles(projectRoot, vars.tasks_root);
677
+
678
+ // Report
679
+ console.log(`\n${OK} ${c.bold}Taskplane initialized!${c.reset}\n`);
680
+ console.log(`${c.bold}Quick start:${c.reset}`);
681
+ console.log(` ${c.cyan}pi${c.reset} # start pi (taskplane auto-loads)`);
682
+ if (preset !== "runner-only") {
683
+ console.log(` ${c.cyan}/orch-plan all${c.reset} # preview waves/lanes/dependencies`);
684
+ console.log(` ${c.cyan}/orch all${c.reset} # run examples via orchestrator`);
685
+ }
686
+ if (!noExamples && exampleTemplateDirs.length > 0) {
687
+ const firstExample = exampleTemplateDirs[0];
688
+ console.log(` ${c.dim}optional single-task mode:${c.reset}`);
689
+ console.log(` ${c.cyan}/task ${vars.tasks_root}/${firstExample}/PROMPT.md${c.reset}`);
690
+ }
691
+ console.log();
692
+ }
693
+
694
+ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
695
+ const dirName = path.basename(projectRoot);
696
+ const slug = slugify(dirName);
697
+ const { test: test_cmd, build: build_cmd } = detectStack(projectRoot);
698
+ return {
699
+ project_name: dirName,
700
+ integration_branch: "main",
701
+ max_lanes: 3,
702
+ worktree_prefix: `${slug}-wt`,
703
+ tmux_prefix: `${slug}-orch`,
704
+ tasks_root: tasksRootOverride || "taskplane-tasks",
705
+ default_area: "general",
706
+ default_prefix: "TP",
707
+ test_cmd,
708
+ build_cmd,
709
+ date: today(),
710
+ };
711
+ }
712
+
713
+ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
714
+ const dirName = path.basename(projectRoot);
715
+ const detected = detectStack(projectRoot);
716
+
717
+ const project_name = await ask("Project name", dirName);
718
+ const integration_branch = await ask("Default branch (fallback — orchestrator uses your current branch at runtime)", "main");
719
+ const max_lanes = parseInt(await ask("Max parallel lanes", "3")) || 3;
720
+ const tasks_root = tasksRootOverride || await ask("Tasks directory", "taskplane-tasks");
721
+ const default_area = await ask("Default area name", "general");
722
+ const default_prefix = await ask("Task ID prefix", "TP");
723
+ const test_cmd = await ask("Test command (agents run this to verify work — blank to skip)", detected.test || "");
724
+ const build_cmd = await ask("Build command (agents run this after tests — blank to skip)", detected.build || "");
725
+
726
+ const slug = slugify(project_name);
727
+ return {
728
+ project_name,
729
+ integration_branch,
730
+ max_lanes,
731
+ worktree_prefix: `${slug}-wt`,
732
+ tmux_prefix: `${slug}-orch`,
733
+ tasks_root,
734
+ default_area,
735
+ default_prefix,
736
+ test_cmd,
737
+ build_cmd,
738
+ date: today(),
739
+ };
740
+ }
741
+
742
+ function printFileList(vars, noExamples, preset, exampleTemplateDirs = []) {
743
+ const files = [
744
+ ".pi/agents/task-worker.md",
745
+ ".pi/agents/task-reviewer.md",
746
+ ".pi/agents/task-merger.md",
747
+ ".pi/task-runner.yaml",
748
+ ];
749
+ if (preset !== "runner-only") files.push(".pi/task-orchestrator.yaml");
750
+ files.push(".pi/taskplane.json");
751
+ files.push(`${vars.tasks_root}/CONTEXT.md`);
752
+ if (!noExamples) {
753
+ for (const exampleName of exampleTemplateDirs) {
754
+ files.push(`${vars.tasks_root}/${exampleName}/PROMPT.md`);
755
+ files.push(`${vars.tasks_root}/${exampleName}/STATUS.md`);
756
+ }
757
+ }
758
+ for (const f of files) console.log(` ${c.green}create${c.reset} ${f}`);
759
+ console.log();
760
+ }
761
+
762
+ // ─── doctor ─────────────────────────────────────────────────────────────────
763
+
764
+ function cmdDoctor() {
765
+ const projectRoot = process.cwd();
766
+ let issues = 0;
767
+
768
+ console.log(`\n${c.bold}Taskplane Doctor${c.reset}\n`);
769
+
770
+ // Check prerequisites
771
+ const checks = [
772
+ { label: "pi installed", check: () => commandExists("pi"), detail: () => getVersion("pi") },
773
+ {
774
+ label: "Node.js >= 20.0.0",
775
+ check: () => {
776
+ const v = process.versions.node;
777
+ return parseInt(v.split(".")[0]) >= 20;
778
+ },
779
+ detail: () => `v${process.versions.node}`,
780
+ },
781
+ { label: "git installed", check: () => commandExists("git"), detail: () => getVersion("git") },
782
+ ];
783
+
784
+ for (const { label, check, detail } of checks) {
785
+ const ok = check();
786
+ const info = ok && detail ? ` ${c.dim}(${detail()})${c.reset}` : "";
787
+ console.log(` ${ok ? OK : FAIL} ${label}${info}`);
788
+ if (!ok) issues++;
789
+ }
790
+
791
+ // Check tmux (optional)
792
+ const hasTmux = commandExists("tmux");
793
+ console.log(
794
+ ` ${hasTmux ? OK : `${WARN}`} tmux installed${hasTmux ? ` ${c.dim}(${getVersion("tmux", "-V")})${c.reset}` : ` ${c.dim}(optional — needed for spawn_mode: tmux)${c.reset}`}`
795
+ );
796
+
797
+ // Check package installation
798
+ const pkgJson = path.join(PACKAGE_ROOT, "package.json");
799
+ const pkgVersion = getPackageVersion();
800
+ const isProjectLocal = PACKAGE_ROOT.includes(".pi");
801
+ const installType = isProjectLocal ? "project-local" : "global";
802
+ console.log(` ${OK} taskplane package installed ${c.dim}(v${pkgVersion}, ${installType})${c.reset}`);
803
+
804
+ // Check project config
805
+ console.log();
806
+ const configFiles = [
807
+ { path: ".pi/task-runner.yaml", required: true },
808
+ { path: ".pi/task-orchestrator.yaml", required: true },
809
+ { path: ".pi/agents/task-worker.md", required: true },
810
+ { path: ".pi/agents/task-reviewer.md", required: true },
811
+ { path: ".pi/agents/task-merger.md", required: true },
812
+ { path: ".pi/taskplane.json", required: false },
813
+ ];
814
+
815
+ for (const { path: relPath, required } of configFiles) {
816
+ const exists = fs.existsSync(path.join(projectRoot, relPath));
817
+ if (exists) {
818
+ console.log(` ${OK} ${relPath} exists`);
819
+ } else if (required) {
820
+ console.log(` ${FAIL} ${relPath} missing`);
821
+ issues++;
822
+ } else {
823
+ console.log(` ${WARN} ${relPath} missing ${c.dim}(optional)${c.reset}`);
824
+ }
825
+ }
826
+
827
+ // Check task areas from config
828
+ const runnerYaml = readYaml(path.join(projectRoot, ".pi", "task-runner.yaml"));
829
+ if (runnerYaml) {
830
+ // Simple regex extraction of task area paths
831
+ const pathMatches = [...runnerYaml.matchAll(/^\s+path:\s*"?([^"\n]+)"?/gm)];
832
+ const contextMatches = [...runnerYaml.matchAll(/^\s+context:\s*"?([^"\n]+)"?/gm)];
833
+
834
+ if (pathMatches.length > 0) {
835
+ console.log();
836
+ for (const match of pathMatches) {
837
+ const areaPath = match[1].trim();
838
+ const exists = fs.existsSync(path.join(projectRoot, areaPath));
839
+ if (exists) {
840
+ console.log(` ${OK} task area path: ${areaPath}`);
841
+ } else {
842
+ console.log(` ${FAIL} task area path: ${areaPath} ${c.dim}(directory not found)${c.reset}`);
843
+ console.log(` ${c.dim}→ Run: mkdir -p ${areaPath}${c.reset}`);
844
+ issues++;
845
+ }
846
+ }
847
+ for (const match of contextMatches) {
848
+ const ctxPath = match[1].trim();
849
+ const exists = fs.existsSync(path.join(projectRoot, ctxPath));
850
+ if (exists) {
851
+ console.log(` ${OK} CONTEXT.md: ${ctxPath}`);
852
+ } else {
853
+ console.log(` ${WARN} CONTEXT.md: ${ctxPath} ${c.dim}(not found)${c.reset}`);
854
+ }
855
+ }
856
+ }
857
+ }
858
+
859
+ console.log();
860
+ if (issues === 0) {
861
+ console.log(`${OK} ${c.green}All checks passed!${c.reset}\n`);
862
+ } else {
863
+ console.log(`${FAIL} ${issues} issue(s) found. Run ${c.cyan}taskplane init${c.reset} to fix config issues.\n`);
864
+ process.exit(1);
865
+ }
866
+ }
867
+
868
+ // ─── version ────────────────────────────────────────────────────────────────
869
+
870
+ function cmdVersion() {
871
+ const pkgVersion = getPackageVersion();
872
+ const isProjectLocal = PACKAGE_ROOT.includes(".pi");
873
+ const installType = isProjectLocal ? `project-local: ${PACKAGE_ROOT}` : `global: ${PACKAGE_ROOT}`;
874
+
875
+ console.log(`\ntaskplane ${c.bold}v${pkgVersion}${c.reset}`);
876
+ console.log(` Package: ${installType}`);
877
+
878
+ // Check for project config
879
+ const projectRoot = process.cwd();
880
+ const tpJson = path.join(projectRoot, ".pi", "taskplane.json");
881
+ if (fs.existsSync(tpJson)) {
882
+ try {
883
+ const info = JSON.parse(fs.readFileSync(tpJson, "utf-8"));
884
+ console.log(` Config: .pi/taskplane.json (v${info.version}, initialized ${info.installedAt?.slice(0, 10) || "unknown"})`);
885
+ } catch {
886
+ console.log(` Config: .pi/taskplane.json (unreadable)`);
887
+ }
888
+ } else {
889
+ console.log(` Config: ${c.dim}not initialized (run taskplane init)${c.reset}`);
890
+ }
891
+
892
+ // Pi version
893
+ const piVersion = getVersion("pi");
894
+ if (piVersion) console.log(` Pi: ${piVersion}`);
895
+
896
+ // Node version
897
+ console.log(` Node: v${process.versions.node}`);
898
+ console.log();
899
+ }
900
+
901
+ function getPackageVersion() {
902
+ try {
903
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"));
904
+ return pkg.version || "unknown";
905
+ } catch {
906
+ return "unknown";
907
+ }
908
+ }
909
+
910
+ // ─── dashboard ──────────────────────────────────────────────────────────────
911
+
912
+ function cmdDashboard(args) {
913
+ const projectRoot = process.cwd();
914
+
915
+ if (!fs.existsSync(DASHBOARD_SERVER)) {
916
+ die(`Dashboard server not found at ${DASHBOARD_SERVER}`);
917
+ }
918
+
919
+ // Pass through args to server.cjs, adding --root
920
+ const serverArgs = ["--root", projectRoot];
921
+
922
+ // Forward --port and --no-open if provided
923
+ for (let i = 0; i < args.length; i++) {
924
+ if (args[i] === "--port" && args[i + 1]) {
925
+ serverArgs.push("--port", args[i + 1]);
926
+ i++;
927
+ } else if (args[i] === "--no-open") {
928
+ serverArgs.push("--no-open");
929
+ }
930
+ }
931
+
932
+ console.log(`\n${c.bold}Taskplane Dashboard${c.reset}`);
933
+ console.log(` Project: ${projectRoot}`);
934
+ console.log(` Server: ${DASHBOARD_SERVER}\n`);
935
+
936
+ const child = spawn("node", [DASHBOARD_SERVER, ...serverArgs], {
937
+ stdio: "inherit",
938
+ cwd: projectRoot,
939
+ });
940
+
941
+ child.on("error", (err) => {
942
+ die(`Failed to start dashboard: ${err.message}`);
943
+ });
944
+
945
+ // Forward signals
946
+ process.on("SIGINT", () => child.kill("SIGINT"));
947
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
948
+
949
+ child.on("exit", (code) => {
950
+ process.exit(code ?? 0);
951
+ });
952
+ }
953
+
954
+ // ─── help ───────────────────────────────────────────────────────────────────
955
+
956
+ function showHelp() {
957
+ const version = getPackageVersion();
958
+ console.log(`
959
+ ${c.bold}taskplane${c.reset} v${version} AI agent orchestration for pi
960
+
961
+ ${c.bold}Usage:${c.reset}
962
+ taskplane <command> [options]
963
+
964
+ ${c.bold}Commands:${c.reset}
965
+ ${c.cyan}init${c.reset} Scaffold Taskplane config in the current project
966
+ ${c.cyan}doctor${c.reset} Validate installation and project configuration
967
+ ${c.cyan}version${c.reset} Show version information
968
+ ${c.cyan}dashboard${c.reset} Launch the web-based orchestrator dashboard
969
+ ${c.cyan}uninstall${c.reset} Remove Taskplane project files and/or package install
970
+ ${c.cyan}help${c.reset} Show this help message
971
+
972
+ ${c.bold}Init options:${c.reset}
973
+ --preset <name> Use a preset: minimal, full, runner-only
974
+ --tasks-root <path> Relative tasks directory to use (e.g. docs/task-management)
975
+ --no-examples Skip example tasks scaffolding
976
+ --include-examples With --tasks-root, include example tasks (default is skip)
977
+ --force Overwrite existing files without prompting
978
+ --dry-run Show what would be created without writing
979
+
980
+ ${c.bold}Dashboard options:${c.reset}
981
+ --port <number> Port to listen on (default: 8099)
982
+ --no-open Don't auto-open browser
983
+
984
+ ${c.bold}Uninstall options:${c.reset}
985
+ --dry-run Show what would be removed
986
+ --yes, -y Skip confirmation prompts
987
+ --package Also remove installed package via pi remove
988
+ --package-only Only remove installed package (skip project cleanup)
989
+ --local Force package uninstall from project-local scope
990
+ --global Force package uninstall from global scope
991
+ --remove-tasks Also remove task area directories from task-runner.yaml
992
+ --all Equivalent to --package + --remove-tasks
993
+
994
+ ${c.bold}Examples:${c.reset}
995
+ taskplane init # Interactive project setup
996
+ taskplane init --preset full # Quick setup with defaults
997
+ taskplane init --preset full --tasks-root docs/task-management
998
+ # Use existing task area path
999
+ taskplane init --dry-run # Preview what would be created
1000
+ taskplane doctor # Check installation health
1001
+ taskplane dashboard # Launch web dashboard
1002
+ taskplane dashboard --port 3000 # Dashboard on custom port
1003
+ taskplane uninstall --dry-run # Preview uninstall actions
1004
+ taskplane uninstall --package --yes # Remove project files + package install
1005
+
1006
+ ${c.bold}Getting started:${c.reset}
1007
+ 1. pi install npm:taskplane # Install the pi package
1008
+ 2. cd my-project && taskplane init # Scaffold project config
1009
+ 3. pi # Start pi — /task and /orch are ready
1010
+ `);
1011
+ }
1012
+
1013
+ // ═════════════════════════════════════════════════════════════════════════════
1014
+ // MAIN
1015
+ // ═════════════════════════════════════════════════════════════════════════════
1016
+
1017
+ const [command, ...args] = process.argv.slice(2);
1018
+
1019
+ switch (command) {
1020
+ case "init":
1021
+ await cmdInit(args);
1022
+ break;
1023
+ case "doctor":
1024
+ cmdDoctor();
1025
+ break;
1026
+ case "version":
1027
+ case "--version":
1028
+ case "-v":
1029
+ cmdVersion();
1030
+ break;
1031
+ case "dashboard":
1032
+ cmdDashboard(args);
1033
+ break;
1034
+ case "uninstall":
1035
+ await cmdUninstall(args);
1036
+ break;
1037
+ case "help":
1038
+ case "--help":
1039
+ case "-h":
1040
+ case undefined:
1041
+ showHelp();
1042
+ break;
1043
+ default:
1044
+ console.error(`${FAIL} Unknown command: ${command}`);
1045
+ showHelp();
1046
+ process.exit(1);
1047
+ }