taskplane 0.22.4 → 0.22.6

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.
@@ -792,7 +792,17 @@ function computeBatchTotalCost(laneStates, telemetry) {
792
792
  function buildDashboardState() {
793
793
  const state = loadBatchState();
794
794
  const tmuxSessions = getTmuxSessions();
795
- const laneStates = loadLaneStates();
795
+ const rawLaneStates = loadLaneStates();
796
+ // Filter stale lane states from previous batches.
797
+ // Lane state files persist across batches (same filename), so without
798
+ // filtering the dashboard shows telemetry from old runs.
799
+ const currentBatchId = state?.batchId || null;
800
+ const laneStates = {};
801
+ for (const [key, ls] of Object.entries(rawLaneStates)) {
802
+ if (!currentBatchId || !ls.batchId || ls.batchId === currentBatchId) {
803
+ laneStates[key] = ls;
804
+ }
805
+ }
796
806
  const telemetry = loadTelemetryData(state);
797
807
  const batchTotalCost = computeBatchTotalCost(laneStates, telemetry);
798
808
  const supervisor = loadSupervisorData(state);
@@ -447,6 +447,7 @@ function writeLaneState(state: TaskState): void {
447
447
  reviewerOutputTokens: state.reviewerOutputTokens || 0,
448
448
  reviewerCacheReadTokens: state.reviewerCacheReadTokens || 0,
449
449
  reviewerCacheWriteTokens: state.reviewerCacheWriteTokens || 0,
450
+ batchId: process.env.ORCH_BATCH_ID || null,
450
451
  timestamp: Date.now(),
451
452
  };
452
453
  writeFileSync(filePath, JSON.stringify(data) + "\n");
@@ -2554,7 +2555,22 @@ export default function (pi: ExtensionAPI) {
2554
2555
  reviewContent, statusPath, num, reviewType, stepNum, state.reviewCounter,
2555
2556
  );
2556
2557
 
2557
- // Set reviewer to idle (NOT clear persistent session stays alive)
2558
+ // After code review: kill the persistent reviewer to free context.
2559
+ // The reviewer persists through a plan+code pair for one step,
2560
+ // then gets a fresh session for the next step.
2561
+ if (reviewType === "code") {
2562
+ console.error(`[task-runner] code review complete for step ${stepNum} — killing reviewer for fresh context on next step`);
2563
+ logExecution(statusPath, `Reviewer R${num}`,
2564
+ `code review complete — killing persistent reviewer (step ${stepNum} cycle done)`);
2565
+ if (state.persistentReviewerKill) {
2566
+ try { state.persistentReviewerKill(); } catch {}
2567
+ }
2568
+ state.persistentReviewerSession = null;
2569
+ state.persistentReviewerKill = null;
2570
+ state.persistentReviewerSignalNum = 0;
2571
+ state.reviewerRespawnCount = 0;
2572
+ }
2573
+
2558
2574
  state.reviewerStatus = "idle";
2559
2575
  state.reviewerType = "";
2560
2576
  state.reviewerStep = 0;
@@ -2642,6 +2658,9 @@ export default function (pi: ExtensionAPI) {
2642
2658
  fallbackContent, statusPath, num, reviewType, stepNum, state.reviewCounter, "fallback",
2643
2659
  );
2644
2660
 
2661
+ // Reset respawn counter on successful fallback review
2662
+ state.reviewerRespawnCount = 0;
2663
+
2645
2664
  clearReviewerState();
2646
2665
  writeLaneState(state);
2647
2666
  updateWidgets();
@@ -43,6 +43,7 @@ import type {
43
43
  TaskplaneConfig,
44
44
  TaskRunnerSection,
45
45
  OrchestratorSection,
46
+ WorkspaceSectionConfig,
46
47
  UserPreferences,
47
48
  } from "./config-schema.ts";
48
49
 
@@ -270,6 +271,71 @@ function mapOrchestratorYaml(raw: any): Partial<OrchestratorSection> {
270
271
  return result;
271
272
  }
272
273
 
274
+ /**
275
+ * Normalize a workspace section loaded from JSON/YAML into camelCase shape.
276
+ *
277
+ * Compatibility: if `routing.taskPacketRepo` is missing, defaults to
278
+ * `routing.defaultRepo` and emits a warning message.
279
+ */
280
+ function normalizeWorkspaceSection(
281
+ rawWorkspace: any,
282
+ sourcePath: string,
283
+ ): WorkspaceSectionConfig | undefined {
284
+ if (!rawWorkspace || typeof rawWorkspace !== "object" || Array.isArray(rawWorkspace)) {
285
+ return undefined;
286
+ }
287
+
288
+ const rawRepos = rawWorkspace.repos;
289
+ if (!rawRepos || typeof rawRepos !== "object" || Array.isArray(rawRepos)) {
290
+ return undefined;
291
+ }
292
+
293
+ const rawRouting = rawWorkspace.routing;
294
+ if (!rawRouting || typeof rawRouting !== "object" || Array.isArray(rawRouting)) {
295
+ return undefined;
296
+ }
297
+
298
+ const repos: WorkspaceSectionConfig["repos"] = {};
299
+ for (const [repoId, repoVal] of Object.entries(rawRepos as Record<string, any>)) {
300
+ if (!repoVal || typeof repoVal !== "object" || Array.isArray(repoVal)) continue;
301
+ const repoObj = repoVal as Record<string, any>;
302
+ if (typeof repoObj.path !== "string" || repoObj.path.trim() === "") continue;
303
+ repos[repoId] = {
304
+ path: repoObj.path,
305
+ ...(typeof repoObj.defaultBranch === "string" && repoObj.defaultBranch.trim()
306
+ ? { defaultBranch: repoObj.defaultBranch }
307
+ : {}),
308
+ };
309
+ }
310
+
311
+ const defaultRepo = typeof rawRouting.defaultRepo === "string" ? rawRouting.defaultRepo.trim() : "";
312
+ const tasksRoot = typeof rawRouting.tasksRoot === "string" ? rawRouting.tasksRoot.trim() : "";
313
+ let taskPacketRepo = typeof rawRouting.taskPacketRepo === "string" ? rawRouting.taskPacketRepo.trim() : "";
314
+
315
+ if (!taskPacketRepo && defaultRepo) {
316
+ taskPacketRepo = defaultRepo;
317
+ console.error(
318
+ `[taskplane] config compatibility: workspace.routing.taskPacketRepo is missing in ${sourcePath}; defaulting to workspace.routing.defaultRepo ('${defaultRepo}'). Add workspace.routing.taskPacketRepo explicitly.`,
319
+ );
320
+ }
321
+
322
+ if (!tasksRoot || !defaultRepo || !taskPacketRepo) {
323
+ return undefined;
324
+ }
325
+
326
+ const strict = rawRouting.strict === true;
327
+
328
+ return {
329
+ repos,
330
+ routing: {
331
+ tasksRoot,
332
+ defaultRepo,
333
+ taskPacketRepo,
334
+ ...(strict ? { strict: true } : {}),
335
+ },
336
+ };
337
+ }
338
+
273
339
 
274
340
  // ── Config File Path Resolution ──────────────────────────────────────
275
341
 
@@ -354,6 +420,12 @@ function loadJsonConfig(configRoot: string): TaskplaneConfig | null {
354
420
  if (parsed.orchestrator) {
355
421
  deepMerge(config.orchestrator, parsed.orchestrator);
356
422
  }
423
+ if (parsed.workspace) {
424
+ const normalizedWorkspace = normalizeWorkspaceSection(parsed.workspace, jsonPath);
425
+ if (normalizedWorkspace) {
426
+ config.workspace = normalizedWorkspace;
427
+ }
428
+ }
357
429
 
358
430
  return config;
359
431
  }
@@ -438,6 +510,29 @@ function loadOrchestratorYaml(configRoot: string): OrchestratorSection {
438
510
  }
439
511
  }
440
512
 
513
+ /**
514
+ * Load optional workspace routing config from legacy `taskplane-workspace.yaml`.
515
+ *
516
+ * This file is fallback-only for workspace metadata when JSON `workspace`
517
+ * section is not present. Malformed files are ignored here — strict validation
518
+ * still happens in workspace runtime loading (`workspace.ts`).
519
+ */
520
+ function loadWorkspaceYaml(configRoot: string): WorkspaceSectionConfig | undefined {
521
+ const yamlPath = resolveConfigFilePath(configRoot, "taskplane-workspace.yaml");
522
+ if (!existsSync(yamlPath)) return undefined;
523
+
524
+ try {
525
+ const raw = readFileSync(yamlPath, "utf-8");
526
+ const loaded = yamlParse(raw) as any;
527
+ if (!loaded || typeof loaded !== "object") return undefined;
528
+
529
+ const converted = convertStructuralKeys(loaded);
530
+ return normalizeWorkspaceSection(converted, yamlPath);
531
+ } catch {
532
+ return undefined;
533
+ }
534
+ }
535
+
441
536
 
442
537
  // ── User Preferences (Layer 2) ───────────────────────────────────────
443
538
 
@@ -586,9 +681,17 @@ export function applyUserPreferences(config: TaskplaneConfig, prefs: UserPrefere
586
681
  * in either location. This allows pointer-resolved roots (e.g.,
587
682
  * `<configRepo>/.taskplane/`) where files are scaffolded directly
588
683
  * without a `.pi/` subdirectory.
684
+ *
685
+ * Includes optional workspace YAML (`taskplane-workspace.yaml`) so
686
+ * workspace-only roots participate in config-root resolution.
589
687
  */
590
688
  export function hasConfigFiles(root: string): boolean {
591
- const files = [PROJECT_CONFIG_FILENAME, "task-runner.yaml", "task-orchestrator.yaml"];
689
+ const files = [
690
+ PROJECT_CONFIG_FILENAME,
691
+ "task-runner.yaml",
692
+ "task-orchestrator.yaml",
693
+ "taskplane-workspace.yaml",
694
+ ];
592
695
  for (const f of files) {
593
696
  if (existsSync(join(root, ".pi", f)) || existsSync(join(root, f))) return true;
594
697
  }
@@ -637,6 +740,7 @@ export function resolveConfigRoot(cwd: string, pointerConfigRoot?: string): stri
637
740
  * Layer 1 — Project config:
638
741
  * 1. `.pi/taskplane-config.json` — JSON-first (new format)
639
742
  * 2. `.pi/task-runner.yaml` + `.pi/task-orchestrator.yaml` — YAML fallback
743
+ * (+ optional `.pi/taskplane-workspace.yaml` workspace section mapping)
640
744
  * 3. Defaults — if no config files exist
641
745
  *
642
746
  * Layer 2 — User preferences (applied on top of Layer 1):
@@ -671,10 +775,12 @@ export function loadProjectConfig(cwd: string, pointerConfigRoot?: string): Task
671
775
  // Fall back to YAML
672
776
  const taskRunner = loadTaskRunnerYaml(configRoot);
673
777
  const orchestrator = loadOrchestratorYaml(configRoot);
778
+ const workspace = loadWorkspaceYaml(configRoot);
674
779
  config = {
675
780
  configVersion: CONFIG_VERSION,
676
781
  taskRunner,
677
782
  orchestrator,
783
+ ...(workspace ? { workspace } : {}),
678
784
  };
679
785
  }
680
786
 
@@ -710,10 +816,12 @@ export function loadLayer1Config(cwd: string, pointerConfigRoot?: string): Taskp
710
816
  // Fall back to YAML
711
817
  const taskRunner = loadTaskRunnerYaml(configRoot);
712
818
  const orchestrator = loadOrchestratorYaml(configRoot);
819
+ const workspace = loadWorkspaceYaml(configRoot);
713
820
  return {
714
821
  configVersion: CONFIG_VERSION,
715
822
  taskRunner,
716
823
  orchestrator,
824
+ ...(workspace ? { workspace } : {}),
717
825
  };
718
826
  }
719
827
 
@@ -415,6 +415,37 @@ export interface OrchestratorSection {
415
415
  }
416
416
 
417
417
 
418
+ // ── Workspace Section Interfaces ─────────────────────────────────────
419
+
420
+ /** Workspace repo definition (JSON config shape). */
421
+ export interface WorkspaceRepoSectionConfig {
422
+ /** Repo root path (relative to workspace root or absolute). */
423
+ path: string;
424
+ /** Optional default branch override. */
425
+ defaultBranch?: string;
426
+ }
427
+
428
+ /** Workspace routing definition (JSON config shape). */
429
+ export interface WorkspaceRoutingSectionConfig {
430
+ /** Shared task packet root directory. */
431
+ tasksRoot: string;
432
+ /** Default repo for unqualified operations. */
433
+ defaultRepo: string;
434
+ /** Packet-home repo owning PROMPT/STATUS/.DONE. */
435
+ taskPacketRepo: string;
436
+ /** Strict repo routing mode. */
437
+ strict?: boolean;
438
+ }
439
+
440
+ /** Optional workspace section in taskplane-config.json. */
441
+ export interface WorkspaceSectionConfig {
442
+ /** Repo map keyed by repo ID. */
443
+ repos: Record<string, WorkspaceRepoSectionConfig>;
444
+ /** Routing contract for workspace mode. */
445
+ routing: WorkspaceRoutingSectionConfig;
446
+ }
447
+
448
+
418
449
  // ── Unified Config ───────────────────────────────────────────────────
419
450
 
420
451
  /**
@@ -442,6 +473,8 @@ export interface TaskplaneConfig {
442
473
  taskRunner: TaskRunnerSection;
443
474
  /** Orchestrator settings */
444
475
  orchestrator: OrchestratorSection;
476
+ /** Optional workspace config (JSON-first; legacy YAML fallback supported). */
477
+ workspace?: WorkspaceSectionConfig;
445
478
  }
