Skip to content

Commit 8a2fe91

Browse files
committed
fix(dag): make YAML authoring explicit
1 parent b5bf3f1 commit 8a2fe91

5 files changed

Lines changed: 165 additions & 38 deletions

File tree

packages/core/src/plugin/command/orchestration-policy.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,18 +159,16 @@ continue QA, reduce scope, use `standard`, or explicitly waive. A `WAIVED`
159159
start is informed only when both `waiver_reason` and `acknowledged_risks` are
160160
non-empty; preserve them for audit.
161161

162-
Do not supply `protocol_version`, `state`, or `fingerprint` in the admission
163-
input. Those are durable audit fields owned by the workflow boundary:
164-
it sets protocol version 1, initializes state from the verdict, normalizes the
165-
Brief for fingerprint computation, and computes the lowercase hexadecimal
166-
SHA-256 hash. A successful deep start alone transitions the durable record to
167-
`CONSUMED`.
162+
The author-written admission input accepts only `brief_revision`, `qa_mode`,
163+
`verdict`, `brief`, and, for an informed waiver, `waiver_reason` and
164+
`acknowledged_risks`. Do not copy any additional fields from a persisted
165+
workflow or tool response. The workflow boundary creates and advances its
166+
durable audit record.
168167

169168
Material changes to goal, scope, constraints, assumptions, or acceptance
170-
criteria create a new brief revision, invalidate the prior fingerprint, and
171-
return admission to questioning. The workflow boundary generates the
172-
replacement fingerprint from the revised Brief. Do not replay QA from a
173-
consumed record after recovery.
169+
criteria create a new brief revision, invalidate the prior admission record,
170+
and return admission to questioning. Do not replay QA from a consumed record
171+
after recovery.
174172

175173
## Role Resolution
176174

@@ -185,8 +183,8 @@ If a required capability has no eligible role, report the missing capability and
185183

186184
## Model Assignment
187185

188-
Never emit `node.model` or `config.node_defaults.model`. Model assignment belongs
189-
to runtime configuration, not the workflow graph:
186+
Workflow YAML has no model-selection field. Model assignment belongs to
187+
runtime configuration, not the workflow graph:
190188

191189
`dag.jsonc` tier → configured agent model → parent session model
192190

packages/core/src/plugin/command/workflow-blocks.md

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,23 @@ Blocks are the high-level interface for assembling a one-off workflow YAML
44
file. The tool compiles them into ordinary durable DAG nodes before validation
55
and persistence. Existing node-based YAML remains compatible.
66

7-
## Shape
7+
## Authoring contract
88

9-
Use `objective` and `blocks` inside `config` for **start**, or alongside
10-
`blocks` for **extend**. A replan uses the same fields inside `fragment`.
9+
Never infer or invent a YAML field. Copy the envelope for the intended action
10+
and change values only. Unknown fields are errors; a field from a tool call,
11+
runtime result, low-level node, or another action does not belong here unless
12+
it is explicitly listed below.
13+
14+
### Start file
15+
16+
The author-written top-level fields are optional `title`, optional `mode`,
17+
optional `admission`, and required `config`. For the block route, `config`
18+
contains required `name`, `objective`, and `blocks`; its only optional fields
19+
are `node_defaults`, `max_concurrency`, `max_node_replan_attempts`, and
20+
`max_total_nodes`.
1121

