Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions docs/harness-dag.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,19 @@ QA 覆盖六个维度:目标、范围、约束与假设、验收标准、证
}
```

准入记录还包含 `protocol_version`、`brief_revision`、`qa_mode`、`verdict`、
`state` 和 `fingerprint`。目标、范围、约束、假设或验收标准发生实质变化时,
应增加 Brief 修订号、生成新指纹,并把旧准入置为 `INVALIDATED` 后重新问答。
YAML 准入输入只包含 `brief_revision`、`qa_mode`、`verdict`、`brief`,以及
WAIVED 所需的审计字段。不要在输入中提供 `protocol_version`、`state` 或
`fingerprint`:工作流边界会设置协议版本,从 verdict 初始化状态,规范化 Brief
后计算小写十六进制 SHA-256 指纹;只有成功启动后才把持久化状态转为
`CONSUMED`。

`action: start` 调用中,`mode` 和 `admission` 与 `config` 同级,而不是
`config` 的子字段。指纹计算会裁剪目标和所有数组字符串、删除空项、对数组排序,
按 Brief 的固定字段顺序序列化为紧凑 JSON,再计算小写十六进制 SHA-256。
启动、扩展和 replan 的图配置都先写入 `.yaml` 或 `.yml` 文件,工具调用只传
`action`、`spec_path` 和该动作所需的少量标识字段。启动文件中,`mode`、
`admission` 与 `config` 同级。校验失败时保留并修改同一文件后重试,不要重新
生成整段 tool-call 参数。

目标、范围、约束、假设或验收标准发生实质变化时,应增加 Brief 修订号,
使旧指纹失效并重新问答;新指纹仍由工作流边界生成。

## Verdict 与恢复路径

Expand Down Expand Up @@ -109,7 +115,7 @@ REJECT
准入和 review 元数据;若显式声明了不完整的 diff review 元数据,引擎只产生
非阻塞诊断。

本能力扩展的是模型可调用的 `workflow` 工具配置。现有 HTTP DAG
本能力扩展的是模型可调用的 `workflow` 工具文件输入。现有 HTTP DAG
查询仍返回持久化工作流行和字符串化 `config`,HTTP 请求/响应 schema、
SDK 的 DAG summary 类型以及 TUI re-export 均未改变,因此不需要重新生成
JavaScript SDK。
21 changes: 11 additions & 10 deletions packages/core/src/plugin/command/orchestration-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,25 +125,26 @@ Maintain a versioned Requirement Brief with this structure:
```

Before start, show a concise brief summary and verdict:
`READY | NOT_READY | WAIVED`, plus QA mode, brief revision, fingerprint, and
remaining blockers. `READY` requires a non-empty goal, scope boundaries,
`READY | NOT_READY | WAIVED`, plus QA mode, brief revision, and remaining
blockers. `READY` requires a non-empty goal, scope boundaries,
acceptance criteria, evidence obligations, review plan, and no blocking
questions. For `NOT_READY`, remain in the parent conversation and offer:
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.

Compute `fingerprint` exactly as the workflow boundary does: trim `goal`; for
every array in the Brief, trim strings, remove blanks, and sort them; preserve
the documented key order; then SHA-256 hash the compact JSON object and encode
it as lowercase hexadecimal. Use normal local calculation tools rather than
inventing a digest.
Do not supply `protocol_version`, `state`, or `fingerprint` in the YAML
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`.

Material changes to goal, scope, constraints, assumptions, or acceptance
criteria create a new brief revision, invalidate the prior fingerprint, and
return admission to questioning. Only a successful deep workflow start consumes
a `READY` or `WAIVED` record. Do not replay QA from a consumed record after
recovery.
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.

## Role Resolution

Expand Down
76 changes: 57 additions & 19 deletions packages/core/src/plugin/command/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,47 @@ as independent workstreams, cross-domain uncertainty, high blast radius,
conflicting constraints, evidence gathering, or multiple verification
perspectives.

Before a deep start, qualify the request interactively in the parent session
and pass `mode: deep` plus a versioned `READY` or informed `WAIVED` admission
record beside `config` in the `action: start` tool call. 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.
Before any graph-carrying action (`start`, `extend`, or `control(replan)`),
write its configuration to a `.yaml` or `.yml` file, then pass only
`spec_path` beside the shallow action fields. Never inline graph nodes,
admission, or replan fragments in the tool call. Keep the file after a
validation failure, edit only the reported problem, and retry the same path.

Before a deep start, qualify the request interactively in the parent session.
The start YAML 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
graph. Use the orchestration policy below for QA modes, round budgets, verdict
recovery, revision invalidation, and waiver audit fields.

A deep start spec has this outer shape:

```yaml
title: Deep review
mode: deep
admission:
brief_revision: 1
qa_mode: STANDARD
verdict: READY
brief:
goal: Review the requested implementation
scope:
in: [requested modules]
out: [unrelated modules]
constraints: [read-only reviewers]
assumptions: [the working tree is the review target]
acceptance_criteria: [all material findings are evidence-backed]
evidence_required: [code references, relevant test results]
risks: [missed cross-module regressions]
review_plan: [parallel review, claim verification, final arbitration]
open_questions: []
blocking_questions: []
config:
name: deep-review
nodes: []
```

## Orchestration Lifecycle

Expand Down Expand Up @@ -92,14 +127,16 @@ the workflow uncreated so the user can configure a model and retry.

## Collaboration Patterns

Four structural patterns cover the common cases. Real workflows often combine them.
Four structural patterns cover the common cases. Real workflows often combine
them. Every YAML block below is workflow spec file content. Save the selected
shape to a `.yaml` file, then call the tool with
`{ action: "start", spec_path: "<that-file>.yaml" }`.

### 1. Staged Pipeline with Gate

Sequential phases where each depends on the previous. Insert a gate node between phases to block downstream execution until quality is confirmed.

```yaml
action: start
config:
name: staged-gate
nodes:
Expand Down Expand Up @@ -148,7 +185,6 @@ the parent an actionable replan or stop decision.
One preparatory node feeds N independent worker nodes, which fan back into a single assembler.

