From 76df8028cbbd2b039b4a048dff875311ef30c31f Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 22:54:10 +0800 Subject: [PATCH 1/4] feat(dag): add workflow draft action for schema-checked graph authoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models writing workflow YAML by hand drift on field names and structure (live-observed: objective at top level, worker instead of worker_type) even with the reference template in context — YAML is an unvalidated text channel, so prompting alone cannot reach zero. This closes the gap at three layers: - workflow(action="draft"): pass the structured config through the tool parameter schema; the harness renders YAML to .opencode/workflow-drafts/, validates it, and returns the spec_path. Unknown fields are rejected by the provider-side schema before any file is written; YAML syntax errors disappear because rendering is code. spec_path contract unchanged. - routing guide now carries the minimal complete start-spec example inline (one hop) plus the field-ownership rules; description budget 5k -> 6.5k. - schemaDiagnostics maps high-frequency drift fields (worker/agent -> worker_type, prompt -> instruction, top-level objective -> config) to "Did you mean" hints, matching both message text and diagnostic path. Verified: test/dag 496/496 green incl. 5 new draft tests (render round-trip, bad-dependency diagnostics, unsafe-name rejection, schema drift rejection, drift hint); core command tests 21/21; tsgo clean in both packages. --- .../src/plugin/command/workflow-routing.md | 46 ++++-- packages/core/test/plugin/command.test.ts | 4 +- packages/opencode/src/dag/validation.ts | 32 +++- packages/opencode/src/tool/workflow.ts | 77 ++++++++- .../test/dag/workflow-authoring.test.ts | 25 +++ .../opencode/test/dag/workflow-tool.test.ts | 146 +++++++++++++++++- .../tool/workflow-provider-schema.test.ts | 10 +- .../tool/workflow-schema-contract.test.ts | 2 +- 8 files changed, 320 insertions(+), 22 deletions(-) diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index ad7c08dd91..b4059640a4 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -68,19 +68,45 @@ DAG; for a verdict, the matching audit is primary. Do not concatenate two comple Read the selected reference, retarget its objective and instructions, and remove phases current evidence already covers. Start its saved `spec_path` -directly only when target and acceptance evidence match. If none fits, load -`guide(topic="blocks")` and compose a task-local YAML. Load +directly only when target and acceptance evidence match. If none fits, compose +a task-local graph. Load `guide(topic="blocks")` for block contracts and `guide(topic="patterns")` only when domains overlap. Use low-level nodes only for fields blocks cannot express. -Write the graph to YAML and validate that `spec_path` before start. Fix every -diagnostic in the same file and revalidate; validation creates no workflow. A -successful start returns the exact workflow ID. The parent owns the graph, -controls, and final report; children own bounded work. End after start and let -the workflow wake the parent. Do not poll merely to wait or claim an unstarted -graph is running. - -## Progressive guidance +Prefer `workflow(action="draft")` over hand-writing YAML: pass the structured +`config` (same fields as the YAML below) and the tool renders and validates the +spec file, returning the `spec_path` to start. Field-name drift is impossible +because the parameter schema rejects unknown fields. Hand-write YAML only for +features draft does not carry (admission, custom bindings). The exact start +shape, for that fallback and for reading draft output: + +```yaml +title: Implement session recovery +config: + name: implement-session-recovery + objective: Implement session recovery with focused tests and review. + blocks: + - id: map + kind: explore + instruction: Locate the ownership and persistence seams. + - id: coding + kind: coding + depends_on: [map] + - id: review + kind: review + depends_on: [coding] +``` + +Top level is `title`/`mode`/`admission` (optional) and `config` (required); +`objective` lives INSIDE `config`; every block field is one of `id` (required), +`kind` (required), `depends_on`, `instruction`, `worker_type`, `required`, +`report_to_parent` — never `worker`, `prompt`, or `agent`. + +Validate that `spec_path` before start. Fix every diagnostic in the same file +and revalidate; validation creates no workflow. A successful start returns the +exact workflow ID. The parent owns the graph, controls, and final report; +children own bounded work. End after start and let the workflow wake the +parent. Do not poll merely to wait or claim an unstarted graph is running.## Progressive guidance `guide` without a topic is the index. Topics: `blocks` for block shape, `interface` for low-level fields, `policy` for recovery, and `patterns` for diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 2ce6be2d73..e49d44aede 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -75,7 +75,9 @@ describe("CommandPlugin.Plugin", () => { it.effect("keeps always-on guidance small and loads detailed topics progressively", () => Effect.sync(() => { - expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(5_000) + // Budget admits the inline start-spec example (one-hop field reference + // for hand-written YAML) while keeping per-action manuals progressive. + expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(6_500) expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes") expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/) expect(CommandPlugin.WorkflowContent).toMatch(/isolated utility\s+scripts/) diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index c7bb864463..6180491843 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -346,6 +346,36 @@ function collectLeafIssues(issue: unknown, path: readonly string[], out: LeafIss if (message) out.push({ path: nextPath.join("") || "$", message }) } +// High-frequency authoring drift: fields the model reaches for from +// neighboring vocabularies, mapped to the field that exists. The decode leaf +// only carries the tag ("UnexpectedKey"); the offending field name lives in +// the diagnostic path, so both are matched. +const FIELD_DRIFT_HINTS: Record = { + worker: "worker_type", + workers: "worker_type", + agent: "worker_type", + prompt: "instruction", + task: "instruction", + objective: "config.objective", + graph: "config", + spec: "config", + nodes: "blocks (or vice versa — exactly one graph source)", + blocks: "nodes (or vice versa — exactly one graph source)", +} + +function driftHint(path: string, message: string) { + for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) { + if ( + message.includes(`"${wrong}"`) || + message.includes(`'${wrong}'`) || + new RegExp(`[\\"\[]${wrong}[\\"\]]`).test(path) + ) { + return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config` + } + } + return "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes" +} + export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { const leaves: LeafIssue[] = [] collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves) @@ -358,7 +388,7 @@ export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { code: DIAGNOSTIC_CODES.schemaInvalid, path: leaf.path, message: leaf.message, - hint: "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes", + hint: driftHint(leaf.path, leaf.message), }), ), ) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 60b0345b82..314164d29a 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -18,6 +18,7 @@ import { SessionID } from "@/session/schema" import { createAdmissionRecord } from "@/dag/admission" import { TerminalViolationError } from "@opencode-ai/core/dag/core/types" import { FSUtil } from "@opencode-ai/core/fs-util" +import { stringify as yamlStringify } from "yaml" import { assertExternalDirectoryEffect } from "./external-directory" import path from "node:path" @@ -110,6 +111,17 @@ const Guide = Schema.Struct({ "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", }), }) +const Draft = Schema.Struct({ + action: Schema.Literal("draft").annotate({ + description: + 'Render a structured graph into a validated YAML spec file and return its spec_path — no workflow is created. Preferred over hand-writing YAML: field names are schema-checked here, eliminating serialization drift', + }), + title: Schema.optional(Schema.String).annotate({ description: "Optional workflow title" }), + config: DagValidation.WorkflowGraphSchema.annotate({ + description: + 'Exactly one graph shape: { name, objective, blocks: [{ id, kind, depends_on?, instruction?, worker_type?, required?, report_to_parent? }], node_defaults?, max_concurrency?, max_node_replan_attempts?, max_total_nodes? } or the low-level { name, nodes: [...] } form. Fields are exhaustive — no others exist', + }), +}) const ValidationProfile = Schema.optional(Schema.Literals(["portable", "environment"])).annotate({ description: "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, project/global/path specs environment", @@ -132,6 +144,7 @@ const ActionParams = Schema.Union([ List, Read, Guide, + Draft, ValidatePath, ]) @@ -251,7 +264,7 @@ export const WorkflowTool = Tool.define< formatValidationError: (error) => [ `Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`, - 'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.', + 'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; draft {title?, config}; validate {spec_path, profile?}. Put graph content in draft (structured, schema-checked) or a .yaml/.yml file; session/project identity is never a parameter.', ].join("\n"), execute: (call: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { @@ -300,6 +313,36 @@ export const WorkflowTool = Tool.define< metadata: {}, } } + case "draft": { + const specPath = yield* writeDraftSpec(params.config, params.title, callingSession.directory).pipe( + Effect.orDie, + ) + const result = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: specPath, content: yield* readDraftSpec(specPath).pipe(Effect.orDie) }, + profile: "portable", + }) + if (!result.valid) { + return { + title: `Workflow draft written with validation errors: ${params.config.name}`, + output: [ + `spec_path: ${specPath}`, + "The file is on disk; fix the errors by calling draft again with corrected fields. Diagnostics:", + ...result.errors.map((d) => `- [${d.code}] ${d.path}: ${d.message}${d.hint ? ` (${d.hint})` : ""}`), + ].join("\n"), + metadata: {}, + } + } + return { + title: `Workflow draft valid: ${params.config.name}`, + output: [ + `spec_path: ${specPath}`, + `nodes: ${result.nodes.length}`, + 'Next: workflow(action="start", spec_path) — or extend the file first for low-level fields, then start.', + ].join("\n"), + metadata: {}, + } + } case "list": { const entries = yield* DagWorkflows.list(callingSession.directory) if (entries.length === 0) { @@ -729,6 +772,38 @@ function validationOutput(result: DagValidation.ValidationResult) { } } +const DRAFT_DIRECTORY = path.join(".opencode", "workflow-drafts") +const DRAFT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/ + +function writeDraftSpec( + config: DagValidation.StartGraph, + title: string | undefined, + directory: string, +): Effect.Effect { + return Effect.gen(function* () { + if (!DRAFT_NAME_PATTERN.test(config.name)) { + return yield* Effect.fail( + new Error( + `Workflow name must match ${DRAFT_NAME_PATTERN.source} (it becomes the spec filename): ${config.name}`, + ), + ) + } + const dir = path.join(directory, DRAFT_DIRECTORY) + yield* Effect.promise(() => Bun.write(Bun.file(path.join(dir, ".keep")), "")) + const specPath = path.join(dir, `${config.name}.yaml`) + const content = yamlStringify({ ...(title ? { title } : {}), config }) + yield* Effect.promise(() => Bun.write(specPath, content)) + return specPath + }) +} + +function readDraftSpec(specPath: string) { + return Effect.tryPromise({ + try: () => Bun.file(specPath).text(), + catch: (error) => new Error(`Failed to read draft spec ${specPath}: ${String(error)}`), + }) +} + function loadSpecFile(specPath: string, directory: string, ctx: Tool.Context) { return Effect.gen(function* () { const filepath = yield* resolveSpecPath(specPath, directory, ctx) diff --git a/packages/opencode/test/dag/workflow-authoring.test.ts b/packages/opencode/test/dag/workflow-authoring.test.ts index 0adb983a39..5c91c8676a 100644 --- a/packages/opencode/test/dag/workflow-authoring.test.ts +++ b/packages/opencode/test/dag/workflow-authoring.test.ts @@ -24,6 +24,31 @@ const start = { } describe("WorkflowAuthoring source-to-graph seam", () => { + it.effect("maps high-frequency field drift to the field that exists", () => + Effect.gen(function* () { + const authoring = WorkflowAuthoring.make() + const result = yield* authoring.prepare({ + action: "start", + source: { + kind: "yaml", + source: "drift.yaml", + content: [ + "config:", + " name: drift", + " objective: Field drift probe.", + " blocks:", + " - id: a", + " kind: coding", + " worker: general", + ].join("\n"), + }, + profile: "portable", + }) + expect(result.valid).toBe(false) + expect(result.errors.some((e) => e.hint.includes('Did you mean "worker_type"?'))).toBe(true) + }), + ) + it.effect("keeps every block-guide YAML envelope executable", () => Effect.gen(function* () { const guide = CommandPlugin.WorkflowBlocksContent diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 178db7edf2..3277ac61b3 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -626,6 +626,47 @@ describe("workflow tool schema (negative tests)", () => { params: { action: "start", spec_path: ".opencode/workflows/deep.yaml" }, }) }) + + it("draft accepts a structured graph and rejects unknown fields", () => { + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + const draft = { + action: "draft", + title: "Structured draft", + config: { + name: "structured-draft", + objective: "Validate the draft rendering path.", + blocks: [ + { id: "map", kind: "explore", instruction: "Map the seams." }, + { id: "review", kind: "review", depends_on: ["map"] }, + ], + }, + } + expect(decode({ params: draft })).toMatchObject({ params: { action: "draft" } }) + // The high-frequency drift shapes die at the schema boundary. + expect(() => + decode({ params: { action: "draft", config: { ...draft.config, blocks: [{ id: "x", kind: "coding", worker: "general" }] } } }), + ).toThrow() + expect(() => decode({ params: { action: "draft", objective: "top level" } })).toThrow() + expect(() => decode({ params: { action: "draft" } })).toThrow() + }) + + it("draft passes mixed blocks+nodes through the schema; the authoring layer rejects them", () => { + const decode = Schema.decodeUnknownSync(Parameters) + // WorkflowGraphSchema is a permissive union at the parameter boundary — + // the compiled authoring check owns the blocks-xor-nodes rule, exercised + // in the execution tests below. + expect(() => + decode({ params: { + action: "draft", + config: { + name: "mixed", + objective: "Both sources at once.", + blocks: [{ id: "a", kind: "coding" }], + nodes: [], + }, + }}), + ).not.toThrow() + }) }) describe("workflow tool execution", () => { @@ -668,7 +709,10 @@ describe("workflow tool execution", () => { const index = yield* workflow.execute({ params: { action: "guide" }}, toolContext()) const blocks = yield* workflow.execute({ params: { action: "guide", topic: "blocks" }}, toolContext()) - expect(workflow.description.length).toBeLessThan(5_000) + // The budget admits the routing guide's inline start-spec example + // (one-hop field reference for hand-written YAML) while still keeping + // per-action manuals out of the always-on description. + expect(workflow.description.length).toBeLessThan(6_500) expect(index.output).toContain("blocks: compose") expect(index.output).not.toContain("# Composable Workflow Blocks") expect(blocks.output).toContain("# Composable Workflow Blocks") @@ -676,6 +720,100 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("draft renders a structured graph into a validated spec file", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + { + params: { + action: "draft", + title: "Draft round trip", + config: { + name: "draft-round-trip", + objective: "Validate the draft rendering path.", + blocks: [ + { id: "map", kind: "explore", instruction: "Map the seams.\nSecond line with: colons and quotes." }, + { id: "coding", kind: "coding", depends_on: ["map"] }, + { id: "verify", kind: "verify", depends_on: ["coding"] }, + { id: "review", kind: "review", depends_on: ["verify"] }, + ], + }, + }, + }, + toolContext(), + ) + + expect(result.output).toContain(".opencode/workflow-drafts/draft-round-trip.yaml") + expect(result.output).toContain("nodes: ") + + // The rendered file round-trips through the same authoring path start + // uses, and read returns the authored document with draft content. + const read = yield* workflow.execute( + { params: { action: "read", spec_path: ".opencode/workflow-drafts/draft-round-trip.yaml" } }, + toolContext(), + ) + const parsed = JSON.parse(read.output) + expect(parsed.validation.valid).toBe(true) + expect(parsed.spec.config.name).toBe("draft-round-trip") + expect(parsed.spec.config.blocks).toHaveLength(4) + expect(parsed.spec.title).toBe("Draft round trip") + }), + ) + + runtime.effect("draft reports validation errors without creating a workflow", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const decode = Schema.decodeUnknownSync(Parameters) + // Unknown dependency: valid at the parameter schema, rejected by the + // authoring validation the draft runs before returning. + const result = yield* workflow.execute( + decode({ + params: { + action: "draft", + config: { + name: "draft-bad-edge", + objective: "Broken dependency.", + blocks: [{ id: "a", kind: "coding", depends_on: ["ghost"] }], + }, + }, + }), + toolContext(), + ) + + expect(result.title).toContain("validation errors") + expect(result.output).toContain(".opencode/workflow-drafts/draft-bad-edge.yaml") + expect(result.output).toContain("ghost") + expect(published).toHaveLength(0) + }), + ) + + runtime.effect("draft refuses unsafe workflow names and stays outside the saved library", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const decode = Schema.decodeUnknownSync(Parameters) + + const exit = yield* workflow + .execute( + decode({ + params: { + action: "draft", + config: { name: "../escape", objective: "x", blocks: [{ id: "a", kind: "coding" }] }, + }, + }), + toolContext(), + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + + const list = yield* workflow.execute({ params: { action: "list" } }, toolContext()) + expect(list.output).not.toContain("draft-round-trip") + expect(list.output).not.toContain("workflow-drafts") + }), + ) + runtime.effect("status returns the durable workflow and node state", () => Effect.gen(function* () { const info = yield* WorkflowTool @@ -1099,10 +1237,12 @@ describe("workflow tool execution", () => { expect(published).toHaveLength(0) } - // Recovery guidance tells the model to move graph content into YAML. + // Recovery guidance tells the model to move graph content into draft + // or a YAML file — never an inline spec field. const guidance = workflow.formatValidationError?.(new Error("no branch matched")) ?? "" expect(guidance).toContain("start {spec_path}") - expect(guidance).toContain("Put graph content in a .yaml/.yml file") + expect(guidance).toContain("draft {title?, config}") + expect(guidance).toContain(".yaml/.yml file") }), ) diff --git a/packages/opencode/test/tool/workflow-provider-schema.test.ts b/packages/opencode/test/tool/workflow-provider-schema.test.ts index d85181b468..310d5ed68d 100644 --- a/packages/opencode/test/tool/workflow-provider-schema.test.ts +++ b/packages/opencode/test/tool/workflow-provider-schema.test.ts @@ -59,12 +59,12 @@ function record(node: JsonSchemaNode | undefined): Record { - test("base wire shape is a plain-object root carrying the 10-branch union in params", async () => { + test("base wire shape is a plain-object root carrying the 11-branch union in params", async () => { const schema = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode expect(schema.type).toBe("object") expect(schema.anyOf).toBeUndefined() expect(schema.required).toEqual(["params"]) - expect(branches(schema).length).toBe(10) + expect(branches(schema).length).toBe(11) const flat = JSON.stringify(schema) expect(flat).not.toContain('"session_id"') expect(flat).not.toContain('"project_id"') @@ -117,7 +117,7 @@ describe("workflow provider-facing schema", () => { ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode expect(root(transformed).type).toBe("object") - expect(branches(transformed).length).toBe(10) + expect(branches(transformed).length).toBe(11) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "validate", "spec_path").length).toBeGreaterThan(0) }) @@ -127,7 +127,7 @@ describe("workflow provider-facing schema", () => { geminiModel, ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode - expect(branches(transformed).length).toBe(10) + expect(branches(transformed).length).toBe(11) expect(branchByAction(transformed, "start", "spec_path").length).toBe(1) expect(branchByAction(transformed, "start", "spec")).toEqual([]) const resultBranch = branchByAction(transformed, "result")[0] @@ -142,7 +142,7 @@ describe("workflow provider-facing schema", () => { ToolJsonSchema.fromSchema(Parameters as never), ) as JsonSchemaNode expect(root(transformed).type).toBe("object") - expect(branches(transformed)).toHaveLength(10) + expect(branches(transformed)).toHaveLength(11) expect(branchByAction(transformed, "start", "spec_path")).toHaveLength(1) } }) diff --git a/packages/opencode/test/tool/workflow-schema-contract.test.ts b/packages/opencode/test/tool/workflow-schema-contract.test.ts index 2f8a43073b..c53530bba4 100644 --- a/packages/opencode/test/tool/workflow-schema-contract.test.ts +++ b/packages/opencode/test/tool/workflow-schema-contract.test.ts @@ -50,7 +50,7 @@ describe("workflow tool schema contract", () => { test("the union survives intact inside the params property", () => { const transformed = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode - expect(branches(transformed)).toHaveLength(10) + expect(branches(transformed)).toHaveLength(11) expect(transformed.properties?.params).toBeDefined() expect(transformed.required).toEqual(["params"]) }) From 1f606adab3f5bd3b3182b4c8bd4d5ac8734d43ed Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:07:19 +0800 Subject: [PATCH 2/4] test(tool): regenerate workflow wire-shape snapshot for the draft action The draft action commit (76df8028c) added the 11th parameter-union branch but missed test/tool/__snapshots__/parameters.test.ts.snap; the stale snapshot failed 'tool parameters > JSON Schema (wire shape) > workflow' deterministically (surfaced as an external red gate during a concurrent verification run). Regenerated via bun test --update-snapshots; full test/tool + workflow-tool suites green (384 pass). --- .../__snapshots__/parameters.test.ts.snap | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 5e3418bff8..72463528fc 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -680,6 +680,312 @@ exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` ], "type": "object", }, + { + "properties": { + "action": { + "description": "Render a structured graph into a validated YAML spec file and return its spec_path — no workflow is created. Preferred over hand-writing YAML: field names are schema-checked here, eliminating serialization drift", + "enum": [ + "draft", + ], + "type": "string", + }, + "config": { + "anyOf": [ + { + "properties": { + "blocks": { + "description": "Composable blocks compiled into nodes by the runtime", + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "objective": { + "description": "Injected into every generated child prompt; required for blocks", + "type": "string", + }, + }, + "required": [ + "name", + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "nodes": { + "description": "Low-level node declarations", + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "nodes", + ], + "type": "object", + }, + ], + "description": "Exactly one graph shape: { name, objective, blocks: [{ id, kind, depends_on?, instruction?, worker_type?, required?, report_to_parent? }], node_defaults?, max_concurrency?, max_node_replan_attempts?, max_total_nodes? } or the low-level { name, nodes: [...] } form. Fields are exhaustive — no others exist", + }, + "title": { + "description": "Optional workflow title", + "type": "string", + }, + }, + "required": [ + "action", + "config", + ], + "type": "object", + }, { "properties": { "action": { From 20b488c4a6f1afbd7061a8dac7ae8175a87cf7a7 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:40:36 +0800 Subject: [PATCH 3/4] docs(dag): restore the Progressive guidance heading swallowed by an edit The draft-action commit spliced the heading onto the preceding paragraph line, dropping it from rendered markdown structure. --- packages/core/src/plugin/command/workflow-routing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index b4059640a4..bdd6fda2ad 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -106,7 +106,9 @@ Validate that `spec_path` before start. Fix every diagnostic in the same file and revalidate; validation creates no workflow. A successful start returns the exact workflow ID. The parent owns the graph, controls, and final report; children own bounded work. End after start and let the workflow wake the -parent. Do not poll merely to wait or claim an unstarted graph is running.## Progressive guidance +parent. Do not poll merely to wait or claim an unstarted graph is running. + +## Progressive guidance `guide` without a topic is the index. Topics: `blocks` for block shape, `interface` for low-level fields, `policy` for recovery, and `patterns` for From 3cd2d0d81f937cebb9e2931a68271e1e29263424 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 14 Aug 2026 23:52:29 +0800 Subject: [PATCH 4/4] fix(dag): drop regex escapes in driftHint for the oxlint warning ratchet The RegExp escape forms tripped two unnecessary-escape warnings, pushing CI's oxlint count past the --max-warnings=4852 ratchet. Diagnostic paths are JSON.stringify-segmented, so a plain ["field"] substring check covers the same matches without the regex. --- packages/opencode/src/dag/validation.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts index 6180491843..d1126e41bf 100644 --- a/packages/opencode/src/dag/validation.ts +++ b/packages/opencode/src/dag/validation.ts @@ -365,11 +365,7 @@ const FIELD_DRIFT_HINTS: Record = { function driftHint(path: string, message: string) { for (const [wrong, right] of Object.entries(FIELD_DRIFT_HINTS)) { - if ( - message.includes(`"${wrong}"`) || - message.includes(`'${wrong}'`) || - new RegExp(`[\\"\[]${wrong}[\\"\]]`).test(path) - ) { + if (message.includes(`"${wrong}"`) || path.includes(`["${wrong}"]`)) { return `Did you mean "${right}"? Every block field is one of id, kind, depends_on, instruction, worker_type, required, report_to_parent; objective lives inside config` } }