diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index 1a7fb4f..caf622d 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -51,6 +51,8 @@ export type CacheScope = (typeof CACHE_SCOPES)[number]; const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"] as const; /** Phase types that must NOT be cached across runs (a fresh result is required each run). */ const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament", "script"] as const; +/** `cwd` is a literal path / workspace keyword, not an interpolated field. */ +const CWD_PLACEHOLDER_RE = /\{[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\}/; const ParallelTaskSchema = Type.Object( { @@ -649,6 +651,11 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va errors.push(`Phase '${p.id}': '${key}' must be a string, got ${typeof v}`); } } + if (typeof p.cwd === "string" && CWD_PLACEHOLDER_RE.test(p.cwd)) { + errors.push( + `Phase '${p.id}': 'cwd' does not support interpolation placeholders (${p.cwd}). Use a literal path, or a reserved workspace keyword ('temp', 'dedicated', 'worktree').`, + ); + } // dependsOn / from entries are string phase-id refs that flow into the graph // helpers and nodeId(); a non-string entry would crash the renderer. for (const key of ["dependsOn", "from"] as const) { diff --git a/packages/taskflow-core/test/schema.test.ts b/packages/taskflow-core/test/schema.test.ts index 48495f5..7d5fd6b 100644 --- a/packages/taskflow-core/test/schema.test.ts +++ b/packages/taskflow-core/test/schema.test.ts @@ -71,6 +71,31 @@ test("validateTaskflow: new phase types and fields", () => { ); }); +test("validateTaskflow: cwd must be a literal path or workspace keyword (no interpolation)", () => { + const withArgs = validateTaskflow({ + name: "x", + phases: [{ id: "p", type: "agent", task: "t", cwd: "{args.repo_dir}" }], + }); + assert.equal(withArgs.ok, false); + assert.match(withArgs.errors.join("\n"), /cwd.*does not support interpolation placeholders/i); + + const withSteps = validateTaskflow({ + name: "x", + phases: [{ id: "p", type: "agent", task: "t", cwd: "{steps.plan.output}" }], + }); + assert.equal(withSteps.ok, false); + assert.match(withSteps.errors.join("\n"), /cwd.*does not support interpolation placeholders/i); + + assert.equal( + validateTaskflow({ name: "x", phases: [{ id: "p", type: "agent", task: "t", cwd: "./repo" }] }).ok, + true, + ); + assert.equal( + validateTaskflow({ name: "x", phases: [{ id: "p", type: "agent", task: "t", cwd: "worktree" }] }).ok, + true, + ); +}); + test("validateTaskflow: duplicate ids and unknown deps", () => { const dup = { name: "x", phases: [{ id: "p", type: "agent", task: "t" }, { id: "p", type: "agent", task: "t" }] }; assert.equal(validateTaskflow(dup).ok, false);