1222
```yaml
23+
title: Implement session recovery
1324
config:
1425
name: implement-session-recovery
1526
objective: Implement session recovery with focused tests and evidence-backed review.
@@ -33,6 +44,69 @@ config:
3344
depends_on: [verify]
3445
```
3546
47+
### Extend file
48+
49+
An extend file contains exactly `objective` and `blocks`. It has no `config`,
50+
`name`, or `fragment` wrapper.
51+
52+
```yaml
53+
objective: Add regression coverage for the newly confirmed recovery edge case.
54+
blocks:
55+
- id: recovery-fix
56+
kind: coding
57+
instruction: Implement only the confirmed edge-case fix and its regression test.
58+
- id: recovery-verify
59+
kind: verify
60+
depends_on: [recovery-fix]
61+
```
62+
63+
### Replan file
64+
65+
A replan file contains only `fragment`. For the block route, `fragment` has the
66+
same fields as start's `config`: required `name`, `objective`, and `blocks`,
67+
plus the four optional config fields listed above.
68+
69+
```yaml
70+
fragment:
71+
name: recover-session-recovery
72+
objective: Replace the invalid route with a diagnosed, verified repair path.
73+
blocks:
74+
- id: diagnose
75+
kind: debug
76+
instruction: Reproduce the failure and identify the evidence-backed root cause.
77+
- id: repair
78+
kind: coding
79+
depends_on: [diagnose]
80+
instruction: Apply the smallest repair supported by the diagnosis.
81+
- id: verify
82+
kind: verify
83+
depends_on: [repair]
84+
```
85+
86+
Every block accepts only `id`, `kind`, `depends_on`, `instruction`,
87+
`worker_type`, `required`, and `report_to_parent`. `id` and `kind` are required.
88+
Allowed `kind` values are `explore`, `plan`, `prototype`, `debug`, `coding`,
89+
`verify`, `review`, and `synthesize`. Omit `worker_type` unless the exact
90+
configured agent name is already known; never invent one.
91+
92+
For optional `node_defaults`, the only fields are `required`,
93+
`report_to_parent`, and `worker_config`; `worker_config` accepts only
94+
`timeout_ms`.
95+
96+
`action`, `workflow_id`, `operation`, and `spec_path` are tool-call fields, not
97+
YAML fields. `profile` is also a tool-call field used only by validate. The
98+
listed YAML fields are exhaustive: do not copy extra fields from read/status
99+
output or persisted runtime records. Deep admission is the only exception to
100+
the minimal start envelope; load `guide(topic=policy)` and copy its
101+
author-written admission shape instead of guessing fields.
102+
103+
Use these exact calls after writing the file:
104+
105+
- validate: `{ action: "validate", spec_path: "workflow.yaml", profile: "portable" }`
106+
- start: `{ action: "start", spec_path: "workflow.yaml" }`
107+
- extend: `{ action: "extend", workflow_id: "dag_...", spec_path: "extend.yaml" }`
108+
- replan: `{ action: "control", operation: "replan", workflow_id: "dag_...", spec_path: "replan.yaml" }`
109+
36110
This guide owns the author-written block fields and semantics. The action
37111
schema stays shallow and accepts only `spec_path`; the YAML validator rejects
38112
unknown or missing graph fields by name and reports each error with its path.

packages/core/src/plugin/command/workflow.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ file and retry with the same path.
4444

4545
Before a deep start, qualify the request interactively in the parent session.
4646
The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED`
47-
admission input, and `config` at the same level. The admission input contains
48-
`brief_revision`, `qa_mode`, `verdict`, `brief`, and waiver audit fields when
49-
applicable; the workflow boundary owns `protocol_version`, `state`, and
50-
`fingerprint`. Do not put admission QA inside the graph: its answers define the
47+
admission input, and `config` at the same level. The admission input accepts
48+
only `brief_revision`, `qa_mode`, `verdict`, `brief`, and waiver audit fields
49+
when applicable. Do not copy additional fields from persisted records or tool
50+
responses. Do not put admission QA inside the graph: its answers define the
5151
graph. Use the orchestration policy below for QA modes, round budgets, verdict
5252
recovery, revision invalidation, and waiver audit fields.
5353

@@ -142,8 +142,9 @@ config:
142142
timeout_ms: 600000
143143
```
144144

145-
Never emit `node.model` or `config.node_defaults.model`. Model selection is
146-
configuration-owned: critical nodes (`required: true` and review workers) use
145+
The listed node and default fields are exhaustive; workflow YAML has no
146+
model-selection field. Model selection is configuration-owned: critical nodes
147+
(`required: true` and review workers) use
147148
the `advanced` tier in `dag.jsonc`, other nodes use `standard`, then resolution
148149
falls back to the selected agent model and the parent-session model. If no
149150
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:
467468

468469
## Model Assignment Strategy
469470

