From 9a69eb01234fbfee90fa40945b11ef2ef8132f61 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 05:39:18 +0800 Subject: [PATCH 01/27] fix(test): declare per-test timeouts for dag-node-supervision long tests (#384) --- .../opencode/test/dag/dag-node-supervision.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/dag/dag-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index a957a6232..85101bed2 100644 --- a/packages/opencode/test/dag/dag-node-supervision.test.ts +++ b/packages/opencode/test/dag/dag-node-supervision.test.ts @@ -240,7 +240,9 @@ const supervisionProgress = (store: DagStore.Interface, dagID: string, nodeID: s }) // bun's default per-test timeout is 5s; several bodies here need 8-40s at a -// 2s deadline — run this file with --timeout 30000 (the CI suite default). +// 2s deadline — the long tests below declare their own per-test timeout +// (it(..., 30000), matching the CI suite's --timeout 30000) so bare focused +// invocations don't red-fail them. describe("DAG node supervision — deadline enforcement (production incident)", () => { it("healthy: a node past its deadline gets escalated by the watcher", async () => { await Effect.runPromise( @@ -353,7 +355,7 @@ describe("DAG node supervision — deadline enforcement (production incident)", }), ), ) - }) + }, 30_000) // Review R4 issue 1 (P0): the sweep's layer-scoped fiber has no ambient // InstanceRef, so a real SessionPrompt.cancel DIES at @@ -396,7 +398,7 @@ describe("DAG node supervision — deadline enforcement (production incident)", }), ), ) - }) + }, 30_000) // Review R1 issue 2 (false-positive kill): a LIVE watcher on a node whose // escalation cadence spans multiple sweep intervals must never be swept — @@ -444,7 +446,7 @@ describe("DAG node supervision — deadline enforcement (production incident)", }), ), ) - }) + }, 30_000) }) describe("DagSupervisionSweep cadence derivation (pure)", () => { From f32d0375d3c6c128559bc2db65d8f0ab202cd303 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 05:39:49 +0800 Subject: [PATCH 02/27] chore: record delivery binding for issue384 --- .specgit.yaml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 100746866..a9719a5a2 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,8 @@ version: 1 -delivery: issue378 +delivery: issue384 context: kind: branch - branch: feat/378-issue378 + branch: feat/384-issue384 issues: - - 378 - - 379 - - 380 - - 381 -pr: 382 + - 384 +pr: 385 From 74a15ec5e3f0acd3c0e753eb1108a613a09189ea Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:14:04 +0800 Subject: [PATCH 03/27] chore(dag): record delivery binding for issues 386-388 --- .specgit.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index a9719a5a2..41db33230 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,9 @@ version: 1 -delivery: issue384 +delivery: end-structured-output context: kind: branch - branch: feat/384-issue384 + branch: fix/386-end-structured-output issues: - - 384 -pr: 385 + - 386 + - 387 + - 388 From 1c4d30add9a4d52cac666f271b5da66713b57e8e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:14:24 +0800 Subject: [PATCH 04/27] fix(dag): instruct schema nodes to report only through submit_result The output_schema instruction and the submit_result tool description now state the single-authority contract: the summary belongs inside the payload, message text must not duplicate it, and a successful submission ends the turn without restating the result. Previously nothing told the child not to narrate the report in prose before submitting, so the same content entered the child transcript twice and the post-submit replay step carried both copies (issue #386). --- packages/opencode/src/dag/runtime/loop.ts | 2 +- packages/opencode/src/tool/submit_result.txt | 2 + .../dag/dag-schema-prompt-contract.test.ts | 208 ++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/dag/dag-schema-prompt-contract.test.ts diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 27e1fb289..8d5dd29be 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -278,7 +278,7 @@ const serviceLayer = Layer.effect( if (nodeConfig?.output_schema) { promptParts.push({ type: "text", - text: `\n\nYou MUST call the submit_result tool with a JSON payload matching this schema before ending your turn:\n${JSON.stringify(nodeConfig.output_schema, null, 2)}`, + text: `\n\nYou MUST call the submit_result tool with a JSON payload matching this schema before ending your turn:\n${JSON.stringify(nodeConfig.output_schema, null, 2)}\nPut your full summary inside the payload. Do not repeat the payload in your message text. After submit_result succeeds, end your turn without restating the result.`, }) } diff --git a/packages/opencode/src/tool/submit_result.txt b/packages/opencode/src/tool/submit_result.txt index 14b792908..c523c9fd2 100644 --- a/packages/opencode/src/tool/submit_result.txt +++ b/packages/opencode/src/tool/submit_result.txt @@ -4,4 +4,6 @@ This tool is only relevant when you are running as a child session of a DAG work If the payload does not match the schema, the tool returns a validation error — correct the payload and call again within the same session. The result is not final until this tool succeeds. +The payload is the single authoritative report: put the full result, including any summary, inside the payload itself. Do not duplicate the payload in your message text. Once submit_result succeeds, end your turn without restating the result. + If you are not in a DAG workflow child session, this tool has no effect. diff --git a/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts new file mode 100644 index 000000000..d7884ec8c --- /dev/null +++ b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts @@ -0,0 +1,208 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- harness +// deliberately mirrors dag-wake-integration.test.ts: mocked service layers and +// row fixtures use `as never` type shims (mock objects implement only the +// interface slice the scenario exercises). The shims are type-only. +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Issue #386 — structured-output single-authority contract. + * + * A DAG node with output_schema must instruct the child, up front, that the + * submit_result payload is the single authoritative report: the summary lives + * inside the payload, prose must not duplicate it, and a successful submission + * ends the turn. Both delivery channels carry the contract: the DAG-generated + * schema instruction part (loop.ts) and the submit_result tool description. + */ +import { describe, expect, it } from "bun:test" +import path from "node:path" +import { Deferred, Effect, Layer, Option, Queue } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { Database } from "@opencode-ai/core/database/database" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { EventV2 } from "@opencode-ai/core/event" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Agent } from "@/agent/agent" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagLoop } from "@/dag/runtime/loop" +import { InstanceRef } from "@/effect/instance-ref" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionPrompt } from "@/session/prompt" +import { MessageID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { testEffect } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +const integration = testEffect(Layer.empty) + +interface PromptGate { + readonly input: SessionPrompt.PromptInput + readonly release: Deferred.Deferred +} + +function reply(sessionID: string, text: string): SessionV1.WithParts { + return { + info: { + id: MessageID.ascending(), + role: "assistant", + parentID: MessageID.ascending(), + sessionID: sessionID as never, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: process.cwd(), root: process.cwd() }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "test-model" as never, + providerID: "test" as never, + time: { created: Date.now() }, + finish: "stop", + }, + parts: text ? [{ type: "text", text }] as never : [], + } +} + +function schemaNode(): NodeConfig { + return { + id: "report", + name: "report", + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: "Summarize the delivery" }, + output_schema: { + type: "object", + required: ["summary"], + properties: { summary: { type: "string" } }, + }, + } +} + +function contractLayer(childPrompts: Queue.Queue) { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const status = SessionStatus.layer.pipe(Layer.provide(bridge)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) + const session = Layer.mock(Session.Service, { + get: () => Effect.succeed({ id: "ses_parent", permission: [], agent: "build" } as never), + create: () => Effect.succeed({ id: "ses_child_1" } as never), + messages: () => Effect.succeed([]), + }) + const deliver = Effect.fn("test.SessionPrompt.deliver")(function* (value: SessionPrompt.PromptInput) { + const sessionID = value.sessionID as string + const release = yield* Deferred.make() + yield* Queue.offer(childPrompts, { input: value, release }) + return reply(sessionID, yield* Deferred.await(release)) + }) + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ + cancel: () => Effect.void, + prompt: deliver, + promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), + })) + const agent = Layer.mock(Agent.Service, { + get: () => Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), + }) + const loop = DagLoop.layer.pipe( + Layer.provide(base), + Layer.provide(session), + Layer.provide(prompt), + Layer.provide(agent), + ) + return Layer.merge(base, loop) +} + +function runContractTest(test: (services: { + readonly dag: Dag.Interface + readonly loop: DagLoop.Interface + readonly childPrompts: Queue.Queue +}) => Effect.Effect) { + return Effect.gen(function* () { + const childPrompts = yield* Queue.unbounded() + return yield* Effect.gen(function* () { + const dag = yield* Dag.Service + const loop = yield* DagLoop.Service + const database = yield* Database.Service + yield* database.db.insert(ProjectTable).values({ + id: "project-1" as never, + worktree: process.cwd() as never, + sandboxes: [], + }).run().pipe(Effect.orDie) + yield* database.db.insert(SessionTable).values({ + id: "ses_parent" as never, + project_id: "project-1" as never, + slug: "parent", + directory: process.cwd(), + title: "Parent", + version: "test", + }).run().pipe(Effect.orDie) + yield* loop.init() + return yield* test({ dag, loop, childPrompts }) + }).pipe( + Effect.provide(contractLayer(childPrompts)), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { id: "project-1" }, + } as never), + Effect.scoped, + ) + }) +} + +function promptText(input: SessionPrompt.PromptInput) { + return input.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") +} + +describe("DAG schema prompt contract (issue #386)", () => { + it("schema instruction tells the child the payload is the single authoritative report", async () => { + await Effect.runPromise( + runContractTest(({ dag, childPrompts }) => + Effect.gen(function* () { + yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Schema prompt contract", + config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + }) + const gate = yield* Queue.take(childPrompts) + const prompt = promptText(gate.input) + expect(prompt).toContain("submit_result") + // Summary belongs inside the payload, not in prose. + expect(prompt).toContain("Put your full summary inside the payload") + // Prose must not duplicate the payload. + expect(prompt).toContain("Do not repeat the payload in your message text") + // A successful submission ends the turn. + expect(prompt).toContain("end your turn without restating the result") + yield* Deferred.succeed(gate.release, "done") + }), + ), + ) + }) + + it("submit_result tool description carries the same single-authority contract", async () => { + const description = await Bun.file( + path.join(import.meta.dir, "../../src/tool/submit_result.txt"), + ).text() + expect(description).toContain("Do not duplicate the payload") + expect(description).toContain("end your turn without restating the result") + }) +}) From eb3240c9037b454485a437e64298e0f84e151dfd Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:18:54 +0800 Subject: [PATCH 05/27] fix(dag): drop block instructions that duplicate the objective A block instruction equal to the workflow objective (after trim and line-ending normalization) rendered the same content twice in the single child prompt: once via the objective section and again via the Block-specific instruction section. The compiler now drops the instruction instead of duplicating it; genuinely block-specific instructions keep their place and ordering (issue #387). --- packages/opencode/src/dag/blocks.ts | 10 ++++++-- packages/opencode/test/dag/blocks.test.ts | 28 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index fc010a014..83f4ea962 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -332,7 +332,13 @@ function node(input: { review?: NodeConfig["review"] outputSchema?: Record }): NodeConfig { - const instruction = input.instruction?.trim() ? "Block-specific instruction:\n{{instruction}}" : "" + // issue #387: an instruction equal to the objective (after trim and + // line-ending normalization) would render the same content twice in the + // single child prompt — the objective section already carries it, so the + // instruction is dropped instead of duplicated. + const equivalent = (a: string, b: string) => a.trim().replace(/\r\n/g, "\n") === b.trim().replace(/\r\n/g, "\n") + const hasInstruction = input.instruction?.trim() && !equivalent(input.instruction, input.objective) + const instruction = hasInstruction ? "Block-specific instruction:\n{{instruction}}" : "" // issue #323: a reporting checkpoint adjudicates a direction, so its // prompt must demand adversarial independent verification. The production // incident: a gate confirmed parent-supplied "defect evidence" that was a @@ -360,7 +366,7 @@ function node(input: { .join("\n\n"), input: { objective: input.objective, - ...(input.instruction?.trim() ? { instruction: input.instruction.trim() } : {}), + ...(hasInstruction ? { instruction: input.instruction!.trim() } : {}), }, }, ...(input.condition ? { condition: input.condition } : {}), diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index a36184d27..9eb430bb7 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -455,5 +455,33 @@ describe("workflow blocks", () => { ], }), ).toThrow("depends on multiple review gates") + + // issue #387: an instruction that duplicates the objective (after + // trim/line-ending normalization) must not be emitted twice in the single + // child prompt — the objective section already carries the content. + const duplicated = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the memory feature", + blocks: [{ id: "map", kind: "explore", instruction: "Ship the memory feature" }], + }) + const inline = duplicated[0]?.prompt_template.inline ?? "" + expect(inline).not.toContain("Block-specific instruction") + expect(duplicated[0]?.prompt_template.input).not.toHaveProperty("instruction") + + const whitespaceEquivalent = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the memory feature", + blocks: [{ id: "map", kind: "explore", instruction: " Ship the memory feature\r\n" }], + }) + expect(whitespaceEquivalent[0]?.prompt_template.inline).not.toContain("Block-specific instruction") + expect(whitespaceEquivalent[0]?.prompt_template.input).not.toHaveProperty("instruction") + + // A genuinely block-specific instruction stays, ordered after the objective. + const distinct = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the memory feature", + blocks: [{ id: "map", kind: "explore", instruction: "Focus on the persistence seam" }], + }) + const distinctInline = distinct[0]?.prompt_template.inline ?? "" + expect(distinctInline).toContain("Block-specific instruction:\n{{instruction}}") + expect(distinct[0]?.prompt_template.input).toMatchObject({ instruction: "Focus on the persistence seam" }) + expect(distinctInline.indexOf("Workflow objective")).toBeLessThan(distinctInline.indexOf("Block-specific instruction")) }) }) From bee75d5c2503fc32e04cb1cd0dc11c1a1eea71bc Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:23:01 +0800 Subject: [PATCH 06/27] fix(dag): preserve output file references during crash recovery The live completion path captures {content_ref, size, sha256, summary} into captured_output when a schemaless node's final reply IS one existing absolute file path. Recovery settled the same reply inline without the capture, so the same completed child produced different durable output metadata depending on crash timing. Recovery now reuses captureOutputFileRef (plus the report-area gitignore guarantee) with the same best-effort fallback: any anomaly keeps the plain inline settlement and never fails the node (issue #388). --- packages/opencode/src/dag/runtime/loop.ts | 1 + packages/opencode/src/dag/runtime/recovery.ts | 23 +++++ .../opencode/test/dag/dag-recovery.test.ts | 88 ++++++++++++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 8d5dd29be..0f37a9c25 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -423,6 +423,7 @@ const serviceLayer = Layer.effect( // fails such nodes loudly instead of undefined-completing them. config ?? null, lastAssistantText, + ctx.directory, ).pipe( Effect.provideService(Dag.Service, dag), ) diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index cd7647324..5013b39a5 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -32,6 +32,7 @@ import { reviewImplementationFingerprint } from "../review-lifecycle" import { resolveInputMapping } from "./eval" import { settleCapturedOutput } from "./capture" import type { CapturedSettlement } from "./capture" +import { captureOutputFileRef, ensureReportAreaGitignore } from "./output-ref" export function reconcileWorkflow( dagID: string, @@ -39,6 +40,7 @@ export function reconcileWorkflow( cancelSession?: (sessionID: string) => Effect.Effect, workflowConfig?: { nodes: Pick[] } | null, lastAssistantText?: (childSessionID: string) => Effect.Effect, + directory?: string, ): Effect.Effect<{ reconciled: number; ownershipLost: number }, Error, Dag.Service> { return Effect.gen(function* () { const dag = yield* Dag.Service @@ -143,6 +145,27 @@ export function reconcileWorkflow( const rawText = lastAssistantText ? (yield* lastAssistantText(node.childSessionId)) ?? "" : undefined + // #388 parity with the live path: when the recovered reply IS one + // existing absolute file path, capture the same {content_ref, size, + // sha256, summary} receipt submit-time detection records, so live + // and recovered settlement produce identical durable output + // metadata. Best-effort like the live path — any anomaly keeps the + // plain inline completion and never fails the node. + if (rawText) { + const fileRef = yield* captureOutputFileRef(rawText) + if (fileRef) { + yield* dag.store.setCapturedOutput(node.childSessionId, fileRef).pipe( + Effect.catchCause((cause) => + Effect.logWarning("DAG recovery output-ref capture persistence failed — inline output preserved", { + dagID, + nodeID: node.id, + cause, + }), + ), + ) + if (directory) yield* ensureReportAreaGitignore(directory, fileRef.path) + } + } yield* settle(node.id, dag.nodeCompleted(dagID, node.id, rawText)) } reconciled++ diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index 322e78b77..e90575cc4 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, afterAll } from "bun:test" +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { Effect, Exit, Layer } from "effect" import { reconcileWorkflow } from "@/dag/runtime/recovery" import { Dag } from "@/dag/dag" @@ -7,6 +11,12 @@ import { WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/s import { TerminalViolationError } from "@opencode-ai/core/dag/core/types" import { makeNodeRow } from "./fixtures" +const tmpRoots: string[] = [] + +afterAll(async () => { + for (const dir of tmpRoots) await fs.rm(dir, { recursive: true, force: true }) +}) + type TrackedEvent = { type: string nodeID: string @@ -15,11 +25,18 @@ type TrackedEvent = { trigger?: string } -function makeDagLayer(nodes: DagStore.NodeRow[], trackedEvents: TrackedEvent[], actions?: string[]) { +function makeDagLayer( + nodes: DagStore.NodeRow[], + trackedEvents: TrackedEvent[], + actions?: string[], + capturedWrites?: { sid: string; payload: unknown }[], +) { return Layer.mock(Dag.Service, { store: { getNodes: () => Effect.succeed(nodes), getNode: (id: string) => Effect.succeed(nodes.find((n) => n.id === id)), + setCapturedOutput: (sid: string, payload: unknown) => + Effect.sync(() => capturedWrites?.push({ sid, payload })), } as unknown as DagStore.Interface, nodeCompleted: Effect.fn("stub.nodeCompleted")((dagID: string, nodeID: string, output: unknown) => Effect.sync(() => trackedEvents.push({ @@ -511,3 +528,70 @@ describe("rehydration via toSchedulingNodes", () => { expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1" }) }) }) + +// issue #388: the live path captures {content_ref, size, sha256, summary} +// when a schemaless node's final reply IS one existing absolute file path +// (spawn.ts → output-ref.ts). Recovery must produce the same durable receipt +// for the same reply instead of diverging by crash timing. +describe("reconcileWorkflow output file refs (issue #388)", () => { + it("captures the same file_ref receipt as the live path for an absolute-path reply", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dag-recovery-ref-")) + tmpRoots.push(dir) + const reportPath = path.join(dir, "report.md") + const content = "recovered report body" + await Bun.write(reportPath, content) + + const events: TrackedEvent[] = [] + const captured: { sid: string; payload: unknown }[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events, undefined, captured) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + await Effect.runPromise( + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed(reportPath), + dir, + ).pipe(Effect.provide(dagLayer)), + ) + + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1", output: reportPath }) + expect(captured).toEqual([{ + sid: "ses_1", + payload: { + kind: "file_ref", + content_ref: reportPath, + path: reportPath, + size: Buffer.byteLength(content), + sha256: createHash("sha256").update(content).digest("hex"), + summary: content, + }, + }]) + }) + + it("keeps the inline settlement and captures nothing when the reply is not an existing path", async () => { + const events: TrackedEvent[] = [] + const captured: { sid: string; payload: unknown }[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events, undefined, captured) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + await Effect.runPromise( + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed(`Report written to ${path.join(os.tmpdir(), "dag-recovery-ghost.md")}`), + process.cwd(), + ).pipe(Effect.provide(dagLayer)), + ) + + expect(captured).toEqual([]) + const completed = events.find((event) => event.type === "nodeCompleted") + expect(completed?.output).toMatch(/^Report written to /) + }) +}) From 9ed10e7a659696049366dc76251ce3917ca1b90e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 15:06:04 +0800 Subject: [PATCH 07/27] fix(dag): close review gaps on duplication fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue #386: drive the acceptance chain past prompt construction — replay the submit_result durable write and assert spawn's completion gate settles the node with the payload as durable output - issue #387: dedicated compiler cases for duplicate-drop and the objective-plus-detail superset (exact, not fuzzy, equivalence) - issue #388: live-path file-ref capture asserted in lockstep with the recovery receipt (identical durable effects side-by-side) - drop an unnecessary non-null assertion and an unused binding; disable unsafe-assertion lint in the recovery mock harness (4851 -> 4846 on the merge ref, under the 4850 ratchet) --- packages/opencode/src/dag/blocks.ts | 10 ++-- packages/opencode/test/dag/blocks.test.ts | 29 ++++++++--- .../opencode/test/dag/dag-recovery.test.ts | 9 +++- .../dag/dag-schema-prompt-contract.test.ts | 41 ++++++++++++++-- .../test/dag/dag-wake-integration.test.ts | 49 +++++++++++++++++++ 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 83f4ea962..38bb0a272 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -335,9 +335,11 @@ function node(input: { // issue #387: an instruction equal to the objective (after trim and // line-ending normalization) would render the same content twice in the // single child prompt — the objective section already carries it, so the - // instruction is dropped instead of duplicated. + // instruction is dropped instead of duplicated. Equivalence is exact, not + // fuzzy: an instruction carrying the objective plus additional detail stays. const equivalent = (a: string, b: string) => a.trim().replace(/\r\n/g, "\n") === b.trim().replace(/\r\n/g, "\n") - const hasInstruction = input.instruction?.trim() && !equivalent(input.instruction, input.objective) + const instructionText = input.instruction?.trim() ?? "" + const hasInstruction = instructionText !== "" && !equivalent(instructionText, input.objective) const instruction = hasInstruction ? "Block-specific instruction:\n{{instruction}}" : "" // issue #323: a reporting checkpoint adjudicates a direction, so its // prompt must demand adversarial independent verification. The production @@ -366,7 +368,7 @@ function node(input: { .join("\n\n"), input: { objective: input.objective, - ...(hasInstruction ? { instruction: input.instruction!.trim() } : {}), + ...(hasInstruction ? { instruction: instructionText } : {}), }, }, ...(input.condition ? { condition: input.condition } : {}), @@ -527,7 +529,7 @@ function reviewWriterTopology(block: WorkflowBlock, blocks: WorkflowBlock[]): Re `Implementation review "${block.id}" requires exactly one verification ancestor; found ${verifications.length}`, ) } - const verification = verifications[0]! + const verification = verifications[0] const verifiedImplementations = implementations.filter((candidate) => dependsTransitively(blocks, verification.id, candidate.id), ) diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index 9eb430bb7..cf6e22011 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -455,16 +455,17 @@ describe("workflow blocks", () => { ], }), ).toThrow("depends on multiple review gates") + }) - // issue #387: an instruction that duplicates the objective (after - // trim/line-ending normalization) must not be emitted twice in the single - // child prompt — the objective section already carries the content. + // issue #387: an instruction that duplicates the objective (after + // trim/line-ending normalization) must not be emitted twice in the single + // child prompt — the objective section already carries the content. + it("drops an instruction that duplicates the objective (issue #387)", () => { const duplicated = DagBlocks.compileWorkflowBlocks({ objective: "Ship the memory feature", blocks: [{ id: "map", kind: "explore", instruction: "Ship the memory feature" }], }) - const inline = duplicated[0]?.prompt_template.inline ?? "" - expect(inline).not.toContain("Block-specific instruction") + expect(duplicated[0]?.prompt_template.inline).not.toContain("Block-specific instruction") expect(duplicated[0]?.prompt_template.input).not.toHaveProperty("instruction") const whitespaceEquivalent = DagBlocks.compileWorkflowBlocks({ @@ -473,8 +474,12 @@ describe("workflow blocks", () => { }) expect(whitespaceEquivalent[0]?.prompt_template.inline).not.toContain("Block-specific instruction") expect(whitespaceEquivalent[0]?.prompt_template.input).not.toHaveProperty("instruction") + }) - // A genuinely block-specific instruction stays, ordered after the objective. + // Equivalence is exact, not fuzzy: block-specific instructions survive — + // both a fully distinct one and one that carries the objective plus + // additional detail — ordered after the objective. + it("keeps block-specific instructions that extend the objective (issue #387)", () => { const distinct = DagBlocks.compileWorkflowBlocks({ objective: "Ship the memory feature", blocks: [{ id: "map", kind: "explore", instruction: "Focus on the persistence seam" }], @@ -482,6 +487,16 @@ describe("workflow blocks", () => { const distinctInline = distinct[0]?.prompt_template.inline ?? "" expect(distinctInline).toContain("Block-specific instruction:\n{{instruction}}") expect(distinct[0]?.prompt_template.input).toMatchObject({ instruction: "Focus on the persistence seam" }) - expect(distinctInline.indexOf("Workflow objective")).toBeLessThan(distinctInline.indexOf("Block-specific instruction")) + + const extended = DagBlocks.compileWorkflowBlocks({ + objective: "Ship the memory feature", + blocks: [{ id: "map", kind: "explore", instruction: "Ship the memory feature, then profile the persistence seam" }], + }) + const extendedInline = extended[0]?.prompt_template.inline ?? "" + expect(extendedInline).toContain("Block-specific instruction:\n{{instruction}}") + expect(extended[0]?.prompt_template.input).toMatchObject({ + instruction: "Ship the memory feature, then profile the persistence seam", + }) + expect(extendedInline.indexOf("Workflow objective")).toBeLessThan(extendedInline.indexOf("Block-specific instruction")) }) }) diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index e90575cc4..f829cc631 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -1,3 +1,6 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- mock dag +// layers and row fixtures use `as unknown as DagStore.Interface` shims that +// implement only the interface slice each scenario exercises. import { describe, expect, it, afterAll } from "bun:test" import { createHash } from "node:crypto" import fs from "node:fs/promises" @@ -532,7 +535,11 @@ describe("rehydration via toSchedulingNodes", () => { // issue #388: the live path captures {content_ref, size, sha256, summary} // when a schemaless node's final reply IS one existing absolute file path // (spawn.ts → output-ref.ts). Recovery must produce the same durable receipt -// for the same reply instead of diverging by crash timing. +// for the same reply instead of diverging by crash timing. Behavioral +// lockstep with the live path is asserted side-by-side in +// dag-wake-integration.test.ts "captures a file_ref receipt when a +// schemaless reply is one absolute path (issue #388)" — keep both green or +// neither ships. describe("reconcileWorkflow output file refs (issue #388)", () => { it("captures the same file_ref receipt as the live path for an absolute-path reply", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dag-recovery-ref-")) diff --git a/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts index d7884ec8c..24eb8b72b 100644 --- a/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts +++ b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts @@ -33,11 +33,9 @@ import { SessionPrompt } from "@/session/prompt" import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" -import { testEffect } from "../lib/effect" +import { pollWithTimeout } from "../lib/effect" import { withIdleAdmission } from "../lib/session-prompt" -const integration = testEffect(Layer.empty) - interface PromptGate { readonly input: SessionPrompt.PromptInput readonly release: Deferred.Deferred @@ -130,6 +128,7 @@ function contractLayer(childPrompts: Queue.Queue) { function runContractTest(test: (services: { readonly dag: Dag.Interface readonly loop: DagLoop.Interface + readonly store: DagStore.Interface readonly childPrompts: Queue.Queue }) => Effect.Effect) { return Effect.gen(function* () { @@ -137,6 +136,7 @@ function runContractTest(test: (services: { return yield* Effect.gen(function* () { const dag = yield* Dag.Service const loop = yield* DagLoop.Service + const store = yield* DagStore.Service const database = yield* Database.Service yield* database.db.insert(ProjectTable).values({ id: "project-1" as never, @@ -152,7 +152,7 @@ function runContractTest(test: (services: { version: "test", }).run().pipe(Effect.orDie) yield* loop.init() - return yield* test({ dag, loop, childPrompts }) + return yield* test({ dag, loop, store, childPrompts }) }).pipe( Effect.provide(contractLayer(childPrompts)), Effect.provideService(InstanceRef, { @@ -198,6 +198,39 @@ describe("DAG schema prompt contract (issue #386)", () => { ) }) + // The issue-#386 acceptance chain does not stop at prompt construction: + // the child submits through submit_result, the capture lands durably, and + // the node settles with the payload as its durable output — prose plays no + // part in settlement. The gate replays exactly the durable write the real + // tool performs (store.setCapturedOutput); spawn's completion gate + // (settleCapturedOutput) runs unmocked below it. + it("settles the node from the submit_result payload as the durable output", async () => { + await Effect.runPromise( + runContractTest(({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Schema prompt contract", + config: { name: "schema-prompt-contract", nodes: [schemaNode()] }, + }) + const gate = yield* Queue.take(childPrompts) + const payload = { summary: "Delivered through submit_result only." } + yield* store.setCapturedOutput(gate.input.sessionID as string, payload) + // The contract-compliant reply: no payload duplication in prose. + yield* Deferred.succeed(gate.release, "Submitted.") + const node = yield* pollWithTimeout( + store.getNode(dagID, "report").pipe( + Effect.map((row) => row?.status === "completed" ? row : undefined), + ), + "schema node did not complete from the submitted payload", + ) + expect(node.output).toEqual(payload) + }), + ), + ) + }) + it("submit_result tool description carries the same single-authority contract", async () => { const description = await Bun.file( path.join(import.meta.dir, "../../src/tool/submit_result.txt"), diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 560e48e45..c41dc0091 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test" +import { createHash } from "node:crypto" import * as fs from "node:fs/promises" +import * as os from "node:os" import * as path from "node:path" import { Deferred, Effect, Fiber, Layer, Option, Queue } from "effect" import type { SessionV1 } from "@opencode-ai/core/v1/session" @@ -559,6 +561,53 @@ describe("DagLoop atomic wake integration", () => { ) }) + // issue #388 live path: when a schemaless node's final reply IS one + // existing absolute file path, submit-time detection records the durable + // {content_ref, size, sha256, summary} receipt while the settlement stays + // the raw path. Keep in lockstep with dag-recovery.test.ts + // "reconcileWorkflow output file refs (issue #388)" — live and recovery + // must produce identical durable effects for the same reply. + it("captures a file_ref receipt when a schemaless reply is one absolute path (issue #388)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dag-live-ref-")) + const reportPath = path.join(dir, "report.md") + const content = "live report body" + await fs.writeFile(reportPath, content) + try { + await Effect.runPromise( + runWakeTest(({ dag, store, childPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "File-ref live capture", + config: { name: "file-ref-live-capture", nodes: [node("file-report")] }, + }) + + const report = yield* takeWithin(childPrompts, "file-report did not start") + yield* Deferred.succeed(report.release, reportPath) + const row = yield* pollWithTimeout( + store.getNode(dagID, "file-report").pipe( + Effect.map((item) => item?.status === "completed" ? item : undefined), + ), + "file-ref node did not complete", + ) + expect(row.output).toBe(reportPath) + expect(row.capturedOutput).toEqual({ + kind: "file_ref", + content_ref: reportPath, + path: reportPath, + size: Buffer.byteLength(content), + sha256: createHash("sha256").update(content).digest("hex"), + summary: content, + }) + }), + ), + ) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + integration.live("runs an additive wave after a terminal checkpoint wake", () => runWakeTest(({ dag, store, childPrompts, parentPrompts }) => Effect.gen(function* () { From 4f4d9df7eec14ac63aa98d1182427f383e506e51 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 15:06:57 +0800 Subject: [PATCH 08/27] chore(dag): record pr binding for delivery 386-388 --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 41db33230..39ba8f3ba 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -7,3 +7,4 @@ issues: - 386 - 387 - 388 +pr: 390 From d9975be1eccf1bd823cf6a32b7a1e83bc786b320 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 15:44:31 +0800 Subject: [PATCH 09/27] test(dag): cover recovery persistence-failure and reader paths - output-ref persistence failure completes inline (best-effort contract) - makeLastAssistantTextReader: last assistant text + missing-session tolerance, restoring the 95% recovery.ts coverage floor (93.88% -> 99.49%) --- .../opencode/test/dag/dag-recovery.test.ts | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index f829cc631..a24275bf0 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -7,7 +7,8 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { Effect, Exit, Layer } from "effect" -import { reconcileWorkflow } from "@/dag/runtime/recovery" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { reconcileWorkflow, makeLastAssistantTextReader } from "@/dag/runtime/recovery" import { Dag } from "@/dag/dag" import type { DagStore } from "@opencode-ai/core/dag/store" import { WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" @@ -33,13 +34,16 @@ function makeDagLayer( trackedEvents: TrackedEvent[], actions?: string[], capturedWrites?: { sid: string; payload: unknown }[], + opts?: { capturedFail?: boolean }, ) { return Layer.mock(Dag.Service, { store: { getNodes: () => Effect.succeed(nodes), getNode: (id: string) => Effect.succeed(nodes.find((n) => n.id === id)), setCapturedOutput: (sid: string, payload: unknown) => - Effect.sync(() => capturedWrites?.push({ sid, payload })), + opts?.capturedFail + ? Effect.fail(new Error("captured output persistence boom")) + : Effect.sync(() => capturedWrites?.push({ sid, payload })), } as unknown as DagStore.Interface, nodeCompleted: Effect.fn("stub.nodeCompleted")((dagID: string, nodeID: string, output: unknown) => Effect.sync(() => trackedEvents.push({ @@ -601,4 +605,63 @@ describe("reconcileWorkflow output file refs (issue #388)", () => { const completed = events.find((event) => event.type === "nodeCompleted") expect(completed?.output).toMatch(/^Report written to /) }) + + // #388 best-effort contract: a captured-output persistence failure logs a + // warning and NEVER fails the node — the inline completion survives. + it("completes inline even when output-ref persistence fails (issue #388)", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "dag-recovery-refboom-")) + tmpRoots.push(dir) + const reportPath = path.join(dir, "report.md") + await Bun.write(reportPath, "doomed receipt") + const events: TrackedEvent[] = [] + const nodes = [makeNodeRow({ id: "n1", status: "running", childSessionId: "ses_1" })] + const dagLayer = makeDagLayer(nodes, events, undefined, undefined, { capturedFail: true }) + const checkStatus = () => Effect.succeed<"active" | "completed" | "failed" | "unknown">("completed") + + await Effect.runPromise( + reconcileWorkflow( + "wf-1", + checkStatus, + undefined, + { nodes: [{ id: "n1" }] }, + () => Effect.succeed(reportPath), + dir, + ).pipe(Effect.provide(dagLayer)), + ) + + expect(events).toContainEqual({ type: "nodeCompleted", nodeID: "n1", output: reportPath }) + expect(events).not.toContainEqual({ type: "nodeFailed", nodeID: "n1" }) + }) +}) + +// #345: the schemaless completion mirror — recovery reads the child's last +// assistant text exactly as spawn settles it. Direct reader contract. +describe("makeLastAssistantTextReader (#345)", () => { + function assistantText(text: string): SessionV1.WithParts { + return { + info: { + id: "m1", + role: "assistant", + sessionID: "ses_1", + time: { created: 0 }, + agent: "build", + model: { providerID: "p", modelID: "m" }, + }, + parts: [{ type: "text", text }], + } as never + } + + it("returns the last assistant text part from the child transcript", async () => { + const reader = makeLastAssistantTextReader({ + messages: () => Effect.succeed([assistantText("attempt one"), assistantText("final verdict: GO")]), + } as never) + expect(await Effect.runPromise(reader("ses_1"))).toBe("final verdict: GO") + }) + + it("treats a missing child session as no text instead of failing recovery", async () => { + const reader = makeLastAssistantTextReader({ + messages: () => Effect.fail({ _tag: "NotFoundError", message: "session gone" } as never), + } as never) + expect(await Effect.runPromise(reader("ses_ghost"))).toBeUndefined() + }) }) From 2d3623c575dc6f1c67b477e9c7ba72f3a0ddaca0 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:29:08 +0800 Subject: [PATCH 10/27] refactor(dag): reduce /dag-auto to pure workflow routing, retire /dag-init /dag-flow /dag-template-update --- AGENTS.md | 2 +- README.md | 8 +- README.zh.md | 6 +- packages/core/src/plugin/command.ts | 24 +--- packages/core/src/plugin/command/dag-auto.txt | 100 ++++---------- packages/core/src/plugin/command/dag-flow.txt | 32 ----- packages/core/src/plugin/command/dag-init.txt | 99 -------------- .../plugin/command/dag-template-update.txt | 127 ------------------ packages/core/src/plugin/command/workflow.md | 4 +- packages/core/test/plugin/command.test.ts | 75 ++++++----- .../opencode/script/validate-dag-templates.ts | 3 +- packages/opencode/src/command/index.ts | 24 ---- .../opencode/test/command/command.test.ts | 91 ++++--------- .../src/feature-plugins/home/tips-view.tsx | 2 +- .../feature-plugins/system/dag-inspector.tsx | 2 +- 15 files changed, 111 insertions(+), 488 deletions(-) delete mode 100644 packages/core/src/plugin/command/dag-flow.txt delete mode 100644 packages/core/src/plugin/command/dag-init.txt delete mode 100644 packages/core/src/plugin/command/dag-template-update.txt diff --git a/AGENTS.md b/AGENTS.md index a8dd70ee6..1467e4c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,7 +215,7 @@ This repository owns the DAG schema, compiler, validator, runtime, and release i ## DAG command family -- Built-in commands ship compiled into the binary: `/dag-flow` (resident orchestration router), `/dag-init` (platform handshake → writes `.opencode/dag-init.json`), `/dag-auto` (six-block ultra-flow driver), `/dag-template-update` (template refresh without git). User command files shadow built-ins by name; register new built-ins through `packages/core/src/plugin/command.ts` + `packages/opencode/src/command/index.ts` (`Default` registry). +- Built-in commands ship compiled into the binary: `/dag-auto` (requirement → workflow routing: classify, match a saved DAG route, retarget, validate, start). Platform delivery (issues, PRs, CI, merge, release) is specgit's job — never part of `/dag-*`. User command files shadow built-ins by name; register new built-ins through `packages/core/src/plugin/command.ts` + `packages/opencode/src/command/index.ts` (`Default` registry). - Templates come from `opencode-dag-config`: 7 domains × `full`/`lite` plus cross-domain routes (`ultra-flow-route`, `release-route`). Precedence: project `.opencode/workflows/` > global config dir > builtin snapshot (the release pipeline compiles the config repo into the binary via `DAG_TEMPLATES_DIR`). - `dag.jsonc` supplies DAG node model tiers: `advanced` for `required: true` and review nodes, `standard` otherwise. Never pin `model` inside saved workflow specs. diff --git a/README.md b/README.md index 028106958..9fb28054d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Three terms worth knowing: - Composable blocks (`explore`, `plan`, `prototype`, `debug`, `coding`, `verify`, `review`, `synthesize`) compile into the node graph; low-level node fields remain available for anything blocks cannot express. - `workflow(action="draft")` renders a structured graph through the tool schema into a validated YAML spec — field-name mistakes are rejected by the provider, not discovered at validation time. -- Saved workflow libraries at three scopes (project / global / builtin), startable by name; the `/dag-flow` command picks a curated reference topology and retargets it to the task at hand. +- Saved workflow libraries at three scopes (project / global / builtin), startable by name; the `/dag-auto` command routes a requirement to a curated reference topology and retargets it to the task at hand. - Model tiers in `dag.jsonc` separate decisions from volume: critical nodes on the `advanced` model, fan-out work on `standard`. **Reliability** @@ -67,7 +67,7 @@ Three terms worth knowing: ## Using workflows Nothing has to be configured to try it: ask for work that has stages, parallel -parts, or a review gate in the middle (`/dag-flow `), and the agent +parts, or a review gate in the middle (`/dag-auto `), and the agent designs a graph and runs it. Three things turn that into a repeatable setup of your own. @@ -108,8 +108,8 @@ directory and it gains a **name**: Resolution takes the first match in that order, so a project file shadows a global one with the same name, and both shadow the builtin tier. The global scope is maintained by the [`opencode-dag-config`](https://github.com/LeXwDeX/opencode-dag-config) -repository; the `/dag-template-update` command syncs it (preview of -new/changed/unchanged files, backup before overwrite, QA decision gate). A minimal spec: +repository (sync it with a plain `git clone`/`git pull` into your config +dir). A minimal spec: ```yaml title: Dependency audit diff --git a/README.zh.md b/README.zh.md index babc3017b..02e40fc1e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -37,7 +37,7 @@ GraphAgent 是本项目对外的产品名;仓库以 **OpenCode-GraphAgent** - 可组合块(`explore`、`plan`、`prototype`、`debug`、`coding`、`verify`、`review`、`synthesize`)编译成节点图;块表达不了的还有低级节点字段兜底。 - `workflow(action="draft")` 通过工具参数传结构化图,harness 渲染并校验出 YAML spec——字段写错在 provider 侧就被拒,不用等到校验才发现。 -- 三级作用域的工作流库(项目 / 全局 / 内嵌),按名字启动;`/dag-flow` 命令挑选合适的参考拓扑并注入当前任务。 +- 三级作用域的工作流库(项目 / 全局 / 内嵌),按名字启动;`/dag-auto` 命令把需求路由到合适的参考拓扑并注入当前任务。 - `dag.jsonc` 的模型分层把决策和跑量分开:关键节点用 `advanced` 模型,扇出跑量用 `standard`。 **可靠性** @@ -60,7 +60,7 @@ GraphAgent 是本项目对外的产品名;仓库以 **OpenCode-GraphAgent** ## 工作流怎么用 -不配置也能直接试:给它一件有阶段、有可并行部分、或者中间需要一道审查门禁的活(`/dag-flow <任务>`),智能体自己会建图并跑起来。想把它变成你自己的一套固定流程,有三件事: +不配置也能直接试:给它一件有阶段、有可并行部分、或者中间需要一道审查门禁的活(`/dag-auto <任务>`),智能体自己会建图并跑起来。想把它变成你自己的一套固定流程,有三件事: ### 1. 选定模型分层 —— `.opencode/dag.jsonc` @@ -90,7 +90,7 @@ GraphAgent 是本项目对外的产品名;仓库以 **OpenCode-GraphAgent** | 全局级 | `/workflows/.yaml` | 本机所有项目 | | 内嵌级 | 编译进正式版二进制 | 每个正式版安装——兜底解析层 | -解析按此顺序取第一个命中的名字:项目级遮蔽同名的全局级,二者都遮蔽内嵌级。全局作用域由 [`opencode-dag-config`](https://github.com/LeXwDeX/opencode-dag-config) 仓库维护,`/dag-template-update` 命令负责同步(预览新增/变更/不变清单,覆盖前备份,QA 决策门禁)。一个最小的 spec: +解析按此顺序取第一个命中的名字:项目级遮蔽同名的全局级,二者都遮蔽内嵌级。全局作用域由 [`opencode-dag-config`](https://github.com/LeXwDeX/opencode-dag-config) 仓库维护(直接 `git clone`/`git pull` 到配置目录即可同步)。一个最小的 spec: ```yaml title: Dependency audit diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 653ed3e89..3d57799ab 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -7,9 +7,6 @@ import { Effect } from "effect" import { Location } from "../location" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" -import DAG_FLOW_PROMPT from "./command/dag-flow.txt" -import DAG_TEMPLATE_UPDATE_PROMPT from "./command/dag-template-update.txt" -import DAG_INIT_PROMPT from "./command/dag-init.txt" import DAG_AUTO_PROMPT from "./command/dag-auto.txt" import workflowRouting from "./command/workflow-routing.md" with { type: "text" } import workflowBlocks from "./command/workflow-blocks.md" with { type: "text" } @@ -17,20 +14,13 @@ import workflowContent from "./command/workflow.md" with { type: "text" } import orchestrationPolicy from "./command/orchestration-policy.md" with { type: "text" } import orchestrationDomains from "./command/orchestration-domains.md" with { type: "text" } -export const DagFlowDescription = "Start a dependency-graph multi-agent workflow for the supplied task" -export const DagTemplateUpdateDescription = "Update the global DAG reference templates from opencode-dag-config" -export const DagInitDescription = - "Connect this repo to GitHub/GitLab, verify issue/PR permissions, and prepare everything /dag-auto needs" export const DagAutoDescription = - "Finish it: drive the composed ultra-flow (exploration → design → development → acceptance → release → summary) to completion" + "Route a requirement to a composed DAG workflow: classify, match a saved route, retarget, validate, start" export const WorkflowFactsContent = workflowContent export const WorkflowBlocksContent = workflowBlocks export const OrchestrationPolicyContent = orchestrationPolicy export const OrchestrationDomainsContent = orchestrationDomains export const WorkflowContent = workflowRouting -export const DagFlowContent = DAG_FLOW_PROMPT -export const DagTemplateUpdateContent = DAG_TEMPLATE_UPDATE_PROMPT -export const DagInitContent = DAG_INIT_PROMPT export const DagAutoContent = DAG_AUTO_PROMPT export const Plugin = define({ @@ -47,18 +37,6 @@ export const Plugin = define({ command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) - draft.update("dag-flow", (command) => { - command.template = DagFlowContent - command.description = DagFlowDescription - }) - draft.update("dag-template-update", (command) => { - command.template = DAG_TEMPLATE_UPDATE_PROMPT - command.description = DagTemplateUpdateDescription - }) - draft.update("dag-init", (command) => { - command.template = DagInitContent - command.description = DagInitDescription - }) draft.update("dag-auto", (command) => { command.template = DagAutoContent command.description = DagAutoDescription diff --git a/packages/core/src/plugin/command/dag-auto.txt b/packages/core/src/plugin/command/dag-auto.txt index 31c9ab540..7ed1df3da 100644 --- a/packages/core/src/plugin/command/dag-auto.txt +++ b/packages/core/src/plugin/command/dag-auto.txt @@ -1,26 +1,12 @@ -You are running `/dag-auto`. Its essence: the user says "finish this thing" -once, and you drive a composed ultra-flow to completion — all the way from -exploration through design, development, test and acceptance, build and -release, to the summary, without the user ever typing "continue". The flow -is audited and debugged BY DESIGN: direction checkpoints run between every -two blocks, repair goes through bounded replan, and delivery counts only -when CI is green and the ordered merge lands. - -**The stage classifier is a methodology embedded in this command, not a -skill or external router.** Three disciplines: (1) classify the disposition -of the current state before acting, (2) template-first — match a saved DAG -route before inventing a graph, (3) routing decisions stay in this parent -conversation; children receive concrete work. You apply these yourself at -every boundary wake. +You are running `/dag-auto`: the routing and workflow-composition driver of +the `/dag-*` command family. Its essence: classify the request, match a +saved DAG route, retarget it to the real task, validate, and start it. +Routing decisions stay in this parent conversation; children receive +concrete work. That is ALL this command does — it never touches platform +delivery: no issues, no PRs, no CI watching, no merge or release mechanics. Arguments: $ARGUMENTS -## Phase 0 — Gate - -Read `.opencode/dag-init.json`. Missing → STOP and tell the user to run -`/dag-init` first: auto cannot execute without a verified platform -connection, issue/PR permissions, and template availability. - ## Phase 1 — Entry classification Classify the request; never ask the user to pick a route: @@ -31,41 +17,32 @@ Classify the request; never ask the user to pick a route: `stage-release` (+ its checkpoint) when the project has no release mechanics, write the retargeted YAML to `.opencode/.dag-specs/.yaml`, then `workflow(action="validate")` and `workflow(action="start")`. -- **Narrow single-block request** ("review this PR", "grill this plan", +- **Narrow single-block request** ("review this diff", "grill this plan", "decide X", "只做设计阶段") → run ONLY the matching domain route (`product-planning`, `technical-design`, `project-development`, `code-review`, `debug-repair`, `security-audit`, `performance-audit` — full/lite by risk) or the single ultra-flow stage, and end on its report. Template-first for every route: `workflow(action="list")` → `read` → -retarget → start; create from scratch only when nothing fits. +retarget → validate → start; create from scratch only when nothing fits. -## Phase 2 — Driving the ultra-flow +## Phase 2 — Driving the graph **Auto contract.** Drive each stage/checkpoint wake to completion and advance automatically. Do not pause to ask "shall I continue". The ONLY -interruptions allowed are user-owned decisions: the spec confirmation gate, -a product decision checkpoint, the release human gate, a merge gate the -rulesets reserve for a human, or a cap exceeded (retries / replan loops). -Everything else advances on its own. +interruptions allowed are user-owned decisions (the product decision +checkpoint below) or a cap exceeded (retries / replan loops). Everything +else advances on its own. **Checkpoint wakes.** Every checkpoint node carries a verdict `{verdict: continue|replan, findings, target?}` reporting direction correctness: - `continue` → do nothing; the graph already advances by itself. - `replan` → use the findings + target to add correction nodes via - `workflow(action="control", operation="replan")` (never restart the - ultra-flow from scratch). The loop is bounded: at most **3 back-edges**; - on the third, stop and hand the user ONE decision point with the complete - state (findings, attempts, diffs). - -**Spec confirmation gate.** Before the design stage creates the platform -issue, present the spec draft (title + body) in chat and wait for ONE -confirmation; create the issue only after it. Spec content = issue body -(label `dag-spec`), the issue IS the atom — no X.Y task fragmentation. -Multiple specs in one plan → one tracking issue listing the spec issues IN -PLAN ORDER (that list is the merge order contract). Skip the gate only when -the user said `full-auto`. + `workflow(action="control", operation="replan")` (never restart the flow + from scratch). The loop is bounded: at most **3 back-edges**; on the + third, stop and hand the user ONE decision point with the complete state + (findings, attempts, diffs). **Product decision checkpoint.** Force a user decision when the flow hits: a new external dependency, a breaking change, a public API change, or an @@ -74,45 +51,18 @@ materially changes behavior, scope, or acceptance. Present the recommended answer and wait for one combined confirmation; write the result into the retargeted objective/instructions, never into child prompts. -## Phase 3 — Platform delivery rules +## Phase 3 — Completion report -1. **Auto CI/CD + TDD watching.** After push, run `gh pr checks --watch - --fail-fast` as a background task so the session wakes when checks - settle. TDD evidence is double: the development stage ran behavior checks - at public seams locally, remote CI re-runs them. CI failure → scoped - repair pass, re-push; cap 3 auto-fix retries, then hand back with failing - checks and logs. -2. **Ordered merge.** Strictly in plan order: PR-N merges only after PR-(N-1) - is confirmed merged. Before each merge gate, rebase the branch on the - advanced base, push, and re-watch CI. Merge acceptance = CI green + - review verdict, never "checkbox done". Respect rulesets from - `/dag-init`; when merge requires a human actor or approval, stop at the - gate and say exactly what to click. -3. **Release human gate.** The release stage defaults to HOLD: execute the - publish only after the user confirms the release brief (mechanism, - version derived from latest tag + commit types, changelog). Argument - `release-auto` overrides. -4. **Monitoring scope.** Nothing watches spec files or task boxes - mid-process. Watched milestones only: issue-closed, PR-merged, CI checks. -5. **Remote truth.** Single source of truth is the platform: every re-entry - reconciles from `gh issue view` / `gh pr list --state all --json` / - `gh pr checks`. Interrupted runs resume by re-querying — the ultra-flow - survives session restarts by finding its stage from remote state plus the - durable workflow graph. - -## Phase 4 — Completion report - -End with ONE consolidated summary: stages executed (incl. skipped with -reason), checkpoint verdicts and replan passes used, issue number(s), PR -URL(s), CI status, merge position in the plan, release outcome (or -documented skip), and any gate waiting on the human. +End with ONE consolidated summary: the route chosen (saved template name or +task-local graph), stages executed (incl. skipped with reason), checkpoint +verdicts and replan passes used, the exact Workflow ID, and any gate +waiting on the user. ## Rules -- Never create issues/PRs before the `/dag-init` config is verified. -- Never write workflow state to local files; local persistence is only the - retargeted spec YAML under `.opencode/.dag-specs/` and - `.opencode/dag-init.json`. - Routing decisions live in this conversation; child nodes get concrete work, not routing questions. -- Supported platforms: GitHub and GitLab (self-hosted included) only. +- Never write workflow state to local files; local persistence is only the + retargeted spec YAML under `.opencode/.dag-specs/`. +- Platform delivery (issues, PRs, CI, merge, release) is out of scope for + this command; if the user asks for it, name the boundary and stop. diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt deleted file mode 100644 index 595f1f202..000000000 --- a/packages/core/src/plugin/command/dag-flow.txt +++ /dev/null @@ -1,32 +0,0 @@ -# Start a DAG Workflow - - -$ARGUMENTS - - -If the task is empty or contains only whitespace, ask for it; do not start a workflow. -Otherwise apply the resident Orchestration Router and route the -request through one consolidated graph. `/dag-flow` explicitly selects DAG -execution; the router still owns any material Decision Checkpoint. - -Apply the Router's selected reference or fresh-block path. Preserve the task, -user constraints, named roles, read-only limits, acceptance checks, and -confirmed decisions in the objective and block instructions, then pass the -task-local YAML file's `spec_path`. - -Validate the YAML path, then call the workflow tool with `action=start` in the -first response after the route is ready. Printing a plan or YAML does not start it. Never invent -worker types or model IDs. If a configured capability or model is unavailable, -report the actual gap and leave the workflow uncreated. - -On success, report the exact Workflow ID and initial state, tell the user they -can run `/dag` for live inspection, and end the response. The workflow wakes -the parent when attention is needed. Do not poll, sleep, or loop to wait. On -failure, state that it did not start and report the real error; do not invent a -replacement run. - -A final synthesis block must contain the requested result rather than a plan or -placeholder. If its wake message says `truncated=true`, the parent reads every -page with `workflow(action="result")` before verification. The parent verifies -that complete artifact, disposes of any non-ACCEPT review verdict, and gives the -user one final report. diff --git a/packages/core/src/plugin/command/dag-init.txt b/packages/core/src/plugin/command/dag-init.txt deleted file mode 100644 index 46e58de17..000000000 --- a/packages/core/src/plugin/command/dag-init.txt +++ /dev/null @@ -1,99 +0,0 @@ -You are running `/dag-init`: the platform handshake and readiness initializer -for the `/dag-*` command family. `/dag-auto` binds workflow specs to issues, -PRs, and CI — without a configured GitHub or GitLab remote it cannot work. -Your job is to verify that binding is possible AND that the auto pipeline has -everything it needs, then record the connection. - -Arguments (optional): $ARGUMENTS - -## Steps - -1. **Detect the platform from the git remote.** - - Run `git remote get-url origin`. Supported platforms are ONLY GitHub and - GitLab (including self-hosted/private GitLab). - - Not a git repo, or no `origin` → STOP: "`/dag-*` requires a git remote." - - Host is `github.com` → platform `github`, CLI `gh`. - - Host is `gitlab.com` → platform `gitlab`, CLI `glab`. - - Any other host → decide whether it is a self-hosted GitLab. GitLab's - `/api/v4/version` endpoint REQUIRES authentication by API design, so a - healthy instance answers a bare request with 401 — that 401 is POSITIVE - evidence (the endpoint exists and answers), not a failure. Probe order: - `curl -sSf https:///api/v4/version`; a version JSON response OR a - 401/GitLab-shaped error → classify as GitLab; connection refused or a - non-GitLab answer → STOP: "unsupported platform: only GitHub and GitLab - (self-hosted included) are supported." Bitbucket/Gitea/other remotes are - rejected here. Once classified, re-verify with `glab api version` after - step 2 auth passes. Platform `gitlab` (self-hosted), CLI `glab` pinned - to that host. - - When `origin` and a different `upstream` exist and point at different - repositories, ask the user ONCE which remote `/dag-auto` should bind to - and record the choice; otherwise bind `origin`. - -2. **Verify CLI and auth.** - - `gh auth status` (or `glab auth status`) must report the CLI installed and - authenticated against the detected host. If not, stop and tell the user - exactly what to install/login. - -3. **Probe permissions and capabilities.** For GitHub: - - - `gh api repos/{owner}/{repo}` → `default_branch` and `permissions.push`. - Push permission is required (gates branch push and PR creation; issue - creation rides with it). - - `gh issue list --limit 1 --json number` → issue read access. - - `gh api repos/{owner}/{repo}/actions/workflows` → CI presence. Zero - means WARN: the CI/CD + TDD watch step of `/dag-auto` has nothing to - monitor remotely. - - `gh api repos/{owner}/{repo}/rulesets` → record whether branch - protection/rulesets exist. They constrain the auto merge step (required - checks, merge actor); auto must respect them, so knowing is required. - - For GitLab use the `glab` equivalents (`glab repo view`, - `glab issue list --per-page 1`; CI = `.gitlab-ci.yml` present; protection - rules via `glab api projects/:id/protected_branches`). - -4. **Check template availability.** `/dag-auto` dispatches into saved DAG - route templates. Templates ship three ways, in precedence order: project - `.opencode/workflows/`, global `/workflows/`, and the builtin - templates compiled into the release. If `workflow(action="list")` returns - no templates at all, WARN with the install command: - `git clone git@github.com:LeXwDeX/opencode-dag-config.git ~/.config/opencode/workflows` - -5. **Write the connection config — only if every required check passed.** - - Write `.opencode/dag-init.json` in the project root: - - ```json - { - "platform": "github", - "repo": "owner/name", - "remote": "origin", - "base_branch": "main", - "cli": "gh", - "can_push": true, - "has_ci": true, - "has_rulesets": true, - "has_templates": true, - "merge_policy": "ordered", - "checked_at": "" - } - ``` - - If the file already exists, re-run the probes and refresh it (the command - is idempotent). This file is the ONLY local state the `/dag-*` family - keeps — connection config, never workflow state. It is safe to commit. - -6. **Report.** Print a checklist table: platform, auth, issue access, push - permission, CI presence, rulesets, template availability, config path - written. WARN rows do not block writing the config but must name which - `/dag-auto` capability they degrade. If any REQUIRED check failed, print - which one and STOP — do not write the config. - -## Rules - -- Never create throwaway issues/PRs as probes; permission checks are - read-only API calls. -- Never store tokens in the config file — auth lives in `gh`/`glab`. -- Do not proceed past a failed required check; partial handshake state is - worse than none. diff --git a/packages/core/src/plugin/command/dag-template-update.txt b/packages/core/src/plugin/command/dag-template-update.txt deleted file mode 100644 index 87d1fc985..000000000 --- a/packages/core/src/plugin/command/dag-template-update.txt +++ /dev/null @@ -1,127 +0,0 @@ -# Update Global DAG Reference Templates - -The user invoked `/dag-template-update` to update the global DAG reference -templates. These templates live in the opencode config directory (the same -trust level as `dag.jsonc`) and are curated by the `opencode-dag-config` -repository — shared across projects as the fallback scope, with project-level -`.opencode/workflows/` overrides taking precedence. - -The update downloads the repository archive (zip) — no git install, no SSH -key, and the target directory does not need to be a git repository. - - -$ARGUMENTS - - -## Determine the config directory - -The global workflow library directory is `/workflows`: - -1. If the `OPENCODE_CONFIG_DIR` environment variable is set, the config - directory is its value. -2. Otherwise resolve the platform config directory for opencode from the - environment: default `~/.config/opencode/` on macOS/Linux, but respect - `XDG_CONFIG_HOME` when set (runtime resolution follows the same order). - -## Download the templates - -Download the archive from the pinned repository URL (fixed — if the user -wants a different source they must say so explicitly): - -``` -https://codeload.github.com/LeXwDeX/opencode-dag-config/zip/refs/heads/main -``` - -Extract it into a temporary directory. The archive contains a top-level folder -(typically `opencode-dag-config-main/`) whose root holds the `*.yaml` and `*.yml` -templates. - -## Dry-run preview (always show before applying) - -Compare the extracted templates against the current -`/workflows/` and classify every template: - -- `NEW` — exists in the archive, not present locally -- `UNCHANGED` — same filename, identical content (skip) -- `UPDATE` — same filename, different content (needs overwrite) -- local-only files (present locally, absent from the archive) are kept as-is - -Show the user the three lists, or report that nothing needs updating. - -## Validate downloaded templates (fail closed, before any replacement) - -Before any copy or overwrite, discover and validate EVERY extracted `*.yaml` and `*.yml` template with -the same validation authority `start` and `list` use — the workflow tool's -`validate` action. For each extracted template call: - -``` -workflow(action: "validate", spec_path: "", profile: "portable") -``` - -- Every template must come back `valid: true`. -- If both `.yaml` and `.yml` exist, abort before applying anything; - one logical workflow name cannot have two source files. -- If ANY template is invalid: keep the current global library exactly as it - is — copy nothing, overwrite nothing. Report a per-file diagnostic list - (code, path, message, hint) for every failing template plus the names that - passed, and stop. Treat validation failure like a download failure: never - partially apply. -- Use the portable profile: the global library doubles as the distributable - builtin source, so a template that only works inside one specific project - does not belong here. - -## Merge - -- If there are no `UPDATE` entries: merge directly — copy `NEW` templates in, - skip `UNCHANGED`, leave local-only files untouched. -- If `UPDATE` entries exist, do not overwrite silently. Ask the user how to - proceed (QA): - - overwrite all updates (backup first) - - skip all updates, only add `NEW` templates - - decide per file -- If the user declines or cannot decide, only add `NEW` templates and report - the skipped updates. -- Before any overwrite, back up the local file next to the original with a - timestamped suffix (e.g. `.yaml.bak-`). If the backup - fails (read-only directory, disk full, permissions), abort the overwrite of - that file and report the error — never overwrite without a backup. - -## Concurrency lock - -Another session may be updating the same directory. Take an exclusive lock on -`/workflows` before downloading or merging: - -- Create the lock with `mkdir /workflows/.dag-update.lock` (mkdir - is atomic — if it fails because the directory exists, another update is in - progress). -- If the lock is held, wait briefly and retry a few times; if it is still held, - report that an update is already running and stop. -- Remove the lock (`rmdir /workflows/.dag-update.lock`) after the - merge finishes, including on failure. - -## Verify - -After applying, confirm the update actually landed by comparing file contents, -not just the workflow library listing: - -- Re-read each updated file from `/workflows/` and compare its - content to the extracted archive copy — they must match. -- Run `workflow(action: "list")` and report the resulting template count plus - the names that changed (added / updated / skipped). Note that a project-level - template with the same name shadows the global one in the listing. -- Report the backup locations when any file was overwritten. - -## Failure handling - -- Download failure (network, 404, rate limit): report the actual error - verbatim and stop — never invent success. -- Extraction failure (corrupt archive): report and stop. -- Validation failure (any template invalid): report per-file diagnostics and - stop; the existing library stays untouched. -- If `/workflows` does not exist, create it before applying. - -## Notes - -- Project-level templates (`.opencode/workflows/`) override global ones with - the same name — a user may see no change for a name the project already - shadows. Mention this when relevant. diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index 6afcc414b..ddb6ef955 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -1,5 +1,5 @@ @@ -575,4 +575,4 @@ omitted content from its preview. - No `node_complete` action — completion is automatic - No `history` action — inspect a known workflow with `status`; browsing running workflows remains TUI-only (`list` shows saved specs, not running workflows) -- No runtime-side magical topology selection — `/dag-flow` selects and adapts saved reference graphs in the parent agent; the workflow runtime executes the resulting validated spec +- No runtime-side magical topology selection — the routing command (`/dag-auto`) selects and adapts saved reference graphs in the parent agent; the workflow runtime executes the resulting validated spec diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 2cd3f05bc..93ef984fd 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -43,28 +43,6 @@ describe("CommandPlugin.Plugin", () => { description: "review changes [commit|branch|pr], defaults to uncommitted", subtask: true, }) - expect(yield* command.get("dag-flow")).toMatchObject({ - name: "dag-flow", - description: CommandPlugin.DagFlowDescription, - template: CommandPlugin.DagFlowContent, - }) - expect(CommandPlugin.DagFlowContent).toContain("$ARGUMENTS") - expect(CommandPlugin.DagFlowContent).toContain("`action=start`") - expect(CommandPlugin.DagFlowContent).toContain("exact Workflow ID") - expect(CommandPlugin.DagFlowContent).toContain("run `/dag`") - expect(CommandPlugin.DagFlowContent).toContain("resident Orchestration Router") - expect(CommandPlugin.DagFlowContent).toContain("Decision Checkpoint") - expect(yield* command.get("dag-init")).toMatchObject({ - name: "dag-init", - description: CommandPlugin.DagInitDescription, - template: CommandPlugin.DagInitContent, - }) - expect(CommandPlugin.DagInitContent).toContain("$ARGUMENTS") - expect(CommandPlugin.DagInitContent).toContain("unsupported platform: only GitHub and GitLab") - expect(CommandPlugin.DagInitContent).toContain("401 is POSITIVE") - expect(CommandPlugin.DagInitContent).toContain("re-verify with `glab api version`") - expect(CommandPlugin.DagInitContent).toContain(".opencode/dag-init.json") - expect(CommandPlugin.DagInitContent).toContain("merge_policy") expect(yield* command.get("dag-auto")).toMatchObject({ name: "dag-auto", description: CommandPlugin.DagAutoDescription, @@ -73,9 +51,47 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.DagAutoContent).toContain("$ARGUMENTS") expect(CommandPlugin.DagAutoContent).toContain("ultra-flow-route") expect(CommandPlugin.DagAutoContent).toContain("continue|replan") - expect(CommandPlugin.DagAutoContent).toContain("issue IS the atom") - expect(CommandPlugin.DagAutoContent).toContain("full-auto") - expect(CommandPlugin.DagAutoContent).toContain("Ordered merge") + expect(CommandPlugin.DagAutoContent).toContain("3 back-edges") + expect(CommandPlugin.DagAutoContent).toContain("Product decision checkpoint") + }), + ) + + it.effect("retires the platform-delivery commands", () => + Effect.gen(function* () { + const command = yield* CommandV2.Service + yield* CommandPlugin.Plugin.effect( + host({ + command: { transform: command.transform, reload: command.reload }, + }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), + ) + + expect(yield* command.get("dag-init")).toBeUndefined() + expect(yield* command.get("dag-flow")).toBeUndefined() + expect(yield* command.get("dag-template-update")).toBeUndefined() + }), + ) + + it.effect("keeps /dag-auto free of platform-delivery vocabulary", () => + Effect.sync(() => { + const content = CommandPlugin.DagAutoContent + expect(content).not.toContain("dag-init") + // The intro/Rules name the boundary in the negative ("no issues, no + // PRs"); actionable delivery mechanics must stay absent. + expect(content).not.toContain("issue number") + expect(content).not.toContain("gh pr") + expect(content).not.toContain("pr checks") + expect(content).not.toContain("Ordered merge") + expect(content).not.toContain("rebase") + expect(content).not.toContain("release brief") + expect(content).toContain("routing and workflow-composition") + expect(content).toContain("workflow(action=\"list\")") + expect(content).toContain("workflow(action=\"validate\")") + expect(content).toContain("workflow(action=\"start\")") }), ) @@ -91,7 +107,7 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowFactsContent).not.toContain("## When to start a workflow") expect(CommandPlugin.WorkflowFactsContent).not.toContain("when ANY") expect(CommandPlugin.WorkflowFactsContent).not.toContain("- **Multi-model**:") - expect(CommandPlugin.DagFlowContent).toContain("`action=start`") + expect(CommandPlugin.DagAutoContent).toContain('workflow(action="start")') }), ) @@ -157,7 +173,7 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("only the user's delivery standard") expect(CommandPlugin.WorkflowContent).toContain("One `task` child") expect(CommandPlugin.WorkflowContent).toContain("One `workflow` DAG") - expect(CommandPlugin.DagFlowContent).toMatch(/one consolidated\s+graph/) + expect(CommandPlugin.DagAutoContent).toContain("ONE consolidated summary") }), ) @@ -175,8 +191,8 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowFactsContent).not.toContain('spec_path: "code-review"') expect(CommandPlugin.WorkflowFactsContent).toMatch(/Retarget its\s+objective and block instructions/) expect(CommandPlugin.WorkflowFactsContent).not.toContain("pass `spec` inline") - expect(CommandPlugin.DagFlowContent).toContain("task-local YAML file") - expect(CommandPlugin.DagFlowContent).toContain("`spec_path`") + expect(CommandPlugin.DagAutoContent).toContain(".opencode/.dag-specs/") + expect(CommandPlugin.DagAutoContent).toContain("retarget") }), ) @@ -481,7 +497,6 @@ describe("CommandPlugin.Plugin", () => { // continuation node keeps non-ACCEPT verdicts from dead-ending the graph. expect(reviewExample).toContain("condition: 'arbitrate.output.verdict != \"ACCEPT\"'") expect(CommandPlugin.WorkflowFactsContent).toContain("an early\n`control(complete)` workflow remains terminal") - expect(CommandPlugin.DagFlowContent).toContain("must contain the requested result") expect(CommandPlugin.WorkflowFactsContent).toContain("project, global, and builtin scopes") expect(CommandPlugin.WorkflowFactsContent).toContain("bounded objectives") expect(CommandPlugin.WorkflowFactsContent).toContain("validation status") diff --git a/packages/opencode/script/validate-dag-templates.ts b/packages/opencode/script/validate-dag-templates.ts index 4eec64394..40278b09e 100644 --- a/packages/opencode/script/validate-dag-templates.ts +++ b/packages/opencode/script/validate-dag-templates.ts @@ -2,8 +2,7 @@ * Directory-level template validator (change repair-workflow-authoring-validation, §4.3). * * Reuses the runtime source-to-graph authority (WorkflowAuthoring) so - * config-repo CI, release packaging, and /dag-template-update all enforce the - * same portable contract. Emits machine-readable diagnostics plus the runtime, + * config-repo CI and release packaging enforce the same portable contract. Emits machine-readable diagnostics plus the runtime, * template, and compatibility commit identifiers, and exits non-zero when any * template is invalid. * diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index fea6d0097..4e4b50e02 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -51,9 +51,6 @@ export const Default = { GOAL: "goal", SUBGOAL: "subgoal", MEMORY: "memory", - DAG_FLOW: "dag-flow", - DAG_TEMPLATE_UPDATE: "dag-template-update", - DAG_INIT: "dag-init", DAG_AUTO: "dag-auto", IMPORT_HOOKS: "import-claude-hooks", CREATE_HOOK: "create-hook", @@ -118,27 +115,6 @@ export const layer = Layer.effect( template: "", hints: ["$ARGUMENTS"], } - commands[Default.DAG_FLOW] = { - name: Default.DAG_FLOW, - description: CommandPlugin.DagFlowDescription, - source: "command", - template: CommandPlugin.DagFlowContent, - hints: hints(CommandPlugin.DagFlowContent), - } - commands[Default.DAG_TEMPLATE_UPDATE] = { - name: Default.DAG_TEMPLATE_UPDATE, - description: CommandPlugin.DagTemplateUpdateDescription, - source: "command", - template: CommandPlugin.DagTemplateUpdateContent, - hints: hints(CommandPlugin.DagTemplateUpdateContent), - } - commands[Default.DAG_INIT] = { - name: Default.DAG_INIT, - description: CommandPlugin.DagInitDescription, - source: "command", - template: CommandPlugin.DagInitContent, - hints: hints(CommandPlugin.DagInitContent), - } commands[Default.DAG_AUTO] = { name: Default.DAG_AUTO, description: CommandPlugin.DagAutoDescription, diff --git a/packages/opencode/test/command/command.test.ts b/packages/opencode/test/command/command.test.ts index a1a7c182b..8b6a0ae3b 100644 --- a/packages/opencode/test/command/command.test.ts +++ b/packages/opencode/test/command/command.test.ts @@ -35,8 +35,8 @@ function commandLayer(commands: Record { }), ) - it.instance("registers the canonical dag-flow command without a built-in workflow fallback", () => + it.instance("registers the canonical dag-auto command", () => Effect.gen(function* () { const commands = yield* Command.Service - const command = yield* commands.get("dag-flow") + const command = yield* commands.get("dag-auto") expect(command).toMatchObject({ - name: "dag-flow", - description: CommandPlugin.DagFlowDescription, + name: "dag-auto", + description: CommandPlugin.DagAutoDescription, source: "command", - template: CommandPlugin.DagFlowContent, + template: CommandPlugin.DagAutoContent, hints: ["$ARGUMENTS"], }) expect(yield* commands.get("workflow")).toBeUndefined() }), ) - it.instance("registers the canonical dag-template-update command", () => + it.instance("retires the platform-delivery commands", () => Effect.gen(function* () { const commands = yield* Command.Service - expect(yield* commands.get("dag-template-update")).toMatchObject({ - name: "dag-template-update", - description: CommandPlugin.DagTemplateUpdateDescription, - source: "command", - template: CommandPlugin.DagTemplateUpdateContent, - hints: ["$ARGUMENTS"], - }) + expect(yield* commands.get("dag-init")).toBeUndefined() + expect(yield* commands.get("dag-flow")).toBeUndefined() + expect(yield* commands.get("dag-template-update")).toBeUndefined() }), ) - it.instance("registers the canonical dag-init and dag-auto commands", () => + overridden.instance("allows configured dag-auto commands to override the built-in", () => Effect.gen(function* () { const commands = yield* Command.Service - - expect(yield* commands.get("dag-init")).toMatchObject({ - name: "dag-init", - description: CommandPlugin.DagInitDescription, - source: "command", - template: CommandPlugin.DagInitContent, - hints: ["$ARGUMENTS"], - }) expect(yield* commands.get("dag-auto")).toMatchObject({ - name: "dag-auto", - description: CommandPlugin.DagAutoDescription, - source: "command", - template: CommandPlugin.DagAutoContent, - hints: ["$ARGUMENTS"], - }) - }), - ) - - overridden.instance("allows configured dag-flow commands to override the built-in", () => - Effect.gen(function* () { - const commands = yield* Command.Service - expect(yield* commands.get("dag-flow")).toMatchObject({ - description: "Custom DAG flow", + description: "Custom DAG auto", template: "Custom task:\n$ARGUMENTS", }) }), @@ -120,47 +95,35 @@ describe("legacy command registry", () => { it.effect("preserves complete multi-line command arguments", () => Effect.sync(() => { const input = "Investigate auth\nThen run the focused tests" - const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagFlowContent, input) + const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagAutoContent, input) - expect(expanded).toContain(`\n${input}\n`) + expect(expanded).toContain(`Arguments: ${input}`) expect(expanded).not.toContain("$ARGUMENTS") }), ) - it.effect("returns after starting a DAG instead of polling its status", () => - Effect.sync(() => { - const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagFlowContent, "Run two parallel workers") - - expect(expanded).toContain("Do not poll") - expect(expanded).toContain("and end the response") - }), - ) - - it.effect("requires router-driven compilation without dropping task constraints", () => + it.effect("routes template-first and never mentions platform delivery", () => Effect.sync(() => { const expanded = SessionPrompt.expandCommandTemplate( - CommandPlugin.DagFlowContent, + CommandPlugin.DagAutoContent, "Use @security-reviewer to review this project. Do not modify files.", ) - expect(expanded).toContain("resident Orchestration Router") - expect(expanded).not.toContain('workflow(action="list")') - expect(expanded).not.toContain('workflow(action="read"') - expect(expanded).toMatch(/Preserve\s+the task,\s+user constraints/) - expect(expanded).toContain("worker types or model IDs") - expect(expanded).toContain("configured capability or model") - expect(expanded).toContain("real error") - expect(expanded).toContain("final synthesis block must contain the requested result") + expect(expanded).toContain("workflow(action=\"list\")") + expect(expanded).toContain("never ask the user to pick a route") + expect(expanded).toContain("ultra-flow-route") + expect(expanded).not.toContain("dag-init") + expect(expanded).not.toContain("`gh ") + expect(expanded).not.toContain("Ordered merge") }), ) - it.effect("keeps the blank-task guard when dag-flow has no arguments", () => + it.effect("keeps the blank-arguments form valid", () => Effect.sync(() => { - const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagFlowContent, " ") + const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagAutoContent, "") - expect(expanded).toContain("\n \n") - expect(expanded).toContain("empty or contains only whitespace") - expect(expanded).toContain("do not start a workflow") + expect(expanded).toContain("Arguments:") + expect(expanded).not.toContain("$ARGUMENTS") }), ) }) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 27f28c0b2..7e2f32441 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -186,7 +186,7 @@ const TIPS: Tip[] = [ (shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"), (shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"), "Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers", - "Run {highlight}/dag-flow {/highlight} to start a DAG workflow, then {highlight}/dag{/highlight} to inspect it", + "Run {highlight}/dag-auto {/highlight} to start a DAG workflow, then {highlight}/dag{/highlight} to inspect it", (shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`, (shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"), (shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"), diff --git a/packages/tui/src/feature-plugins/system/dag-inspector.tsx b/packages/tui/src/feature-plugins/system/dag-inspector.tsx index 6a672515e..dcf2b98da 100644 --- a/packages/tui/src/feature-plugins/system/dag-inspector.tsx +++ b/packages/tui/src/feature-plugins/system/dag-inspector.tsx @@ -519,7 +519,7 @@ function DagInspector(props: { api: TuiPluginApi }) { No workflows for this session - {"Run /dag-flow inside a session to start an orchestration"} + {"Run /dag-auto inside a session to start an orchestration"} From 8db3b727253f1edc7e3c84c7bdf116f9b26f6694 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:29:44 +0800 Subject: [PATCH 11/27] chore: record delivery binding for reduce-dag-auto --- .specgit.yaml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 39ba8f3ba..4d59cdf94 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,10 +1,8 @@ version: 1 -delivery: end-structured-output +delivery: reduce-dag-auto context: kind: branch - branch: fix/386-end-structured-output + branch: refactor/392-reduce-dag-auto issues: - - 386 - - 387 - - 388 -pr: 390 + - 392 +pr: 393 From d3e2772891b42a9f2df1182917b5235767d42564 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:32:35 +0800 Subject: [PATCH 12/27] chore(session): record delivery binding for issue 389 --- .specgit.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 4d59cdf94..5c6d893ac 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: reduce-dag-auto +delivery: issue389 context: kind: branch - branch: refactor/392-reduce-dag-auto + branch: feat/todo-step-reminders issues: - - 392 -pr: 393 + - 389 +pr: 394 From b8317bccc2ae355ea786910069128e83a3d8e7f8 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 14:32:56 +0800 Subject: [PATCH 13/27] feat(session): re-surface uncompleted todos each model step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todo lists drifted silently: todowrite is the only write path and nothing ever re-surfaced the list, so completed work stayed pending, stale items lingered, and in_progress was never set. A new TodoReminders pass runs after SessionReminders in the session run loop and appends ONE synthetic in-memory part (model-visible, never persisted) to the last user message whenever the session holds uncompleted todos — covering tool-free steps too, which a PreToolUse-based reminder cannot. Skip conditions: no todos, all settled (completed or cancelled), or the turn's last assistant message already contains a successful todowrite call (the model just updated the list itself; a failed call does not satisfy the guard). Applies per session, including child/subagent sessions that hold their own todos (issue #389). --- packages/opencode/src/session/prompt.ts | 11 + .../opencode/src/session/todo-reminders.ts | 68 ++++++ .../test/session/todo-reminders.test.ts | 218 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 packages/opencode/src/session/todo-reminders.ts create mode 100644 packages/opencode/test/session/todo-reminders.test.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e6d30bfe0..2139e4412 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -58,6 +58,8 @@ import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionReminders } from "./reminders" +import { Todo } from "./todo" +import { TodoReminders } from "./todo-reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { SettingsHook, HOOK_REWAKE_SENTINEL, type TriggerResult } from "@/hook/settings" @@ -149,6 +151,7 @@ export const layer = Layer.effect( const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service const image = yield* Image.Service + const todoSvc = yield* Todo.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service @@ -1684,6 +1687,12 @@ export const layer = Layer.effect( Effect.provideService(FSUtil.Service, fsys), Effect.provideService(Session.Service, sessions), ) + // Issue #389: re-surface uncompleted todos once per model step + // (in-memory synthetic part, skipped when all settled or when the + // turn just updated the list via todowrite). + msgs = yield* TodoReminders.apply({ messages: msgs, sessionID }).pipe( + Effect.provideService(Todo.Service, todoSvc), + ) const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -2183,6 +2192,7 @@ export const defaultLayer = Layer.suspend(() => RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, HookStartContext.defaultLayer, + Todo.defaultLayer, ), ), ), @@ -2338,6 +2348,7 @@ export const node = LayerNode.make(layer, [ RuntimeFlags.node, Database.node, Memory.node, + Todo.node, HookStartContext.node, SettingsHook.node, Goal.node, ]) diff --git a/packages/opencode/src/session/todo-reminders.ts b/packages/opencode/src/session/todo-reminders.ts new file mode 100644 index 000000000..2b3211994 --- /dev/null +++ b/packages/opencode/src/session/todo-reminders.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Per-step todo stale-state reminder (issue #389). + * + * Todo lists drift silently: nothing re-surfaces the list once written, so + * completed work stays pending and stale items linger. While a session holds + * uncompleted todos, every model step appends ONE synthetic text part with the + * current uncompleted items to the last user message — model-visible, never + * persisted (same in-memory convention as SessionReminders' plan-mode parts). + * + * Skip conditions: + * - no todos for the session, or nothing uncompleted (completed and + * cancelled both count as settled) + * - freshness guard: the current turn's last assistant message already + * contains a successful todowrite call — the model just updated the list + * itself, so this step's request does not nag about it + */ +import { Effect } from "effect" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import type { SessionID } from "./schema" +import { PartID } from "./schema" +import { Todo } from "./todo" + +const TODO_WRITE_TOOL = "todowrite" + +function turnJustUpdatedTodos(messages: SessionV1.WithParts[]): boolean { + const lastAssistant = messages.findLast((msg) => msg.info.role === "assistant") + if (!lastAssistant) return false + return lastAssistant.parts.some( + (part): part is SessionV1.ToolPart => + part.type === "tool" && part.tool === TODO_WRITE_TOOL && part.state.status === "completed", + ) +} + +function renderReminder(uncompleted: Todo.Info[]): string { + const lines = uncompleted.map((todo) => `- ${todo.status}: ${todo.content}`) + return [ + `[todo reminder] ${uncompleted.length} uncompleted todo item${uncompleted.length === 1 ? "" : "s"}:`, + ...lines, + "Keep the list current: mark items completed when done, adjust stale entries, and set in_progress only for the item you are actively working on. Update via todowrite.", + ].join("\n") +} + +export const apply = Effect.fn("TodoReminders.apply")(function* (input: { + messages: SessionV1.WithParts[] + sessionID: SessionID +}) { + const todo = yield* Todo.Service + const todos = yield* todo.get(input.sessionID) + const uncompleted = todos.filter((item) => item.status !== "completed" && item.status !== "cancelled") + if (uncompleted.length === 0) return input.messages + const userMessage = input.messages.findLast((msg) => msg.info.role === "user") + if (!userMessage) return input.messages + if (turnJustUpdatedTodos(input.messages)) return input.messages + userMessage.parts.push({ + id: PartID.ascending(), + messageID: userMessage.info.id, + sessionID: input.sessionID, + type: "text", + text: renderReminder(uncompleted), + synthetic: true, + } satisfies SessionV1.TextPart) + return input.messages +}) + +export * as TodoReminders from "./todo-reminders" diff --git a/packages/opencode/test/session/todo-reminders.test.ts b/packages/opencode/test/session/todo-reminders.test.ts new file mode 100644 index 000000000..052f49012 --- /dev/null +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -0,0 +1,218 @@ +// oxlint-disable typescript-eslint/no-unsafe-type-assertion -- fixtures +// mirror dag-wake-integration.test.ts: message/part fixtures use `as never` +// shims implementing only the slice the scenario exercises. Type-only. +// SPDX-FileCopyrightText: 2026 LeXwDeX +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Issue #389 — per-step todo stale-state reminder. + * + * While a session holds uncompleted todos, every model step (including + * tool-free steps) re-surfaces the current list as ONE synthetic part on the + * last user message — model-visible, never persisted. Skip conditions: + * - no todos for the session + * - nothing uncompleted (completed and cancelled both count as settled) + * - freshness guard: the current turn's last assistant message already + * contains a successful todowrite call (the model just updated the list) + */ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { SessionID, PartID, MessageID } from "@/session/schema" +import { Todo } from "@/session/todo" +import { TodoReminders } from "@/session/todo-reminders" +import { testEffect } from "../lib/effect" + +const runtime = testEffect(Layer.empty) + +function makeTodoLayer(todos: Todo.Info[]) { + return Layer.mock(Todo.Service, { + get: (sessionID: SessionID) => Effect.succeed(todos.length > 0 ? todos.filter(() => sessionID === sessionID) : []), + }) +} + +let clock = 0 + +function userMessage(text: string): SessionV1.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "user", + sessionID: SessionID.make("ses_1"), + time: { created: clock++ }, + agent: "build", + model: { providerID: "test" as never, modelID: "m" as never }, + }, + parts: [{ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "text", + text, + }] as never, + } +} + +function assistantMessage( + tools: { name: string; status: string }[] = [], + text?: string, +): SessionV1.WithParts { + const id = MessageID.ascending() + const parts: Record[] = tools.map((t) => ({ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "tool", + callID: `call-${clock++}`, + tool: t.name, + state: { + status: t.status, + input: {}, + ...(t.status === "completed" ? { output: "", title: "" } : t.status === "error" ? { error: "boom" } : {}), + }, + })) + if (text) { + parts.push({ + id: PartID.ascending(), + messageID: id, + sessionID: SessionID.make("ses_1"), + type: "text", + text, + }) + } + return { + info: { + id, + role: "assistant", + sessionID: SessionID.make("ses_1"), + parentID: MessageID.ascending(), + time: { created: clock++ }, + mode: "build", + agent: "build", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "m" as never, + providerID: "test" as never, + path: { cwd: "/tmp", root: "/tmp" }, + finish: "stop", + }, + parts: parts as never, + } +} + +function lastUser(messages: SessionV1.WithParts[]) { + return messages.findLast((m) => m.info.role === "user") +} + +describe("TodoReminders.apply (issue #389)", () => { + runtime.effect("injects nothing when the session has no todos", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe(Effect.provide(makeTodoLayer([]))) + expect(result).toBe(messages) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("injects nothing when every todo is settled (completed or cancelled)", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "completed", priority: "high" }, + { content: "b", status: "cancelled", priority: "low" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("injects exactly one synthetic reminder with uncompleted statuses", () => + Effect.gen(function* () { + const messages = [userMessage("work")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "implement reminder module", status: "in_progress", priority: "high" }, + { content: "add tests", status: "pending", priority: "high" }, + { content: "shipped", status: "completed", priority: "low" }, + ])), + ) + const last = lastUser(result) + expect(last?.parts).toHaveLength(2) + const reminder = last?.parts.at(-1) as never as { type: string; text: string; synthetic?: boolean } + expect(reminder.type).toBe("text") + expect(reminder.synthetic).toBe(true) + expect(reminder.text).toContain("implement reminder module") + expect(reminder.text).toContain("in_progress") + expect(reminder.text).toContain("add tests") + expect(reminder.text).toContain("pending") + expect(reminder.text).not.toContain("shipped") + expect(reminder.text).toContain("todowrite") + }), + ) + + runtime.effect("freshness guard: skips when the turn's last assistant message contains a successful todowrite", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "completed" }], "updated"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("an older todowrite does not suppress the reminder once further steps followed", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "completed" }]), + assistantMessage([{ name: "read", status: "completed" }], "read the file"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + }), + ) + + runtime.effect("a failed todowrite does not satisfy the freshness guard", () => + Effect.gen(function* () { + const messages = [ + userMessage("work"), + assistantMessage([{ name: "todowrite", status: "error" }]), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + }), + ) +}) From 44f40958c8b71cadde61540d99f89a1e9c0adeb8 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 15:34:17 +0800 Subject: [PATCH 14/27] docs(session): correct todo-reminders precedent note and simplify test mock - the in-memory convention cites SessionReminders' non-plan-mode branch; its plan-mode branch persists via updatePart and is not the pattern - the freshness guard is session-scoped (findLast), not turn-scoped - drop the tautological sessionID filter in the test mock --- packages/opencode/src/session/todo-reminders.ts | 10 ++++++---- packages/opencode/test/session/todo-reminders.test.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/todo-reminders.ts b/packages/opencode/src/session/todo-reminders.ts index 2b3211994..926528813 100644 --- a/packages/opencode/src/session/todo-reminders.ts +++ b/packages/opencode/src/session/todo-reminders.ts @@ -8,14 +8,16 @@ * completed work stays pending and stale items linger. While a session holds * uncompleted todos, every model step appends ONE synthetic text part with the * current uncompleted items to the last user message — model-visible, never - * persisted (same in-memory convention as SessionReminders' plan-mode parts). + * persisted (same in-memory convention as SessionReminders' non-plan-mode + * parts; its plan-mode branch persists instead, which is NOT the pattern + * here). * * Skip conditions: * - no todos for the session, or nothing uncompleted (completed and * cancelled both count as settled) - * - freshness guard: the current turn's last assistant message already - * contains a successful todowrite call — the model just updated the list - * itself, so this step's request does not nag about it + * - freshness guard: the session's last assistant message already contains + * a successful todowrite call — the model just updated the list itself, + * so this step's request does not nag about it */ import { Effect } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" diff --git a/packages/opencode/test/session/todo-reminders.test.ts b/packages/opencode/test/session/todo-reminders.test.ts index 052f49012..aca92255c 100644 --- a/packages/opencode/test/session/todo-reminders.test.ts +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -27,7 +27,7 @@ const runtime = testEffect(Layer.empty) function makeTodoLayer(todos: Todo.Info[]) { return Layer.mock(Todo.Service, { - get: (sessionID: SessionID) => Effect.succeed(todos.length > 0 ? todos.filter(() => sessionID === sessionID) : []), + get: () => Effect.succeed(todos), }) } From bed6ac72672e1a36142dfb81ccd2df97751c1136 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:20:00 +0800 Subject: [PATCH 15/27] feat(release): render per-series release notes with fail-closed validation --- .github/RELEASE_NOTES_TEMPLATE.md | 2 +- .github/releases/README.md | 59 ++++ .github/workflows/release-fork.yml | 27 +- .gitignore | 1 + packages/opencode/script/release-notes.ts | 245 ++++++++++++++ packages/opencode/script/release-version.ts | 10 +- packages/opencode/test/release-notes.test.ts | 305 ++++++++++++++++++ .../opencode/test/release-version.test.ts | 24 ++ 8 files changed, 668 insertions(+), 5 deletions(-) create mode 100644 .github/releases/README.md create mode 100644 packages/opencode/script/release-notes.ts create mode 100644 packages/opencode/test/release-notes.test.ts diff --git a/.github/RELEASE_NOTES_TEMPLATE.md b/.github/RELEASE_NOTES_TEMPLATE.md index aa826820a..b9ba61702 100644 --- a/.github/RELEASE_NOTES_TEMPLATE.md +++ b/.github/RELEASE_NOTES_TEMPLATE.md @@ -57,4 +57,4 @@ typecheck: N/N packages green --- -**Full changelog:** `{previous_tag}...{current_tag}` +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.github/releases/README.md b/.github/releases/README.md new file mode 100644 index 000000000..a2ed7240a --- /dev/null +++ b/.github/releases/README.md @@ -0,0 +1,59 @@ +# Release Notes Series Files + +One markdown file per release series — `.github/releases/vX.Y.Z.md` — is the +source of truth for the GitHub Release body. The `dev` prereleases +(`X.Y.Z-dev.1 … dev.N`) and the `main` stable promotion (`X.Y.Z`) of a series +all render the **same** file; only the channel word differs. + +The release job (`.github/workflows/release-fork.yml`) renders and validates +the file **before** `gh release create` and fails closed on any violation — a +release can never ship with placeholder notes. + +## Lifecycle + +1. **Series starts** — when `X.Y.Z` becomes the next version, the release job + looks for `.github/releases/vX.Y.Z.md`. Until that file is committed, every + release attempt of the series fails; the validator error names the exact + expected path. This is intentional. +2. **Dev prereleases** — each `X.Y.Z-dev.N` build re-renders the current file + content. Update the file as the series evolves. +3. **Stable promotion** — the `main` release of `X.Y.Z` renders the same file; + `{Prerelease/Stable}` becomes `Stable`. The compare range always spans from + the last stable tag, not from the previous `-dev.N`. +4. **Series closes** — after the stable release ships, the file remains as the + historical record. The next series needs its own new `vX.Y.(Z+1).md`. + +## Placeholders + +Five tokens are machine-substituted at render time: + +| Token | Replaced with | +| -------------------- | --------------------------------------------------------- | +| `{VERSION}` | bare semver, e.g. `1.0.10` (no `v` prefix) | +| `{Prerelease/Stable}` | `Prerelease` on `dev`, `Stable` on `main` | +| `{branch}` | releasing branch name (`dev` or `main`) | +| `{previous_tag}` | latest existing stable tag, e.g. `graphagent-v1.0.9` | +| `{current_tag}` | the tag being released, e.g. `graphagent-v1.0.10` | + +The template also contains authoring-guidance braces (`{Feature name}`, +`{module}`, `{One-sentence summary …}`). These are **not** substituted — +replace every one of them with real content. The validator fails on any +residual `{` or `}` in the rendered notes. + +## Authoring rules (enforced fail-closed) + +- Start from `.github/RELEASE_NOTES_TEMPLATE.md` and keep the exact `### ` + emoji headings, their canonical order, and the `---` separators between + sections. Omit sections that have no content — do not leave empty headers. +- Copy the emoji headings verbatim from the template; never retype them. The + 🏗️ (Architecture / Refactor) and ⚙️ (CI / Engineering) headings end with an + invisible U+FE0F variation selector that editors and copy-paste can strip. +- Prose must be ASCII everywhere except the emoji headings themselves. +- `### 🧪 Test Summary` and `### 🔍 Verification` are mandatory in every + release; the Test Summary body needs at least one fenced code block. +- The final line is the full-changelog compare link with the repository slug + written out literally (`https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}`). + A repository rename fails validation on purpose — update the series file. + +The grammar is implemented in `packages/opencode/script/release-notes.ts` +(rule errors are prefixed `[release-notes]`). diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index c6ad8e31f..06cf50e09 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -70,6 +70,8 @@ jobs: tag: ${{ steps.release-version.outputs.tag }} prerelease: ${{ steps.release-version.outputs.prerelease }} latest: ${{ steps.release-version.outputs.latest }} + previous_tag: ${{ steps.release-version.outputs.previous_tag }} + steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -270,6 +272,29 @@ jobs: echo "--- SHA256SUMS ---" cat SHA256SUMS + - name: Setup Bun + uses: ./.github/actions/setup-bun + with: + save-cache: false + + # Render + validate the per-series notes file (.github/releases/vX.Y.Z.md) + # BEFORE creating the release. Fail closed: a missing or invalid series + # file stops the job here, so a release can never ship with placeholder + # notes. The script derives the series filename from --version; the + # workflow passes only primitives. Rendered notes go to RUNNER_TEMP and + # are never attached as a release asset. + - name: Render Release Notes (fail closed) + run: | + bun run ./packages/opencode/script/release-notes.ts \ + --notes-dir ".github/releases" \ + --version "${{ needs.version.outputs.version }}" \ + --channel "${{ needs.version.outputs.channel }}" \ + --branch "${{ github.ref_name }}" \ + --tag "${{ needs.version.outputs.tag }}" \ + --previous-tag "${{ needs.version.outputs.previous_tag }}" \ + --repo "${{ github.repository }}" \ + --out "$RUNNER_TEMP/RELEASE_NOTES.md" + - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -284,7 +309,7 @@ jobs: fi gh release create "${{ needs.version.outputs.tag }}" \ --title "OpenCode GraphAgent v${{ needs.version.outputs.version }}" \ - --notes "GraphAgent release from branch ${{ github.ref_name }}" \ + --notes-file "$RUNNER_TEMP/RELEASE_NOTES.md" \ --target "${{ github.sha }}" \ "${EXTRA_FLAGS[@]}" \ release-assets/* diff --git a/.gitignore b/.gitignore index 9df3f7ff3..1ede19fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ tsconfig.tsbuildinfo .opencode/commands/ .opencode/skills .qoder +.opencode/workflow-reports/ diff --git a/packages/opencode/script/release-notes.ts b/packages/opencode/script/release-notes.ts new file mode 100644 index 000000000..2e53371e6 --- /dev/null +++ b/packages/opencode/script/release-notes.ts @@ -0,0 +1,245 @@ +import { basename, join } from "node:path" + +const tagPrefix = "graphagent-v" +const versionPattern = /^(\d+\.\d+\.\d+)(?:-dev\.\d+)?$/ +const channelLinePattern = /^(Prerelease|Stable) release from `(dev|main)` branch\. \S/ +const asciiLinePattern = /^[\x00-\x7F]*$/ + +// Headings must equal .github/RELEASE_NOTES_TEMPLATE.md codepoint-for-codepoint: +// 🏗️ and ⚙️ carry a U+FE0F variation selector that plain-text editing strips. +export const canonicalHeadings: readonly string[] = [ + "### 🎯 Features", + "### 🐛 Bug Fixes", + "### 🏗️ Architecture / Refactor", + "### ⚙️ CI / Engineering", + "### 📦 Dependencies / Tooling", + "### 🧪 Test Summary", + "### 🔍 Verification", +] +const changeHeadings = canonicalHeadings.slice(0, 5) +const testSummaryHeading = canonicalHeadings[5]! +const verificationHeading = canonicalHeadings[6]! + +export type ReleaseNotesInput = { + version: string + channel: "main" | "dev" + branch: string + tag: string + previousTag: string + repo: string +} + +export class ReleaseNotesError extends Error { + constructor(message: string) { + super(`[release-notes] ${message}`) + this.name = "ReleaseNotesError" + } +} + +export function seriesFor(version: string): string { + const match = versionPattern.exec(version) + if (!match) throw new ReleaseNotesError(`malformed version "${version}" (expected X.Y.Z or X.Y.Z-dev.N)`) + return match[1]! +} + +export function seriesFileFor(version: string): string { + return `.github/releases/v${seriesFor(version)}.md` +} + +function channelWord(channel: "main" | "dev") { + return channel === "main" ? "Stable" : "Prerelease" +} + +export function renderPlaceholders(source: string, input: ReleaseNotesInput): string { + return source + .replaceAll("{VERSION}", input.version) + .replaceAll("{Prerelease/Stable}", channelWord(input.channel)) + .replaceAll("{branch}", input.branch) + .replaceAll("{previous_tag}", input.previousTag) + .replaceAll("{current_tag}", input.tag) +} + +export function expectedFinalLine(input: ReleaseNotesInput): string { + return `**Full changelog:** [\`${input.previousTag}\`...\`${input.tag}\`](https://github.com/${input.repo}/compare/${input.previousTag}...${input.tag})` +} + +export function validateAndRender(source: string, input: ReleaseNotesInput): string { + const rendered = renderPlaceholders(source, input) + const lines = rendered.split(/\r?\n/) + const nonBlank = lines.filter((line) => line.trim().length > 0) + + if (nonBlank[0] !== `## opencode ${input.version}`) + throw new ReleaseNotesError(`first line must be "## opencode ${input.version}", found ${quote(nonBlank[0])}`) + + const channelMatch = channelLinePattern.exec(nonBlank[1] ?? "") + if (!channelMatch || channelMatch[1] !== channelWord(input.channel) || channelMatch[2] !== input.branch) + throw new ReleaseNotesError( + `second line must be "${channelWord(input.channel)} release from \`${input.branch}\` branch. ", found ${quote(nonBlank[1])}`, + ) + + const headings = lines.filter((line) => line.startsWith("### ")) + for (const heading of headings) { + if (!canonicalHeadings.includes(heading)) + throw new ReleaseNotesError(`unknown section heading ${quote(heading)} — must equal a template heading codepoint-for-codepoint`) + } + let previousIndex = -1 + for (const heading of headings) { + const index = canonicalHeadings.indexOf(heading) + if (index <= previousIndex) + throw new ReleaseNotesError(`section headings must follow template order without duplicates, violated at ${quote(heading)}`) + previousIndex = index + } + + if (!headings.includes(testSummaryHeading)) throw new ReleaseNotesError(`missing required section ${quote(testSummaryHeading)}`) + if (!headings.includes(verificationHeading)) throw new ReleaseNotesError(`missing required section ${quote(verificationHeading)}`) + + const blocks = splitBlocks(lines) + const sections = blocks.slice(1, -1).map((block) => { + const content = block.filter((line) => line.trim().length > 0) + return { heading: content[0], body: content.slice(1) } + }) + + if (!sections.some((section) => changeHeadings.includes(section.heading ?? "") && section.body.length > 0)) + throw new ReleaseNotesError( + "no change section with content — at least one of Features, Bug Fixes, Architecture / Refactor, CI / Engineering, Dependencies / Tooling must have a non-empty body", + ) + + for (const section of sections) { + if ((section.heading ?? "").startsWith("### ") && section.body.length === 0) + throw new ReleaseNotesError(`empty section ${quote(section.heading)}`) + } + + const testSection = sections.find((section) => section.heading === testSummaryHeading) + if (testSection && !hasNonEmptyFence(testSection.body)) + throw new ReleaseNotesError("Test Summary must contain at least one fenced code block with non-empty content") + + for (const section of sections) { + if (!section.heading?.startsWith("### ")) + throw new ReleaseNotesError('invalid "---" separator structure: every block between separators must start with a "### " heading') + } + const blockStarts = sections.filter((section) => section.heading?.startsWith("### ")).length + if (blockStarts !== headings.length) + throw new ReleaseNotesError( + `invalid "---" separator structure: ${headings.length} section headings but only ${blockStarts} start their own block — exactly one "---" is required between the intro, between consecutive sections, and before the final changelog line`, + ) + + if (input.tag !== `${tagPrefix}${input.version}`) + throw new ReleaseNotesError(`tag must be "${tagPrefix}${input.version}" for version ${input.version}, received "${input.tag}"`) + if (input.previousTag.length === 0) + throw new ReleaseNotesError("previousTag is empty — no stable graphagent-v* tag exists, so no changelog range can be rendered") + const expected = expectedFinalLine(input) + const finalLines = (blocks[blocks.length - 1] ?? []).filter((line) => line.trim().length > 0) + if (finalLines.length !== 1 || finalLines[0] !== expected) + throw new ReleaseNotesError(`final line must be ${quote(expected)}, alone after the last "---" separator`) + + if (rendered.includes("{") || rendered.includes("}")) { + const residual = nonBlank.filter((line) => line.includes("{") || line.includes("}")).slice(0, 3) + throw new ReleaseNotesError(`unresolved "{" or "}" placeholders remain: ${residual.map(quote).join(" ")}`) + } + + for (const line of lines) { + if (!asciiLinePattern.test(line) && !canonicalHeadings.includes(line)) + throw new ReleaseNotesError(`non-ASCII line outside the canonical emoji headings: ${quote(line)}`) + } + + return rendered +} + +function splitBlocks(lines: readonly string[]) { + const blocks: string[][] = [] + let current: string[] = [] + for (const line of lines) { + if (line.trim() === "---") { + blocks.push(current) + current = [] + } else { + current.push(line) + } + } + blocks.push(current) + return blocks +} + +function hasNonEmptyFence(body: readonly string[]) { + let open = false + let content = false + for (const line of body) { + if (line.trim().startsWith("```")) { + if (open && content) return true + open = !open + content = false + } else if (open && line.trim().length > 0) { + content = true + } + } + return false +} + +function quote(value: string | undefined) { + return JSON.stringify(value ?? "(empty)") +} + +function readArgs(argv: readonly string[]) { + const args: Record = {} + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i]! + if (!key.startsWith("--")) throw new ReleaseNotesError(`unexpected argument "${key}" (expected --key value pairs)`) + const value = argv[i + 1] + if (value === undefined) throw new ReleaseNotesError(`missing value for ${key}`) + args[key.slice(2)] = value + } + return args +} + +function requireArg(args: Record, name: string) { + const value = args[name] + if (value === undefined) throw new ReleaseNotesError(`--${name} is required`) + return value +} + +function resolveFile(args: Record, version: string) { + const fileName = `v${seriesFor(version)}.md` + if (args.file !== undefined && args["notes-dir"] !== undefined) + throw new ReleaseNotesError("pass either --file or --notes-dir, not both") + if (args.file !== undefined) { + if (basename(args.file) !== fileName) + throw new ReleaseNotesError(`series mismatch: --file ${args.file} must be named ${fileName} for version ${version}`) + return args.file + } + if (args["notes-dir"] !== undefined) return join(args["notes-dir"], fileName) + throw new ReleaseNotesError(`--file or --notes-dir is required (expected series file ${seriesFileFor(version)})`) +} + +async function main() { + const args = readArgs(process.argv.slice(2)) + const version = requireArg(args, "version") + const channel = requireArg(args, "channel") + if (channel !== "main" && channel !== "dev") + throw new ReleaseNotesError(`--channel must be "main" or "dev", received "${channel}"`) + const file = resolveFile(args, version) + if (!(await Bun.file(file).exists())) + throw new ReleaseNotesError( + `missing series file ${file} — create ${seriesFileFor(version)} (relative to the repo root) for this release series`, + ) + const input: ReleaseNotesInput = { + version, + channel, + branch: requireArg(args, "branch"), + tag: requireArg(args, "tag"), + previousTag: requireArg(args, "previous-tag"), + repo: requireArg(args, "repo"), + } + const rendered = validateAndRender(await Bun.file(file).text(), input) + const out = requireArg(args, "out") + await Bun.write(out, rendered) + console.log(`Release notes rendered for ${input.tag} -> ${out}`) +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/packages/opencode/script/release-version.ts b/packages/opencode/script/release-version.ts index 5f8cddecb..75bb17c3b 100644 --- a/packages/opencode/script/release-version.ts +++ b/packages/opencode/script/release-version.ts @@ -13,12 +13,13 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) const latest = input.tags .flatMap((tag) => { const version = parseStableTag(tag) - return version ? [version] : [] + return version ? [{ tag, version }] : [] }) - .toSorted(compareVersion) + .toSorted((left, right) => compareVersion(left.version, right.version)) .at(-1) - const target = nextVersion(latest) + const target = nextVersion(latest?.version) const base = target.join(".") + const previousTag = latest?.tag ?? "" if (channel === "main") { return { @@ -27,6 +28,7 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) tag: `${tagPrefix}${base}`, prerelease: false, latest: true, + previous_tag: previousTag, } } @@ -45,6 +47,7 @@ export function resolveReleaseVersion(input: { branch: string; tags: string[] }) tag: `${tagPrefix}${version}`, prerelease: true, latest: false, + previous_tag: previousTag, } } @@ -107,6 +110,7 @@ async function main() { `tag=${release.tag}`, `prerelease=${release.prerelease}`, `latest=${release.latest}`, + `previous_tag=${release.previous_tag}`, "", ].join("\n"), ) diff --git a/packages/opencode/test/release-notes.test.ts b/packages/opencode/test/release-notes.test.ts new file mode 100644 index 000000000..0af1da057 --- /dev/null +++ b/packages/opencode/test/release-notes.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { seriesFileFor, validateAndRender } from "../script/release-notes" + +// Fixtures derive their headings from the live template so that editing a +// template emoji, order, or heading count changes test outcome instead of +// silently drifting away from the grammar the validator enforces. +const template = await Bun.file(new URL("../../../.github/RELEASE_NOTES_TEMPLATE.md", import.meta.url)).text() +const headings = template.split(/\r?\n/).filter((line) => line.startsWith("### ")) + +const input = { + version: "1.0.10", + channel: "main", + branch: "main", + tag: "graphagent-v1.0.10", + previousTag: "graphagent-v1.0.9", + repo: "LeXwDeX/OpenCode-GraphAgent", +} as const + +const devInput = { + version: "1.0.10-dev.3", + channel: "dev", + branch: "dev", + tag: "graphagent-v1.0.10-dev.3", + previousTag: "graphagent-v1.0.9", + repo: input.repo, +} as const + +type Section = { heading: string; body: string } + +const defaultIntro = "{Prerelease/Stable} release from `{branch}` branch. Ships the fail-closed release notes harness." + +function changelogLine(repo: string) { + return `**Full changelog:** [\`{previous_tag}\`...\`{current_tag}\`](https://github.com/${repo}/compare/{previous_tag}...{current_tag})` +} + +function expectedFinalLine(source: { previousTag: string; tag: string; repo: string }) { + return `**Full changelog:** [\`${source.previousTag}\`...\`${source.tag}\`](https://github.com/${source.repo}/compare/${source.previousTag}...${source.tag})` +} + +function defaultSections(): Section[] { + return [ + { heading: headings[0], body: "- **Notes harness**: Series files render through a validator that fails closed on every rule." }, + { heading: headings[1], body: "- **Placeholder notes**: Releases no longer publish a placeholder body." }, + { heading: headings[2], body: "- **Renderer**: One script renders and validates the series file before the release exists." }, + { heading: headings[3], body: "- The release job renders notes from the committed series file before creating the release." }, + { heading: headings[4], body: "- No dependency changes in this series." }, + { + heading: headings[5], + body: "```\nrelease-notes: 12 pass\ntotal: 12 tests, 0 failures\ntypecheck: 1/1 packages green\n```", + }, + { heading: headings[6], body: "Rendered with bun test from packages/opencode and mutation-checked rule by rule." }, + ] +} + +function buildSource(options: { sections?: Section[]; intro?: string; title?: string; finalLine?: string }) { + return [ + options.title ?? "## opencode {VERSION}", + "", + options.intro ?? defaultIntro, + "", + "---", + "", + ...(options.sections ?? defaultSections()).flatMap((section) => [section.heading, "", section.body, "", "---", ""]), + options.finalLine ?? changelogLine(input.repo), + ].join("\n") +} + +describe("release notes series resolution", () => { + test("maps both channels of one series to the same series file", () => { + expect(seriesFileFor("1.0.10")).toBe(".github/releases/v1.0.10.md") + expect(seriesFileFor("1.0.10-dev.3")).toBe(".github/releases/v1.0.10.md") + }) + + test("rejects malformed versions", () => { + expect(() => seriesFileFor("main")).toThrow() + expect(() => seriesFileFor("1.0")).toThrow() + expect(() => seriesFileFor("")).toThrow() + }) +}) + +describe("release notes rendering", () => { + test("renders a valid stable series file", () => { + const rendered = validateAndRender(buildSource({}), input) + + expect(rendered).toContain("## opencode 1.0.10") + expect(rendered).toContain("Stable release from `main` branch.") + expect(rendered).not.toContain("{") + expect(rendered).not.toContain("}") + expect(rendered.endsWith(expectedFinalLine(input))).toBe(true) + }) + + test("renders the same series file for a dev prerelease of that series", () => { + const rendered = validateAndRender(buildSource({}), devInput) + + expect(rendered).toContain("## opencode 1.0.10-dev.3") + expect(rendered).toContain("Prerelease release from `dev` branch.") + expect(rendered.endsWith(expectedFinalLine(devInput))).toBe(true) + expect(rendered).not.toContain("{") + }) +}) + +describe("release notes grammar (fail closed)", () => { + test("rejects a title that does not match the released version", () => { + expect(() => validateAndRender(buildSource({ title: "## opencode 9.9.9" }), input)).toThrow("[release-notes]") + }) + + test("rejects a hardcoded channel or branch word in the intro", () => { + const stableWord = "Stable release from `dev` branch. Ships the harness." + expect(() => validateAndRender(buildSource({ intro: stableWord }), devInput)).toThrow("[release-notes]") + const mainBranch = "Prerelease release from `main` branch. Ships the harness." + expect(() => validateAndRender(buildSource({ intro: mainBranch }), devInput)).toThrow("[release-notes]") + }) + + test("rejects unknown, de-variated, duplicated, or reordered headings", () => { + const sections = defaultSections() + const unknown = sections.toSpliced(0, 1, { heading: "### 🎁 Gifts", body: sections[0].body }) + expect(() => validateAndRender(buildSource({ sections: unknown }), input)).toThrow("[release-notes]") + + for (const index of [2, 3]) { + const devariated = sections.toSpliced(index, 1, { + heading: headings[index].replaceAll("\uFE0F", ""), + body: sections[index].body, + }) + expect(() => validateAndRender(buildSource({ sections: devariated }), input)).toThrow("[release-notes]") + } + + const duplicated = [sections[0], sections[0], ...sections.slice(1)] + expect(() => validateAndRender(buildSource({ sections: duplicated }), input)).toThrow("[release-notes]") + + const reordered = [sections[1], sections[0], ...sections.slice(2)] + expect(() => validateAndRender(buildSource({ sections: reordered }), input)).toThrow("[release-notes]") + }) + + test("rejects a missing Test Summary or Verification section", () => { + expect(() => validateAndRender(buildSource({ sections: defaultSections().toSpliced(5, 1) }), input)).toThrow( + "[release-notes]", + ) + expect(() => validateAndRender(buildSource({ sections: defaultSections().toSpliced(6, 1) }), input)).toThrow( + "[release-notes]", + ) + }) + + test("rejects a file with no change section at all", () => { + expect(() => validateAndRender(buildSource({ sections: defaultSections().slice(5) }), input)).toThrow( + "[release-notes]", + ) + }) + + test("rejects an empty section body", () => { + const emptied = defaultSections().toSpliced(0, 1, { heading: headings[0], body: "" }) + expect(() => validateAndRender(buildSource({ sections: emptied }), input)).toThrow("[release-notes]") + }) + + test("rejects a Test Summary without a non-empty fenced block", () => { + const plain = defaultSections().toSpliced(5, 1, { heading: headings[5], body: "All 12 tests passed." }) + expect(() => validateAndRender(buildSource({ sections: plain }), input)).toThrow("[release-notes]") + const emptyFence = defaultSections().toSpliced(5, 1, { heading: headings[5], body: "```\n```" }) + expect(() => validateAndRender(buildSource({ sections: emptyFence }), input)).toThrow("[release-notes]") + }) + + test("rejects doubled, missing, or stray --- separators", () => { + const source = buildSource({}) + const doubled = source.replace(`---\n\n${headings[0]}`, `---\n\n---\n\n${headings[0]}`) + expect(() => validateAndRender(doubled, input)).toThrow("[release-notes]") + const missing = source.replace(`\n\n---\n\n${headings[0]}`, `\n\n${headings[0]}`) + expect(() => validateAndRender(missing, input)).toThrow("[release-notes]") + const stray = defaultSections().toSpliced(1, 1, { heading: headings[1], body: "- One fix.\n---\n- Another fix." }) + expect(() => validateAndRender(buildSource({ sections: stray }), input)).toThrow("[release-notes]") + }) + + test("rejects a wrong final changelog line", () => { + const bareRange = "**Full changelog:** `{previous_tag}...{current_tag}`" + expect(() => validateAndRender(buildSource({ finalLine: bareRange }), input)).toThrow("[release-notes]") + const wrongRepo = changelogLine("some-other/repo") + expect(() => validateAndRender(buildSource({ finalLine: wrongRepo }), input)).toThrow("[release-notes]") + const wrongTag = expectedFinalLine(input).replaceAll(input.tag, "graphagent-v1.0.11") + expect(() => validateAndRender(buildSource({ finalLine: wrongTag }), input)).toThrow("[release-notes]") + expect(() => validateAndRender(buildSource({ finalLine: "That is all." }), input)).toThrow("[release-notes]") + }) + + test("rejects an empty previous tag or a tag that does not match the version", () => { + expect(() => validateAndRender(buildSource({}), { ...input, previousTag: "" })).toThrow("[release-notes]") + expect(() => validateAndRender(buildSource({}), { ...input, tag: "graphagent-v9.9.9" })).toThrow("[release-notes]") + }) + + test("rejects residual placeholders anywhere in the rendered notes", () => { + const residual = defaultSections().toSpliced(0, 1, { + heading: headings[0], + body: "- **Notes harness**: Uses {summary} to describe the change.", + }) + expect(() => validateAndRender(buildSource({ sections: residual }), input)).toThrow("[release-notes]") + }) + + test("rejects non-ASCII prose outside the emoji headings", () => { + const accented = defaultSections().toSpliced(0, 1, { heading: headings[0], body: "- Adds café support." }) + expect(() => validateAndRender(buildSource({ sections: accented }), input)).toThrow("[release-notes]") + const emDash = "Stable release from `main` branch. Adds rich text — with an em dash." + expect(() => validateAndRender(buildSource({ intro: emDash }), input)).toThrow("[release-notes]") + }) +}) + +const notesScript = path.resolve(import.meta.dir, "../script/release-notes.ts") + +async function runNotesScript(options: { dir: string; out: string; version?: string }) { + const version = options.version ?? input.version + const child = Bun.spawn( + [ + "bun", + "run", + notesScript, + "--notes-dir", + ".github/releases", + "--version", + version, + "--channel", + input.channel, + "--branch", + input.branch, + "--tag", + `graphagent-v${version}`, + "--previous-tag", + input.previousTag, + "--repo", + input.repo, + "--out", + options.out, + ], + { cwd: options.dir, stdout: "pipe", stderr: "pipe" }, + ) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + return { stdout, stderr, exitCode } +} + +describe("release notes CLI", () => { + test("fails closed when the series file is missing or from another series", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "release-notes-")) + try { + const out = path.join(dir, "RELEASE_NOTES.md") + + const missing = await runNotesScript({ dir, out }) + expect(missing.exitCode).not.toBe(0) + expect(missing.stderr).toContain("[release-notes]") + expect(missing.stderr).toContain("v1.0.10.md") + + await mkdir(path.join(dir, ".github/releases"), { recursive: true }) + await writeFile(path.join(dir, ".github/releases/v1.0.10.md"), buildSource({})) + const wrongSeries = await runNotesScript({ dir, out, version: "1.0.11" }) + expect(wrongSeries.exitCode).not.toBe(0) + expect(wrongSeries.stderr).toContain("[release-notes]") + expect(wrongSeries.stderr).toContain("v1.0.11.md") + + expect(await Bun.file(out).exists()).toBe(false) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("renders the series file to --out only after validation succeeds", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "release-notes-")) + try { + await mkdir(path.join(dir, ".github/releases"), { recursive: true }) + await writeFile(path.join(dir, ".github/releases/v1.0.10.md"), buildSource({})) + const out = path.join(dir, "RELEASE_NOTES.md") + + const result = await runNotesScript({ dir, out }) + + expect(result.exitCode).toBe(0) + expect((await Bun.file(out).text()).trim()).toBe(validateAndRender(buildSource({}), input).trim()) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("release notes template and workflow wiring", () => { + test("template stays the grammar authority the fixtures are derived from", () => { + expect(headings).toHaveLength(7) + expect(new Set(headings).size).toBe(7) + expect(template).toContain("**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/") + expect(template).toContain("/compare/{previous_tag}...{current_tag})") + }) + + test("release job renders and validates notes before gh release create", async () => { + const workflow = await Bun.file(new URL("../../../.github/workflows/release-fork.yml", import.meta.url)).text() + const releaseJob = workflow.slice(workflow.indexOf("\n release:")) + + expect(workflow).not.toContain('--notes "GraphAgent release from branch') + expect(releaseJob).toContain("script/release-notes.ts") + expect(releaseJob).toContain('--notes-dir ".github/releases"') + expect(releaseJob).toContain("--previous-tag") + expect(releaseJob).toContain("needs.version.outputs.previous_tag") + expect(releaseJob).toContain('--out "$RUNNER_TEMP/RELEASE_NOTES.md"') + expect(releaseJob.indexOf("release-notes.ts")).toBeLessThan(releaseJob.indexOf("gh release create")) + expect(releaseJob).toContain('gh release create "${{ needs.version.outputs.tag }}"') + expect(releaseJob).toContain('--notes-file "$RUNNER_TEMP/RELEASE_NOTES.md"') + expect(releaseJob).toContain("./.github/actions/setup-bun") + }) +}) diff --git a/packages/opencode/test/release-version.test.ts b/packages/opencode/test/release-version.test.ts index e0542d9ad..1486b03f2 100644 --- a/packages/opencode/test/release-version.test.ts +++ b/packages/opencode/test/release-version.test.ts @@ -9,6 +9,7 @@ describe("GraphAgent release versions", () => { tag: "graphagent-v1.0.0", prerelease: false, latest: true, + previous_tag: "", }) }) @@ -24,6 +25,7 @@ describe("GraphAgent release versions", () => { tag: "graphagent-v1.0.0-dev.1", prerelease: true, latest: false, + previous_tag: "", }) }) @@ -53,6 +55,26 @@ describe("GraphAgent release versions", () => { ) }) + test("seeds previous_tag from the latest stable tag for both channels", () => { + expect(resolveReleaseVersion({ branch: "dev", tags: ["graphagent-v1.0.8", "graphagent-v1.0.9"] })).toEqual({ + channel: "dev", + version: "1.0.10-dev.1", + tag: "graphagent-v1.0.10-dev.1", + prerelease: true, + latest: false, + previous_tag: "graphagent-v1.0.9", + }) + expect( + resolveReleaseVersion({ branch: "main", tags: ["graphagent-v1.0.8", "graphagent-v1.0.9"] }).previous_tag, + ).toBe("graphagent-v1.0.9") + }) + + test("keeps previous_tag on the last stable across the dev series", () => { + expect( + resolveReleaseVersion({ branch: "dev", tags: ["graphagent-v1.0.9", "graphagent-v1.0.10-dev.2"] }).previous_tag, + ).toBe("graphagent-v1.0.9") + }) + test("wires one resolved version into both the build and GitHub Release", async () => { const workflow = await Bun.file(new URL("../../../.github/workflows/release-fork.yml", import.meta.url)).text() @@ -62,5 +84,7 @@ describe("GraphAgent release versions", () => { expect(workflow).toContain("OPENCODE_VERSION: ${{ needs.version.outputs.version }}") expect(workflow).toContain('gh release create "${{ needs.version.outputs.tag }}"') expect(workflow).toContain("--prerelease --latest=false") + expect(workflow).toContain("previous_tag: ${{ steps.release-version.outputs.previous_tag }}") + expect(workflow).toContain("needs.version.outputs.previous_tag") }) }) From d6ce53a83dabd20a4dd7b854482ab348957a8fc6 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:20:27 +0800 Subject: [PATCH 16/27] chore(specgit): narrow acceptance workflow to main and document duplicate-issue check --- .github/workflows/specgit-accept.yml | 44 +++++++++++----------------- AGENTS.md | 13 ++++++++ CLAUDE.md | 13 ++++++++ 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index ff9080efd..46dfb5b6e 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,12 +2,7 @@ name: SpecGit Acceptance on: pull_request: - # Delivery PRs target dev (fast-integration layer) and are promoted to - # main via the release PR — main's legacy branch protection also requires - # the SpecGit Acceptance check, so the verdict must run on both targets. - # dev→main promotion stays governed by the protect-main Ruleset's four - # required checks. - branches: [dev, main] + branches: [main] permissions: contents: read @@ -16,10 +11,7 @@ jobs: specgit-acceptance: name: SpecGit Acceptance runs-on: ubuntu-latest - # Must exceed the slowest required sibling (Unit Tests (linux) runs - # ~28min on PRs): the verdict waits for every policy check to reach a - # terminal state before evaluating. - timeout-minutes: 45 + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,18 +23,20 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '22' + node-version: '20.19.0' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile - # This repo is a bun workspace and does not vendor the SpecGit CLI; - # install the published CLI instead of building from source. Pinned - # with a caret floor (#366): the CLI releases multiple times a day and - # an unpinned install would let an unnoticed upstream change flip CI - # acceptance verdicts repo-wide. - - name: Install specgit CLI - run: npm install -g specgit@^0.5.0 + - name: Build CLI + run: pnpm run build - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -57,11 +51,9 @@ jobs: run: | node --input-type=module <<'EOF' import { readFileSync } from 'node:fs'; - // Minimal parse of policy.yaml's required_checks block list — - // avoids a yaml dependency in this bun-based repo. - const policy = readFileSync('spec_git/policy.yaml', 'utf8'); - const section = policy.slice(policy.indexOf('required_checks:')); - const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); + import { parse } from 'yaml'; + const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); + const required = policy.required_checks ?? []; const headers = { authorization: 'Bearer ' + process.env.GH_TOKEN, accept: 'application/vnd.github+json', @@ -74,9 +66,7 @@ jobs: const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); return retried !== undefined && terminal.has(byName.get(retried)); }; - // Must outlast the slowest required sibling (Unit Tests (linux) - // runs ~28min on PRs); the job timeout above bounds this too. - const deadline = Date.now() + 40 * 60 * 1000; + const deadline = Date.now() + 15 * 60 * 1000; while (Date.now() < deadline) { const res = await fetch(url, { headers }); if (!res.ok) throw new Error('check-runs API ' + res.status); @@ -95,6 +85,6 @@ jobs: EOF - name: specgit finish - run: specgit finish --json + run: node bin/specgit.js finish --json env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index 1467e4c43..ee8e1066f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,6 +265,19 @@ re-init; keep manual guidance outside them. origin. `specgit doctor` probes git, repository, origin, gh, and policy. +### Before creating an issue, check for duplicates + +- Before running `specgit issue` with a new title, search the tracker for + similar open work: `gh issue list` with keywords from the title + (state, labels, and search terms via `gh search issues`). +- Open and read every plausible candidate (`gh issue view `) — compare + the WHY, not just the wording. +- If a candidate covers the same WHY, continue that issue instead of + creating a new one; if it is close but different, say how they differ. +- When unsure, ask the requester to decide between continuing the existing + issue and creating a duplicate. The team ships one line of work per WHY, + never two. + ### Issue granularity One issue = one independently verifiable WHY. If a deliverable cannot be diff --git a/CLAUDE.md b/CLAUDE.md index 04434d780..27e995774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,19 @@ re-init; keep manual guidance outside them. origin. `specgit doctor` probes git, repository, origin, gh, and policy. +### Before creating an issue, check for duplicates + +- Before running `specgit issue` with a new title, search the tracker for + similar open work: `gh issue list` with keywords from the title + (state, labels, and search terms via `gh search issues`). +- Open and read every plausible candidate (`gh issue view `) — compare + the WHY, not just the wording. +- If a candidate covers the same WHY, continue that issue instead of + creating a new one; if it is close but different, say how they differ. +- When unsure, ask the requester to decide between continuing the existing + issue and creating a duplicate. The team ships one line of work per WHY, + never two. + ### Issue granularity One issue = one independently verifiable WHY. If a deliverable cannot be From fa674a3ca960fdb7cf08297023ff21f28d02b188 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:21:59 +0800 Subject: [PATCH 17/27] test(session): pin todo-reminder run-loop guarantees Encode the PR #391 review O3 backlog as explicit behavior tests: - one reminder per model step regardless of parallel tool fan-out - per-step fresh reads never accumulate reminders (no persistence) - a compacted transcript still receives the reminder --- .../test/session/todo-reminders.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/opencode/test/session/todo-reminders.test.ts b/packages/opencode/test/session/todo-reminders.test.ts index aca92255c..0d5c8fb9e 100644 --- a/packages/opencode/test/session/todo-reminders.test.ts +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -216,3 +216,80 @@ describe("TodoReminders.apply (issue #389)", () => { }), ) }) + +// The run-loop structural guarantees called out by the PR #391 review (O3): +// apply runs once per model step BEFORE tool resolution — never once per +// tool — and its mutation lives only in that step's in-memory request. +describe("TodoReminders run-loop guarantees (issue #389 review)", () => { + runtime.effect("parallel tools in one step still see exactly one reminder", () => + Effect.gen(function* () { + // The rejected PreToolUse design would have injected once per tool + // call; the run-loop seam injects once per step, so a fan-out of N + // completed tools (none a fresh todowrite) yields ONE reminder. + const messages = [ + userMessage("work"), + assistantMessage([ + { name: "read", status: "completed" }, + { name: "grep", status: "completed" }, + { name: "bash", status: "completed" }, + ], "ran three tools"), + ] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + const last = lastUser(result) + expect(last?.parts).toHaveLength(2) + expect(last?.parts.filter((part) => (part as never as { text?: string }).text?.startsWith("[todo reminder]"))).toHaveLength(1) + }), + ) + + runtime.effect("consecutive steps with fresh per-step reads never accumulate reminders", () => + Effect.gen(function* () { + // The run loop re-derives msgs from the database each step, so the + // synthetic part never persists and never stacks across steps. + const todoLayer = makeTodoLayer([{ content: "a", status: "pending", priority: "high" }]) + const durableBase = [userMessage("work")] + + const step1 = structuredClone(durableBase) + const result1 = yield* TodoReminders.apply({ messages: step1, sessionID: SessionID.make("ses_1") }).pipe( + Effect.provide(todoLayer), + ) + expect(lastUser(result1)?.parts).toHaveLength(2) + + const step2 = structuredClone(durableBase) + const result2 = yield* TodoReminders.apply({ messages: step2, sessionID: SessionID.make("ses_1") }).pipe( + Effect.provide(todoLayer), + ) + expect(lastUser(result2)?.parts).toHaveLength(2) + + // The durable base stays pristine — the injection is per-request only. + expect(lastUser(durableBase)?.parts).toHaveLength(1) + }), + ) + + runtime.effect("a compacted transcript still receives the reminder", () => + Effect.gen(function* () { + // Compaction runs before apply on the per-step fresh read: the + // filtered transcript still ends in a user message, and with no + // assistant carrying a fresh todowrite the reminder must survive. + const messages = [userMessage("compacted summary: prior turns elided, work continues")] + const result = yield* TodoReminders.apply({ + messages, + sessionID: SessionID.make("ses_1"), + }).pipe( + Effect.provide(makeTodoLayer([ + { content: "a", status: "pending", priority: "high" }, + ])), + ) + expect(lastUser(result)?.parts).toHaveLength(2) + const reminder = lastUser(result)?.parts.at(-1) as never as { text: string; synthetic?: boolean } + expect(reminder.synthetic).toBe(true) + expect(reminder.text).toContain("[todo reminder]") + }), + ) +}) From a9cf9004125ba41b608257f746f57138bda8181e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 16:37:11 +0800 Subject: [PATCH 18/27] chore: rename branch to feat/todo-step-reminders and drop redundant assertions - .specgit.yaml context.branch follows the renamed delivery branch (feat/389-issue389 was a duplicated-name mistake; naming convention is type/short-name) - remove four unnecessary non-null assertions in release-notes.ts so the branch adds zero lint warnings over the dev baseline (4850) --- packages/opencode/script/release-notes.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/script/release-notes.ts b/packages/opencode/script/release-notes.ts index 2e53371e6..9f32aebeb 100644 --- a/packages/opencode/script/release-notes.ts +++ b/packages/opencode/script/release-notes.ts @@ -17,8 +17,8 @@ export const canonicalHeadings: readonly string[] = [ "### 🔍 Verification", ] const changeHeadings = canonicalHeadings.slice(0, 5) -const testSummaryHeading = canonicalHeadings[5]! -const verificationHeading = canonicalHeadings[6]! +const testSummaryHeading = canonicalHeadings[5] +const verificationHeading = canonicalHeadings[6] export type ReleaseNotesInput = { version: string @@ -39,7 +39,7 @@ export class ReleaseNotesError extends Error { export function seriesFor(version: string): string { const match = versionPattern.exec(version) if (!match) throw new ReleaseNotesError(`malformed version "${version}" (expected X.Y.Z or X.Y.Z-dev.N)`) - return match[1]! + return match[1] } export function seriesFileFor(version: string): string { @@ -182,8 +182,8 @@ function quote(value: string | undefined) { function readArgs(argv: readonly string[]) { const args: Record = {} for (let i = 0; i < argv.length; i += 2) { - const key = argv[i]! - if (!key.startsWith("--")) throw new ReleaseNotesError(`unexpected argument "${key}" (expected --key value pairs)`) + const key = argv[i] + if (key === undefined || !key.startsWith("--")) throw new ReleaseNotesError(`unexpected argument "${key}" (expected --key value pairs)`) const value = argv[i + 1] if (value === undefined) throw new ReleaseNotesError(`missing value for ${key}`) args[key.slice(2)] = value From a1ce6d4e6a26d04e14db7ba1e00e91559d3334f6 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 18:37:13 +0800 Subject: [PATCH 19/27] fix(memory): deliver the response schema to schema-blind providers and make /memory state truthful MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #395: openai-compatible downgrades response_format to bare json_object and never sees the streamObject schema, so the maintenance model free-styles a fresh shape every call and validation always rejects — topics are never created. Render the draft-07 JSON Schema ($refs inlined, nullable-union arms simplified) into the system prompt; GenerateError now renders an empty provider error message legibly instead of blank. #396: /memory replies for non-on/off arguments were hardcoded to "Memory remains off"; they now report the true state via Memory.status(). #397: statusReason omits the model gate — an enabled config whose model no longer resolves reads as "Memory on" while Memory is inert. statusReason now returns an actionable model-unavailability reason and a failed /memory on surfaces it instead of a bare "remains off". --- packages/opencode/src/memory/memory.ts | 35 ++- packages/opencode/src/memory/model.ts | 69 +++++- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/test/memory/memory.test.ts | 107 +++++++++ .../opencode/test/memory/model-wire.test.ts | 210 ++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 13 +- 6 files changed, 421 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/test/memory/model-wire.test.ts diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 232983391..f5d767746 100644 --- a/packages/opencode/src/memory/memory.ts +++ b/packages/opencode/src/memory/memory.ts @@ -60,6 +60,10 @@ export interface Interface { * project passes every activation gate. Surface this wherever a silent * "remains off" would leave the user guessing (e.g. /memory on). */ readonly statusReason: () => Effect.Effect + /** Truthful one-line state for /memory status surfaces: the statusReason + * blocker when a gate (identity, init, model availability) holds Memory + * inert, else the actual on/off state. */ + readonly status: () => Effect.Effect } export class Service extends Context.Service()("@opencode/Memory") {} @@ -774,7 +778,9 @@ export const layer: Layer.Layer< // #350: the why-is-Memory-inert companion of configuration()'s fail-closed // gates. Mirrors their order; only the gates a user can act on produce a // reason (identity retirement and admission repair stay log-only — they - // are operator concerns, not /memory on guidance). + // are operator concerns, not /memory on guidance). #397: an enabled + // config whose model no longer resolves is equally inert — active() gates + // on resolveModel() — so it gets its own actionable reason. const statusReason = Effect.fn("Memory.statusReason")(function* () { const ctx = yield* InstanceState.context const current = yield* project.get(ctx.project.id) @@ -784,9 +790,29 @@ export const layer: Layer.Layer< if (current.vcs !== "git") return "Memory requires a git repository." if (!current.time.initialized) return "Memory is unavailable until the project is initialized — run /init first, then /memory on." + // An unreadable config/store answers "cannot determine" rather than + // failing the status surface. + const optioned = yield* Effect.option(configuration()) + const loaded = Option.isSome(optioned) ? optioned.value?.loaded : undefined + if (loaded?.config.enabled) { + // resolveModel answers undefined (not a failure) when the model is + // absent from the provider list; Effect.option only catches the + // torn-read ModelNotFoundError edge — both mean unavailable here. + const model = yield* Effect.option(resolveModel(loaded.config)) + if (!Option.isSome(model) || model.value === undefined) + return "Memory is enabled but its configured model is unavailable — run /memory on to reselect a replacement, or set `model` in .opencode/memory.jsonc to an installed provider/model." + } return undefined }) + const status: Interface["status"] = Effect.fn("Memory.status")(function* () { + const reason = yield* statusReason() + if (reason) return reason + const optioned = yield* Effect.option(configuration()) + const loaded = Option.isSome(optioned) ? optioned.value?.loaded : undefined + return loaded?.config.enabled ? "Memory on" : "Memory remains off" + }) + const setEnabledUnsafe = Effect.fn("Memory.setEnabledUnsafe")(function* (enabled: boolean) { const initial = yield* configuration() if (!initial) { @@ -827,13 +853,16 @@ export const layer: Layer.Layer< Effect.catchCause((cause) => Effect.gen(function* () { yield* Effect.logWarning("MEMORY command failed", { cause }) - return "Memory remains off" + // #397: a failure that statusReason can explain (e.g. no + // installed model to reselect) surfaces the actionable reason + // instead of a bare "remains off". + return (yield* statusReason()) ?? "Memory remains off" }), ), ), ) - return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason }) + return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason, status }) }), ) diff --git a/packages/opencode/src/memory/model.ts b/packages/opencode/src/memory/model.ts index f8d7c2285..bdd0c9221 100644 --- a/packages/opencode/src/memory/model.ts +++ b/packages/opencode/src/memory/model.ts @@ -5,11 +5,13 @@ import { Context, Duration, Effect, Layer, Schema } from "effect" import { streamObject } from "ai" import { Provider } from "@/provider/provider" -// Liveness is judged per-chunk, never by a whole-call wall clock: a stream -// that keeps delivering parts is alive, however long the reasoning runs. -// CONNECT_TIMEOUT bounds the wait for the FIRST part; IDLE_TIMEOUT bounds the -// silence BETWEEN parts and is re-armed by every arriving part. Generation -// still terminates on its own via max_output_tokens / a natural stop. +// Liveness is judged per-part, never by a whole-call wall clock: a stream +// that keeps delivering parts is alive, however long the call runs. Two +// caveats: streamObject's fullStream DROPS reasoning-only parts (the ai SDK +// forwards text-delta/finish/error only), so a model that reasons silently +// past these windows still trips the timers — and CONNECT_TIMEOUT bounds the +// wait for the FIRST part while IDLE_TIMEOUT bounds the silence BETWEEN +// parts, re-armed by every arriving part. const CONNECT_TIMEOUT = Duration.seconds(60) const IDLE_TIMEOUT = Duration.seconds(60) @@ -37,7 +39,10 @@ export class GenerateError extends Schema.TaggedErrorClass()("Mem cause: Schema.Defect(), }) { override get message() { - return `MEMORY model call failed: ${String(this.cause)}` + // openai-compatible flattens a provider SSE error event to its bare + // message string, which can be empty — keep the failure identifiable. + const cause = String(this.cause) + return `MEMORY model call failed: ${cause === "" ? "(provider stream error with an empty message)" : cause}` } } @@ -76,8 +81,9 @@ function requireJsonToken(request: Request): Request { // Signals that the stream went silent past the liveness window. export class Stalled extends Error {} -// Drains `parts`, re-arming the idle watchdog on EVERY part (so a live stream -// that keeps delivering — reasoning deltas included — never trips the timer). +// Drains `parts`, re-arming the idle watchdog on every part the consumer +// sees. NOTE: for streamObject that excludes reasoning-only parts (they are +// filtered out upstream), so silent reasoning does NOT count as liveness. // Arms `connectTimeout` until the first part and `idleTimeout` between parts; // a silent window invokes `onStall` (abort the request) and fails with // `Stalled`, while an `errorOf` hit fails with that part's error. @@ -123,6 +129,45 @@ export const drainWithLiveness = (input: { })() }) +// Providers without structured-outputs support (every openai-compatible model +// today) downgrade response_format to bare {"type":"json_object"} and never +// see the schema passed to streamObject — the model then free-styles a +// different shape every call and client-side validation always rejects +// (issue #395). The schema therefore rides in the system prompt: the draft-07 +// document with every $ref inlined, since "#/definitions/..." pointers are +// meaningless to the model. Optional fields arrive as anyOf [T, null]; the +// null arm is dropped so the schema reads "provide T or omit the key", +// matching what the decoder actually accepts. +function jsonSchemaText(schema: Schema.Decoder) { + const doc = (Schema.toStandardJSONSchemaV1(schema as never) as Record)["~standard"] as { + readonly jsonSchema: { readonly input: (options: { readonly target: "draft-07" }) => unknown } + } + const root = doc.jsonSchema.input({ target: "draft-07" }) as Record + const defs = { ...(root.definitions as Record), ...(root.$defs as Record) } + const walk = (node: unknown, refs: ReadonlySet): unknown => { + if (Array.isArray(node)) return node.map((item) => walk(item, refs)) + if (node === null || typeof node !== "object") return node + const record = node as Record + const ref = typeof record.$ref === "string" ? /^#\/(?:\$defs|definitions)\/(.+)$/.exec(record.$ref)?.[1] : undefined + if (ref !== undefined) { + if (refs.has(ref)) return {} + const target = defs[ref] ?? {} + return walk(target, new Set([...refs, ref])) + } + if (Array.isArray(record.anyOf) && Object.keys(record).length === 1) { + const kept = (record.anyOf as Record[]).filter((arm) => arm.type !== "null") + if (kept.length === 1) return walk(kept[0], refs) + } + const out: Record = {} + for (const [key, value] of Object.entries(record)) { + if (key === "definitions" || key === "$defs" || key === "$id" || key === "$schema") continue + out[key] = walk(value, refs) + } + return out + } + return JSON.stringify(walk(root, new Set())) +} + const streamGenerate = (input: { language: Parameters[0]["model"] system: string @@ -132,8 +177,9 @@ const streamGenerate = (input: { maxOutputTokens: number connectTimeout: Duration.Duration idleTimeout: Duration.Duration -}) => - Effect.tryPromise({ +}): Effect.Effect => { + const system = `${input.system}\n\nThe response must be a single JSON object that validates against this JSON Schema:\n${jsonSchemaText(input.schema)}` + return Effect.tryPromise({ try: (signal) => (async () => { const controller = new AbortController() @@ -142,7 +188,7 @@ const streamGenerate = (input: { try { const result = streamObject({ model: input.language, - system: input.system, + system, prompt: input.prompt, schema: Object.assign( Schema.toStandardSchemaV1(input.schema), @@ -167,6 +213,7 @@ const streamGenerate = (input: { })(), catch: (cause) => (cause instanceof Stalled ? new TimeoutError() : new GenerateError({ cause })), }) +} export const layer = Layer.effect( Service, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2139e4412..ea7aa6db4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1887,12 +1887,14 @@ export const layer = Layer.effect( if (input.command === "memory") { const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service)) const argument = input.arguments.trim() + // #396: anything that is not an exact on/off is a status query — + // report the true state instead of a hardcoded "remains off". const result = memory ? argument === "on" ? yield* memory.setEnabled(true) : argument === "off" ? yield* memory.setEnabled(false) - : "Memory remains off" + : yield* memory.status() : "Memory remains off" const model = yield* currentModel(input.sessionID) const agentName = input.agent ?? (yield* agents.defaultAgent()) diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 9b711d389..f6e85eb5d 100644 --- a/packages/opencode/test/memory/memory.test.ts +++ b/packages/opencode/test/memory/memory.test.ts @@ -2128,6 +2128,113 @@ describe("memory enablement", () => { ) }) +function statusFixture() { + const replacement = ProviderTest.model({ + providerID: ProviderV2.ID.make("test"), + id: ModelV2.ID.make("replacement"), + }) + const state: { config: MemorySchema.Config; available: boolean } = { + config: { ...config, enabled: true, model: "removed/model" }, + available: true, + } + const providerLayer = Layer.mock(Provider.Service, { + list: () => + Effect.succeed( + state.available + ? { [replacement.providerID]: ProviderTest.info({ id: replacement.providerID, models: { [replacement.id]: replacement } }) } + : {}, + ), + getModel: (providerID, modelID) => + Effect.succeed( + ProviderTest.model({ + providerID, + id: modelID, + }), + ), + }) + const layer = Memory.layer.pipe( + Layer.provide( + Layer.mergeAll( + emptyConfigLayer, + EffectFlock.defaultLayer, + MemoryHome.defaultLayer, + MemoryIdentityFence.defaultLayer, + providerLayer, + Layer.mock(Project.Service, { + get: (id) => + Effect.succeed({ + id, + worktree: "/unused", + vcs: "git" as const, + time: { created: 0, updated: 0, initialized: 1 }, + sandboxes: [], + }), + }), + Layer.mock(MemoryConfig.Service, { + load: (directory) => + Effect.succeed({ config: state.config, path: directory, level: "project" as const }), + loadGlobal: () => Effect.succeed(undefined), + writeGlobal: () => Effect.succeed(true), + writeProject: () => Effect.void, + }), + readyAdmissionLayer, + MemoryLock.defaultLayer, + Layer.mock(MemoryModel.Service, { + generate: () => Effect.die(new Error("status surfaces must not call a model")), + }), + Layer.mock(MemoryStore.Service, { + readTopics: () => Effect.succeed([]), + }), + ), + ), + ) + return { state, it: testEffect(layer) } +} + +describe("memory status truthfulness (issues #396 #397)", () => { + const status = statusFixture() + + status.it.instance( + "reports why an enabled config is inert when its model is gone", + () => + Effect.gen(function* () { + const memory = yield* Memory.Service + const reason = yield* memory.statusReason() + if (reason === undefined) return yield* Effect.fail(new Error("expected a model-unavailability reason")) + expect(reason).toContain("model is unavailable") + expect(yield* memory.status()).toBe(reason) + expect(yield* memory.setEnabled(true)).toContain("model is unavailable") + }), + { git: true }, + ) + + status.it.instance( + "reports the true on/off state once the model resolves", + () => + Effect.gen(function* () { + const memory = yield* Memory.Service + status.state.config = { ...config, enabled: true, model: "test/replacement" } + expect(yield* memory.statusReason()).toBeUndefined() + expect(yield* memory.status()).toBe("Memory on") + status.state.config = { ...config, enabled: false, model: "test/replacement" } + expect(yield* memory.status()).toBe("Memory remains off") + }), + { git: true }, + ) + + status.it.instance( + "surfaces the model reason when /memory on cannot reselect any model", + () => + Effect.gen(function* () { + status.state.config = { ...config, enabled: true, model: "removed/model" } + status.state.available = false + const memory = yield* Memory.Service + expect(yield* memory.setEnabled(true)).toContain("model is unavailable") + }), + { git: true }, + ) +}) + function assistant( parentID: MessageID, sessionID: SessionID, diff --git a/packages/opencode/test/memory/model-wire.test.ts b/packages/opencode/test/memory/model-wire.test.ts new file mode 100644 index 000000000..9c8a3bed0 --- /dev/null +++ b/packages/opencode/test/memory/model-wire.test.ts @@ -0,0 +1,210 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import fs from "node:fs/promises" +import path from "node:path" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { Memory } from "@/memory/memory" +import { MemoryModel } from "@/memory/model" +import { MemoryPrompts } from "@/memory/prompts" +import { MemorySchema } from "@/memory/schema" +import { MemoryStore } from "@/memory/store" +import { Project } from "@/project/project" +import { Provider } from "@/provider/provider" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { provideTmpdirServer } from "../fixture/fixture" +import { pollWithTimeout, testEffect } from "../lib/effect" +import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { testProviderConfig } from "../lib/test-provider" + +const ref = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test-model") } + +const memoryConfig = { + schema_version: 1, + enabled: true, + model: "test/test-model", + topic_limit: 10, + turn_interval: 5, + injection: { max_topics: 3, max_tokens: 1_200 }, +} satisfies MemorySchema.Config + +const createTopicReply = reply() + .text( + JSON.stringify({ + actions: [ + { + type: "create_topic", + name: "Reply style", + summary: "The user confirmed a durable preference for concise replies.", + categories: ["preference"], + keywords: ["replies", "concise"], + related_topics: [], + item: { + kind: "preference", + content: "User prefers concise replies.", + rationale: "The user confirmed this preference and it is long-term.", + }, + }, + ], + }), + ) + .stop() + +// #395 regression: openai-compatible providers downgrade response_format to +// bare {"type":"json_object"} and never receive the streamObject schema, so +// the model free-styles a fresh shape every call and validation rejects it. +// The matchers below only answer requests that visibly carry the schema, so +// every test in this file fails the moment the schema leaves the wire again. +const wireIt = testEffect( + LayerNode.buildLayer( + LayerNode.group([ + Provider.node, + MemoryModel.node, + CrossSpawnSpawner.node, + LayerNode.make(TestLLMServer.layer, []), + ]), + ), +) + +const wireGenerate = (schema: Schema.Decoder, system: string) => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ref.providerID, ref.modelID) + return yield* (yield* MemoryModel.Service).generate({ + model, + system, + prompt: "User confirmed: replies stay concise.", + schema, + maxOutputTokens: 2_048, + }) + }) + +describe("memory model wire schema (issue #395)", () => { + wireIt.live("carries the maintenance schema on the wire so the model can conform", () => + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.pushMatch( + (hit) => JSON.stringify(hit.body).includes("create_topic"), + reply().text('{"actions":[{"type":"no_change"}]}').stop(), + ) + const result = yield* wireGenerate(MemorySchema.MaintenanceResponse, MemoryPrompts.MAINTAIN_SYSTEM) + expect(result).toEqual({ actions: [{ type: "no_change" }] }) + const inputs = yield* llm.inputs + const maintenance = inputs.find((input) => JSON.stringify(input.messages).includes("create_topic")) + expect(maintenance).toBeDefined() + expect(maintenance?.response_format).toEqual({ type: "json_object" }) + }), + { config: (url) => testProviderConfig(url) }, + ), + ) + + wireIt.live("keeps a provider error with an empty message legible", () => + provideTmpdirServer( + ({ llm }) => + Effect.gen(function* () { + yield* llm.push(raw({ head: [{ error: { message: "" } }] })) + const failure = yield* wireGenerate(MemorySchema.MaintenanceResponse, MemoryPrompts.MAINTAIN_SYSTEM).pipe( + Effect.flip, + ) + if (!(failure instanceof MemoryModel.GenerateError)) + return yield* Effect.fail(new Error(`expected GenerateError, got: ${String(failure)}`)) + expect(failure.message).toBe("MEMORY model call failed: (provider stream error with an empty message)") + }), + { config: (url) => testProviderConfig(url) }, + ), + ) +}) + +const stackIt = testEffect( + LayerNode.buildLayer( + LayerNode.group([ + Memory.node, + Project.node, + MemoryStore.node, + CrossSpawnSpawner.node, + LayerNode.make(TestLLMServer.layer, []), + ]), + ), +) + +describe("memory maintenance end to end (issue #395)", () => { + stackIt.live("creates a topic when the wire schema lets the model conform", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const project = yield* Project.Service + const registered = yield* project.fromDirectory(dir) + yield* project.setInitialized(registered.project.id) + const configDir = path.join(dir, ".opencode") + yield* Effect.promise(() => fs.mkdir(configDir, { recursive: true })) + yield* Effect.promise(() => fs.writeFile(path.join(configDir, "memory.jsonc"), JSON.stringify(memoryConfig))) + + yield* llm.pushMatch( + (hit) => JSON.stringify(hit.body).includes("topic_ids"), + reply().text('{"topic_ids":[]}').stop(), + ) + yield* llm.pushMatch((hit) => JSON.stringify(hit.body).includes("create_topic"), createTopicReply) + + const sessionID = SessionID.make("ses_memory_wire") + const userID = MessageID.ascending() + const messages: SessionV1.WithParts[] = [ + { + info: { + id: userID, + role: "user", + sessionID, + time: { created: Date.now() }, + agent: "build", + model: ref, + }, + parts: [ + { + id: PartID.ascending(), + messageID: userID, + sessionID, + type: "text", + text: "以后回复保持简洁,这点长期有效", + }, + ], + }, + { + info: { + id: MessageID.ascending(), + role: "assistant", + sessionID, + parentID: userID, + mode: "build", + agent: "build", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + providerID: ref.providerID, + modelID: ref.modelID, + time: { created: Date.now() }, + finish: "end_turn", + }, + parts: [], + }, + ] + + yield* (yield* Memory.Service).checkpoint({ sessionID, messages }) + + const store = yield* MemoryStore.Service + const projectID = (yield* InstanceState.context).project.id + const topics = yield* pollWithTimeout( + Effect.suspend(() => store.readTopics(projectID)).pipe( + Effect.map((all) => (all.length > 0 ? all : undefined)), + ), + "maintenance never committed a topic", + ) + expect(topics[0]?.metadata.categories).toEqual(["preference"]) + }), + { config: (url) => testProviderConfig(url), git: true }, + ), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 0139cc10c..f181c97f0 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -248,6 +248,8 @@ function makePrompt(input?: PromptLayerOptions) { context: () => Effect.succeed(input?.memoryContext ?? []), checkpoint: () => Effect.succeed(input?.memoryContext ?? []), setEnabled: (enabled) => Effect.succeed(enabled ? ("Memory on" as const) : ("Memory off" as const)), + statusReason: () => Effect.succeed(undefined), + status: () => Effect.succeed("Memory on"), }) const deps = Layer.mergeAll( hookRecorderLayer, @@ -2372,12 +2374,15 @@ it.instance("stores the slash invocation as visible text and hides the expanded }), ) -noLLMServer.instance("dispatches /memory on and off without running a model turn", () => +noLLMServer.instance("dispatches /memory on, off, and status without running a model turn", () => Effect.gen(function* () { const { prompt, sessions, chat } = yield* boot() const off = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "off" }) const on = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "on" }) + // #396: a non-on/off argument is a status query — the reply must reflect + // the service's true state instead of the old hardcoded "remains off". + const status = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "" }) const unsupported = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "topic 20" }) expect(off.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([ @@ -2388,9 +2393,13 @@ noLLMServer.instance("dispatches /memory on and off without running a model turn "/memory on", "Memory on", ]) + expect(status.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([ + "/memory", + "Memory on", + ]) expect(unsupported.parts.filter((part) => part.type === "text").map((part) => part.text)).toEqual([ "/memory topic 20", - "Memory remains off", + "Memory on", ]) expect((yield* sessions.messages({ sessionID: chat.id })).every((message) => message.info.role === "user")).toBe(true) }), From c98e053052d997c40f7754580d365cd9141880a6 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 19:17:56 +0800 Subject: [PATCH 20/27] chore: record delivery binding for memory-topic-creation --- .specgit.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 5c6d893ac..78c488e2c 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,10 @@ version: 1 -delivery: issue389 +delivery: memory-topic-creation context: kind: branch - branch: feat/todo-step-reminders + branch: fix/395-memory-topic-creation issues: - - 389 -pr: 394 + - 395 + - 396 + - 397 +pr: 398 From 7f2e06c2f069256d3106caf4db4dcc6e42840156 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 19:32:27 +0800 Subject: [PATCH 21/27] refactor(memory): replace jsonSchemaText assertions with type guards Zero new lint warnings vs the dev baseline (the ratchet cap was exceeded by 5 on CI); behavior identical, guarded by test/memory/model-wire.test.ts. --- packages/opencode/src/memory/model.ts | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/memory/model.ts b/packages/opencode/src/memory/model.ts index bdd0c9221..f36241238 100644 --- a/packages/opencode/src/memory/model.ts +++ b/packages/opencode/src/memory/model.ts @@ -139,27 +139,22 @@ export const drainWithLiveness = (input: { // null arm is dropped so the schema reads "provide T or omit the key", // matching what the decoder actually accepts. function jsonSchemaText(schema: Schema.Decoder) { - const doc = (Schema.toStandardJSONSchemaV1(schema as never) as Record)["~standard"] as { - readonly jsonSchema: { readonly input: (options: { readonly target: "draft-07" }) => unknown } - } - const root = doc.jsonSchema.input({ target: "draft-07" }) as Record - const defs = { ...(root.definitions as Record), ...(root.$defs as Record) } + const root = Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-07" }) + const defs = { ...recordOf(root.definitions), ...recordOf(root.$defs) } const walk = (node: unknown, refs: ReadonlySet): unknown => { if (Array.isArray(node)) return node.map((item) => walk(item, refs)) - if (node === null || typeof node !== "object") return node - const record = node as Record - const ref = typeof record.$ref === "string" ? /^#\/(?:\$defs|definitions)\/(.+)$/.exec(record.$ref)?.[1] : undefined + if (!isRecord(node)) return node + const ref = typeof node.$ref === "string" ? /^#\/(?:\$defs|definitions)\/(.+)$/.exec(node.$ref)?.[1] : undefined if (ref !== undefined) { if (refs.has(ref)) return {} - const target = defs[ref] ?? {} - return walk(target, new Set([...refs, ref])) + return walk(defs[ref] ?? {}, new Set([...refs, ref])) } - if (Array.isArray(record.anyOf) && Object.keys(record).length === 1) { - const kept = (record.anyOf as Record[]).filter((arm) => arm.type !== "null") + if (Array.isArray(node.anyOf) && Object.keys(node).length === 1) { + const kept = node.anyOf.filter(isRecord).filter((arm) => arm.type !== "null") if (kept.length === 1) return walk(kept[0], refs) } const out: Record = {} - for (const [key, value] of Object.entries(record)) { + for (const [key, value] of Object.entries(node)) { if (key === "definitions" || key === "$defs" || key === "$id" || key === "$schema") continue out[key] = walk(value, refs) } @@ -168,6 +163,14 @@ function jsonSchemaText(schema: Schema.Decoder) { return JSON.stringify(walk(root, new Set())) } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function recordOf(value: unknown): Record { + return isRecord(value) ? value : {} +} + const streamGenerate = (input: { language: Parameters[0]["model"] system: string From 43d6915647480f12e277771d190d554fbb41061e Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 20:16:47 +0800 Subject: [PATCH 22/27] fix(ci): restore the proven specgit-accept install strategy on main-only trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The d6ce53a83 rewrite switched to pnpm + build-from-source, but this is a bun workspace (packageManager: bun@1.3.14, no pnpm-lock.yaml): the setup step fails with 'No pnpm version is specified' and the wait step's 'yaml' import would not resolve without pnpm-installed node_modules. First run on the dev→main promotion PR (#399) exposed it. Restore the verified steps (node 22 + npm install -g specgit@^0.5.0 + regex policy parse + 40min wait budget); keep the main-only trigger narrowing. --- .github/workflows/specgit-accept.yml | 41 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index 46dfb5b6e..c280272a4 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,6 +2,10 @@ name: SpecGit Acceptance on: pull_request: + # Delivery PRs target dev (fast-integration layer); the acceptance + # verdict runs only on the dev→main promotion PR, where protect-main's + # checks apply. Keep the trigger main-only (d6ce53a83): running it on + # dev PRs duplicated the verdict against the lighter dev gate. branches: [main] permissions: @@ -11,7 +15,10 @@ jobs: specgit-acceptance: name: SpecGit Acceptance runs-on: ubuntu-latest - timeout-minutes: 15 + # Must exceed the slowest required sibling (Unit Tests (linux) runs + # ~28min on PRs): the verdict waits for every policy check to reach a + # terminal state before evaluating. + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -23,20 +30,18 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20.19.0' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile + node-version: '22' - - name: Build CLI - run: pnpm run build + # This repo is a bun workspace and does not vendor the SpecGit CLI; + # install the published CLI instead of building from source. Pinned + # with a caret floor (#366): the CLI releases multiple times a day and + # an unpinned install would let an unnoticed upstream change flip CI + # acceptance verdicts repo-wide. + - name: Install specgit CLI + run: npm install -g specgit@^0.5.0 - name: Wait for sibling checks # The verdict must see the OTHER required checks in a terminal @@ -51,9 +56,11 @@ jobs: run: | node --input-type=module <<'EOF' import { readFileSync } from 'node:fs'; - import { parse } from 'yaml'; - const policy = parse(readFileSync('spec_git/policy.yaml', 'utf8')); - const required = policy.required_checks ?? []; + // Minimal parse of policy.yaml's required_checks block list — + // avoids a yaml dependency in this bun-based repo. + const policy = readFileSync('spec_git/policy.yaml', 'utf8'); + const section = policy.slice(policy.indexOf('required_checks:')); + const required = [...section.matchAll(/^\s*-\s*(.+)$/gm)].map((m) => m[1].trim()); const headers = { authorization: 'Bearer ' + process.env.GH_TOKEN, accept: 'application/vnd.github+json', @@ -66,7 +73,9 @@ jobs: const retried = [...byName.keys()].find((k) => k.startsWith(name + ' (')); return retried !== undefined && terminal.has(byName.get(retried)); }; - const deadline = Date.now() + 15 * 60 * 1000; + // Must outlast the slowest required sibling (Unit Tests (linux) + // runs ~28min on PRs); the job timeout above bounds this too. + const deadline = Date.now() + 40 * 60 * 1000; while (Date.now() < deadline) { const res = await fetch(url, { headers }); if (!res.ok) throw new Error('check-runs API ' + res.status); @@ -85,6 +94,6 @@ jobs: EOF - name: specgit finish - run: node bin/specgit.js finish --json + run: specgit finish --json env: GH_TOKEN: ${{ github.token }} From d904141c9d4b8d60f4f017b0a100e73ea74d4cf2 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 20:52:49 +0800 Subject: [PATCH 23/27] docs(release): disclose acceptance-workflow rollback chain and add series-file step to release train --- workflows/release-train.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 workflows/release-train.md diff --git a/workflows/release-train.md b/workflows/release-train.md new file mode 100644 index 000000000..ee79ee2df --- /dev/null +++ b/workflows/release-train.md @@ -0,0 +1,23 @@ +# Release Train(发布列车) + +一条从 issue 清理到正式发版的批次交付循环。全程 SpecGit 化:issue → delivery PR → CI 门禁 → 合并 → 发版。所有条目先处理完,再统一处理 PR 问题——batch 住操作,不逐个排队。 + +## Trigger + +人工发起:用户说"发版 / 跑一趟发布列车",且满足发车条件——dev 上无开放 delivery PR。 + +## Steps + +1. **验车**:确认 dev 分支 CI 全绿(push 触发的 Typecheck + 全量测试),open issue 与已合入 dev 的交付一一对应(合并到 main 时 GitHub 才自动关 issue,开着 ≠ 未处理)。 +2. **复盘**:对目标子系统做完整性复盘(每次发车指定一个;本轮为 MEMORY ON:`/init` 盖章 `project.time_initialized` → `/memory on` → `memory_search` 可用的启用链路,外加写入路径验证)。发现按 specgit 粒度开 issue——一个独立可验证的 WHY 一个 issue。 +3. **Checkpoint(唯一的阻塞确认)**:呈现复盘报告——发现清单、新建 issues、修复计划、本班车范围——一次确认。确认后连续执行到发版,不再打断。 +4. **修复班车**:`specgit issue ...` 把全部新 issue 绑进一个 delivery(多 issue 一 PR),从 main 切出 `fix/**` 分支,TDD 修复,PR → dev。PR 上 CI/TDD 失败 → 原 delivery 内追加 commit 修复;已合入 dev 后才暴露的回退 → 开新 fix issue 进下一班车。 +5. **升级**:修复 PR 合并、dev push 全量绿后,立即开 dev → main PR(protect-main 全量门禁:Typecheck + Unit Tests + E2E 双平台)。全程监控,失败即修。 +6. **发版**:main 合并后、触发 `release-fork` 前,先写 series 文件——按 `.github/RELEASE_NOTES_TEMPLATE.md` 渲染并提交 `.github/releases/vX.Y.Z.md`(版本号由 latest tag + commit 类型推导;渲染 fail-closed,失败即阻断发版)。随后 `release-fork` 触发正式版。issue 随 dev→main 合并自动关闭。 +7. **Brief**:一趟车一份收尾汇报——本班次 issue 清单、PR、CI 结论、版本号与 release 链接、遗留(进下一班车的条目)。 + +## Rules + +- SpecGit 铁律优先:`specgit finish` 非 0 不请求合并;不削弱 policy;只认 `--json`。 +- 发布已由步骤 3 的确认授权,正式版不再二次询问。 +- 回退(合并后 CI 挂)永不静默:开 issue、进下一班车、在 Brief 中显式列出。 From b4c58cbfa4e3c0a0a072269e7857768e7cdd6df1 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 21:00:37 +0800 Subject: [PATCH 24/27] docs(release): add v1.18.0 series notes --- .github/releases/v1.18.0.md | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/releases/v1.18.0.md diff --git a/.github/releases/v1.18.0.md b/.github/releases/v1.18.0.md new file mode 100644 index 000000000..069123ea5 --- /dev/null +++ b/.github/releases/v1.18.0.md @@ -0,0 +1,44 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Session todo reminders, per-series release notes, truthful MEMORY state, and DAG output-duplication fixes. + +--- + +### 🎯 Features + +- **Stale todo reminders (PR #394)**: sessions with uncompleted todos now inject a one-shot synthetic reminder on every model step, including steps without tool calls, so stale items stay visible to the model until they are completed or cancelled; sub-agent sessions track their own todos, and a freshness guard skips the reminder right after a successful todowrite call. +- **Per-series release notes (PR #394)**: each release series renders its GitHub Release body from `.github/releases/vX.Y.Z.md` with fail-closed validation before `gh release create`; a missing or invalid series file stops the release instead of shipping placeholder notes. +- **/dag-auto as pure workflow routing (PR #393)**: `/dag-auto` no longer embeds orchestration logic of its own and only routes to workflows; `/dag-init`, `/dag-flow`, and `/dag-template-update` are retired. + +--- + +### 🐛 Bug Fixes + +- **DAG prompt and structured-output duplication, #386-388 (PR #390)**: schema-node prompts and the `submit_result` tool description now state a single authority contract (summary only in the payload, no restating in the body, end the turn after a successful submit); block compilation drops instructions that merely duplicate the objective; crash recovery reuses the live output-file-reference capture so durable receipts match between live and recovered runs. +- **MEMORY silent-failure cluster, #395-397 (PR #398)**: the response schema is now rendered into the system prompt for schema-blind providers whose `response_format` downgrade made every topic commit fail validation; `/memory` replies report the true state instead of a hardcoded "Memory remains off"; `statusReason` surfaces the model gate so an enabled config whose model no longer resolves reads as unavailable instead of "on" and inert. +- **CI acceptance workflow rollback (PR #400)**: the specgit-accept pnpm rewrite that broke `Setup pnpm` on the first main-target PR was rolled back to the verified node22 + `npm i -g specgit@^0.5.0` install, keeping the main-only trigger. + +--- + +### 🧪 Test Summary + +``` +CI gates on the dev-to-main promotion (PR #399): +Typecheck: pass +Unit Tests (linux): pass +E2E Tests (linux): pass +E2E Tests (windows): pass +SpecGit Acceptance: pass +``` + +--- + +### 🔍 Verification + +- Provider wire behavior behind the MEMORY fixes is pinned by `test/memory/model-wire.test.ts`; the todo-reminder run-loop guarantees are pinned by `test/session/todo-reminders.test.ts` (empty-list skip, settled-list skip, freshness guard, failed-write guard). +- MEMORY was verified end-to-end against the live provider: a checkpoint run over an initialized git project committed a real memory topic; topic id, Memory home path, and timings are recorded in the release-remediation workflow evidence. +- Every delivery in this batch (PRs #390, #393, #394, #398, #400) shipped through the specgit harness with an accepted verdict, and all promotion gates are green on PR #399. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) From ab4c2be1e5d455469399ec35ba798a9516506a69 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 21:16:16 +0800 Subject: [PATCH 25/27] chore: record delivery binding for release-train-remediation --- .specgit.yaml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 78c488e2c..0dc7b3717 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,10 +1,8 @@ version: 1 -delivery: memory-topic-creation +delivery: release-train-remediation context: kind: branch - branch: fix/395-memory-topic-creation + branch: fix/401-release-train-remediation issues: - - 395 - - 396 - - 397 -pr: 398 + - 401 + - 402 From 0407e0ece8fac9e38ba134ca93be0905a1652ffd Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 21:39:07 +0800 Subject: [PATCH 26/27] docs(release): rename series file to mechanically derived v1.0.29 and align release-train spec Review findings R2/S2/S3: release-version.ts derives patch+1 over graphagent-v* tags only (1.0.28 -> 1.0.29; the v1.17.11-* family is invisible to it), so the series file must be named for the derived version. The train spec now states the mechanical derivation, requires closing keywords on the promotion PR (R1 lesson), and branches fix deliveries from dev. Local workflow state dirs are gitignored. --- .github/releases/{v1.18.0.md => v1.0.29.md} | 0 .gitignore | 6 ++++++ workflows/release-train.md | 8 ++++---- 3 files changed, 10 insertions(+), 4 deletions(-) rename .github/releases/{v1.18.0.md => v1.0.29.md} (100%) diff --git a/.github/releases/v1.18.0.md b/.github/releases/v1.0.29.md similarity index 100% rename from .github/releases/v1.18.0.md rename to .github/releases/v1.0.29.md diff --git a/.gitignore b/.gitignore index 1ede19fc3..ee0809b00 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,9 @@ tsconfig.tsbuildinfo .opencode/skills .qoder .opencode/workflow-reports/ + +# opencode local workflow state +.opencode/.dag-specs/ +.opencode/dag-init.json +.opencode/workflow-drafts/ +.opencode/workflow-reports/ diff --git a/workflows/release-train.md b/workflows/release-train.md index ee79ee2df..74bb74942 100644 --- a/workflows/release-train.md +++ b/workflows/release-train.md @@ -8,12 +8,12 @@ ## Steps -1. **验车**:确认 dev 分支 CI 全绿(push 触发的 Typecheck + 全量测试),open issue 与已合入 dev 的交付一一对应(合并到 main 时 GitHub 才自动关 issue,开着 ≠ 未处理)。 +1. **验车**:确认 dev 分支 CI 全绿(push 触发的 Typecheck + 全量测试),open issue 与已合入 dev 的交付一一对应。注意:issue 只在 dev→main PR body 含 closing keywords(`Closes #N`)时才自动关闭——开升级 PR 时必须带上本班次的全部 closing 行。 2. **复盘**:对目标子系统做完整性复盘(每次发车指定一个;本轮为 MEMORY ON:`/init` 盖章 `project.time_initialized` → `/memory on` → `memory_search` 可用的启用链路,外加写入路径验证)。发现按 specgit 粒度开 issue——一个独立可验证的 WHY 一个 issue。 3. **Checkpoint(唯一的阻塞确认)**:呈现复盘报告——发现清单、新建 issues、修复计划、本班车范围——一次确认。确认后连续执行到发版,不再打断。 -4. **修复班车**:`specgit issue ...` 把全部新 issue 绑进一个 delivery(多 issue 一 PR),从 main 切出 `fix/**` 分支,TDD 修复,PR → dev。PR 上 CI/TDD 失败 → 原 delivery 内追加 commit 修复;已合入 dev 后才暴露的回退 → 开新 fix issue 进下一班车。 -5. **升级**:修复 PR 合并、dev push 全量绿后,立即开 dev → main PR(protect-main 全量门禁:Typecheck + Unit Tests + E2E 双平台)。全程监控,失败即修。 -6. **发版**:main 合并后、触发 `release-fork` 前,先写 series 文件——按 `.github/RELEASE_NOTES_TEMPLATE.md` 渲染并提交 `.github/releases/vX.Y.Z.md`(版本号由 latest tag + commit 类型推导;渲染 fail-closed,失败即阻断发版)。随后 `release-fork` 触发正式版。issue 随 dev→main 合并自动关闭。 +4. **修复班车**:`specgit issue ...` 把全部新 issue 绑进一个 delivery(多 issue 一 PR),从 dev 切出 `fix/**` 分支,TDD 修复,PR → dev。PR 上 CI/TDD 失败 → 原 delivery 内追加 commit 修复;已合入 dev 后才暴露的回退 → 开新 fix issue 进下一班车。 +5. **升级**:修复 PR 合并、dev push 全量绿后,立即开 dev → main PR(protect-main 全量门禁:Typecheck + Unit Tests + E2E 双平台),body 带本班次全部 closing keywords。全程监控,失败即修。 +6. **发版**:main 合并后、触发 `release-fork` 前,先写 series 文件——按 `.github/RELEASE_NOTES_TEMPLATE.md` 渲染并提交 `.github/releases/vX.Y.Z.md`。版本号是 `release-version.ts` 的机械推导(`graphagent-v*` 标签上的 patch+1,不读 commit 类型),系列文件必须以推导出的版本命名;渲染 fail-closed,失败即阻断发版。随后 `release-fork` 触发正式版。 7. **Brief**:一趟车一份收尾汇报——本班次 issue 清单、PR、CI 结论、版本号与 release 链接、遗留(进下一班车的条目)。 ## Rules From 29c787392aae6be6cc5a084dc2b5c996581a3e29 Mon Sep 17 00:00:00 2001 From: Lex Date: Thu, 20 Aug 2026 21:39:37 +0800 Subject: [PATCH 27/27] chore: record delivery binding for release-train-remediation --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 0dc7b3717..63243e620 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -6,3 +6,4 @@ context: issues: - 401 - 402 +pr: 403