taskplane 0.3.0 → 0.4.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.
@@ -23,6 +23,12 @@ const MAX_PORT_ATTEMPTS = 20;
23
23
  const POLL_INTERVAL = 2000; // ms between state checks
24
24
 
25
25
  // REPO_ROOT is resolved after parseArgs() — see initialization below.
26
+ // In workspace mode, REPO_ROOT is the workspace root (passed via --root).
27
+ // All dashboard state paths (batch-state, lane-state, conversation logs,
28
+ // batch-history) live at <REPO_ROOT>/.pi/ — this is runtime/sidecar state
29
+ // which does NOT follow the taskplane-pointer.json resolution chain.
30
+ // The pointer directs config/agent lookups to a config repo, but the
31
+ // dashboard only reads state files, so no pointer resolution is needed here.
26
32
  let REPO_ROOT;
27
33
  let BATCH_STATE_PATH;
28
34
  let BATCH_HISTORY_PATH;
@@ -630,7 +636,13 @@ async function findPort(server, start, explicit) {
630
636
  async function main() {
631
637
  const opts = parseArgs();
632
638
 
633
- // Resolve project root: --root flag > cwd
639
+ // Resolve project root: --root flag > cwd.
640
+ // In workspace mode this is the workspace root. All state/sidecar files
641
+ // (batch-state, lane-state, conversation logs, batch-history) live at
642
+ // <REPO_ROOT>/.pi/ and are NOT affected by taskplane-pointer.json.
643
+ // The pointer only redirects config/agent resolution in task-runner and
644
+ // orchestrator — the dashboard reads only runtime state, so no pointer
645
+ // resolution is performed here.
634
646
  REPO_ROOT = path.resolve(opts.root || process.cwd());
635
647
  BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
636
648
  BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
@@ -26,7 +26,9 @@ import {
26
26
  } from "fs";
27
27
  import { tmpdir } from "os";
28
28
  import { join, dirname, basename, resolve } from "path";
29
- import { parse as yamlParse } from "yaml";
29
+ import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
30
+ import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
31
+ import type { PointerResolution } from "./taskplane/types.ts";
30
32
 
31
33
 
32
34
  // ── Types ────────────────────────────────────────────────────────────
@@ -137,54 +139,75 @@ const DEFAULT_CONFIG: TaskConfig = {
137
139
  },
138
140
  };
139
141
 
140
- function loadConfig(cwd: string): TaskConfig {
141
- let configPath = join(cwd, ".pi", "task-runner.yaml");
142
- // In workspace mode, the worker runs in a repo worktree not the workspace root.
143
- // TASKPLANE_WORKSPACE_ROOT tells us where .pi/task-runner.yaml actually lives.
144
- if (!existsSync(configPath) && process.env.TASKPLANE_WORKSPACE_ROOT) {
145
- configPath = join(process.env.TASKPLANE_WORKSPACE_ROOT, ".pi", "task-runner.yaml");
146
- }
147
- if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
142
+ // ── Pointer Resolution (Workspace Mode) ──────────────────────────────
143
+
144
+ /** Track whether a pointer warning has been logged this session (log once). */
145
+ let _pointerWarningLogged = false;
146
+
147
+ /**
148
+ * Resolve the workspace pointer for config and agent path redirection.
149
+ *
150
+ * In workspace mode (TASKPLANE_WORKSPACE_ROOT set), reads the pointer
151
+ * file and resolves config/agent roots to the config repo. In repo mode,
152
+ * returns null (no pointer resolution needed).
153
+ *
154
+ * All pointer failures are non-fatal: missing, malformed, or invalid
155
+ * pointer files produce a warning and fall back to existing paths.
156
+ * Warning is logged to stderr once per session for operator visibility.
157
+ *
158
+ * @returns PointerResolution with resolved paths, or null in repo mode
159
+ */
160
+ function resolveTaskRunnerPointer(): PointerResolution | null {
161
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
162
+ if (!wsRoot) return null; // repo mode — no pointer needed
163
+
148
164
  try {
149
- const raw = readFileSync(configPath, "utf-8");
150
- const loaded = yamlParse(raw) as any;
151
- // Parse standards_overrides: Record<areaName, { docs?, rules? }>
152
- const rawOverrides = loaded?.standards_overrides || {};
153
- const parsedOverrides: Record<string, { docs?: string[]; rules?: string[] }> = {};
154
- for (const [key, val] of Object.entries(rawOverrides)) {
155
- if (val && typeof val === "object") {
156
- const v = val as any;
157
- parsedOverrides[key] = {
158
- docs: Array.isArray(v.docs) ? v.docs : undefined,
159
- rules: Array.isArray(v.rules) ? v.rules : undefined,
160
- };
161
- }
162
- }
165
+ const wsConfig = loadWorkspaceConfig(wsRoot);
166
+ const result = resolvePointer(wsRoot, wsConfig);
163
167
 
164
- // Parse task_areas minimally (we only need path for standards resolution)
165
- const rawAreas = loaded?.task_areas || {};
166
- const parsedAreas: Record<string, { path: string }> = {};
167
- for (const [key, val] of Object.entries(rawAreas)) {
168
- if (val && typeof val === "object" && (val as any).path) {
169
- parsedAreas[key] = { path: (val as any).path };
170
- }
168
+ // Surface pointer warnings once per session for operator visibility
169
+ if (result?.warning && !_pointerWarningLogged) {
170
+ _pointerWarningLogged = true;
171
+ console.error(`[task-runner] pointer: ${result.warning}`);
171
172
  }
172
173
 
173
- return {
174
- project: { ...DEFAULT_CONFIG.project, ...loaded?.project },
175
- paths: { ...DEFAULT_CONFIG.paths, ...loaded?.paths },
176
- testing: { commands: { ...DEFAULT_CONFIG.testing.commands, ...loaded?.testing?.commands } },
177
- standards: {
178
- docs: loaded?.standards?.docs || DEFAULT_CONFIG.standards.docs,
179
- rules: loaded?.standards?.rules || DEFAULT_CONFIG.standards.rules,
180
- },
181
- standards_overrides: parsedOverrides,
182
- task_areas: parsedAreas,
183
- worker: { ...DEFAULT_CONFIG.worker, ...loaded?.worker },
184
- reviewer: { ...DEFAULT_CONFIG.reviewer, ...loaded?.reviewer },
185
- context: { ...DEFAULT_CONFIG.context, ...loaded?.context },
186
- };
174
+ return result;
175
+ } catch {
176
+ // Workspace config load failure — fall back gracefully
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /** Reset pointer warning state (for testing only). */
182
+ export function _resetPointerWarning(): void {
183
+ _pointerWarningLogged = false;
184
+ }
185
+
186
+ /** Expose loadAgentDef for testing (not part of public API). */
187
+ export const _loadAgentDef = (cwd: string, name: string) => loadAgentDef(cwd, name);
188
+
189
+ /**
190
+ * Load task-runner config via the unified config loader.
191
+ *
192
+ * Reads `.pi/taskplane-config.json` first; falls back to YAML files;
193
+ * then defaults. Returns the legacy snake_case TaskConfig shape so all
194
+ * downstream consumers remain unchanged.
195
+ *
196
+ * Config root resolution order (workspace mode with pointer):
197
+ * 1. cwd has config files → use cwd (local override)
198
+ * 2. Pointer-resolved config root has config files → use it
199
+ * 3. TASKPLANE_WORKSPACE_ROOT has config files → use it (legacy fallback)
200
+ * 4. Fall back to cwd (loaders will return defaults)
201
+ *
202
+ * Repo mode: pointer is ignored, existing behavior unchanged.
203
+ */
204
+ export function loadConfig(cwd: string): TaskConfig {
205
+ try {
206
+ const pointer = resolveTaskRunnerPointer();
207
+ const unified = loadProjectConfig(cwd, pointer?.configRoot);
208
+ return toTaskConfig(unified);
187
209
  } catch {
210
+ // If config loading fails (e.g., malformed JSON), fall back to defaults
188
211
  return { ...DEFAULT_CONFIG };
189
212
  }
190
213
  }
@@ -339,21 +362,153 @@ function clearConversationLog(prefix: string): void {
339
362
 
340
363
  // ── Agent Loader ─────────────────────────────────────────────────────
341
364
 
365
+ /**
366
+ * Parse a markdown agent file into frontmatter key-value pairs and body content.
367
+ * Returns null if the file doesn't exist or has no frontmatter block.
368
+ */
369
+ function parseAgentFile(filePath: string): { fm: Record<string, string>; body: string } | null {
370
+ if (!existsSync(filePath)) return null;
371
+ const raw = readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
372
+ const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
373
+ if (!match) return null;
374
+ const fm: Record<string, string> = {};
375
+ for (const line of match[1].split("\n")) {
376
+ const idx = line.indexOf(":");
377
+ if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
378
+ }
379
+ return { fm, body: match[2].trim() };
380
+ }
381
+
382
+ /** Cached package root — resolved once, reused for all agent file lookups. */
383
+ let _packageRoot: string | null = null;
384
+
385
+ /**
386
+ * Find the taskplane package root directory.
387
+ *
388
+ * Strategy: this file lives at <package-root>/extensions/task-runner.ts.
389
+ * When pi loads it via `-e`, it resolves the full path. We can find the
390
+ * package root by searching for package.json with name "taskplane"
391
+ * starting from known candidate locations.
392
+ */
393
+ function findPackageRoot(): string {
394
+ if (_packageRoot !== null) return _packageRoot;
395
+
396
+ // Strategy 1: Walk up from this file's location via require.resolve or npm paths
397
+ const candidates: string[] = [];
398
+
399
+ // The extension is loaded by pi from the installed package location.
400
+ // Check well-known npm global paths.
401
+ const home = process.env.HOME || process.env.USERPROFILE || "";
402
+ if (home) {
403
+ candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
404
+ candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
405
+ }
406
+ candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
407
+
408
+ // Strategy 2: resolve from pi's node_modules peer
409
+ try {
410
+ const piPath = process.argv[1] || "";
411
+ const piPkgDir = resolve(piPath, "..", "..");
412
+ candidates.push(join(piPkgDir, "..", "taskplane"));
413
+ } catch { /* ignore */ }
414
+
415
+ // Strategy 3: Check TASKPLANE_WORKSPACE_ROOT project-local install
416
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
417
+ if (wsRoot) {
418
+ candidates.push(join(wsRoot, ".pi", "npm", "node_modules", "taskplane"));
419
+ candidates.push(join(wsRoot, "node_modules", "taskplane"));
420
+ }
421
+
422
+ for (const dir of candidates) {
423
+ try {
424
+ const pkgPath = join(dir, "package.json");
425
+ if (existsSync(pkgPath)) {
426
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
427
+ if (pkg.name === "taskplane") {
428
+ _packageRoot = dir;
429
+ return dir;
430
+ }
431
+ }
432
+ } catch { /* ignore */ }
433
+ }
434
+
435
+ _packageRoot = "";
436
+ return "";
437
+ }
438
+
439
+ /**
440
+ * Resolve the package-shipped base agent file path.
441
+ * Base files live in the package's templates/agents/ directory.
442
+ */
443
+ function resolveBaseAgentPath(name: string): string {
444
+ const root = findPackageRoot();
445
+ if (!root) return "";
446
+ return join(root, "templates", "agents", `${name}.md`);
447
+ }
448
+
449
+ /**
450
+ * Load an agent definition with prompt inheritance.
451
+ *
452
+ * Inheritance model (default: compose base + local):
453
+ * 1. Load base agent from the shipped package (templates/agents/{name}.md)
454
+ * 2. Load local agent from .pi/agents/{name}.md (if it exists) — or from
455
+ * the pointer-resolved agent root in workspace mode
456
+ * 3. If local file has `standalone: true` in frontmatter, use it as-is (no base)
457
+ * 4. Otherwise, compose: base prompt + separator + local content
458
+ * 5. Local frontmatter values (tools, model) override base values
459
+ *
460
+ * Local override resolution order:
461
+ * 1. `<cwd>/.pi/agents/{name}.md` — worktree/repo local override (always first)
462
+ * 2. `<cwd>/agents/{name}.md` — worktree/repo local override (legacy location)
463
+ * 3. `<pointerAgentRoot>/{name}.md` — pointer-resolved config repo agents (workspace mode)
464
+ * First found wins. If none found, base file is used directly.
465
+ *
466
+ * If no base file exists (e.g., custom agent), local file is used as-is.
467
+ */
342
468
  function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
343
- const paths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
344
- for (const p of paths) {
345
- if (!existsSync(p)) continue;
346
- const raw = readFileSync(p, "utf-8").replace(/\r\n/g, "\n");
347
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
348
- if (!match) continue;
349
- const fm: Record<string, string> = {};
350
- for (const line of match[1].split("\n")) {
351
- const idx = line.indexOf(":");
352
- if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
353
- }
354
- return { systemPrompt: match[2].trim(), tools: fm.tools || "read,grep,find,ls", model: fm.model || "" };
469
+ const basePath = resolveBaseAgentPath(name);
470
+ const localPaths = [join(cwd, ".pi", "agents", `${name}.md`), join(cwd, "agents", `${name}.md`)];
471
+
472
+ // In workspace mode, add pointer-resolved agent root as fallback
473
+ const pointer = resolveTaskRunnerPointer();
474
+ if (pointer?.agentRoot) {
475
+ localPaths.push(join(pointer.agentRoot, `${name}.md`));
476
+ }
477
+
478
+ // Load base from package
479
+ const baseDef = parseAgentFile(basePath);
480
+
481
+ // Load local override (first found wins)
482
+ let localDef: { fm: Record<string, string>; body: string } | null = null;
483
+ for (const p of localPaths) {
484
+ localDef = parseAgentFile(p);
485
+ if (localDef) break;
355
486
  }
356
- return null;
487
+
488
+ // No base and no local → null
489
+ if (!baseDef && !localDef) return null;
490
+
491
+ // Local with standalone: true → use local as-is, ignore base
492
+ if (localDef?.fm.standalone === "true") {
493
+ return {
494
+ systemPrompt: localDef.body,
495
+ tools: localDef.fm.tools || "read,grep,find,ls",
496
+ model: localDef.fm.model || "",
497
+ };
498
+ }
499
+
500
+ // Compose base + local
501
+ const basePrompt = baseDef?.body || "";
502
+ const localPrompt = localDef?.body || "";
503
+ const composedPrompt = localPrompt
504
+ ? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt
505
+ : basePrompt;
506
+
507
+ // Local frontmatter overrides base (tools, model)
508
+ const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls";
509
+ const model = localDef?.fm.model || baseDef?.fm.model || "";
510
+
511
+ return { systemPrompt: composedPrompt.trim(), tools, model };
357
512
  }
358
513
 
359
514
  // ── PROMPT.md Parser ─────────────────────────────────────────────────