From 8a2fe91bbddc826831b5926ef135b4826929de6d Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 13 Aug 2026 20:49:00 +0800 Subject: [PATCH] fix(dag): make YAML authoring explicit --- .../plugin/command/orchestration-policy.md | 22 +++-- .../src/plugin/command/workflow-blocks.md | 80 ++++++++++++++++++- packages/core/src/plugin/command/workflow.md | 22 ++--- packages/core/test/plugin/command.test.ts | 49 +++++++++--- .../test/dag/workflow-authoring.test.ts | 30 +++++++ 5 files changed, 165 insertions(+), 38 deletions(-) diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index b51985896f..f0c557f52f 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -159,18 +159,16 @@ continue QA, reduce scope, use `standard`, or explicitly waive. A `WAIVED` start is informed only when both `waiver_reason` and `acknowledged_risks` are non-empty; preserve them for audit. -Do not supply `protocol_version`, `state`, or `fingerprint` in the admission -input. Those are durable audit fields owned by the workflow boundary: -it sets protocol version 1, initializes state from the verdict, normalizes the -Brief for fingerprint computation, and computes the lowercase hexadecimal -SHA-256 hash. A successful deep start alone transitions the durable record to -`CONSUMED`. +The author-written admission input accepts only `brief_revision`, `qa_mode`, +`verdict`, `brief`, and, for an informed waiver, `waiver_reason` and +`acknowledged_risks`. Do not copy any additional fields from a persisted +workflow or tool response. The workflow boundary creates and advances its +durable audit record. Material changes to goal, scope, constraints, assumptions, or acceptance -criteria create a new brief revision, invalidate the prior fingerprint, and -return admission to questioning. The workflow boundary generates the -replacement fingerprint from the revised Brief. Do not replay QA from a -consumed record after recovery. +criteria create a new brief revision, invalidate the prior admission record, +and return admission to questioning. Do not replay QA from a consumed record +after recovery. ## Role Resolution @@ -185,8 +183,8 @@ If a required capability has no eligible role, report the missing capability and ## Model Assignment -Never emit `node.model` or `config.node_defaults.model`. Model assignment belongs -to runtime configuration, not the workflow graph: +Workflow YAML has no model-selection field. Model assignment belongs to +runtime configuration, not the workflow graph: `dag.jsonc` tier → configured agent model → parent session model diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md index 0e06745d42..59e29148ce 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -4,12 +4,23 @@ Blocks are the high-level interface for assembling a one-off workflow YAML file. The tool compiles them into ordinary durable DAG nodes before validation and persistence. Existing node-based YAML remains compatible. -## Shape +## Authoring contract -Use `objective` and `blocks` inside `config` for **start**, or alongside -`blocks` for **extend**. A replan uses the same fields inside `fragment`. +Never infer or invent a YAML field. Copy the envelope for the intended action +and change values only. Unknown fields are errors; a field from a tool call, +runtime result, low-level node, or another action does not belong here unless +it is explicitly listed below. + +### Start file + +The author-written top-level fields are optional `title`, optional `mode`, +optional `admission`, and required `config`. For the block route, `config` +contains required `name`, `objective`, and `blocks`; its only optional fields +are `node_defaults`, `max_concurrency`, `max_node_replan_attempts`, and +`max_total_nodes`. ```yaml +title: Implement session recovery config: name: implement-session-recovery objective: Implement session recovery with focused tests and evidence-backed review. @@ -33,6 +44,69 @@ config: depends_on: [verify] ``` +### Extend file + +An extend file contains exactly `objective` and `blocks`. It has no `config`, +`name`, or `fragment` wrapper. + +```yaml +objective: Add regression coverage for the newly confirmed recovery edge case. +blocks: + - id: recovery-fix + kind: coding + instruction: Implement only the confirmed edge-case fix and its regression test. + - id: recovery-verify + kind: verify + depends_on: [recovery-fix] +``` + +### Replan file + +A replan file contains only `fragment`. For the block route, `fragment` has the +same fields as start's `config`: required `name`, `objective`, and `blocks`, +plus the four optional config fields listed above. + +```yaml +fragment: + name: recover-session-recovery + objective: Replace the invalid route with a diagnosed, verified repair path. + blocks: + - id: diagnose + kind: debug + instruction: Reproduce the failure and identify the evidence-backed root cause. + - id: repair + kind: coding + depends_on: [diagnose] + instruction: Apply the smallest repair supported by the diagnosis. + - id: verify + kind: verify + depends_on: [repair] +``` + +Every block accepts only `id`, `kind`, `depends_on`, `instruction`, +`worker_type`, `required`, and `report_to_parent`. `id` and `kind` are required. +Allowed `kind` values are `explore`, `plan`, `prototype`, `debug`, `coding`, +`verify`, `review`, and `synthesize`. Omit `worker_type` unless the exact +configured agent name is already known; never invent one. + +For optional `node_defaults`, the only fields are `required`, +`report_to_parent`, and `worker_config`; `worker_config` accepts only +`timeout_ms`. + +`action`, `workflow_id`, `operation`, and `spec_path` are tool-call fields, not +YAML fields. `profile` is also a tool-call field used only by validate. The +listed YAML fields are exhaustive: do not copy extra fields from read/status +output or persisted runtime records. Deep admission is the only exception to +the minimal start envelope; load `guide(topic=policy)` and copy its +author-written admission shape instead of guessing fields. + +Use these exact calls after writing the file: + +- validate: `{ action: "validate", spec_path: "workflow.yaml", profile: "portable" }` +- start: `{ action: "start", spec_path: "workflow.yaml" }` +- extend: `{ action: "extend", workflow_id: "dag_...", spec_path: "extend.yaml" }` +- replan: `{ action: "control", operation: "replan", workflow_id: "dag_...", spec_path: "replan.yaml" }` + This guide owns the author-written block fields and semantics. The action schema stays shallow and accepts only `spec_path`; the YAML validator rejects unknown or missing graph fields by name and reports each error with its path. diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index c802c0a880..4429b37c7d 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -44,10 +44,10 @@ file and retry with the same path. Before a deep start, qualify the request interactively in the parent session. The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED` -admission input, and `config` at the same level. The admission input contains -`brief_revision`, `qa_mode`, `verdict`, `brief`, and waiver audit fields when -applicable; the workflow boundary owns `protocol_version`, `state`, and -`fingerprint`. Do not put admission QA inside the graph: its answers define the +admission input, and `config` at the same level. The admission input accepts +only `brief_revision`, `qa_mode`, `verdict`, `brief`, and waiver audit fields +when applicable. Do not copy additional fields from persisted records or tool +responses. Do not put admission QA inside the graph: its answers define the graph. Use the orchestration policy below for QA modes, round budgets, verdict recovery, revision invalidation, and waiver audit fields. @@ -142,8 +142,9 @@ config: timeout_ms: 600000 ``` -Never emit `node.model` or `config.node_defaults.model`. Model selection is -configuration-owned: critical nodes (`required: true` and review workers) use +The listed node and default fields are exhaustive; workflow YAML has no +model-selection field. Model selection is configuration-owned: critical nodes +(`required: true` and review workers) use the `advanced` tier in `dag.jsonc`, other nodes use `standard`, then resolution falls back to the selected agent model and the parent-session model. If no source provides a model, the workflow tool starts parent-session QA and leaves @@ -467,11 +468,10 @@ is believed to be running. Two disciplines close the gap: ## Model Assignment Strategy -Workflow definitions MUST NOT specify `node.model` or -`config.node_defaults.model`. Resolution follows the `dag.jsonc` tier, then the -configured agent model, then the parent-session model. If all three are absent, -the workflow tool asks the user to configure a model and does not create the -workflow. +Workflow YAML has no model-selection field. Resolution follows the `dag.jsonc` +tier, then the configured agent model, then the parent-session model. If all +three are absent, the workflow tool asks the user to configure a model and does +not create the workflow. - Expensive models for planning, review, and arbitration — high-stakes decisions where reasoning quality matters. - Fast models for mechanical implementation — well-specified edits where speed and cost matter. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 328c5424c4..63627074e7 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -124,6 +124,32 @@ describe("CommandPlugin.Plugin", () => { }), ) + it.effect("publishes an exact block YAML authoring contract", () => + Effect.sync(() => { + expect(CommandPlugin.WorkflowBlocksContent).toContain("Never infer or invent a YAML field") + expect(CommandPlugin.WorkflowBlocksContent).toContain("### Start file") + expect(CommandPlugin.WorkflowBlocksContent).toContain("### Extend file") + expect(CommandPlugin.WorkflowBlocksContent).toContain("### Replan file") + expect(CommandPlugin.WorkflowBlocksContent).toMatch( + /`id`, `kind`, `depends_on`, `instruction`,\s+`worker_type`, `required`, and `report_to_parent`/, + ) + expect(CommandPlugin.WorkflowBlocksContent).toMatch( + /`action`, `workflow_id`, `operation`, and `spec_path` are tool-call fields/, + ) + const authoringContract = CommandPlugin.WorkflowBlocksContent.slice( + CommandPlugin.WorkflowBlocksContent.indexOf("## Authoring contract"), + CommandPlugin.WorkflowBlocksContent.indexOf("This guide owns"), + ) + expect(authoringContract).toMatch(/[Tt]he\s+listed YAML fields are exhaustive/) + expect(authoringContract).not.toMatch( + /session_id|project_id|protocol_version|fingerprint|node_defaults\.model|node\.model/, + ) + expect(CommandPlugin.WorkflowBlocksContent).toContain( + '{ action: "control", operation: "replan", workflow_id: "dag_...", spec_path: "replan.yaml" }', + ) + }), + ) + it.effect("preserves opt-outs read-only scope and explicit role assignments", () => Effect.sync(() => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("single agent") @@ -137,9 +163,7 @@ describe("CommandPlugin.Plugin", () => { it.effect("documents config-first model fallback without invented identifiers", () => Effect.sync(() => { - expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "Never emit `node.model` or `config.node_defaults.model`", - ) + expect(CommandPlugin.OrchestrationPolicyContent).toContain("Workflow YAML has no model-selection field") expect(CommandPlugin.OrchestrationPolicyContent).toContain( "`dag.jsonc` tier → configured agent model → parent session model", ) @@ -338,14 +362,17 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("waiver_reason") expect(CommandPlugin.OrchestrationPolicyContent).toContain("acknowledged_risks") expect(CommandPlugin.OrchestrationPolicyContent).toContain("Material changes") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("invalidate the prior fingerprint") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("SHA-256 hash") - expect(CommandPlugin.WorkflowFactsContent).toContain( - "The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED`", + expect(CommandPlugin.OrchestrationPolicyContent).toContain("invalidate the prior admission record") + const admissionPolicy = CommandPlugin.OrchestrationPolicyContent.slice( + CommandPlugin.OrchestrationPolicyContent.indexOf("## Deep Admission QA"), + CommandPlugin.OrchestrationPolicyContent.indexOf("## Role Resolution"), ) + expect(admissionPolicy).not.toMatch(/protocol_version|fingerprint/) expect(CommandPlugin.WorkflowFactsContent).toContain( - "the workflow boundary owns `protocol_version`, `state`, and\n`fingerprint`", + "The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED`", ) + expect(CommandPlugin.WorkflowFactsContent).toContain("The admission input accepts\nonly `brief_revision`") + expect(CommandPlugin.WorkflowFactsContent).not.toMatch(/protocol_version|node_defaults\.model|node\.model/) expect(CommandPlugin.WorkflowFactsContent).toContain("A one-off graph may use a") expect(CommandPlugin.WorkflowFactsContent).toContain("task-local file") expect(CommandPlugin.WorkflowFactsContent).not.toContain("`config.mode`") @@ -388,11 +415,9 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowFactsContent).not.toContain("Gate failure cancels the workflow automatically") expect(CommandPlugin.WorkflowFactsContent).toContain("Static `prompt_template.input`") expect(CommandPlugin.WorkflowFactsContent).toContain("it must\nnever appear as `[object Object]`") - expect(CommandPlugin.WorkflowFactsContent).toContain( - "Workflow definitions MUST NOT specify `node.model` or\n`config.node_defaults.model`", - ) + expect(CommandPlugin.WorkflowFactsContent).toContain("Workflow YAML has no model-selection field") expect(CommandPlugin.WorkflowFactsContent).toMatch( - /`dag\.jsonc` tier, then the\s+configured agent model, then the parent-session model/, + /`dag\.jsonc`\s+tier, then the\s+configured agent model, then the parent-session model/, ) expect(CommandPlugin.WorkflowFactsContent).toContain("Propose-then-assemble") const reviewExample = CommandPlugin.WorkflowFactsContent.slice( diff --git a/packages/opencode/test/dag/workflow-authoring.test.ts b/packages/opencode/test/dag/workflow-authoring.test.ts index 1df8901e03..0adb983a39 100644 --- a/packages/opencode/test/dag/workflow-authoring.test.ts +++ b/packages/opencode/test/dag/workflow-authoring.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { WorkflowAuthoring } from "../../src/dag/authoring" import { DagValidation } from "../../src/dag/validation" import { testEffect } from "../lib/effect" @@ -23,6 +24,35 @@ const start = { } describe("WorkflowAuthoring source-to-graph seam", () => { + it.effect("keeps every block-guide YAML envelope executable", () => + Effect.gen(function* () { + const guide = CommandPlugin.WorkflowBlocksContent + const example = (heading: string) => { + const section = guide.slice(guide.indexOf(`### ${heading}`)) + const match = section.match(/```yaml\n([\s\S]*?)```/) + expect(match?.[1]).toBeDefined() + return match?.[1] ?? "" + } + const authoring = WorkflowAuthoring.make() + const inputs = [ + { action: "start" as const, content: example("Start file") }, + { action: "extend" as const, content: example("Extend file") }, + { action: "replan" as const, content: example("Replan file") }, + ] + + for (const input of inputs) { + const result = yield* authoring.prepare({ + action: input.action, + source: { kind: "yaml", source: `${input.action}.yaml`, content: input.content }, + profile: "portable", + }) + expect(result.errors).toEqual([]) + expect(result.valid).toBe(true) + expect(result.prepared?.nodes.length).toBeGreaterThan(0) + } + }), + ) + it.effect("prepares start, extend, and replan through one action-aware interface", () => Effect.gen(function* () { const authoring = WorkflowAuthoring.make()