diff --git a/.github/RELEASE_NOTES_TEMPLATE.md b/.github/RELEASE_NOTES_TEMPLATE.md index aa826820a3..b9ba617025 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 0000000000..a2ed7240a8 --- /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/releases/v1.0.29.md b/.github/releases/v1.0.29.md new file mode 100644 index 0000000000..069123ea5b --- /dev/null +++ b/.github/releases/v1.0.29.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}) diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index c6ad8e31f5..06cf50e097 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/.github/workflows/specgit-accept.yml b/.github/workflows/specgit-accept.yml index ff9080efd3..c280272a4b 100644 --- a/.github/workflows/specgit-accept.yml +++ b/.github/workflows/specgit-accept.yml @@ -2,12 +2,11 @@ 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] + # 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: contents: read diff --git a/.gitignore b/.gitignore index 9df3f7ff3f..ee0809b005 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,10 @@ tsconfig.tsbuildinfo .opencode/commands/ .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/.specgit.yaml b/.specgit.yaml index 1007468662..63243e620b 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,11 +1,9 @@ version: 1 -delivery: issue378 +delivery: release-train-remediation context: kind: branch - branch: feat/378-issue378 + branch: fix/401-release-train-remediation issues: - - 378 - - 379 - - 380 - - 381 -pr: 382 + - 401 + - 402 +pr: 403 diff --git a/AGENTS.md b/AGENTS.md index a8dd70ee60..ee8e1066f7 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. @@ -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 04434d7803..27e995774a 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 diff --git a/README.md b/README.md index 028106958e..9fb28054d4 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 babc3017b5..02e40fc1e4 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 653ed3e895..3d57799ab7 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 31c9ab540f..7ed1df3da4 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 595f1f2029..0000000000 --- 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 46e58de173..0000000000 --- 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 87d1fc985d..0000000000 --- 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 6afcc414b6..ddb6ef9555 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 2cd3f05bc6..93ef984fd8 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/release-notes.ts b/packages/opencode/script/release-notes.ts new file mode 100644 index 0000000000..9f32aebebf --- /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 === 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 + } + 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 5f8cddecb7..75bb17c3b6 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/script/validate-dag-templates.ts b/packages/opencode/script/validate-dag-templates.ts index 4eec643946..40278b09e4 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 fea6d00978..4e4b50e02a 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/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index fc010a014e..38bb0a2725 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -332,7 +332,15 @@ 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. 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 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 // incident: a gate confirmed parent-supplied "defect evidence" that was a @@ -360,7 +368,7 @@ function node(input: { .join("\n\n"), input: { objective: input.objective, - ...(input.instruction?.trim() ? { instruction: input.instruction.trim() } : {}), + ...(hasInstruction ? { instruction: instructionText } : {}), }, }, ...(input.condition ? { condition: input.condition } : {}), @@ -521,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/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 27e1fb2895..0f37a9c25b 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.`, }) } @@ -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 cd76473244..5013b39a52 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/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts index 2329833914..f5d767746c 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 f8d7c22855..f362412387 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,48 @@ 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 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 (!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 {} + return walk(defs[ref] ?? {}, new Set([...refs, ref])) + } + 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(node)) { + if (key === "definitions" || key === "$defs" || key === "$id" || key === "$schema") continue + out[key] = walk(value, refs) + } + return out + } + 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 @@ -132,8 +180,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 +191,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 +216,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 e6d30bfe03..ea7aa6db40 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(), @@ -1878,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()) @@ -2183,6 +2194,7 @@ export const defaultLayer = Layer.suspend(() => RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, HookStartContext.defaultLayer, + Todo.defaultLayer, ), ), ), @@ -2338,6 +2350,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 0000000000..9265288138 --- /dev/null +++ b/packages/opencode/src/session/todo-reminders.ts @@ -0,0 +1,70 @@ +// 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' 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 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" +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/src/tool/submit_result.txt b/packages/opencode/src/tool/submit_result.txt index 14b7929087..c523c9fd2e 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/command/command.test.ts b/packages/opencode/test/command/command.test.ts index a1a7c182b4..8b6a0ae3b0 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/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index a36184d272..cf6e22011f 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -456,4 +456,47 @@ 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. + 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" }], + }) + expect(duplicated[0]?.prompt_template.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") + }) + + // 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" }], + }) + 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" }) + + 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-node-supervision.test.ts b/packages/opencode/test/dag/dag-node-supervision.test.ts index a957a62320..85101bed2d 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)", () => { diff --git a/packages/opencode/test/dag/dag-recovery.test.ts b/packages/opencode/test/dag/dag-recovery.test.ts index 322e78b774..a24275bf0a 100644 --- a/packages/opencode/test/dag/dag-recovery.test.ts +++ b/packages/opencode/test/dag/dag-recovery.test.ts @@ -1,12 +1,26 @@ -import { describe, expect, it } from "bun:test" +// 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" +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" 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 +29,21 @@ 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 }[], + 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) => + 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({ @@ -511,3 +535,133 @@ 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. 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-")) + 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 /) + }) + + // #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() + }) +}) 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 0000000000..24eb8b72b1 --- /dev/null +++ b/packages/opencode/test/dag/dag-schema-prompt-contract.test.ts @@ -0,0 +1,241 @@ +// 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 { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" + +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 store: DagStore.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 store = yield* DagStore.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, store, 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") + }), + ), + ) + }) + + // 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"), + ).text() + expect(description).toContain("Do not duplicate the payload") + expect(description).toContain("end your turn without restating the result") + }) +}) diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 560e48e457..c41dc0091e 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* () { diff --git a/packages/opencode/test/memory/memory.test.ts b/packages/opencode/test/memory/memory.test.ts index 9b711d3897..f6e85eb5d3 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 0000000000..9c8a3bed04 --- /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/release-notes.test.ts b/packages/opencode/test/release-notes.test.ts new file mode 100644 index 0000000000..0af1da0575 --- /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 e0542d9ad9..1486b03f24 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") }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 0139cc10c4..f181c97f02 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) }), 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 0000000000..0d5c8fb9e5 --- /dev/null +++ b/packages/opencode/test/session/todo-reminders.test.ts @@ -0,0 +1,295 @@ +// 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: () => Effect.succeed(todos), + }) +} + +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) + }), + ) +}) + +// 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]") + }), + ) +}) diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 27f28c0b2a..7e2f324418 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 6a672515ea..dcf2b98da1 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"} diff --git a/workflows/release-train.md b/workflows/release-train.md new file mode 100644 index 0000000000..74bb749429 --- /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 的交付一一对应。注意: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),从 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 + +- SpecGit 铁律优先:`specgit finish` 非 0 不请求合并;不削弱 policy;只认 `--json`。 +- 发布已由步骤 3 的确认授权,正式版不再二次询问。 +- 回退(合并后 CI 挂)永不静默:开 issue、进下一班车、在 Brief 中显式列出。