```yaml
action: start
config:
name: parallel-fan-out
nodes:
Expand Down Expand Up @@ -191,7 +227,6 @@ config:
Multiple reviewer nodes with different perspectives examine the same artifact. A final arbiter synthesizes their verdicts. The arbiter must not be a silent terminal leaf: gate an in-graph continuation node on its verdict (shown below), or dispose of the reported verdict at the wake boundary per the Verdict Disposal Contract.

```yaml
action: start
config:
name: adversarial-review
nodes:
Expand Down Expand Up @@ -270,7 +305,6 @@ never silently terminalize the graph.
Multiple independent generators produce candidate solutions; a converger selects and refines.

```yaml
action: start
config:
name: diverge-converge
nodes:
Expand Down Expand Up @@ -381,7 +415,9 @@ For ad-hoc prompts, use `prompt_template: { inline: "...", input: {...} }`.
Static `prompt_template.input` supplies literal, local template values; it does
not read upstream node output. Inline templates interpolate those static values
and direct dependency variables. Use `input_mapping` when an upstream output
needs a stable variable name or field selection.
needs a stable variable name or field selection. When a mapped placeholder
contains an object or array, interpolation renders it as indented JSON; it must
never appear as `[object Object]`.

## Budget Declaration

Expand Down Expand Up @@ -415,25 +451,27 @@ All nodes share the same workspace. Write conflicts are an orchestration concern

### Actions

**start** — Create a workflow from a YAML-declared graph. Pass the graph as
`{ action: "start", config: { name, nodes, ...defaultsAndBudgets } }`; `nodes`
at the tool-call top level is only for `extend`, not `start`. Returns the
workflow ID. Nodes declare `depends_on` (node IDs); layers and execution order
are computed automatically.
**start** — Create a workflow from a YAML spec with `config` and optional
`title`, `mode`, and admission input at the file root. Write the file first,
then call `{ action: "start", spec_path: ".opencode/workflows/name.yaml" }`.
Returns the workflow ID. Nodes declare `depends_on` (node IDs); layers and
execution order are computed automatically.

**extend** — Add nodes to a running workflow. Existing nodes are unaffected;
new nodes are immediately eligible for scheduling if their dependencies are
met. It also accepts a genuinely additive wave after a reporting leaf
checkpoint naturally completed the current graph; an early
`control(complete)` workflow remains terminal.
`control(complete)` workflow remains terminal. Put the new nodes under a
file-root `nodes` array, then call
`{ action: "extend", workflow_id: "dag_...", spec_path: "extend.yaml" }`.

**status** — Read the durable state of one workflow and all of its nodes. Pass `workflow_id`. Use it when the user explicitly asks for current state or once before a decision that requires fresh state, such as replan/control. Do not poll a running workflow merely to wait: node reports and terminal outcomes wake the parent session automatically.

**control** — Control a running workflow:
- `pause` — let running nodes finish, don't spawn new ones (pause does NOT stop nodes that are already running). On a cancel/replan intent, always pause FIRST: it needs no fragment and freezes scheduling while you compose the replan, so the graph cannot terminalize under you.
- `resume` — resume scheduling
- `cancel` — cancel the entire workflow
- `replan` — submit a YAML fragment; running nodes can be `restart: true` or `cancel: true`; pending nodes absent from the fragment are cancelled. Valid while paused — the pause → compose fragment → replan → resume sequence is the safe path.
- `replan` — write a YAML file with a file-root `fragment` object containing the graph fields and node definitions, then pass its `spec_path`; running nodes can be `restart: true` or `cancel: true`; pending nodes absent from the fragment are cancelled. Valid while paused — the pause → compose file → replan → resume sequence is the safe path.
- `complete` — early-complete: remaining pending nodes are skipped (non-violation)
- `step` — advance exactly one ready node (the first by node ID lexicographic order), then wait. Use for controlled debugging or staged verification of a critical path. Unlike `pause`, which freezes all scheduling, `step` advances one node and re-waits. A second `step` while the stepped node is still running is rejected. Use `resume` to return to full-speed scheduling. Nodes are selected in lexicographic ID order for determinism.

Expand Down
9 changes: 6 additions & 3 deletions packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,12 @@ describe("CommandPlugin.Plugin", () => {
expect(CommandPlugin.OrchestrationPolicyContent).toContain("invalidate the prior fingerprint")
expect(CommandPlugin.OrchestrationPolicyContent).toContain("SHA-256 hash")
expect(CommandPlugin.WorkflowFactsContent).toContain(
"pass `mode: deep` plus a versioned `READY` or informed `WAIVED` admission",
"The start YAML places `mode: deep`, a versioned `READY` or informed `WAIVED`",
)
expect(CommandPlugin.WorkflowFactsContent).toContain(
"beside `config` in the `action: start` tool call",
"the workflow boundary owns `protocol_version`, `state`, and\n`fingerprint`",
)
expect(CommandPlugin.WorkflowFactsContent).toContain("pass only\n`spec_path` beside the shallow action fields")
expect(CommandPlugin.WorkflowFactsContent).not.toContain("`config.mode`")
}),
)
Expand Down Expand Up @@ -316,7 +317,8 @@ describe("CommandPlugin.Plugin", () => {

expect(graphExamples.length).toBeGreaterThan(0)
for (const example of graphExamples) {
expect(example).toContain("action: start\nconfig:")
expect(example).toMatch(/^config:/m)
expect(example).not.toMatch(/^action:/m)
expect(example).toMatch(/\n nodes:/)
expect(example).not.toMatch(/^nodes:/m)
expect(example.match(/^\s+- id:/gm)?.length).toBe(example.match(/^ {6}name:/gm)?.length)
Expand All @@ -328,6 +330,7 @@ describe("CommandPlugin.Plugin", () => {
expect(CommandPlugin.WorkflowFactsContent).not.toContain('input: { findings: "from explore" }')
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`",
)
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/src/dag/admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export const AdmissionRecord = Schema.Struct({
})
export type AdmissionRecord = typeof AdmissionRecord.Type