446
479
 
447
480
 
@@ -475,7 +475,7 @@ export function buildLaneEnvVars(
475
475
 
476
476
  const vars: Record<string, string> = {
477
477
  TASK_AUTOSTART: relativePath,
478
- TASK_RUNNER_SPAWN_MODE: "subprocess",
478
+ TASK_RUNNER_SPAWN_MODE: "tmux",
479
479
  TASK_RUNNER_TMUX_PREFIX: lane.tmuxSessionName,
480
480
  ORCH_SIDECAR_DIR: join(workspaceRoot || repoRoot, ".pi"),
481
481
  NODE_PATH: nodePath,
@@ -915,6 +915,10 @@ export function spawnLaneSession(
915
915
 
916
916
  // Build env vars
917
917
  const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
918
+ // Pass batch ID so task-runner can include it in lane state for dashboard filtering
919
+ if (config.orchestrator?.batchId) {
920
+ envVars.ORCH_BATCH_ID = config.orchestrator.batchId;
921
+ }
918
922
  if (extraEnvVars) {
919
923
  Object.assign(envVars, extraEnvVars);
920
924
  }
@@ -1457,6 +1457,8 @@ export default function (pi: ExtensionAPI) {
1457
1457
  * and return early with a user-facing error when null.
1458
1458
  */
1459
1459
  let execCtx: ExecutionContext | null = null;
1460
+ /** Last startup error message to surface consistently through command guards. */
1461
+ let execCtxInitError: string | null = null;
1460
1462
 
1461
1463
  // ── Widget Rendering ─────────────────────────────────────────────
1462
1464
 
@@ -1477,17 +1479,18 @@ export default function (pi: ExtensionAPI) {
1477
1479
 
1478
1480
  // ── Command Guard ────────────────────────────────────────────────
1479
1481
 
1482
+ function getExecCtxInitErrorMessage(): string {
1483
+ return execCtxInitError ??
1484
+ "❌ Orchestrator not initialized. Startup failed before execution context was created.\nRestart the session after fixing configuration/setup issues.";
1485
+ }
1486
+
1480
1487
  /**
1481
1488
  * Guard: returns true if execution context is initialized, false otherwise.
1482
1489
  * Emits a user-facing error notification when the context is missing.
1483
1490
  */
1484
1491
  function requireExecCtx(ctx: ExtensionContext): boolean {
1485
1492
  if (execCtx) return true;
1486
- ctx.ui.notify(
1487
- "❌ Orchestrator not initialized. Workspace configuration failed at startup.\n" +
1488
- "Fix the workspace config or remove it to use repo mode, then restart.",
1489
- "error",
1490
- );
1493
+ ctx.ui.notify(getExecCtxInitErrorMessage(), "error");
1491
1494
  return false;
1492
1495
  }
1493
1496
 
@@ -1720,7 +1723,7 @@ export default function (pi: ExtensionAPI) {
1720
1723
 
1721
1724
  if (!execCtx) {
1722
1725
  return {
1723
- message: "❌ Orchestrator not initialized. Workspace configuration failed at startup.\nFix the workspace config or remove it to use repo mode, then restart.",
1726
+ message: getExecCtxInitErrorMessage(),
1724
1727
  error: true,
1725
1728
  };
1726
1729
  }
@@ -2050,7 +2053,7 @@ export default function (pi: ExtensionAPI) {
2050
2053
  function doOrchResume(force: boolean, ctx: ExtensionContext): { message: string; error?: boolean } {
2051
2054
  if (!execCtx) {
2052
2055
  return {
2053
- message: "❌ Orchestrator not initialized. Workspace configuration failed at startup.\nFix the workspace config or remove it to use repo mode, then restart.",
2056
+ message: getExecCtxInitErrorMessage(),
2054
2057
  error: true,
2055
2058
  };
2056
2059
  }
@@ -2786,7 +2789,7 @@ export default function (pi: ExtensionAPI) {
2786
2789
  ): Promise<{ message: string; error?: boolean; level?: "info" | "warning" | "error" }> {
2787
2790
  if (!execCtx) {
2788
2791
  return {
2789
- message: "❌ Orchestrator not initialized. Workspace configuration failed at startup.\nFix the workspace config or remove it to use repo mode, then restart.",
2792
+ message: getExecCtxInitErrorMessage(),
2790
2793
  error: true,
2791
2794
  };
2792
2795
  }
@@ -3653,24 +3656,35 @@ export default function (pi: ExtensionAPI) {
3653
3656
  orchWidgetCtx = ctx;
3654
3657
 
3655
3658
  // ── Build execution context (config + workspace mode detection) ──
3656
- // Reset execCtx before loading to prevent stale state on re-init
3659
+ // Reset startup state before loading to prevent stale errors on re-init.
3657
3660
  execCtx = null;
3661
+ execCtxInitError = null;
3658
3662
  try {
3659
3663
  execCtx = buildExecutionContext(ctx.cwd, loadOrchestratorConfig, loadTaskRunnerConfig);
3660
3664
  } catch (err: unknown) {
3661
3665
  if (err instanceof WorkspaceConfigError) {
3662
- // Workspace config is present but invalid fatal startup error.
3663
- // Leave execCtx null; command guard will block all commands except abort.
3664
- ctx.ui.notify(
3665
- `❌ Workspace configuration error [${err.code}]\n\n` +
3666
- `${err.message}\n\n` +
3667
- `Fix the workspace config at .pi/taskplane-workspace.yaml or remove it to use repo mode.\n` +
3668
- `Orchestrator commands are disabled until this is resolved.`,
3669
- "error",
3670
- );
3666
+ // Startup is fatal when workspace config is invalid OR repo-mode setup
3667
+ // requirements are not met (non-git cwd without workspace config).
3668
+ const setupError = err.code === "WORKSPACE_SETUP_REQUIRED";
3669
+ execCtxInitError = setupError
3670
+ ? (
3671
+ `❌ Orchestrator startup blocked [${err.code}]\n\n` +
3672
+ `${err.message}\n\n` +
3673
+ `Orchestrator commands are disabled until this setup issue is resolved.`
3674
+ )
3675
+ : (
3676
+ `❌ Workspace configuration error [${err.code}]\n\n` +
3677
+ `${err.message}\n\n` +
3678
+ `Fix the workspace config at .pi/taskplane-workspace.yaml (or taskplane-config.json workspace section), then restart.\n` +
3679
+ `Orchestrator commands are disabled until this is resolved.`
3680
+ );
3681
+
3682
+ ctx.ui.notify(execCtxInitError, "error");
3671
3683
  ctx.ui.setStatus(
3672
3684
  "task-orchestrator",
3673
- "🔀 Orchestrator · ❌ startup failed (workspace config error)",
3685
+ setupError
3686
+ ? "🔀 Orchestrator · ❌ startup failed (setup required)"
3687
+ : "🔀 Orchestrator · ❌ startup failed (workspace config error)",
3674
3688
  );
3675
3689
  return;
3676
3690
  }
@@ -2832,10 +2832,11 @@ export const BATCH_HISTORY_MAX_ENTRIES = 100;
2832
2832
  * coordinates multiple repos and a shared task root.
2833
2833
  *
2834
2834
  * Mode determination rules:
2835
- * 1. No workspace config file repo mode (non-fatal default, silent).
2836
- * 2. Workspace config file present + invalid → fatal error with actionable
2835
+ * 1. Workspace config file present + invalid fatal error with actionable
2837
2836
  * `WorkspaceConfigError` (never silently falls back to repo mode).
2838
- * 3. Workspace config file present + valid → workspace mode.
2837
+ * 2. Workspace config file present + valid → workspace mode.
2838
+ * 3. No workspace config + cwd is a git repo → repo mode.
2839
+ * 4. No workspace config + cwd is not a git repo → `WORKSPACE_SETUP_REQUIRED`.
2839
2840
  */
2840
2841
  export type WorkspaceMode = "repo" | "workspace";
2841
2842
 
@@ -2872,6 +2873,15 @@ export interface WorkspaceRoutingConfig {
2872
2873
  * Must reference a valid key in `WorkspaceConfig.repos`.
2873
2874
  */
2874
2875
  defaultRepo: string;
2876
+ /**
2877
+ * Repo ID that owns task packet files (PROMPT.md/STATUS.md/.DONE/.reviews).
2878
+ *
2879
+ * Required at runtime. Legacy workspace YAML without this field is
2880
+ * compatibility-mapped to `defaultRepo` during load with a warning.
2881
+ *
2882
+ * Invariant: `tasksRoot` must resolve inside `repos[taskPacketRepo].path`.
2883
+ */
2884
+ taskPacketRepo: string;
2875
2885
  /**
2876
2886
  * When true, every task MUST declare an explicit execution target
2877
2887
  * (via `## Execution Target` section or inline `**Repo:**` in PROMPT.md).
@@ -2964,6 +2974,10 @@ export interface ExecutionContext {
2964
2974
  * - WORKSPACE_TASKS_ROOT_NOT_FOUND: `routing.tasks_root` path does not exist on disk
2965
2975
  * - WORKSPACE_MISSING_DEFAULT_REPO: `routing.default_repo` is missing or empty
2966
2976
  * - WORKSPACE_DEFAULT_REPO_NOT_FOUND: `routing.default_repo` references a repo ID not in the repos map
2977
+ * - WORKSPACE_TASK_PACKET_REPO_NOT_FOUND: `routing.task_packet_repo` references a repo ID not in the repos map
2978
+ * - WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO: `routing.tasks_root` resolves outside `repos[routing.task_packet_repo].path`
2979
+ * - WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT: A configured task-area path resolves outside `routing.tasks_root`
2980
+ * - WORKSPACE_SETUP_REQUIRED: No workspace config and cwd is not a git repository
2967
2981
  * - WORKSPACE_DUPLICATE_REPO_PATH: Two or more repos share the same filesystem path
2968
2982
  * - WORKSPACE_SCHEMA_INVALID: Config file has valid YAML but missing/invalid top-level structure
2969
2983
  */
@@ -2978,10 +2992,12 @@ export type WorkspaceConfigErrorCode =
2978
2992
  | "WORKSPACE_TASKS_ROOT_NOT_FOUND"
2979
2993
  | "WORKSPACE_MISSING_DEFAULT_REPO"
2980
2994
  | "WORKSPACE_DEFAULT_REPO_NOT_FOUND"
2995
+ | "WORKSPACE_TASK_PACKET_REPO_NOT_FOUND"
2996
+ | "WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO"
2997
+ | "WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT"
2998
+ | "WORKSPACE_SETUP_REQUIRED"
2981
2999
  | "WORKSPACE_DUPLICATE_REPO_PATH"
2982
- | "WORKSPACE_SCHEMA_INVALID";
2983
-
2984
- /**
3000
+ | "WORKSPACE_SCHEMA_INVALID";/**
2985
3001
  * Typed error class for workspace configuration failures.
2986
3002
  *
2987
3003
  * Thrown during workspace config loading/validation when the config file
@@ -2,8 +2,9 @@
2
2
  * Workspace configuration loading and validation.
3
3
  *
4
4
  * Detects workspace mode by checking for `.pi/taskplane-workspace.yaml`.
5
- * When the file is absent, the orchestrator runs in repo mode (default).
6
5
  * When the file is present, it must be valid — invalid files are fatal.
6
+ * When absent, `loadWorkspaceConfig()` returns null and `buildExecutionContext()`
7
+ * decides repo-mode eligibility (cwd must be a git repository).
7
8
  *
8
9
  * Validation order (deterministic, fail-fast):
9
10
  * 1. File existence check → absent = repo mode (return null)
@@ -20,6 +21,8 @@
20
21
  * 9. routing.tasks_root exists → WORKSPACE_TASKS_ROOT_NOT_FOUND
21
22
  * 10. routing.default_repo present → WORKSPACE_MISSING_DEFAULT_REPO
22
23
  * 11. routing.default_repo valid → WORKSPACE_DEFAULT_REPO_NOT_FOUND
24
+ * 12. routing.task_packet_repo valid (or compat fallback) → WORKSPACE_TASK_PACKET_REPO_NOT_FOUND
25
+ * 13. routing.tasks_root inside packet-home repo → WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO
23
26
  *
24
27
  * Path normalization rules:
25
28
  * - Relative paths are resolved against workspaceRoot.
@@ -95,6 +98,16 @@ function resolveAbsolutePath(p: string, base: string): string {
95
98
  }
96
99
  }
97
100
 
101
+ /**
102
+ * True when `childPath` is the same path as `parentPath` or contained within it.
103
+ * Uses canonicalized paths for cross-platform, case-insensitive comparison.
104
+ */
105
+ function isPathWithinContainer(childPath: string, parentPath: string): boolean {
106
+ const child = canonicalizePath(childPath, "");
107
+ const parent = canonicalizePath(parentPath, "");
108
+ return child === parent || child.startsWith(`${parent}/`);
109
+ }
110
+
98
111
 
99
112
  // ── Pointer Resolution ───────────────────────────────────────────────
100
113
 
@@ -498,7 +511,50 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu
498
511
  );
499
512
  }
500
513
 
501
- // ── 12. routing.strict (optional boolean, default false) ─────
514
+ // 12. routing.task_packet_repo (required by v1 contract)
515
+ // Compatibility policy: if omitted, default to routing.default_repo and
516
+ // emit a warning so legacy configs remain deterministic.
517
+ const hasTaskPacketRepo = Object.prototype.hasOwnProperty.call(rawRouting, "task_packet_repo");
518
+ const rawTaskPacketRepo = rawRouting.task_packet_repo;
519
+ let taskPacketRepoId = defaultRepoId;
520
+
521
+ if (hasTaskPacketRepo) {
522
+ if (typeof rawTaskPacketRepo !== "string" || rawTaskPacketRepo.trim() === "") {
523
+ throw new WorkspaceConfigError(
524
+ "WORKSPACE_SCHEMA_INVALID",
525
+ "Workspace config 'routing.task_packet_repo' must be a non-empty string when provided.",
526
+ undefined,
527
+ configFile,
528
+ );
529
+ }
530
+ taskPacketRepoId = rawTaskPacketRepo.trim();
531
+ } else {
532
+ console.error(
533
+ `[taskplane] workspace compatibility: 'routing.task_packet_repo' is missing in ${configFile}; defaulting to routing.default_repo ('${defaultRepoId}'). Add 'routing.task_packet_repo' explicitly.`,
534
+ );
535
+ }
536
+
537
+ if (!repos.has(taskPacketRepoId)) {
538
+ throw new WorkspaceConfigError(
539
+ "WORKSPACE_TASK_PACKET_REPO_NOT_FOUND",
540
+ `routing.task_packet_repo '${taskPacketRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`,
541
+ undefined,
542
+ configFile,
543
+ );
544
+ }
545
+
546
+ // 13. tasks_root must be inside repos[task_packet_repo].path
547
+ const packetRepoPath = repos.get(taskPacketRepoId)!.path;
548
+ if (!isPathWithinContainer(tasksRootAbsolute, packetRepoPath)) {
549
+ throw new WorkspaceConfigError(
550
+ "WORKSPACE_TASKS_ROOT_OUTSIDE_PACKET_REPO",
551
+ `routing.tasks_root '${tasksRootAbsolute}' must be inside packet-home repo '${taskPacketRepoId}' (${packetRepoPath}). Update routing.tasks_root or routing.task_packet_repo.`,
552
+ undefined,
553
+ tasksRootAbsolute,
554
+ );
555
+ }
556
+
557
+ // ── 14. routing.strict (optional boolean, default false) ─────
502
558
  const rawStrict = rawRouting.strict;
503
559
  if (rawStrict !== undefined) {
504
560
  // null (from bare `strict:` or `strict: null` in YAML) is rejected
@@ -518,6 +574,7 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu
518
574
  const routing: WorkspaceRoutingConfig = {
519
575
  tasksRoot: tasksRootAbsolute,
520
576
  defaultRepo: defaultRepoId,
577
+ taskPacketRepo: taskPacketRepoId,
521
578
  ...(strict ? { strict: true } : {}),
522
579
  };
523
580
 
@@ -531,6 +588,39 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu
531
588
  }
532
589
 
533
590
 
591
+ // ── Cross-Config Validation ─────────────────────────────────────────
592
+
593
+ /**
594
+ * Enforce that every configured task area resolves inside workspace routing.tasksRoot.
595
+ *
596
+ * This is a cross-config invariant and therefore runs after both workspace and
597
+ * task-runner configs are loaded.
598
+ */
599
+ export function validateTaskAreasWithinTasksRoot(
600
+ workspaceRoot: string,
601
+ workspaceConfig: WorkspaceConfig,
602
+ taskRunnerConfig: import("./types.ts").TaskRunnerConfig,
603
+ ): void {
604
+ const tasksRoot = workspaceConfig.routing.tasksRoot;
605
+ const areaEntries = Object.entries(taskRunnerConfig.task_areas ?? {}).sort((a, b) =>
606
+ a[0].localeCompare(b[0])
607
+ );
608
+
609
+ for (const [areaName, area] of areaEntries) {
610
+ const areaPathRaw = (area?.path ?? "").trim();
611
+ const areaAbsolute = resolveAbsolutePath(areaPathRaw, workspaceRoot);
612
+ if (!isPathWithinContainer(areaAbsolute, tasksRoot)) {
613
+ throw new WorkspaceConfigError(
614
+ "WORKSPACE_TASK_AREA_OUTSIDE_TASKS_ROOT",
615
+ `Task area '${areaName}' path '${areaAbsolute}' must be inside routing.tasks_root '${tasksRoot}'. Move the area under tasks_root or update task_areas.${areaName}.path.`,
616
+ undefined,
617
+ areaAbsolute,
618
+ );
619
+ }
620
+ }
621
+ }
622
+
623
+
534
624
  // ── Execution Context Builder ────────────────────────────────────────
535
625
 
536
626
  /**
@@ -543,8 +633,14 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu
543
633
  * @param loadOrchConfig - Orchestrator config loader (for testability)
544
634
  * @param loadTaskConfig - Task runner config loader (for testability)
545
635
  * @returns ExecutionContext ready for orchestrator consumption
546
- * @throws WorkspaceConfigError if workspace config is present but invalid
636
+ * @throws WorkspaceConfigError if workspace config is present but invalid,
637
+ * or when no workspace config exists and `cwd` is not a git repository.
547
638
  */
639
+ function isInsideGitRepo(cwd: string): boolean {
640
+ const probe = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
641
+ return probe.ok && probe.stdout.trim() === "true";
642
+ }
643
+
548
644
  export function buildExecutionContext(
549
645
  cwd: string,
550
646
  loadOrchConfig: (root: string, pointerConfigRoot?: string) => import("./types.ts").OrchestratorConfig,
@@ -553,6 +649,19 @@ export function buildExecutionContext(
553
649
  const workspaceConfig = loadWorkspaceConfig(cwd);
554
650
 
555
651
  if (workspaceConfig === null) {
652
+ // Deterministic mode guard: without workspace config, repo mode is only
653
+ // valid when cwd is a git repository.
654
+ if (!isInsideGitRepo(cwd)) {
655
+ const wsConfigFile = workspaceConfigPath(cwd);
656
+ throw new WorkspaceConfigError(
657
+ "WORKSPACE_SETUP_REQUIRED",
658
+ `No workspace config found at ${wsConfigFile}, and current directory is not a git repository: ${cwd}. ` +
659
+ `Run Taskplane from a git repository, or create ${wsConfigFile} (taskplane init) to use workspace mode.`,
660
+ undefined,
661
+ cwd,
662
+ );
663
+ }
664
+
556
665
  // Repo mode: pointer is ignored entirely. Config loads from cwd.
557
666
  const orchestratorConfig = loadOrchConfig(cwd);
558
667
  const taskRunnerConfig = loadTaskConfig(cwd);
@@ -580,6 +689,9 @@ export function buildExecutionContext(
580
689
  const orchestratorConfig = loadOrchConfig(cwd, pointerConfigRoot);
581
690
  const taskRunnerConfig = loadTaskConfig(cwd, pointerConfigRoot);
582
691
 
692
+ // Cross-config invariant: every task-area path must live under routing.tasks_root.
693
+ validateTaskAreasWithinTasksRoot(cwd, workspaceConfig, taskRunnerConfig);
694
+
583
695
  const defaultRepo = workspaceConfig.repos.get(workspaceConfig.routing.defaultRepo)!;
584
696
  return {
585
697
  workspaceRoot: cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.22.4",
3
+ "version": "0.22.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -289,33 +289,35 @@ checkpoints protect against regressions even when intermediate steps use targete
289
289
  2. The merge agent (before merging to the orchestrator branch)
290
290
  3. CI (before merging to main)
291
291
 
292
- ## File Reading Strategy (Context Budget)
292
+ ## File Reading Strategy (Context Budget) — CRITICAL
293
293
 
294
- Your context window is finite. Reading large files whole wastes budget and risks
295
- triggering the context-pressure safety net (85% wrap-up, 95% → kill).
296
- Use targeted reads instead:
294
+ Your context window is finite. **Reading large files without offset/limit is the
295
+ #1 cause of context exhaustion** one full read of a 3000-line file consumes
296
+ ~5% of a 1M context window. Three such reads = 15% gone before you've done
297
+ anything.
298
+
299
+ ### HARD RULES
300
+
301
+ 1. **NEVER read a file > 500 lines without offset/limit.** Always grep first.
302
+ 2. **NEVER read the same file twice in full.** Re-read only the changed region.
303
+ 3. **ALWAYS check file size before reading:** `wc -l <file>` or `ls -la <file>`
297
304
 
298
305
  ### Pattern: grep-first, read-with-offset
299
306
 
300
- 1. **Locate** the relevant section with `grep` or `find`:
301
- ```
302
- grep -n "function buildPrompt" extensions/task-runner.ts
303
- ```
304
- 2. **Read** just that region with `offset` and `limit`:
305
- ```
306
- read extensions/task-runner.ts (offset: 1773, limit: 50)
307
- ```
308
- 3. **Edit** surgically with exact `oldText → newText`
307
+ 1. **Check size:** `wc -l extensions/task-runner.ts` 4100 lines (DO NOT read fully)
308
+ 2. **Locate** the relevant section: `grep -n "function buildPrompt" extensions/task-runner.ts`
309
+ 3. **Read** just that region: `read extensions/task-runner.ts (offset: 1773, limit: 50)`
310
+ 4. **Edit** surgically with exact `oldText → newText`
309
311
 
310
312
  ### When to read a full file
311
313
 
312
314
  - Files under ~500 lines — read the whole thing, it's fine
313
- - Config files, test files, templates — usually small enough to read fully
315
+ - Config files, small test files, templates — usually small enough
314
316
  - New files you're creating — read after writing to verify
315
317
 
316
318
  ### When NOT to read a full file
317
319
 
318
- - Source files over ~1000 lines — grep first, read the relevant region
320
+ - Source files over ~500 lines — grep first, read with offset/limit
319
321
  - Generated files, lock files, large data files — almost never need full reads
320
322
  - Files you've already read this session — re-read only the changed region
321
323