diff --git a/skills/workflows/SKILL.md b/skills/workflows/SKILL.md index 9b91fe472..2af06061e 100644 --- a/skills/workflows/SKILL.md +++ b/skills/workflows/SKILL.md @@ -1,13 +1,23 @@ --- name: workflows -description: Run a persisted multi-agent DevSpace workflow from a script file or a named project workflow, then wait for or inspect its result. +description: Write, run, and inspect persisted multi-agent DevSpace workflows, JavaScript scripts that compose subagent calls with agent, phase, parallel, and pipeline. --- # DevSpace workflows Use a workflow when a task has a repeatable multi-agent plan. Keep one-off delegation on the `subagents` commands. -Named workflows live in the current project's `.devspace/workflows/` directory. Run a named workflow or a specific file: +Named workflows live in the current project's `.devspace/workflows/` directory. + +## Write a workflow + +Before writing or editing a workflow script, read `reference.md` next to this file. It defines the only syntax and primitives the sandbox accepts (`agent`, `phase`, `parallel`, `pipeline`, `workflow`, `log`, `args`), the `meta` rules, `agent` options, structured output schemas, and limits. Do not guess the API. + +Then read `patterns.md` for proven engineering loops to adapt: plan with a critic, implement-review-fix until approved, a QA gate that loops until tests pass, explore-then-synthesize, multi-lens review with verified findings, bake-offs between agents, mapping one change over many units, debugging with competing hypotheses, and composing them. + +## Run a workflow + +Run a named workflow or a specific file: ```bash devspace workflow run --name [--args ''] @@ -40,10 +50,10 @@ Cancel only when the parent task no longer needs the run: devspace workflow cancel ``` -Resume an interrupted or failed run from its durable call history: +Resume an interrupted or failed run from its durable call history. Resume reuses the original arguments and write mode: ```bash -devspace workflow run --resume [--args ''] +devspace workflow run --resume ``` Use `--json` only for scripts that need structured output. diff --git a/skills/workflows/patterns.md b/skills/workflows/patterns.md new file mode 100644 index 000000000..a8faafc8d --- /dev/null +++ b/skills/workflows/patterns.md @@ -0,0 +1,309 @@ +# DevSpace workflow patterns + +Read `reference.md` first for the syntax. These are proven shapes for engineering work: adapt them, combine them, and +keep the parts that fit the task. Each one is a complete script. + +## Designing a workflow + +- **Pick targets per role.** Run `devspace agents targets` and match each step to the provider or profile that is best + at it: a strong reasoner to plan and judge, a careful editor to implement, a cheap fast model to explore, and a + different vendor to review. Pass them in `args` so one script serves many setups. +- **Decide with schemas.** Any step whose result controls the script (approve or not, pass or fail, a list to fan + out over) should return JSON through `schema`, never prose that the script has to parse. +- **Bound every loop.** Loops end on a verdict *or* a maximum round count. Budget agent turns up front: a run has 128, + and each schema correction costs one. +- **Make prompts self-contained.** A subagent only sees its prompt and the project. Paste in the plan, the diff + summary, or the earlier findings it needs. +- **Keep readers read-only.** In a run started with `--write-mode allowed`, every call inherits write access unless + it passes `writeMode: "read_only"`. Give it to every planner, reviewer, tester, judge, and investigator. +- **Use the right workspace.** Read-only steps run in the checkout. Sequential edits share one named worktree + (`isolation: "worktree"` plus `workspace`). Parallel edits each get their own worktree. +- **Handle per-item failures.** `parallel` and `pipeline` never throw; check each `status`. +- **Keep it resumable.** Same inputs should produce the same calls in the same order: no timestamps or random values + in prompts. + +Start writing runs with `--write-mode allowed`; everything else stays read-only by default. + +## Plan with a critic + +Draft a plan, have a second model attack it, and revise until it holds up. + +```js +export const meta = { name: "plan", description: "Draft, critique, and revise an implementation plan" }; + +const { task, planner = "codex", critic = "claude", rounds = 3 } = args; +const PLAN = { + type: "object", + properties: { steps: { type: "array", items: { type: "string" } }, risks: { type: "array", items: { type: "string" } } }, + required: ["steps", "risks"], +}; +const CRITIQUE = { + type: "object", + properties: { approved: { type: "boolean" }, problems: { type: "array", items: { type: "string" } } }, + required: ["approved", "problems"], +}; + +let plan = await agent(`Write an implementation plan for: ${task}. Read the relevant code first.`, { target: planner, writeMode: "read_only", schema: PLAN, label: "draft" }); + +for (let round = 1; round <= rounds; round++) { + const critique = await agent( + `Critique this plan for "${task}". Check it against the code; approve only if it is complete and correct.\n${JSON.stringify(plan)}`, + { target: critic, writeMode: "read_only", schema: CRITIQUE, label: `critique-${round}` }, + ); + if (critique.approved) return { plan, rounds: round }; + plan = await agent( + `Revise the plan to fix these problems:\n${critique.problems.join("\n")}\nPlan:\n${JSON.stringify(plan)}`, + { target: planner, writeMode: "read_only", schema: PLAN, label: `revise-${round}` }, + ); +} + +return { plan, rounds, approved: false }; +``` + +## Implement, review, fix until approved + +The core engineering loop. Edits stay in one named worktree so each fix builds on the last. + +```js +export const meta = { name: "build", description: "Implement, then review and fix until the reviewer approves" }; + +const { task, implementer = "claude", reviewer = "codex", rounds = 3 } = args; +const REVIEW = { + type: "object", + properties: { approved: { type: "boolean" }, findings: { type: "array", items: { type: "string" } } }, + required: ["approved", "findings"], +}; + +await phase("Implement", (agent) => agent(`Implement: ${task}. Add or update tests.`, { + target: implementer, writeMode: "allowed", isolation: "worktree", workspace: "feature", +})); + +for (let round = 1; round <= rounds; round++) { + const review = await phase(`Review ${round}`, (agent) => agent( + `Review the uncommitted diff for: ${task}. Approve only if it is correct, tested, and complete. Cite file:line.`, + { target: reviewer, writeMode: "read_only", workspace: "feature", schema: REVIEW }, + )); + if (review.approved) return { approved: true, rounds: round }; + await phase(`Fix ${round}`, (agent) => agent( + `A reviewer found these problems in your change for "${task}". Fix all of them:\n${review.findings.join("\n")}`, + { target: implementer, writeMode: "allowed", workspace: "feature" }, + )); +} + +return { approved: false, rounds }; +``` + +## QA gate + +Implement, then have a separate agent run the tests and report; fix and retry until green. + +```js +export const meta = { name: "qa", description: "Implement and loop until the test suite passes" }; + +const { task, implementer = "claude", tester = "codex", attempts = 3 } = args; +const RESULT = { + type: "object", + properties: { passed: { type: "boolean" }, failures: { type: "array", items: { type: "string" } } }, + required: ["passed", "failures"], +}; + +await agent(`Implement: ${task}.`, { target: implementer, writeMode: "allowed", isolation: "worktree", workspace: "qa" }); + +for (let attempt = 1; attempt <= attempts; attempt++) { + const result = await agent( + "Run the project's tests and type checks. Report each failure with its error output. Do not change any files.", + { target: tester, writeMode: "read_only", workspace: "qa", schema: RESULT, label: `test-${attempt}` }, + ); + if (result.passed) return { passed: true, attempts: attempt }; + await agent(`Make these failures pass without weakening the tests:\n${result.failures.join("\n")}`, { + target: implementer, writeMode: "allowed", workspace: "qa", label: `fix-${attempt}`, + }); +} + +return { passed: false, attempts }; +``` + +## Explore, then synthesize + +Fan cheap explorers out over separate questions, then let one model combine what they found. + +```js +export const meta = { name: "explore", description: "Answer several questions in parallel, then synthesize", concurrency: 4 }; + +const { goal, questions, explorer = "opencode", synthesizer = "codex" } = args; + +const answers = await phase("Explore", (agent) => parallel(questions, (question) => agent( + `${question}\nAnswer from the code only. Cite file paths and symbols.`, + { target: explorer, writeMode: "read_only", label: question.slice(0, 40) }, +))); + +const found = answers.map((answer, index) => ({ + question: questions[index], + answer: answer.status === "completed" ? answer.value : `failed: ${answer.error.message}`, +})); + +return await phase("Synthesize", (agent) => agent( + `Goal: ${goal}\nCombine these findings into one answer and name what is still unknown:\n${JSON.stringify(found)}`, + { target: synthesizer, writeMode: "read_only" }, +)); +``` + +## Multi-lens review with verification + +Review from several angles at once, then have a different model confirm each finding before reporting it, which +filters out false positives. + +```js +export const meta = { name: "review", description: "Review from several angles and keep only verified findings", concurrency: 4 }; + +const { scope = "the uncommitted diff", reviewers = ["codex", "claude"], verifier = "codex" } = args; +const lenses = ["correctness and edge cases", "security", "performance", "missing tests"]; +const FINDINGS = { + type: "object", + properties: { + findings: { + type: "array", + items: { + type: "object", + properties: { location: { type: "string" }, issue: { type: "string" } }, + required: ["location", "issue"], + }, + }, + }, + required: ["findings"], +}; +const VERDICT = { + type: "object", + properties: { real: { type: "boolean" }, reason: { type: "string" } }, + required: ["real", "reason"], +}; + +const reviews = await phase("Review", (agent) => parallel(lenses, (lens, index) => agent( + `Review ${scope} only for ${lens}. Report concrete issues with file:line, or none.`, + { target: reviewers[index % reviewers.length], writeMode: "read_only", schema: FINDINGS, label: lens }, +))); +const findings = reviews.flatMap((review) => (review.status === "completed" ? review.value.findings : [])); + +const verdicts = await phase("Verify", (agent) => parallel(findings, (finding) => agent( + `Check whether this reported issue is real by reading the code. Be skeptical.\n${JSON.stringify(finding)}`, + { target: verifier, writeMode: "read_only", schema: VERDICT }, +))); + +return findings + .map((finding, index) => ({ ...finding, verdict: verdicts[index] })) + .filter(({ verdict }) => verdict.status === "completed" && verdict.value.real) + .map(({ verdict, ...finding }) => ({ ...finding, reason: verdict.value.reason })); +``` + +## Bake-off + +Several implementers solve the same task in separate worktrees; a judge picks the best. Use it when the approach is +uncertain or quality matters more than turns. + +```js +export const meta = { name: "bakeoff", description: "Compare implementations from several agents and pick one" }; + +const { task, implementers = ["claude", "codex"], judge = "codex" } = args; +const PICK = { + type: "object", + properties: { winner: { type: "integer" }, reason: { type: "string" } }, + required: ["winner", "reason"], +}; + +const attempts = await phase("Implement", (agent) => parallel(implementers, (target) => agent( + `Implement: ${task}. Finish with a summary of your approach and which files you changed.`, + { target, writeMode: "allowed", isolation: "worktree", label: target }, +))); + +const candidates = attempts + .map((attempt, index) => ({ index, implementer: implementers[index], attempt })) + .filter(({ attempt }) => attempt.status === "completed"); +if (candidates.length === 0) throw new Error("Every implementation failed."); + +const pick = await phase("Judge", (agent) => agent( + `Pick the best implementation of "${task}" by index. Weigh correctness, simplicity, and tests.\n` + + candidates.map(({ index, implementer, attempt }) => `#${index} (${implementer}): ${attempt.value}`).join("\n\n"), + { target: judge, writeMode: "read_only", schema: PICK }, +)); + +return { winner: implementers[pick.winner], reason: pick.reason }; +``` + +The winning worktree is kept; inspect it with `devspace workflow calls ` and apply it from there. + +## Map over many items + +When the same change applies to many independent units (modules, endpoints, files), list them first, then run each +through the same stages. Each item gets its own worktree so parallel edits never collide. + +```js +export const meta = { name: "migrate", description: "Apply one change to every matching unit, then check each", concurrency: 3 }; + +const { change, finder = "opencode", worker = "claude", checker = "codex" } = args; +const UNITS = { type: "object", properties: { units: { type: "array", items: { type: "string" } } }, required: ["units"] }; +const CHECK = { type: "object", properties: { ok: { type: "boolean" }, notes: { type: "string" } }, required: ["ok", "notes"] }; + +const { units } = await agent(`List every file or module that needs this change, one per entry: ${change}`, { + target: finder, writeMode: "read_only", schema: UNITS, +}); + +const results = await pipeline( + units, + (unit, _item, index) => agent(`Apply this change to ${unit} only: ${change}`, { + target: worker, writeMode: "allowed", isolation: "worktree", workspace: `unit-${index}`, label: unit, + }), + (_summary, unit, index) => agent(`Verify the change to ${unit} is complete and correct: ${change}`, { + target: checker, writeMode: "read_only", workspace: `unit-${index}`, schema: CHECK, + }), +); + +return units.map((unit, index) => ({ unit, ...results[index] })); +``` + +## Debug with competing hypotheses + +Generate several explanations for a bug, test each independently, then fix the one that holds. + +```js +export const meta = { name: "debug", description: "Investigate competing hypotheses, then fix the confirmed cause" }; + +const { bug, thinker = "codex", investigator = "opencode", fixer = "claude" } = args; +const HYPOTHESES = { type: "object", properties: { hypotheses: { type: "array", items: { type: "string" } } }, required: ["hypotheses"] }; +const EVIDENCE = { type: "object", properties: { confirmed: { type: "boolean" }, evidence: { type: "string" } }, required: ["confirmed", "evidence"] }; + +const { hypotheses } = await agent(`List up to 4 distinct root-cause hypotheses for this bug, most likely first: ${bug}`, { + target: thinker, writeMode: "read_only", schema: HYPOTHESES, +}); + +const checks = await phase("Investigate", (agent) => parallel(hypotheses, (hypothesis) => agent( + `Bug: ${bug}\nTest this hypothesis against the code and, if useful, by running commands. Do not edit files.\nHypothesis: ${hypothesis}`, + { target: investigator, writeMode: "read_only", schema: EVIDENCE }, +))); + +const index = checks.findIndex((check) => check.status === "completed" && check.value.confirmed); +if (index === -1) return { fixed: false, checks }; + +const cause = { hypothesis: hypotheses[index], evidence: checks[index].value.evidence }; +await phase("Fix", (agent) => agent( + `Fix this bug at its root cause and add a regression test.\nBug: ${bug}\nCause: ${JSON.stringify(cause)}`, + { target: fixer, writeMode: "allowed", isolation: "worktree" }, +)); + +return { fixed: true, cause }; +``` + +## Compose a full pipeline + +Named workflows can call each other one level deep, so the patterns above become building blocks: + +```js +export const meta = { name: "ship", description: "Plan, build with review, then gate on QA" }; + +const plan = await workflow("plan", { task: args.task }); +const build = await workflow("build", { task: `${args.task}\nFollow this plan:\n${JSON.stringify(plan.plan)}` }); +return { plan, build }; +``` + +Nested workflows share the parent's turn budget, cancellation, and named worktrees: a nested workflow that uses +`workspace: "feature"` works on the same tree as any other step in the run that created it. Run the parent with the +write mode the nested workflows need, since they cannot exceed it. diff --git a/skills/workflows/reference.md b/skills/workflows/reference.md new file mode 100644 index 000000000..840e2924f --- /dev/null +++ b/skills/workflows/reference.md @@ -0,0 +1,133 @@ +# Writing DevSpace workflows + +A workflow is one JavaScript file. It runs in an isolated QuickJS sandbox with no filesystem, network, `import`, +`require`, or timers: the only capabilities are the primitives below. Save named workflows as +`.devspace/workflows/.js` in the project, or run any file with `devspace workflow run --file `. + +## Shape + +```js +export const meta = { + name: "review-auth", + description: "Review auth changes from two perspectives", + concurrency: 2, +}; + +const reviews = await phase("Review", (agent) => parallel( + [ + { target: "codex", focus: "correctness" }, + { target: "claude", focus: "security" }, + ], + ({ target, focus }) => agent(`Review the auth diff for ${focus}. Cite files.`, { target, label: focus }), +)); + +const summary = await phase("Synthesize", (agent) => agent( + `Merge these reviews into one prioritized list: ${JSON.stringify(reviews)}`, + { target: "codex", label: "summary" }, +)); + +return { reviews, summary }; +``` + +- The body runs inside an async function: use top-level `await`, and `return` the run's result. The result must be + JSON-compatible. +- `meta` is optional. When present it must be the first statement, and it is parsed as data, never executed: + - `name`: string, at most 128 characters (default `"workflow"`). + - `description`: string, at most 2048 characters. + - `concurrency`: integer 1–16 (default 4). Caps how many agent calls this run executes at once. + - Values must be literal single-line strings or numbers. No other fields, expressions, or variables. + +## Primitives + +| Primitive | Behavior | +| --- | --- | +| `agent(prompt, options)` | Runs one subagent turn and resolves to its final response: a string, or the parsed JSON value when `options.schema` is set. Rejects if the agent fails or stops. | +| `phase(name, (agent) => ...)` | Groups calls under a named phase in the event log. Use the `agent` passed to the callback so each call is tagged with the phase. Resolves to the callback's value. | +| `parallel(items, worker, concurrency?)` | Calls `worker(item, index)` for every item, at most `concurrency` at a time (default `meta.concurrency`, maximum 16). Never rejects: resolves to one `{ status: "completed", value }` or `{ status: "failed", error: { code, message, retryable } }` per item, in input order. | +| `pipeline(items, ...stages)` | Passes each item through the stages in order, calling `stage(value, item, index)` where `value` is the previous stage's result. Items run concurrently like `parallel` and resolve to the same per-item outcomes. | +| `workflow(name, args?)` | Runs another named workflow from `.devspace/workflows/` and resolves to its result. | +| `log(...values)` | Records JSON-compatible progress in the run's event log. | +| `args` | The frozen JSON arguments passed with `--args` or `--args-file`; `{}` when none were passed. | + +Because `parallel` and `pipeline` report failures per item instead of rejecting, check `status` before using +`value`: + +```js +const results = await parallel(files, (file) => agent(`Summarize ${file}.`, { target: "codex" })); +const summaries = results.filter((r) => r.status === "completed").map((r) => r.value); +const failures = results.filter((r) => r.status === "failed").map((r) => r.error.message); +``` + +## `agent` options + +| Option | Meaning | +| --- | --- | +| `target` | **Required.** A provider or profile name from `devspace agents targets`. | +| `model`, `effort` | Override the target's model or reasoning effort. | +| `label` | Short name shown in `devspace workflow calls`. | +| `schema` | JSON Schema for a structured response (see below). | +| `writeMode` | `"read_only"` or `"allowed"`. Defaults to the run's authority and can never exceed it. | +| `isolation` | `"worktree"`: run this call in its own Git worktree created from committed `HEAD`. | +| `workspace` | A logical name (letters, digits, `_`, `-`; at most 64) for a worktree that later calls in this run reuse. Never a path. The first call that uses a name must also pass `isolation: "worktree"` to create it; later calls pass only `workspace`. | + +Any other option is rejected. Each prompt should be self-contained: a subagent sees only its prompt and the +project, not the rest of the workflow. + +### Structured responses + +Pass `schema` when a later step consumes the result. DevSpace validates the response, asks the same agent once to +fix invalid JSON (read-only, and it counts toward the turn budget), then resolves to the parsed value. + +```js +const { bugs } = await agent("List the bugs in the diff as JSON.", { + target: "codex", + schema: { + type: "object", + properties: { bugs: { type: "array", items: { type: "string" } } }, + required: ["bugs"], + }, +}); +``` + +Schemas are strict draft-07 with inline structural constraints only: at most 16 KiB, 512 nodes, and 16 levels deep. +`$ref`, `$dynamicRef`, `$recursiveRef`, `$async`, `pattern`, `patternProperties`, and `format` are rejected. + +### Edits + +Runs are read-only unless started with `devspace workflow run ... --write-mode allowed`. In a writing run, give +editing calls `writeMode: "allowed"`. Add `isolation: "worktree"` when parallel calls might edit the same files, so +each works in its own worktree; isolated worktrees do not see uncommitted changes. To let several calls work on the +same tree in turn (implement, then review, then fix), create a named worktree once and reuse it: + +```js +await agent("Implement the change.", { target: "claude", writeMode: "allowed", isolation: "worktree", workspace: "feature" }); +await agent("Review the uncommitted diff.", { target: "codex", writeMode: "read_only", workspace: "feature" }); +``` + +Without `workspace` or `isolation`, calls run in the checkout the workflow was started from. Worktrees are kept for +inspection afterwards. + +For proven multi-step engineering loops (plan, implement, review, QA, bake-offs, fan-out), see `patterns.md`. + +## Resume + +`devspace workflow run --resume ` reruns the script with the original arguments. Completed read-only calls are +reused as long as the script makes the same calls in the same order with the same prompts and options; from the first +call that differs, everything runs fresh. Runs that made editing calls cannot be resumed. Keep prompts deterministic +(no timestamps or random values) so resume can reuse work. + +## Limits + +| Boundary | Limit | +| --- | --- | +| Agent turns per run | 128, including schema corrections | +| Nested `workflow()` calls | 8 per run, one level deep | +| Script wall clock | 15 minutes | +| Script source / arguments | 64 KiB / 128 KiB | +| Agent response / run result | 256 KiB | +| `log` and `phase` events | 64 KiB per run | +| Sandbox heap / stack | 32 MiB / 512 KiB | + +Exceeding a limit ends the whole run, including a `workflow()` call nested past one level: `try`/`catch` in the +script cannot recover from it. Ordinary agent failures (unknown target, provider errors, invalid output) are normal +rejections you can catch, and `parallel`/`pipeline` report them per item. diff --git a/src/skills.test.ts b/src/skills.test.ts index d67f4acd7..616b83a46 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -100,6 +100,12 @@ test("the managed subagents skill replaces a stale user copy and workflows need flagged.find((skill) => skill.name === "workflows")?.filePath, join(devspaceSkills, "workflows", "SKILL.md"), ); + // The authoring reference the skill points to is synced beside it, and is not a skill of its own. + assert.match(await readFile(join(devspaceSkills, "workflows", "reference.md"), "utf8"), /# Writing DevSpace workflows/); + assert.deepEqual( + flagged.filter((skill) => skill.filePath.startsWith(join(devspaceSkills, "workflows"))).map((skill) => skill.name), + ["workflows"], + ); } finally { delete process.env.DEVSPACE_EXPERIMENTAL_WORKFLOWS; } diff --git a/src/skills.ts b/src/skills.ts index 2e3f496c4..59201d724 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -2,6 +2,7 @@ import { existsSync, lstatSync, mkdirSync, + readdirSync, readFileSync, renameSync, rmSync, @@ -44,17 +45,23 @@ function bundledSkillsDir(): string { return fileURLToPath(new URL("../skills", import.meta.url)); } -function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): string { - const skillPath = join(name, "SKILL.md"); - const sourcePath = join(bundledSkillsDir(), skillPath); - const targetPath = join(config.devspaceSkillsDir, skillPath); +// Sync every bundled file, not just SKILL.md, so references a skill points to (such as reference.md) ship with it. +function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): void { + const sourceDir = join(bundledSkillsDir(), name); + + for (const file of readdirSync(sourceDir)) { + syncManagedFile(join(sourceDir, file), join(config.devspaceSkillsDir, name, file), name); + } +} + +function syncManagedFile(sourcePath: string, targetPath: string, name: ManagedSkillName): void { const source = readFileSync(sourcePath, "utf8"); if (existsSync(targetPath)) { const stat = lstatSync(targetPath); if (stat.isFile() && source === readFileSync(targetPath, "utf8")) { - return targetPath; + return; } if (stat.isDirectory()) { @@ -72,8 +79,6 @@ function syncManagedSkill(config: ServerConfig, name: ManagedSkillName): string } finally { rmSync(tempPath, { force: true }); } - - return targetPath; } export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] {