diff --git a/README.md b/README.md index 6d2e28a..ae0b90f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > An idea-to-MVP planning pipeline. Drives complex engineering work — code and architecture — through hard-gated phases that won't release a "ship-ready" plan until every decision is accepted and every task is decomposed. -This repository is a Claude Code plugin + bundled MCP server. It runs inside a fresh or template repo, partners with a human and an AI agent, and produces an executable MVP plan: a scoped manifest, a set of accepted decision records, and a dependency-aware task graph. Output goes to Linear (primary) or stays as filesystem artifacts (fallback). +This repository is a Claude Code plugin + bundled MCP server. It runs inside a fresh or template repo, partners with a human and an AI agent, and produces an executable MVP plan: a scoped manifest, a set of accepted decision records, and a dependency-aware task graph. Output goes to Linear, a [Symphony](https://github.com/openai/symphony)-compatible `WORKFLOW.md` for autonomous coding-agent runs, or stays as filesystem artifacts. This project is a derivative of [Joel Parker Henderson's canonical decision-record repo](https://github.com/joelparkerhenderson/decision-record). The canonical explanation of what a DR is and why it matters is preserved at [`docs/explanation/why-decision-records.md`](docs/explanation/why-decision-records.md). What this fork adds is **enforcement**: workflows, tools, and a state machine that make DRs a non-skippable part of planning with an agentic system. @@ -12,19 +12,19 @@ This project is a derivative of [Joel Parker Henderson's canonical decision-reco - **A dynamic wizard.** The agent reads current state and decides the next question — no rigid form. It draws from a seed library of common decisions (language, runtime, auth, data store, etc.) when the territory is familiar. - **Antagonistic review.** Each gate gets reviewed by skeptical lenses (operational + strategic) before progressing. - **A living, machine-readable artifact set.** JSON per record, append-only event log, Markdown views, and a static HTML index. Future-proofed for a richer UI. -- **Handoff to where work actually happens.** Push the completed plan to Linear, or stop at the filesystem. +- **Handoff to where work actually happens.** Push the completed plan to Linear, emit a Symphony `WORKFLOW.md` for autonomous Codex orchestration, or stop at the filesystem. - **Per-project calibration.** Quick POC, MVP, and Full tiers — pick the gate strictness that matches the work. The system won't make you write SWOT analyses for a weekend hack. ## Status -Active development — first usable cut is in. The pipeline is functional end-to-end (intake → scope → decisions → tasks → handoff to filesystem or Linear). A standalone CLI (`decision-record`) ships alongside the Claude Code plugin and MCP server. +Active development. Pipeline is functional end-to-end (intake → scope → decisions → tasks → handoff to filesystem, Linear, or Symphony). Post-handoff outcomes + semantic search over decisions ship in the current cut. A standalone CLI (`decision-record`) ships alongside the Claude Code plugin and MCP server. See [Symphony alignment](docs/explanation/symphony-alignment.md) for the roadmap toward a full project management app. ## Documentation Docs follow the [Diátaxis](https://diataxis.fr) framework — start at [`docs/README.md`](docs/README.md) to orient. - **Brand new?** → [`docs/tutorials/your-first-plan.md`](docs/tutorials/your-first-plan.md) is a 15-minute end-to-end walkthrough. -- **How do I do X?** → [`docs/how-to/`](docs/how-to/) (install, run the CLI, configure providers, hand off to Linear, calibrate gates). +- **How do I do X?** → [`docs/how-to/`](docs/how-to/) (install, run the CLI, configure providers, hand off to Linear or Symphony, track outcomes, search decisions, calibrate gates). - **What's the exact spec?** → [`docs/reference/`](docs/reference/) (CLI flags, MCP tools, data model, gates). - **Why is it built this way?** → [`docs/explanation/`](docs/explanation/) (design rationale, the five phases, why decision records). diff --git a/docs/README.md b/docs/README.md index 8f1ae5b..b94db62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ The decision-record docs follow the [Diátaxis](https://diataxis.fr) framework - [Run the CLI](how-to/run-the-cli.md) — idea, PRD, resume - [Configure LLM providers](how-to/configure-providers.md) — OpenAI, OpenRouter, Ollama, vLLM, LiteLLM - [Hand off to Linear](how-to/handoff-to-linear.md) +- [Hand off to Symphony](how-to/handoff-to-symphony.md) — emit a Symphony `WORKFLOW.md` for autonomous coding-agent runs - [Calibrate gates](how-to/calibrate-gates.md) — `poc` / `mvp` / `full` + overrides - [Track outcomes](how-to/track-outcomes.md) — record post-handoff observations - [Search decisions](how-to/search-decisions.md) — semantic + substring search and reindexing @@ -44,6 +45,7 @@ The decision-record docs follow the [Diátaxis](https://diataxis.fr) framework - [Design rationale](explanation/design-rationale.md) — why filesystem, why hard gates, why lens-rotating skeptic - [The five phases](explanation/the-five-phases.md) — what each phase does and why this shape - [Research notes](explanation/research-notes.md) — broader DR/ADR ecosystem, prior art, and the rationale for outcomes + semantic search +- [Symphony alignment](explanation/symphony-alignment.md) — how this system composes with OpenAI's Symphony orchestrator, and the staged plan for the full project management app ## Outside the docs tree diff --git a/docs/explanation/research-notes.md b/docs/explanation/research-notes.md index b2522ef..34d6232 100644 --- a/docs/explanation/research-notes.md +++ b/docs/explanation/research-notes.md @@ -110,6 +110,12 @@ A new `Outcome` entity, post-handoff, links forward from an accepted decision an The deciding-phase prompt now mandates a `dr_search_decisions` call per prospective topic *before* `dr_propose_decision`. Hits ≥ 0.85 either suppress the new DR or get cited via `related_decisions`. This is the operationalization of "agents should reuse prior art, not re-litigate" that the AgenticAKM literature names but doesn't ship. +## Symphony alignment (April 2026) + +After this release we extended the system to align with [OpenAI's Symphony](https://github.com/openai/symphony) — the open-source orchestrator that turns project work into autonomous coding-agent runs. Our system became the **planning + outcomes layer**; Symphony became the **execution layer**. The wire between them is Linear (today) or a future filesystem tracker extension. + +See [Symphony alignment](symphony-alignment.md) for the staged plan; slice 1 (Symphony handoff target + `WORKFLOW.md` emitter) is shipped. + ## Opportunities — future Out of scope for this release, on the medium-term roadmap: diff --git a/docs/explanation/symphony-alignment.md b/docs/explanation/symphony-alignment.md new file mode 100644 index 0000000..082f4d9 --- /dev/null +++ b/docs/explanation/symphony-alignment.md @@ -0,0 +1,117 @@ +# Symphony alignment — extending decision-record into a project management app + +This document explains where our system sits in the broader [Symphony](https://github.com/openai/symphony) ecosystem and the staged plan for extending decision-record into a full project management app aligned with OpenAI's research. + +## What Symphony is, and why it matters for us + +Symphony is OpenAI's open-source orchestration spec, released April 2026. Its core thesis: stop *supervising* coding agents, start *managing the work*. Every open issue gets a dedicated coding-agent session in an isolated workspace; agents run continuously; humans review the results. + +The spec is language-neutral and ships in a single [SPEC.md](https://github.com/openai/symphony/blob/main/SPEC.md). The Elixir implementation is a reference. OpenAI's internal data shows 500% PR throughput increases on teams running Symphony with disciplined `WORKFLOW.md`. + +The pieces: + +- A long-running daemon polls the issue tracker (Linear today) on a fixed cadence. +- For each eligible issue, it creates a per-issue workspace and launches a Codex coding-agent inside. +- A repo-owned `WORKFLOW.md` defines the runtime contract: tracker config, workspace setup hooks, agent settings, and the prompt template. +- Reconciliation continuously kills runs whose tracker state goes terminal, and retries failures with exponential backoff. +- Optional HTTP server exposes `/api/v1/state` for dashboards. + +Crucially, **Symphony is a scheduler/runner**. It does not produce the work, define the work, or evaluate the work. Those are the layers above and below it. + +## The complementarity + +| Layer | Owner | What it does | +|---|---|---| +| **Planning** | decision-record | Idea → scope → decisions → tasks → handoff | +| **Tracking** | Linear (or future filesystem tracker) | Holds the queue of issues | +| **Execution** | Symphony + Codex | Picks issues off the tracker, runs agents to land PRs | +| **Outcomes** | decision-record | Post-handoff observations linked back to decisions | + +We're a great planning + outcomes layer. Symphony is a great execution layer. The tracker is the wire between them. When the wire is Linear, it's exactly what Symphony already supports. + +## Three slices of alignment + +We've sketched the alignment as three slices, sequenced by leverage and amount of work. + +### Slice 1 — Symphony handoff target (✅ shipped) + +Add `dr_export_symphony` that emits a Symphony-spec-compliant `WORKFLOW.md`. Optionally push tasks to Linear first so the WORKFLOW.md targets the resulting Linear project. + +What this delivers: +- A clean single-tool handoff from planning to execution. +- The prompt template embeds our standing decisions and points the agent at `dr/decisions/` for full context. +- The handoff record captures `workflow_path` so downstream tooling can find it. + +What this does NOT solve: +- Operators still need to run Symphony separately. +- We have no view into what Symphony is doing once it's running. + +See: [Handoff to Symphony](../how-to/handoff-to-symphony.md), the `renderSymphonyWorkflow` module, and the [Symphony spec §5.3](https://github.com/openai/symphony/blob/main/SPEC.md#53-front-matter-schema). + +### Slice 2 — Filesystem tracker extension (planned) + +The Symphony spec [§18.2](https://github.com/openai/symphony/blob/main/SPEC.md#182-recommended-extensions-not-required-for-conformance) lists "Add pluggable issue tracker adapters beyond Linear" as a TODO. Our `dr/tasks/.json` directory is already a perfectly good issue store — it has IDs, states, dependencies, labels, descriptions, and stable URLs (via the rendered .md). + +The plan: +- Define `tracker.kind: filesystem` as an extension to the Symphony front-matter schema. +- Spec the candidate-fetch / state-refresh / terminal-fetch operations against `dr/tasks/*.json`. +- Map task `status` to Symphony's `active_states` / `terminal_states` model (e.g., `open|ready|in_progress` are active; `done|deferred` are terminal). +- Map task `depends_on` to Symphony's `blocked_by` so the dispatch blocker rule (§8.2) prevents work from running ahead of unmet dependencies. +- Implement a small reference adapter in our codebase that can be linked into Symphony either as a plugin (if Symphony's adapter API allows) or as a subprocess Symphony shells out to. +- Open a discussion/PR upstream on `openai/symphony` proposing the filesystem tracker as a conformant extension. + +What this delivers: +- No Linear needed. Our `dr/tasks/` is the tracker. Pure local dev loop. +- Faster cycle time — no GraphQL roundtrips. +- Decisions and tasks live next to the work the agent is doing. + +Cost: +- Need to define the tracker adapter API or wrap our own polling around the spec. +- Need to handle status writes — when an agent moves an issue from `ready` to `done`, our task file must be updated. This is a new responsibility for the agent or the adapter. +- May require upstream changes to Symphony or a fork. + +### Slice 3 — Status surface (planned) + +Symphony exposes `GET /api/v1/state` (running, retrying, token totals, rate limits) and `GET /api/v1/` (per-issue details) per [§13.7](https://github.com/openai/symphony/blob/main/SPEC.md#137-optional-http-server-extension). Our `dr/index.html` is the natural place to surface this data alongside decisions, tasks, and outcomes. + +The plan: +- Add a `dr_symphony_status` tool that hits `http://localhost:/api/v1/state` and returns the parsed snapshot. +- Cache the snapshot in `.dr/cache/symphony-status.json` (gitignored, regeneratable). +- Extend `dr_render` to embed live status data in `dr/index.html`: per-task running/retrying state, token spend, last event, last error. +- Render task rows with a Symphony status pill (running | retrying | done | blocked). +- Plumb agent's PR link back to the task — likely via Symphony's per-issue API once the agent opens the PR. + +What this delivers: +- Our HTML index becomes a single project management view: planning + execution + outcomes. +- Operators don't need to flip between Symphony's dashboard and ours. +- Outcomes can be recorded with direct reference to the agent's session id, PR, and metric. + +Cost: +- HTTP polling adds operational complexity. +- We become coupled to Symphony's optional HTTP extension (it's not REQUIRED for conformance per §13.7). + +## What about the planning-side feedback loop? + +A real project management app closes both ends: planning informs execution, *and* execution informs replanning. Our outcome-tracking work (already shipped) is the first half — outcomes record whether decisions held up. + +The second half — agent-authored AgDR (Agent Decision Records) — is on our backlog from the [research-notes](research-notes.md) doc. The shape: when an agent makes a non-trivial implementation decision (which test framework to add, how to structure a new module), it can record an AgDR linked back to the parent task. The AgDR is read by the next agent picking up dependent work. This is the operationalized version of the [AgenticAKM](https://arxiv.org/abs/2602.04445) paper's vision. + +Symphony's per-session log/event stream is the natural hook: when the agent emits a structured `agent_decision` event, we ingest it and write an AgDR. Slice 3's status surface gives us the wire. + +## Where this stops short + +Even with all three slices, this isn't a "full project management app" by every definition. It doesn't replace: + +- **Sprint planning / roadmapping** — multi-project capacity tracking, OKR alignment, milestone planning. +- **Time tracking / billing** — not our problem. +- **PM-side observability** — Slack notifications, dashboards your VP wants, etc. +- **Multi-tenant control plane** — Symphony itself is explicitly not multi-tenant per [§2.2](https://github.com/openai/symphony/blob/main/SPEC.md#22-non-goals). + +We're aiming for the **agentic-engineering** slice of project management: planning + execution + outcomes for teams running coding agents on real work. That's a defensible slice; we'll resist drifting into the rest until there's a specific need. + +## References + +- [openai/symphony](https://github.com/openai/symphony) — canonical repo and SPEC.md +- [OpenAI announcement](https://openai.com/index/open-source-codex-orchestration-symphony/) — Apr 2026 release +- [Research notes](research-notes.md) — broader DR/ADR ecosystem context, AgDR roadmap, agentic AKM literature +- [Handoff to Symphony](../how-to/handoff-to-symphony.md) — slice 1 usage diff --git a/docs/how-to/handoff-to-symphony.md b/docs/how-to/handoff-to-symphony.md new file mode 100644 index 0000000..5f77c91 --- /dev/null +++ b/docs/how-to/handoff-to-symphony.md @@ -0,0 +1,127 @@ +# Hand off to Symphony + +[Symphony](https://github.com/openai/symphony) is OpenAI's open-source orchestrator that turns project work into autonomous coding-agent runs. It polls an issue tracker (Linear today), creates isolated per-issue workspaces, and runs a Codex coding-agent inside each one. It's designed to let teams **manage work** instead of supervising coding agents. + +Our planning pipeline ends where Symphony begins. `dr_export_symphony` emits a Symphony-spec-compliant `WORKFLOW.md` for the target repo, optionally pushing tasks to Linear first so Symphony has issues to dispatch. + +## What gets emitted + +A single `WORKFLOW.md` at the repo root containing: + +- **YAML front matter** with `tracker`, `polling`, `workspace`, `hooks` (optional), `agent`, and `codex` blocks per [Symphony spec §5.3](https://github.com/openai/symphony/blob/main/SPEC.md). +- **Prompt template** (Liquid syntax) that: + - States project context (title, effort level, accepted-decision count, scope) + - Lists all standing accepted decisions with their selected positions + - Per-issue instructions that tell the coding agent to (1) resolve the Symphony issue to the underlying `dr/tasks/` task, (2) load the `decision_refs` from `dr/decisions/`, (3) honor `depends_on`, (4) implement the task, (5) satisfy `acceptance_criteria`, (6) test, (7) open a PR and move the tracker issue to review + - Explicit guard rails: **do not modify `dr/decisions/` or `dr/outcomes/`**, do not mark issues `done` if work is partial, do not leave the workspace + +## When to use it + +You're handing off when: +- All your scoping, decisions, and tasks are accepted and the project is in `handing-off` phase. +- The execution team wants to use Symphony — they prefer "manage work" to "supervise agents." + +You're **not** using Symphony when: +- The execution team is humans only and you just want Linear or filesystem tracking. +- The work isn't suitable for autonomous coding agents (high-judgment, multi-system, or needs significant human design). + +## Three ways to invoke + +### Through the CLI + +When `LINEAR_API_KEY` is set, the CLI offers Symphony as the first option in the handoff phase: + +``` +> LINEAR_API_KEY detected. Hand off to Symphony (push to Linear + emit WORKFLOW.md for the Codex orchestrator)? [Y/n] +``` + +Accept and supply the team ID, and the CLI will (a) push your tasks to Linear, (b) write `WORKFLOW.md` to the project's working directory, (c) finalize the project as `handed-off` with `target: symphony`. + +### Through the MCP tool + +```jsonc +// Tool: dr_export_symphony +{ + "workflow_path": "WORKFLOW.md", // optional; defaults to /WORKFLOW.md + "linear_team_id": "team-uuid", // optional; if present, push to Linear first + "linear_api_key": "...", // optional; defaults to $LINEAR_API_KEY + "tracker_project_slug": "explicit-slug", // optional; falls back to Linear slug, then "CHANGEME" + "polling_interval_ms": 30000, + "workspace_root": "./.symphony-workspaces", + "after_create_hook": "git clone ...\nnpm install", + "max_concurrent_agents": 5, + "max_turns": 20, + "codex_command": "codex app-server" +} +``` + +Returns `{ target: "symphony", workflow_path, tracker_project_slug, linear, decisions, tasks, project }`. + +The project must be in `handing-off` phase — call `dr_advance` first if needed. + +### Standalone WORKFLOW.md without Linear + +If you don't have `LINEAR_API_KEY` or want to wire a non-Linear tracker by hand later, call the tool with no `linear_team_id`. You'll get a `WORKFLOW.md` with `project_slug: CHANGEME` — edit it before running Symphony. + +## After emission — wiring up Symphony + +The Symphony service is a separate process. Follow the [Symphony README](https://github.com/openai/symphony) to install it (use the experimental Elixir reference impl, or ask a coding agent to build one in your preferred language from the SPEC). Then, in the repo where `WORKFLOW.md` lives: + +```bash +export LINEAR_API_KEY=... # the canonical env var for tracker auth +symphony WORKFLOW.md # or however the impl is invoked +``` + +Symphony will: +1. Watch `WORKFLOW.md` for live changes (`§6.2 Dynamic Reload`). +2. Poll Linear every `polling.interval_ms` for issues in `active_states`. +3. For each eligible issue: create a workspace under `workspace.root`, run the `after_create` hook if first-time, run `before_run` hook, launch Codex with the rendered prompt template. +4. Stream agent updates; track tokens, turn count, rate limits. +5. On stall or terminal state, kill the worker. On clean exit, schedule a continuation tick. +6. Honor exponential-backoff retries on failure. + +## What our handoff record captures + +After a Symphony export, `project.handoff` looks like: + +```jsonc +{ + "target": "symphony", + "target_id": "linear-project-uuid-or-slug-or-CHANGEME", + "target_url": "https://linear.app/.../project/...", // when Linear was pushed + "exported_at": "2026-05-17T...", + "issue_count": 7, + "document_count": 5, + "workflow_path": "/abs/path/to/WORKFLOW.md" +} +``` + +`workflow_path` is new for Symphony handoffs — it tells you and any later tooling where the live WORKFLOW.md sits. + +## Editing WORKFLOW.md after handoff + +Symphony reloads `WORKFLOW.md` on filesystem change. Common edits: + +- **Tighten the prompt** if agents are wandering or skipping acceptance criteria. +- **Add a `before_run` hook** to install deps or sync state before each turn. +- **Tune `agent.max_concurrent_agents`** for parallelism limits. +- **Change `active_states`** if your tracker uses non-default state names. +- **Add a `linear_graphql` tool advertisement** for richer in-session Linear access (extension). + +Treat WORKFLOW.md as a version-controlled, repo-owned policy file. Edits are normal commits. + +## What Symphony does NOT do + +Per [§2.2 Non-Goals](https://github.com/openai/symphony/blob/main/SPEC.md#22-non-goals): + +- It doesn't manage your tracker (no first-class write APIs in the orchestrator — agents do the writes via tools). +- It doesn't prescribe a dashboard. +- It isn't a general workflow engine. +- It doesn't guarantee strong sandboxing — you must explicitly choose your trust posture. + +For each of those: our system covers some via the planning surface (we set the contract and recorded the rationale), and the agent's tools cover the rest at runtime. + +## Where to go next + +- After agents start landing PRs, record `Outcomes` against the decisions those PRs validated or invalidated. See [Track outcomes](track-outcomes.md). +- For the broader roadmap of how this system extends into a full project management app aligned with Symphony, read [Symphony alignment plan](../explanation/symphony-alignment.md). diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index c2a0c9a..4eecaa2 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -254,6 +254,30 @@ Push to Linear via the GraphQL API. Creates a Project, Issues per decision (labe | `dry_run` | boolean | Default `false`. | | `sign_off_by`, `sign_off_actor`, `sign_off_notes` | various | Sign-off metadata. | +### `dr_export_symphony` + +Emit a Symphony-compatible `WORKFLOW.md` to the target repo and finalize handoff with `target: "symphony"`. Optionally push tasks to Linear first so the WORKFLOW.md targets the resulting Linear project. See [Handoff to Symphony](../how-to/handoff-to-symphony.md) for the full workflow. + +| Input | Type | Notes | +|---|---|---| +| `workflow_path` | string? | Where to write WORKFLOW.md. Defaults to `/WORKFLOW.md`. Parent dirs are created. | +| `linear_team_id` | string? | If present, push tasks to Linear before emitting WORKFLOW.md. | +| `linear_api_key` | string? | Linear API token. Defaults to `$LINEAR_API_KEY`. Required when `linear_team_id` is set. | +| `tracker_kind` | `"linear"` | Default `linear` (the only tracker the Symphony spec currently lists). | +| `tracker_project_slug` | string? | Override the slug. Falls back to (a) just-pushed Linear slug, (b) prior Linear handoff's target_id, (c) `"CHANGEME"`. | +| `tracker_api_key_var` | string | Env-var name Symphony reads at runtime. Default `LINEAR_API_KEY`. | +| `tracker_endpoint` | string? | Override the GraphQL endpoint. | +| `polling_interval_ms` | integer? | Default `30000`. | +| `workspace_root` | string? | Default `./.symphony-workspaces`. | +| `after_create_hook` | string? | Shell script Symphony runs once per workspace at creation time (typically `git clone`). | +| `before_run_hook` | string? | Shell script Symphony runs before each turn. | +| `max_concurrent_agents` | integer? | Default `5`. | +| `max_turns` | integer? | Default `20`. | +| `codex_command` | string? | Default `codex app-server`. | +| `sign_off_by`, `sign_off_actor`, `sign_off_notes` | various | Sign-off metadata. | + +Returns `{ target: "symphony", workflow_path, tracker_project_slug, linear, decisions, tasks, project }`. The `handoff` record on the project includes `workflow_path` pointing at the emitted file. + ## Where the schemas live Every tool's input is validated by Zod at the server. JSON Schema mirrors for external consumers live in [`../../schemas/`](../../schemas/). The Zod source of truth is at [`server/src/schemas/index.ts`](../../server/src/schemas/index.ts). diff --git a/server/src/cli/orchestrator.ts b/server/src/cli/orchestrator.ts index 4828a0f..f943641 100644 --- a/server/src/cli/orchestrator.ts +++ b/server/src/cli/orchestrator.ts @@ -270,17 +270,70 @@ async function advanceHandoff(opts: OrchestratorOptions): Promise<{ exitCode: nu success("Artifacts rendered."); const linearAvailable = Boolean(process.env.LINEAR_API_KEY); - let target: "linear" | "filesystem" = "filesystem"; + let target: "linear" | "filesystem" | "symphony" = "filesystem"; if (linearAvailable) { - const wantsLinear = await confirm( - "LINEAR_API_KEY detected. Push the plan to Linear?", + const wantsSymphony = await confirm( + "LINEAR_API_KEY detected. Hand off to Symphony (push to Linear + emit WORKFLOW.md for the Codex orchestrator)?", opts, true ); - target = wantsLinear ? "linear" : "filesystem"; + if (wantsSymphony) { + target = "symphony"; + } else { + const wantsLinear = await confirm( + "Push to Linear only (no Symphony WORKFLOW.md)?", + opts, + true + ); + target = wantsLinear ? "linear" : "filesystem"; + } } + // When LINEAR_API_KEY is not set, target stays 'filesystem'. To emit a + // Symphony WORKFLOW.md without Linear, call dr_export_symphony directly via + // the MCP server — it accepts a placeholder slug. - if (target === "linear") { + if (target === "symphony") { + let teamId: string | undefined; + if (linearAvailable) { + teamId = await ask( + "Linear team ID (blank to skip Linear push):", + opts, + process.env.LINEAR_TEAM_ID ?? "" + ); + if (teamId === "") teamId = undefined; + } + const proceed = await confirm( + teamId + ? `Push tasks to Linear team ${teamId} and emit WORKFLOW.md?` + : "Emit WORKFLOW.md without pushing to Linear?", + opts, + true + ); + if (!proceed) { + warn("Symphony handoff cancelled. Project remains in 'handing-off'."); + return { exitCode: 0 }; + } + const symRes = await callTool(opts.cwd, "dr_export_symphony", { + linear_team_id: teamId, + sign_off_by: "human", + sign_off_actor: "cli-user", + }); + if (!symRes.ok) { + error(`Symphony handoff failed: ${(symRes.errors ?? []).join("; ")}`); + return { exitCode: 1 }; + } + const data = symRes.data as { + workflow_path: string; + linear: { project_url?: string; issues_created: number } | null; + }; + success(`Wrote ${data.workflow_path}`); + if (data.linear) { + success(`Pushed ${data.linear.issues_created} issues to Linear.`); + if (data.linear.project_url) info(`Linear project: ${data.linear.project_url}`); + } else { + info("No Linear push. Edit WORKFLOW.md's tracker.project_slug before running Symphony."); + } + } else if (target === "linear") { const teamId = await ask( "Linear team ID:", opts, diff --git a/server/src/handoff/symphony.ts b/server/src/handoff/symphony.ts new file mode 100644 index 0000000..6e0f395 --- /dev/null +++ b/server/src/handoff/symphony.ts @@ -0,0 +1,292 @@ +import { Decision, Project, Task } from "../schemas/index.js"; + +/** + * Configuration for rendering a Symphony WORKFLOW.md. Mirrors the front-matter + * schema in https://github.com/openai/symphony/blob/main/SPEC.md §5.3. + * + * Designed to be a one-shot snapshot: at handoff time we freeze the runtime + * config and the prompt template so Symphony can consume the result without + * any further coordination. Operators edit WORKFLOW.md after the fact; the + * spec mandates dynamic reload. + */ +export interface SymphonyWorkflowInputs { + project: Project; + decisions: Decision[]; + tasks: Task[]; + /** Issue tracker config. Defaults to Linear because that's the only tracker + * the current spec version (Draft v1) lists as supported (§5.3.1). */ + tracker?: { + kind?: "linear"; + endpoint?: string; + api_key_var?: string; + project_slug?: string; + active_states?: string[]; + terminal_states?: string[]; + }; + polling?: { + interval_ms?: number; + }; + workspace?: { + root?: string; + after_create?: string; + before_run?: string; + after_run?: string; + before_remove?: string; + timeout_ms?: number; + }; + agent?: { + max_concurrent_agents?: number; + max_turns?: number; + max_retry_backoff_ms?: number; + }; + codex?: { + command?: string; + turn_timeout_ms?: number; + read_timeout_ms?: number; + stall_timeout_ms?: number; + }; +} + +const DEFAULTS = { + tracker: { + kind: "linear" as const, + endpoint: "https://api.linear.app/graphql", + api_key_var: "LINEAR_API_KEY", + active_states: ["Todo", "In Progress"], + terminal_states: ["Closed", "Cancelled", "Canceled", "Duplicate", "Done"], + }, + polling: { interval_ms: 30_000 }, + workspace: { + root: "./.symphony-workspaces", + timeout_ms: 60_000, + }, + agent: { + max_concurrent_agents: 5, + max_turns: 20, + max_retry_backoff_ms: 300_000, + }, + codex: { + command: "codex app-server", + turn_timeout_ms: 3_600_000, + read_timeout_ms: 5_000, + stall_timeout_ms: 300_000, + }, +}; + +/** + * Render a Symphony-compliant WORKFLOW.md. The output has YAML front matter + * (§5.2 of the spec) plus a Markdown prompt body using Liquid template syntax + * for `{{ issue.* }}` and `{{ attempt }}` (§5.4). + */ +export function renderSymphonyWorkflow(inputs: SymphonyWorkflowInputs): string { + const front = renderFrontMatter(inputs); + const prompt = renderPromptTemplate(inputs); + return `${front}\n${prompt}\n`; +} + +function renderFrontMatter(inputs: SymphonyWorkflowInputs): string { + const tracker = mergeDefaults(DEFAULTS.tracker, inputs.tracker); + const polling = mergeDefaults(DEFAULTS.polling, inputs.polling); + const workspace = mergeDefaults(DEFAULTS.workspace, inputs.workspace); + const agent = mergeDefaults(DEFAULTS.agent, inputs.agent); + const codex = mergeDefaults(DEFAULTS.codex, inputs.codex); + + const lines: string[] = ["---"]; + lines.push("tracker:"); + lines.push(` kind: ${tracker.kind}`); + lines.push(` endpoint: ${tracker.endpoint}`); + lines.push(` api_key: $${tracker.api_key_var}`); + if (tracker.project_slug) { + lines.push(` project_slug: ${yamlString(tracker.project_slug)}`); + } + lines.push(` active_states: ${yamlList(tracker.active_states ?? [])}`); + lines.push(` terminal_states: ${yamlList(tracker.terminal_states ?? [])}`); + + lines.push("polling:"); + lines.push(` interval_ms: ${polling.interval_ms}`); + + lines.push("workspace:"); + lines.push(` root: ${yamlString(workspace.root!)}`); + + const hooks: [string, string | undefined][] = [ + ["after_create", workspace.after_create], + ["before_run", workspace.before_run], + ["after_run", workspace.after_run], + ["before_remove", workspace.before_remove], + ]; + const presentHooks = hooks.filter(([, v]) => v && v.trim().length > 0); + if (presentHooks.length > 0 || workspace.timeout_ms !== DEFAULTS.workspace.timeout_ms) { + lines.push("hooks:"); + for (const [name, body] of presentHooks) { + lines.push(` ${name}: |`); + for (const ln of (body as string).split("\n")) { + lines.push(` ${ln}`); + } + } + if (workspace.timeout_ms !== undefined) { + lines.push(` timeout_ms: ${workspace.timeout_ms}`); + } + } + + lines.push("agent:"); + lines.push(` max_concurrent_agents: ${agent.max_concurrent_agents}`); + lines.push(` max_turns: ${agent.max_turns}`); + lines.push(` max_retry_backoff_ms: ${agent.max_retry_backoff_ms}`); + + lines.push("codex:"); + lines.push(` command: ${yamlString(codex.command!)}`); + lines.push(` turn_timeout_ms: ${codex.turn_timeout_ms}`); + lines.push(` read_timeout_ms: ${codex.read_timeout_ms}`); + lines.push(` stall_timeout_ms: ${codex.stall_timeout_ms}`); + + lines.push("---"); + return lines.join("\n"); +} + +function renderPromptTemplate(inputs: SymphonyWorkflowInputs): string { + const { project, decisions, tasks } = inputs; + const acceptedDecisions = decisions.filter((d) => d.status === "accepted"); + const taskTotal = tasks.length; + const decisionTotal = acceptedDecisions.length; + + const sections: string[] = []; + + sections.push(`# Symphony workflow: ${project.title}`); + sections.push(""); + sections.push( + "This WORKFLOW.md is generated by [decision-record](https://github.com/protoLabsAI/protoLedger). Edit it; Symphony reloads on change. The full plan that produced this workflow lives in `dr/` in the repo workspace." + ); + sections.push(""); + sections.push("## Project context"); + sections.push(""); + sections.push(`- **Project**: \`${project.id}\` — ${project.title}`); + if (project.description) sections.push(`- **Description**: ${project.description}`); + sections.push(`- **Effort level**: \`${project.effort_level}\``); + sections.push(`- **Decisions accepted**: ${decisionTotal}`); + sections.push(`- **Tasks at handoff**: ${taskTotal}`); + sections.push(""); + if (project.scope) { + if (project.scope.in_scope.length > 0) { + sections.push("**In scope:**"); + for (const s of project.scope.in_scope) sections.push(`- ${s}`); + sections.push(""); + } + if (project.scope.success_criteria.length > 0) { + sections.push("**Success criteria:**"); + for (const s of project.scope.success_criteria) sections.push(`- ${s}`); + sections.push(""); + } + if (project.scope.out_of_scope.length > 0) { + sections.push("**Out of scope:**"); + for (const s of project.scope.out_of_scope) sections.push(`- ${s}`); + sections.push(""); + } + } + + sections.push("## Standing decisions"); + sections.push(""); + if (acceptedDecisions.length === 0) { + sections.push("_No accepted decisions at handoff. Re-run the planning pipeline if a task requires architectural context._"); + } else { + for (const d of acceptedDecisions) { + const pos = d.selected_position ? ` → **${d.selected_position}**` : ""; + sections.push(`- \`${d.id}\` ${d.title}${pos}`); + } + sections.push(""); + sections.push( + "Full content for each decision: read `dr/decisions/.md` in the workspace. **Do not re-litigate accepted decisions** without recording a superseding DR." + ); + } + sections.push(""); + + sections.push("## Per-issue instructions"); + sections.push(""); + sections.push( + "You are picking up an issue from the project tracker. The issue corresponds to a `decision-record` task. Your job is to implement it inside this workspace and hand it back via the tracker." + ); + sections.push(""); + sections.push(`Attempt: {% if attempt %}retry #{{ attempt }}{% else %}first run{% endif %}`); + sections.push(""); + sections.push("**Issue:** `{{ issue.identifier }}` — {{ issue.title }}"); + sections.push(""); + sections.push("{% if issue.description %}"); + sections.push("**Description:**"); + sections.push(""); + sections.push("{{ issue.description }}"); + sections.push("{% endif %}"); + sections.push(""); + sections.push("{% if issue.labels and issue.labels.size > 0 %}"); + sections.push("**Labels:** {% for label in issue.labels %}`{{ label }}`{% unless forloop.last %}, {% endunless %}{% endfor %}"); + sections.push("{% endif %}"); + sections.push(""); + sections.push("{% if issue.branch_name %}"); + sections.push("**Branch:** `{{ issue.branch_name }}`"); + sections.push("{% endif %}"); + sections.push(""); + sections.push("### Workflow"); + sections.push(""); + sections.push( + "1. Resolve this Symphony issue to the underlying decision-record task. Find the task file in `dr/tasks/` whose `external_ref.id` matches `{{ issue.identifier }}` (look at `dr/tasks/T*.json`)." + ); + sections.push( + "2. Read the task's `decision_refs` and load each referenced decision from `dr/decisions/.md`. Honor the selected position and argument as load-bearing constraints." + ); + sections.push( + "3. Check the task's `depends_on` list. If any predecessors are not yet `done` in this workspace, stop and surface that as a blocker rather than working ahead." + ); + sections.push( + "4. Implement the task. Stay inside this workspace; never modify files outside `cwd`. Match the project's existing conventions — read a few neighboring files before writing." + ); + sections.push( + "5. Satisfy the `acceptance_criteria` from the task. If you cannot, surface why; do not silently weaken them." + ); + sections.push( + "6. Run the project's test suite (if any). If tests don't exist, add the minimum that proves your change works." + ); + sections.push( + "7. Open a PR. The PR description MUST cite the task id and the accepted decisions you relied on (`dr:T0001-foo`, `dr:0003-bar`). Move the tracker issue into the project's review/handoff state via the tracker tool." + ); + sections.push(""); + sections.push("### What you MUST NOT do"); + sections.push(""); + sections.push( + "- Modify or delete files in `dr/decisions/` or `dr/outcomes/`. Those are append-only sources of truth. If you believe a decision is wrong, surface it as a comment on the issue and stop." + ); + sections.push( + "- Mark an issue Done if work is partial. Move it to the project's review state instead, and call out gaps explicitly." + ); + sections.push("- Modify files outside this workspace's working directory."); + sections.push(""); + sections.push("### Outcome recording"); + sections.push(""); + sections.push( + "After your change has merged, the project's planning surface records an Outcome record (`dr_record_outcome`) for any decision your work tested. You don't author the Outcome; you provide the evidence — a PR link, a metric, a measured result — in the issue's review comment so the planner can pick it up." + ); + + return sections.join("\n"); +} + +function mergeDefaults< + D extends Record, + O extends Record, +>(defaults: D, overrides: O | undefined): D & O { + const out: Record = { ...defaults }; + if (overrides) { + for (const [k, v] of Object.entries(overrides)) { + if (v !== undefined) out[k] = v; + } + } + return out as D & O; +} + +function yamlString(s: string): string { + if (s.length === 0) return `""`; + if (/^[A-Za-z0-9_\-./~$]+$/.test(s)) return s; + // Quote if it contains anything that could confuse YAML. + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function yamlList(items: string[]): string { + if (items.length === 0) return "[]"; + return `[${items.map(yamlString).join(", ")}]`; +} diff --git a/server/src/schemas/index.ts b/server/src/schemas/index.ts index 54daea0..bbbebfc 100644 --- a/server/src/schemas/index.ts +++ b/server/src/schemas/index.ts @@ -82,12 +82,18 @@ export const SignOffSchema = z.object({ export type SignOff = z.infer; export const HandoffRecordSchema = z.object({ - target: z.enum(["linear", "filesystem"]), + target: z.enum(["linear", "filesystem", "symphony"]), target_id: z.string().optional(), target_url: z.string().url().optional(), exported_at: z.string().datetime(), issue_count: z.number().int().min(0).optional(), document_count: z.number().int().min(0).optional(), + workflow_path: z + .string() + .optional() + .describe( + "Path to the emitted WORKFLOW.md when target='symphony'. Repo-owned, read by the Symphony service." + ), }); export type HandoffRecord = z.infer; diff --git a/server/src/tools/handoff.ts b/server/src/tools/handoff.ts index 23c2aa9..2110fb5 100644 --- a/server/src/tools/handoff.ts +++ b/server/src/tools/handoff.ts @@ -1,8 +1,11 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve as resolvePath, isAbsolute, join } from "node:path"; import { z } from "zod"; import { Store } from "../storage/store.js"; import { fail, ok, registerTool } from "./registry.js"; import { nowIso } from "../util.js"; import { buildExportPlan, executeLinearExport } from "../handoff/linear.js"; +import { renderSymphonyWorkflow } from "../handoff/symphony.js"; import { Task, TaskSchema, PipelineState, Project } from "../schemas/index.js"; function resolveCwd(cwd: string | undefined): string { @@ -19,12 +22,13 @@ async function finalizeHandoff( store: Store, project: Project, state: PipelineState, - target: "linear" | "filesystem", + target: "linear" | "filesystem" | "symphony", result: { target_id?: string; target_url?: string; issue_count?: number; document_count?: number; + workflow_path?: string; }, signOff: { by: "agent" | "human"; actor?: string; notes?: string } ): Promise<{ project: Project; state: PipelineState }> { @@ -36,6 +40,7 @@ async function finalizeHandoff( target_url: result.target_url, issue_count: result.issue_count, document_count: result.document_count, + workflow_path: result.workflow_path, }; const updatedProject: Project = { ...project, @@ -258,4 +263,230 @@ export function registerHandoffTools(): void { }); }, }); + + registerTool({ + name: "dr_export_symphony", + description: + "Emit a Symphony-compatible WORKFLOW.md to the target repo and finalize handoff with target='symphony'. Symphony (https://github.com/openai/symphony) is OpenAI's open-source orchestrator that turns project work into autonomous coding-agent runs. This tool writes a spec-compliant WORKFLOW.md; Symphony itself runs separately and polls the tracker for issues to dispatch. Optionally pushes tasks to Linear first (set `linear_team_id`) so the WORKFLOW.md can target the resulting Linear project; without it, the WORKFLOW.md is emitted with a placeholder slug for you to fill in by hand.", + inputSchema: z.object({ + cwd: z.string().optional(), + workflow_path: z + .string() + .optional() + .describe( + "Where to write WORKFLOW.md. Absolute or relative to --cwd. Defaults to '/WORKFLOW.md'. Symphony reads from the cwd it was launched in." + ), + linear_team_id: z + .string() + .optional() + .describe( + "If provided, push tasks to Linear first and use the resulting project slug in WORKFLOW.md. Requires linear_api_key (or LINEAR_API_KEY env)." + ), + linear_api_key: z.string().optional(), + tracker_kind: z.enum(["linear"]).default("linear"), + tracker_project_slug: z + .string() + .optional() + .describe( + "Override the tracker project slug. If omitted: uses the Linear project pushed in this call, falls back to a prior Linear handoff's target_id, else emits 'CHANGEME' for you to edit." + ), + tracker_api_key_var: z + .string() + .default("LINEAR_API_KEY") + .describe("Environment variable that holds the tracker API token at Symphony runtime."), + tracker_endpoint: z + .string() + .optional() + .describe("Override the tracker GraphQL endpoint. Defaults to Linear's API."), + polling_interval_ms: z.number().int().min(1000).optional(), + workspace_root: z + .string() + .optional() + .describe("Path Symphony uses for per-issue workspaces. Defaults to './.symphony-workspaces'."), + after_create_hook: z + .string() + .optional() + .describe( + "Shell script Symphony runs the first time it creates a workspace for an issue. Typically clones the repo into the workspace." + ), + before_run_hook: z.string().optional(), + max_concurrent_agents: z.number().int().min(1).optional(), + max_turns: z.number().int().min(1).optional(), + codex_command: z + .string() + .optional() + .describe("Command Symphony launches inside the workspace. Defaults to 'codex app-server'."), + ...SignOffShape, + }), + async handler(input) { + const cwd = resolveCwd(input.cwd); + const store = new Store(cwd); + if (!(await store.hasProject())) { + return fail(`No project initialized at ${cwd}.`); + } + const project = await store.readProject(); + const state = await store.readState(); + if (project.status !== "handing-off") { + return fail( + `Project must be in 'handing-off' phase to export. Currently '${project.status}'. Run dr_advance first.` + ); + } + const decisions = await store.listDecisions(); + const tasks = await store.listTasks(); + + let linearPushResult: { + project_id: string; + project_url?: string; + slug?: string; + issues_created: number; + } | null = null; + if (input.linear_team_id) { + const apiKey = input.linear_api_key ?? process.env.LINEAR_API_KEY; + if (!apiKey) { + return fail( + "linear_team_id was provided but no API key found. Pass 'linear_api_key' or set LINEAR_API_KEY." + ); + } + const plan = buildExportPlan(project, decisions, tasks); + try { + const exportResult = await executeLinearExport( + { api_key: apiKey }, + input.linear_team_id, + plan + ); + for (const issue of exportResult.issues) { + if (issue.dr_id.startsWith("T")) { + const task = await store.readTask(issue.dr_id); + const updated: Task = TaskSchema.parse({ + ...task, + external_ref: { + system: "linear", + id: issue.linear.identifier, + url: issue.linear.url, + }, + updated_at: nowIso(), + }); + await store.writeTask(updated); + } + } + // Linear's project URL exposes the slug; the project.id is a UUID. + // Most callers will want the URL slug as the Symphony tracker target. + const url = exportResult.project.url; + const slugFromUrl = url ? url.split("/").filter(Boolean).pop() : undefined; + linearPushResult = { + project_id: exportResult.project.id, + project_url: url, + slug: slugFromUrl, + issues_created: exportResult.issues.length, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await store.appendEvent({ + at: nowIso(), + actor: "system", + kind: "export_failed", + entity_kind: "project", + entity_id: project.id, + payload: { target: "symphony", phase: "linear_push", error: message }, + }); + return fail(`Linear push (for Symphony handoff) failed: ${message}`); + } + } + + const slugFromPriorHandoff = + project.handoff?.target === "linear" ? project.handoff.target_id : undefined; + const projectSlug = + input.tracker_project_slug ?? + linearPushResult?.slug ?? + slugFromPriorHandoff ?? + "CHANGEME"; + + const body = renderSymphonyWorkflow({ + project, + decisions, + tasks, + tracker: { + kind: input.tracker_kind, + endpoint: input.tracker_endpoint, + api_key_var: input.tracker_api_key_var, + project_slug: projectSlug, + }, + polling: input.polling_interval_ms + ? { interval_ms: input.polling_interval_ms } + : undefined, + workspace: { + root: input.workspace_root, + after_create: input.after_create_hook, + before_run: input.before_run_hook, + }, + agent: { + max_concurrent_agents: input.max_concurrent_agents, + max_turns: input.max_turns, + }, + codex: { command: input.codex_command }, + }); + + const requested = input.workflow_path ?? "WORKFLOW.md"; + const absoluteWorkflow = isAbsolute(requested) + ? requested + : resolvePath(join(cwd, requested)); + + const now = nowIso(); + await store.appendEvent({ + at: now, + actor: input.sign_off_by, + actor_name: input.sign_off_actor, + kind: "export_started", + entity_kind: "project", + entity_id: project.id, + payload: { target: "symphony", workflow_path: absoluteWorkflow }, + }); + + try { + await mkdir(dirname(absoluteWorkflow), { recursive: true }); + await writeFile(absoluteWorkflow, body, "utf8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await store.appendEvent({ + at: nowIso(), + actor: "system", + kind: "export_failed", + entity_kind: "project", + entity_id: project.id, + payload: { target: "symphony", error: message, workflow_path: absoluteWorkflow }, + }); + return fail(`Symphony WORKFLOW.md write failed: ${message}`); + } + + const finalized = await finalizeHandoff( + store, + project, + state, + "symphony", + { + target_id: linearPushResult?.project_id ?? projectSlug, + target_url: linearPushResult?.project_url, + issue_count: tasks.length, + document_count: decisions.length, + workflow_path: absoluteWorkflow, + }, + { + by: input.sign_off_by, + actor: input.sign_off_actor, + notes: input.sign_off_notes, + } + ); + + return ok({ + target: "symphony", + workflow_path: absoluteWorkflow, + tracker_kind: input.tracker_kind, + tracker_project_slug: projectSlug, + linear: linearPushResult, + decisions: decisions.length, + tasks: tasks.length, + project: finalized.project, + }); + }, + }); } diff --git a/server/tests/flow-symphony.test.ts b/server/tests/flow-symphony.test.ts new file mode 100644 index 0000000..2ba13d3 --- /dev/null +++ b/server/tests/flow-symphony.test.ts @@ -0,0 +1,292 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { makeTmpProject, TmpProject } from "./helpers/tmp-project.js"; +import { registerAllTools } from "../src/tools/index.js"; +import { getTool } from "../src/tools/registry.js"; +import { Store } from "../src/storage/store.js"; +import { + Decision, + DecisionSchema, + PipelineState, + Project, + Task, + TaskSchema, +} from "../src/schemas/index.js"; +import { resolveEffectiveGateConfig } from "../src/gate.js"; + +const NOW = "2026-05-17T00:00:00.000Z"; + +async function call(name: string, args: Record) { + const tool = getTool(name); + if (!tool) throw new Error(`tool not registered: ${name}`); + const parsed = tool.inputSchema.parse(args); + return tool.handler(parsed); +} + +async function seedHandingOff(project: TmpProject): Promise { + const store = new Store(project.cwd); + await store.ensureLayout(); + const proj: Project = { + id: "demo", + title: "Demo", + description: "for symphony tests", + created_at: NOW, + updated_at: NOW, + effort_level: "poc", + status: "handing-off", + sign_offs: [], + gate_config: { preset: "poc" }, + tags: [], + scope: { + in_scope: ["x"], + out_of_scope: [], + success_criteria: ["y"], + nice_to_have: [], + }, + }; + const state: PipelineState = { + schema_version: "0.1.0", + project_id: "demo", + phase: "handing-off", + effective_gate_config: resolveEffectiveGateConfig({ preset: "poc" }), + next_decision_seq: 2, + next_task_seq: 2, + next_outcome_seq: 1, + pending_questions: [], + gate_failures: [], + last_event_at: NOW, + }; + await store.writeProject(proj); + await store.writeState(state); + + const decision: Decision = DecisionSchema.parse({ + id: "0001-pick-typescript", + number: 1, + slug: "pick-typescript", + title: "Pick TypeScript", + status: "accepted", + template_variant: "canonical", + created_at: NOW, + updated_at: NOW, + positions: [{ title: "TypeScript", pros: [], cons: [], links: [] }], + selected_position: "TypeScript", + argument: "Team expertise.", + assumptions: [], + constraints: [], + opinions: [], + implications: [], + depends_on: [], + related_decisions: [], + related_artifacts: [], + review: [], + tags: [], + sign_off: { by: "human", at: NOW }, + }); + await store.writeDecision(decision); + + const task: Task = TaskSchema.parse({ + id: "T0001-bootstrap", + number: 1, + slug: "bootstrap", + title: "Bootstrap", + status: "ready", + estimate: { unit: "hours", value: 2 }, + acceptance_criteria: ["npm test passes"], + depends_on: [], + decision_refs: ["0001-pick-typescript"], + priority: "p1", + labels: [], + created_at: NOW, + updated_at: NOW, + }); + await store.writeTask(task); +} + +describe("Flow: dr_export_symphony", () => { + before(() => { + if (!getTool("dr_export_symphony")) { + registerAllTools(); + } + }); + + it("rejects when project is not in 'handing-off' phase", async () => { + const project = makeTmpProject("dr-symphony-phase-"); + try { + const store = new Store(project.cwd); + await store.ensureLayout(); + const proj: Project = { + id: "wip", + title: "wip", + description: "", + created_at: NOW, + updated_at: NOW, + effort_level: "poc", + status: "deciding", + sign_offs: [], + gate_config: { preset: "poc" }, + tags: [], + }; + const state: PipelineState = { + schema_version: "0.1.0", + project_id: "wip", + phase: "deciding", + effective_gate_config: resolveEffectiveGateConfig({ preset: "poc" }), + next_decision_seq: 1, + next_task_seq: 1, + next_outcome_seq: 1, + pending_questions: [], + gate_failures: [], + last_event_at: NOW, + }; + await store.writeProject(proj); + await store.writeState(state); + + const res = await call("dr_export_symphony", { cwd: project.cwd }); + assert.equal(res.ok, false); + assert.match(res.errors?.[0] ?? "", /handing-off/); + } finally { + project.dispose(); + } + }); + + it("writes WORKFLOW.md at project root by default and finalizes to handed-off", async () => { + const project = makeTmpProject("dr-symphony-default-"); + try { + await seedHandingOff(project); + + const res = await call("dr_export_symphony", { cwd: project.cwd }); + assert.equal(res.ok, true); + const data = res.data as { + target: string; + workflow_path: string; + tracker_project_slug: string; + decisions: number; + tasks: number; + }; + assert.equal(data.target, "symphony"); + assert.equal(data.tracker_project_slug, "CHANGEME"); + assert.equal(data.decisions, 1); + assert.equal(data.tasks, 1); + + // File on disk + assert.ok(existsSync(data.workflow_path)); + assert.equal(data.workflow_path, join(project.cwd, "WORKFLOW.md")); + const body = readFileSync(data.workflow_path, "utf8"); + assert.match(body, /^---\ntracker:/); + assert.match(body, /\{\{ issue\.identifier \}\}/); + + // Project transitioned to handed-off with symphony target + const proj = project.readJson("dr/project.json"); + assert.equal(proj.status, "handed-off"); + assert.equal(proj.handoff?.target, "symphony"); + assert.equal(proj.handoff?.workflow_path, data.workflow_path); + assert.equal(proj.handoff?.issue_count, 1); + assert.equal(proj.handoff?.document_count, 1); + + // export_started + export_completed events + const kinds = project.events().map((e) => e.kind); + assert.ok(kinds.includes("export_started")); + assert.ok(kinds.includes("export_completed")); + } finally { + project.dispose(); + } + }); + + it("honors explicit workflow_path (relative resolves against cwd)", async () => { + const project = makeTmpProject("dr-symphony-path-"); + try { + await seedHandingOff(project); + const res = await call("dr_export_symphony", { + cwd: project.cwd, + workflow_path: "config/SYM.md", + }); + assert.equal(res.ok, true); + const data = res.data as { workflow_path: string }; + assert.equal(data.workflow_path, join(project.cwd, "config/SYM.md")); + } finally { + project.dispose(); + } + }); + + it("propagates override fields into the WORKFLOW.md front matter", async () => { + const project = makeTmpProject("dr-symphony-overrides-"); + try { + await seedHandingOff(project); + const res = await call("dr_export_symphony", { + cwd: project.cwd, + tracker_project_slug: "explicit-slug", + polling_interval_ms: 15000, + workspace_root: "./symphony-ws", + after_create_hook: "echo hello", + max_concurrent_agents: 3, + max_turns: 7, + }); + assert.equal(res.ok, true); + const body = readFileSync( + (res.data as { workflow_path: string }).workflow_path, + "utf8" + ); + assert.match(body, /project_slug: explicit-slug/); + assert.match(body, /interval_ms: 15000/); + assert.match(body, /root: \.\/symphony-ws/); + assert.match(body, /max_concurrent_agents: 3/); + assert.match(body, /max_turns: 7/); + assert.match(body, /after_create: \|/); + assert.match(body, /^ echo hello$/m); + } finally { + project.dispose(); + } + }); + + it("reuses Linear handoff slug when present and no override given", async () => { + const project = makeTmpProject("dr-symphony-reuse-linear-"); + try { + await seedHandingOff(project); + // Manually set a prior Linear handoff record, but reset status to handing-off + const store = new Store(project.cwd); + const proj = await store.readProject(); + await store.writeProject({ + ...proj, + status: "handing-off", + handoff: { + target: "linear", + target_id: "the-linear-slug", + exported_at: NOW, + }, + }); + + const res = await call("dr_export_symphony", { cwd: project.cwd }); + assert.equal(res.ok, true); + const body = readFileSync( + (res.data as { workflow_path: string }).workflow_path, + "utf8" + ); + assert.match(body, /project_slug: the-linear-slug/); + } finally { + project.dispose(); + } + }); + + it("fails when linear_team_id is set but no API key is available", async () => { + const originalEnv = process.env.LINEAR_API_KEY; + delete process.env.LINEAR_API_KEY; + const project = makeTmpProject("dr-symphony-missing-key-"); + try { + await seedHandingOff(project); + const res = await call("dr_export_symphony", { + cwd: project.cwd, + linear_team_id: "team-123", + }); + assert.equal(res.ok, false); + assert.match(res.errors?.[0] ?? "", /API key/); + // Project remains in handing-off + const proj = project.readJson("dr/project.json"); + assert.equal(proj.status, "handing-off"); + } finally { + if (originalEnv !== undefined) process.env.LINEAR_API_KEY = originalEnv; + project.dispose(); + } + }); +}); diff --git a/server/tests/unit-symphony.test.ts b/server/tests/unit-symphony.test.ts new file mode 100644 index 0000000..17d6f4f --- /dev/null +++ b/server/tests/unit-symphony.test.ts @@ -0,0 +1,187 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + Decision, + DecisionSchema, + Project, + Task, + TaskSchema, +} from "../src/schemas/index.js"; +import { renderSymphonyWorkflow } from "../src/handoff/symphony.js"; + +const NOW = "2026-05-17T00:00:00.000Z"; + +const project: Project = { + id: "demo", + title: "Demo project", + description: "An example", + created_at: NOW, + updated_at: NOW, + effort_level: "poc", + status: "handing-off", + sign_offs: [], + gate_config: { preset: "poc" }, + tags: [], + scope: { + in_scope: ["thing A"], + out_of_scope: ["distant feature"], + success_criteria: ["it ships"], + nice_to_have: [], + }, +}; + +const decision: Decision = DecisionSchema.parse({ + id: "0001-pick-typescript", + number: 1, + slug: "pick-typescript", + title: "Pick TypeScript", + status: "accepted", + template_variant: "canonical", + created_at: NOW, + updated_at: NOW, + positions: [{ title: "TypeScript", pros: [], cons: [], links: [] }], + selected_position: "TypeScript", + argument: "Team expertise.", + assumptions: [], + constraints: [], + opinions: [], + implications: [], + depends_on: [], + related_decisions: [], + related_artifacts: [], + review: [], + tags: [], + sign_off: { by: "human", at: NOW }, +}); + +const proposedDecision: Decision = DecisionSchema.parse({ + ...decision, + id: "0002-pick-postgres", + number: 2, + slug: "pick-postgres", + title: "Pick Postgres", + status: "proposed", + selected_position: undefined, + sign_off: undefined, +}); + +const task: Task = TaskSchema.parse({ + id: "T0001-bootstrap", + number: 1, + slug: "bootstrap", + title: "Bootstrap", + description: "Wire up the project", + status: "ready", + estimate: { unit: "hours", value: 2 }, + acceptance_criteria: ["npm test passes"], + depends_on: [], + decision_refs: ["0001-pick-typescript"], + priority: "p1", + labels: [], + created_at: NOW, + updated_at: NOW, +}); + +describe("renderSymphonyWorkflow — front matter", () => { + it("emits required tracker, polling, workspace, agent, codex blocks", () => { + const out = renderSymphonyWorkflow({ + project, + decisions: [decision], + tasks: [task], + tracker: { project_slug: "demo-slug" }, + }); + assert.match(out, /^---\ntracker:\n/); + assert.match(out, /^\s*kind: linear$/m); + assert.match(out, /endpoint: https:\/\/api\.linear\.app\/graphql/); + assert.match(out, /api_key: \$LINEAR_API_KEY/); + assert.match(out, /project_slug: demo-slug/); + assert.match(out, /active_states: \[Todo, "In Progress"\]/); + assert.match(out, /terminal_states: \[Closed, Cancelled, Canceled, Duplicate, Done\]/); + assert.match(out, /^polling:\n interval_ms: 30000$/m); + assert.match(out, /^workspace:\n root: \.\/\.symphony-workspaces$/m); + assert.match(out, /^agent:\n/m); + assert.match(out, /max_concurrent_agents: 5/); + assert.match(out, /max_turns: 20/); + assert.match(out, /^codex:\n/m); + assert.match(out, /command: "codex app-server"/); + assert.match(out, /turn_timeout_ms: 3600000/); + }); + + it("includes hooks block when after_create is provided, formatted as multiline scalar", () => { + const out = renderSymphonyWorkflow({ + project, + decisions: [decision], + tasks: [task], + workspace: { + after_create: + "git clone https://github.com/example/demo .\nnpm install", + }, + }); + assert.match(out, /^hooks:\n after_create: \|\n/m); + assert.match(out, /^ git clone https:\/\/github\.com\/example\/demo \.$/m); + assert.match(out, /^ npm install$/m); + }); + + it("respects custom overrides", () => { + const out = renderSymphonyWorkflow({ + project, + decisions: [decision], + tasks: [task], + polling: { interval_ms: 60_000 }, + workspace: { root: "/var/tmp/sym" }, + agent: { max_concurrent_agents: 2, max_turns: 5 }, + codex: { command: "codex app-server --verbose" }, + }); + assert.match(out, /interval_ms: 60000/); + assert.match(out, /root: \/var\/tmp\/sym/); + assert.match(out, /max_concurrent_agents: 2/); + assert.match(out, /max_turns: 5/); + assert.match(out, /command: "codex app-server --verbose"/); + }); +}); + +describe("renderSymphonyWorkflow — prompt body", () => { + it("includes project context, standing decisions, per-issue instructions with Liquid", () => { + const out = renderSymphonyWorkflow({ + project, + decisions: [decision, proposedDecision], + tasks: [task], + tracker: { project_slug: "demo-slug" }, + }); + // Title + assert.match(out, /^# Symphony workflow: Demo project$/m); + // Scope is included (rendered with bold markdown labels) + assert.match(out, /\*\*In scope:\*\*\n- thing A/); + assert.match(out, /\*\*Success criteria:\*\*\n- it ships/); + // Only accepted decisions appear under Standing decisions + assert.match(out, /`0001-pick-typescript` Pick TypeScript → \*\*TypeScript\*\*/); + assert.doesNotMatch(out, /0002-pick-postgres/); + // Liquid variables present + assert.match(out, /\{\{ issue\.identifier \}\}/); + assert.match(out, /\{\{ issue\.title \}\}/); + assert.match(out, /\{% if attempt %\}retry #\{\{ attempt \}\}\{% else %\}first run\{% endif %\}/); + // Anti-litigation guard + assert.match(out, /Do not re-litigate accepted decisions/); + // Outcome handoff note + assert.match(out, /dr_record_outcome/); + }); + + it("notes when there are zero accepted decisions", () => { + const out = renderSymphonyWorkflow({ + project: { ...project, scope: undefined }, + decisions: [], + tasks: [task], + }); + assert.match(out, /No accepted decisions at handoff/); + }); + + it("uses CHANGEME-style placeholder when no project_slug is set", () => { + const out = renderSymphonyWorkflow({ + project, + decisions: [decision], + tasks: [task], + }); + // No project_slug line at all when omitted + assert.doesNotMatch(out, /project_slug:/); + }); +});