export const AdmissionInput = Schema.Struct({
brief_revision: Schema.Number,
qa_mode: Schema.Literals(Modes),
verdict: Schema.Literals(Verdicts),
brief: RequirementBrief,
waiver_reason: Schema.optional(Schema.String),
acknowledged_risks: Schema.optional(StringArray),
})
export type AdmissionInput = typeof AdmissionInput.Type

const Transitions = {
UNASSESSED: ["QUESTIONING", "WAIVED"],
QUESTIONING: ["READY", "NOT_READY", "WAIVED"],
Expand Down Expand Up @@ -104,6 +114,20 @@ export function fingerprintBrief(brief: RequirementBrief) {
return new Bun.CryptoHasher("sha256").update(JSON.stringify(canonical)).digest("hex")
}

export function createAdmissionRecord(input: AdmissionInput): AdmissionRecord {
return {
protocol_version: 1,
brief_revision: input.brief_revision,
qa_mode: input.qa_mode,
verdict: input.verdict,
state: input.verdict,
fingerprint: fingerprintBrief(input.brief),
brief: input.brief,
...(input.waiver_reason ? { waiver_reason: input.waiver_reason } : {}),
...(input.acknowledged_risks ? { acknowledged_risks: input.acknowledged_risks } : {}),
}
}

export function validateAdmission(record: AdmissionRecord) {
const errors = [
...(record.protocol_version === 1 ? [] : ["protocol_version must be 1"]),
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/dag/templates/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ function interpolate(template: string, input: Record<string, unknown>) {
const unresolvedPlaceholders: string[] = []
const text = template.replace(INTERPOLATION_RE, (match, key: string) => {
const value = input[key]
if (value !== null && value !== undefined) return String(value)
if (value !== null && value !== undefined) {
return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
}
unresolvedPlaceholders.push(key)
return match
})
Expand Down
Loading
Loading