taskplane 0.22.3 → 0.22.5

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");
@@ -2973,8 +2974,35 @@ export default function (pi: ExtensionAPI) {
2973
2974
  return;
2974
2975
  }
2975
2976
  } else {
2976
- // ── Quality Gate Disabled (default) ──────────────────────
2977
- // Unchanged behavior create .DONE immediately.
2977
+ // ── Empty completion guard ────────────────────────────────
2978
+ // Detect tasks where the worker checked off STATUS.md without
2979
+ // modifying any source files. This catches "shortcut" completions
2980
+ // where the worker concludes work is "already done" without
2981
+ // implementing anything.
2982
+ if (isOrchestratedMode()) {
2983
+ try {
2984
+ const diffResult = spawnSync("git", ["diff", "--name-only", "HEAD"], {
2985
+ cwd: task.taskFolder, encoding: "utf-8", timeout: 10_000,
2986
+ });
2987
+ const changedFiles = (diffResult.stdout || "").split("\n").filter(Boolean);
2988
+ const sourceChanges = changedFiles.filter(f =>
2989
+ !f.endsWith("STATUS.md") && !f.endsWith(".DONE") &&
2990
+ !f.includes(".reviews/") && !f.endsWith("dependencies.json")
2991
+ );
2992
+ if (sourceChanges.length === 0) {
2993
+ logExecution(statusPath, "⚠️ Empty completion",
2994
+ "Worker marked all steps complete but no source files were modified. " +
2995
+ "Only STATUS.md changes detected. This may indicate the worker shortcut " +
2996
+ "the task without implementing. .DONE will still be created, but this " +
2997
+ "should be investigated.");
2998
+ console.error(`[task-runner] WARNING: Task ${task.taskId} completed with zero source file changes`);
2999
+ }
3000
+ } catch {
3001
+ // Best effort — don't block .DONE creation on git check failure
3002
+ }
3003
+ }
3004
+
3005
+ // Create .DONE
2978
3006
  const donePath = join(task.taskFolder, ".DONE");
2979
3007
  writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
2980
3008
  updateStatusField(statusPath, "Status", "✅ Complete");
@@ -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.3",
3
+ "version": "0.22.5",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -354,6 +354,30 @@ files and make sure their file scopes reflect that.
354
354
 
355
355
  ---
356
356
 
357
+ ## Preventing Empty Completions
358
+
359
+ Workers can shortcut tasks by observing that existing code "already satisfies"
360
+ requirements and checking off items without implementing anything. This is the
361
+ most dangerous failure mode — it produces false completions that waste the entire
362
+ pipeline.
363
+
364
+ **Defense: Make deliverables concrete and verifiable.**
365
+
366
+ | ❌ Vague (shortcuttable) | ✅ Concrete (verifiable) |
367
+ |--------------------------|------------------------|
368
+ | "Add taskPacketRepo support" | "Add `taskPacketRepo` field to `WorkspaceRoutingConfig` in types.ts" |
369
+ | "Enforce mode selection" | "Add `validateWorkspaceMode()` function in workspace.ts that throws on invalid state" |
370
+ | "Update config loading" | "Modify `loadWorkspaceConfig()` to parse and validate `taskPacketRepo` from JSON config" |
371
+ | "Add tests" | "Create `tests/packet-home-contract.test.ts` with tests for: valid config, missing field error, invariant violation" |
372
+
373
+ **Rules for task creators:**
374
+ - Every implementation step MUST name specific files to create or modify
375
+ - "Add X" means "write new code that doesn't exist yet" — if it might already exist, say "verify X exists and add tests, or implement if missing"
376
+ - Include at least one NEW test file per task — workers can't shortcut test creation
377
+ - Each step's artifacts list must include at least one source file (not just STATUS.md)
378
+
379
+ ---
380
+
357
381
  ## Key Principles
358
382
 
359
383
  - **Documentation in every task.** Without "Must Update" and "Check If Affected"
@@ -173,6 +173,15 @@ When a reviewer returns REVISE with specific feedback items:
173
173
  - Do NOT expand task scope beyond what the steps require
174
174
  - If you discover something out of scope, note it in STATUS.md Discoveries table
175
175
 
176
+ ## Completion Integrity
177
+
178
+ **Every checked checkbox MUST correspond to a real code change, test, or document edit.** You must NOT check off items by simply observing that existing code appears to satisfy them. Specifically:
179
+
180
+ - **If you believe work is already done:** You must still verify by running tests against the specific requirements AND document what you verified. Check off the item only after confirming with evidence (test output, code inspection notes in STATUS.md).
181
+ - **"No source files changed" is a red flag.** If you complete a task without modifying any source files (only STATUS.md), something is wrong. Every implementation task requires code changes. If you genuinely believe no changes are needed, log a detailed explanation in STATUS.md Discoveries and escalate — do NOT mark the task as complete.
182
+ - **A step that requires "Add X to Y" means you write the code.** Reading existing code and deciding it already satisfies the requirement is not implementation. If the existing code truly covers it, write a test that proves it, and document the finding.
183
+ - **Checking boxes without doing work is the most serious failure mode.** It wastes the entire batch pipeline (review, merge, integration) and produces a false completion that blocks dependent tasks.
184
+
176
185
  ## Review Protocol
177
186
 
178
187
  If you have access to a `review_step` tool, use it at step boundaries to spawn