470-
Workflow definitions MUST NOT specify `node.model` or
471-
`config.node_defaults.model`. Resolution follows the `dag.jsonc` tier, then the
472-
configured agent model, then the parent-session model. If all three are absent,
473-
the workflow tool asks the user to configure a model and does not create the
474-
workflow.
471+
Workflow YAML has no model-selection field. Resolution follows the `dag.jsonc`
472+
tier, then the configured agent model, then the parent-session model. If all
473+
three are absent, the workflow tool asks the user to configure a model and does
474+
not create the workflow.
475475

476476
- Expensive models for planning, review, and arbitration — high-stakes decisions where reasoning quality matters.
477477
- Fast models for mechanical implementation — well-specified edits where speed and cost matter.

packages/core/test/plugin/command.test.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,32 @@ describe("CommandPlugin.Plugin", () => {
124124
}),
125125
)
126126

127+
it.effect("publishes an exact block YAML authoring contract", () =>
128+
Effect.sync(() => {
129+
expect(CommandPlugin.WorkflowBlocksContent).toContain("Never infer or invent a YAML field")
130+
expect(CommandPlugin.WorkflowBlocksContent).toContain("### Start file")
131+
expect(CommandPlugin.WorkflowBlocksContent).toContain("### Extend file")
132+
expect(CommandPlugin.WorkflowBlocksContent).toContain("### Replan file")
133+
expect(CommandPlugin.WorkflowBlocksContent).toMatch(
134+
/`id`, `kind`, `depends_on`, `instruction`,\s+`worker_type`, `required`, and `report_to_parent`/,
135+
)
136+
expect(CommandPlugin.WorkflowBlocksContent).toMatch(
137+
/`action`, `workflow_id`, `operation`, and `spec_path` are tool-call fields/,
138+
)
139+
const authoringContract = CommandPlugin.WorkflowBlocksContent.slice(
140+
CommandPlugin.WorkflowBlocksContent.indexOf("## Authoring contract"),
141+
CommandPlugin.WorkflowBlocksContent.indexOf("This guide owns"),
142+
)
143+
expect(authoringContract).toMatch(/[Tt]he\s+listed YAML fields are exhaustive/)
144+
expect(authoringContract).not.toMatch(
145+
/session_id|project_id|protocol_version|fingerprint|node_defaults\.model|node\.model/,
146+
)
147+
expect(CommandPlugin.WorkflowBlocksContent).toContain(
148+
'{ action: "control", operation: "replan", workflow_id: "dag_...", spec_path: "replan.yaml" }',
149+
)
150+
}),
151+
)
152+
127153
it.effect("preserves opt-outs read-only scope and explicit role assignments", () =>
128154
Effect.sync(() => {
129155
expect(CommandPlugin.OrchestrationPolicyContent).toContain("single agent")
@@ -137,9 +163,7 @@ describe("CommandPlugin.Plugin", () => {
137163

138164
it.effect("documents config-first model fallback without invented identifiers", () =>
139165
Effect.sync(() => {
140-
expect(CommandPlugin.OrchestrationPolicyContent).toContain(
141-
"Never emit `node.model` or `config.node_defaults.model`",
142-
)
166+
expect(CommandPlugin.OrchestrationPolicyContent).toContain("Workflow YAML has no model-selection field")
143167
expect(CommandPlugin.OrchestrationPolicyContent).toContain(
144168
"`dag.jsonc` tier → configured agent model → parent session model",
145169
)
@@ -338,14 +362,17 @@ describe("CommandPlugin.Plugin", () => {
338362
expect(CommandPlugin.OrchestrationPolicyContent).toContain("waiver_reason")
339363
expect(CommandPlugin.OrchestrationPolicyContent).toContain("acknowledged_risks")
340364
expect(CommandPlugin.OrchestrationPolicyContent).toContain("Material changes")
341-
expect(CommandPlugin.OrchestrationPolicyContent).toContain("invalidate the prior fingerprint")
342-
expect(CommandPlugin.OrchestrationPolicyContent).toContain("SHA-256 hash")
343-
expect(CommandPlugin.WorkflowFactsContent).toContain(
344-
"The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED`",
365+
expect(CommandPlugin.OrchestrationPolicyContent).toContain("invalidate the prior admission record")
366+
const admissionPolicy = CommandPlugin.OrchestrationPolicyContent.slice(
367+
CommandPlugin.OrchestrationPolicyContent.indexOf("## Deep Admission QA"),
368+
CommandPlugin.OrchestrationPolicyContent.indexOf("## Role Resolution"),
345369
)
370+
expect(admissionPolicy).not.toMatch(/protocol_version|fingerprint/)
346371
expect(CommandPlugin.WorkflowFactsContent).toContain(
347-
"the workflow boundary owns `protocol_version`, `state`, and\n`fingerprint`",
372+
"The start spec places `mode: deep`, a versioned `READY` or informed `WAIVED`",
348373
)
374+
expect(CommandPlugin.WorkflowFactsContent).toContain("The admission input accepts\nonly `brief_revision`")
375+
expect(CommandPlugin.WorkflowFactsContent).not.toMatch(/protocol_version|node_defaults\.model|node\.model/)
349376
expect(CommandPlugin.WorkflowFactsContent).toContain("A one-off graph may use a")
350377
expect(CommandPlugin.WorkflowFactsContent).toContain("task-local file")
351378
expect(CommandPlugin.WorkflowFactsContent).not.toContain("`config.mode`")
@@ -388,11 +415,9 @@ describe("CommandPlugin.Plugin", () => {
388415
expect(CommandPlugin.WorkflowFactsContent).not.toContain("Gate failure cancels the workflow automatically")
389416
expect(CommandPlugin.WorkflowFactsContent).toContain("Static `prompt_template.input`")
390417
expect(CommandPlugin.WorkflowFactsContent).toContain("it must\nnever appear as `[object Object]`")
391-
expect(CommandPlugin.WorkflowFactsContent).toContain(
392-
"Workflow definitions MUST NOT specify `node.model` or\n`config.node_defaults.model`",
393-
)
418+
expect(CommandPlugin.WorkflowFactsContent).toContain("Workflow YAML has no model-selection field")
394419
expect(CommandPlugin.WorkflowFactsContent).toMatch(
395-
/`dag\.jsonc` tier, then the\s+configured agent model, then the parent-session model/,
420+
/`dag\.jsonc`\s+tier, then the\s+configured agent model, then the parent-session model/,
396421
)
397422
expect(CommandPlugin.WorkflowFactsContent).toContain("Propose-then-assemble")
398423
const reviewExample = CommandPlugin.WorkflowFactsContent.slice(

packages/opencode/test/dag/workflow-authoring.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect } from "bun:test"
22
import { Effect } from "effect"
33
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
4+
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
45
import { WorkflowAuthoring } from "../../src/dag/authoring"
56
import { DagValidation } from "../../src/dag/validation"
67
import { testEffect } from "../lib/effect"
@@ -23,6 +24,35 @@ const start = {
2324
}
2425

2526
describe("WorkflowAuthoring source-to-graph seam", () => {
27+
it.effect("keeps every block-guide YAML envelope executable", () =>
28+
Effect.gen(function* () {
29+
const guide = CommandPlugin.WorkflowBlocksContent
30+
const example = (heading: string) => {
31+
const section = guide.slice(guide.indexOf(`### ${heading}`))
32+
const match = section.match(/```yaml\n([\s\S]*?)```/)
33+
expect(match?.[1]).toBeDefined()
34+
return match?.[1] ?? ""
35+
}
36+
const authoring = WorkflowAuthoring.make()
37+
const inputs = [
38+
{ action: "start" as const, content: example("Start file") },
39+
{ action: "extend" as const, content: example("Extend file") },
40+
{ action: "replan" as const, content: example("Replan file") },
41+
]
42+
43+
for (const input of inputs) {
44+
const result = yield* authoring.prepare({
45+
action: input.action,
46+
source: { kind: "yaml", source: `${input.action}.yaml`, content: input.content },
47+
profile: "portable",
48+
})
49+
expect(result.errors).toEqual([])
50+
expect(result.valid).toBe(true)
51+
expect(result.prepared?.nodes.length).toBeGreaterThan(0)
52+
}
53+
}),
54+
)
55+
2656
it.effect("prepares start, extend, and replan through one action-aware interface", () =>
2757
Effect.gen(function* () {
2858
const authoring = WorkflowAuthoring.make()

0 commit comments

Comments
 (0)