# Openspec Aware Chorus

> OpenSpec-mode authoring for Chorus PM workflows on dsh — the default whenever OpenSpec is usable. Consumes the `## Spec Mode` block the chorus-dsh bundle injects (mode resolved once at load by `src/spec-mode.ts`), scaffolds `openspec/changes/<slug>/` on disk, and mirrors Markdown files into Chorus document drafts via `chorus mcp call --arg-file` (package-local `chorus-mcp-call.mjs` wrapper as fallback). When OpenSpec isn't usable the mode resolves to spec-lite (see `spec-lite-chorus`). Required reading for the proposal, develop, and yolo skills.

- Skill: `chorus-aidlc/openspec-aware-chorus` (Agent Skill)
- Install (CLI): `npx skillmds@latest add chorus-aidlc/openspec-aware-chorus`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chorus-aidlc/openspec-aware-chorus/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: AGPL-3.0
- Author: chorus-aidlc (https://skillmd.com/u/chorus-aidlc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/chorus-aidlc/openspec-aware-chorus

---


# OpenSpec-aware Authoring (dsh plugin)

This skill is a **shared sub-procedure** invoked by the Chorus stage skills (proposal, develop, yolo) whenever the resolved spec mode is a usable [OpenSpec CLI](https://github.com/Fission-AI/OpenSpec) setup. It is one of the modes the chorus-dsh bundle resolves at load:

- Activates when the resolved spec mode is a **usable OpenSpec** (see §1): `CHORUS_SPEC_MODE=openspec` *or* unset, **and** `CHORUS_OPENSPEC_MODE` not `off`, an `openspec/` directory at the project root, and the `openspec` CLI on `PATH`.
- Otherwise the calling skill follows the resolved `CHORUS_SPEC_MODE` — **spec-lite** (the default when OpenSpec isn't usable) or free-form (`=off`). This skill is then a **no-op**.

> **See also — `spec-lite-chorus` (the lightweight fallback):** OpenSpec (this skill) stays the default whenever it is usable. When OpenSpec is absent or disabled — or `CHORUS_SPEC_MODE=lite` — the mode resolves to **spec-lite**: a durable local `.chorus/specs/<slug>/spec.md` (never synced) + per-change dated folders `<slug>/<YYYY-MM-DD>-<change-slug>/` of Chorus-typed docs mirrored 1:1 into Chorus via the same `--arg-file` transport. See the `spec-lite-chorus` skill.

> **Tool namespace:** Chorus MCP tools are exposed under a `mcp__chorus__` prefix on dsh (e.g. `mcp__chorus__chorus_pm_create_proposal`). Bare names are used in prose for readability — prepend `mcp__chorus__` when invoking the MCP tools directly. **Document-mirror calls do NOT go through the MCP harness at all** — they go through the `chorus` CLI (`chorus mcp call`, preferred) or the package-local `chorus-mcp-call.mjs` wrapper (fallback) (see §2 Rule 1), which talk to the Chorus MCP endpoint over HTTP using your API key, independent of the `mcp__chorus__` namespacing.

---

## §1. Detection — read the mode the bundle already resolved

> **dsh difference:** the Claude Code plugin resolves the spec mode in a SessionStart hook. dsh has no SessionStart hook, but the **chorus-dsh bundle resolves the mode once at plugin load** (`resolveSpecMode`, the single source of truth — the TS mirror of the canonical bash resolver) and both (a) injects a `## Spec Mode` block into your first-step context and (b) publishes `CHORUS_SPEC_MODE` + `CHORUS_OPENSPEC_ACTIVE` to the process environment, before the daemon-origin gate, so interactive and daemon-woken sessions inherit it. **Consume that value — do NOT re-run detection and do NOT hand-roll an OpenSpec-only check.**

The bundle marks OpenSpec active (a `CHORUS_OPENSPEC_ACTIVE=1` line + `CHORUS_SPEC_MODE=openspec`) only when `CHORUS_SPEC_MODE` is `openspec` **or unset**, **and all three** of these hold:

1. `CHORUS_OPENSPEC_MODE` is **not** set to `off` (explicit opt-out wins).
2. The project root contains an `openspec/` directory (i.e. someone ran `openspec init` here).
3. The `openspec` CLI is on `PATH`.

Both signals (2) and (3) are required because the OpenSpec authoring path needs the working directory **and** the CLI — having one without the other leaves the workflow unrunnable. If signal (2) holds but (3) does not, the bundle's `## Spec Mode` note carries an install hint (`npm i -g @fission-ai/openspec`); pass it through if asked rather than silently choosing another mode.

### How to read the value

Look for the `## Spec Mode` section near the top of your context:

```
## Spec Mode

CHORUS_SPEC_MODE=openspec (default — openspec/ directory + openspec CLI both present)

CHORUS_OPENSPEC_ACTIVE=1 (openspec/ directory + openspec CLI both present)
```

or (resolved to lite / off — no `CHORUS_OPENSPEC_ACTIVE=1` line):

```
## Spec Mode

CHORUS_SPEC_MODE=lite (default — OpenSpec not usable: no openspec/ directory at /path/to/repo/openspec)
```

Branch:

- `CHORUS_OPENSPEC_ACTIVE=1` line present (equivalently the `CHORUS_OPENSPEC_ACTIVE` env var is `1`) → follow §3 (OpenSpec authoring).
- No `CHORUS_OPENSPEC_ACTIVE=1` → this skill is a **no-op**; return to the caller, which follows the resolved `CHORUS_SPEC_MODE` (**spec-lite** — see the `spec-lite-chorus` skill — or free-form when `=off`). **Do not** scaffold `openspec/changes/`. **Do not** add the slug line to the proposal description.

### Manual fallback (context genuinely absent)

If you were spawned mid-session without the `## Spec Mode` context, read the environment variables the bundle published — `CHORUS_OPENSPEC_ACTIVE` (`1` ⇒ §3) and `CHORUS_SPEC_MODE` (`lite`/`off` ⇒ no-op, caller follows that mode). **Never hand-roll an OpenSpec-only three-check that hard-codes "else free-form"** — that ignores `CHORUS_SPEC_MODE` and mis-routes a `lite` repo to free-form. Only if **both** env vars are genuinely unset (a broken/older bundle) resolve the full mode yourself: an explicit `CHORUS_SPEC_MODE` (`lite`/`openspec`/`off`) wins; otherwise OpenSpec is active iff `CHORUS_OPENSPEC_MODE` ≠ `off` **and** an `openspec/` dir is present **and** `openspec --version` succeeds, else the mode is **spec-lite** (never free-form-by-default).

```bash
# Last-resort resolution when NEITHER CHORUS_OPENSPEC_ACTIVE nor CHORUS_SPEC_MODE
# was published (broken bundle). PROJECT_DIR defaults to $PWD (dsh exports no CLAUDE_PROJECT_DIR).
PROJECT_DIR="${PWD}"
case "${CHORUS_SPEC_MODE:-}" in
  openspec|"") : ;;                       # may be OpenSpec — probe below
  lite|off) echo "no-op: follow CHORUS_SPEC_MODE=$CHORUS_SPEC_MODE"; return 0 2>/dev/null || exit 0 ;;
esac
if [ "${CHORUS_OPENSPEC_MODE:-}" != "off" ] && [ -d "${PROJECT_DIR}/openspec" ] && openspec --version >/dev/null 2>&1; then
  RESOLVED_OPENSPEC_ACTIVE=1              # follow §3
else
  RESOLVED_OPENSPEC_ACTIVE=0             # no-op; caller follows spec-lite (default), NOT free-form
fi
echo "RESOLVED_OPENSPEC_ACTIVE=$RESOLVED_OPENSPEC_ACTIVE"
```

---

## §2. ⛔ Two non-negotiable rules

Both are enforced at review time. Both have caused incidents in past releases.

### Rule 1 — Fill `content` from the file (CLI preferred, bash-wrapper fallback); never re-type document content from agent output

Document/draft mirror calls (`chorus_pm_add_document_draft`, `chorus_pm_update_document_draft`, `chorus_pm_update_document`) **MUST** fill the `content` field from the local file's bytes, never from a hand-typed body. Calling these tools directly from the agent's MCP harness with a hand-typed `content` field is a **protocol violation** for OpenSpec mode and will fail review. Use whichever transport is available, preferred first:

- **Primary — the `chorus` CLI:** `chorus mcp call <tool_name> '<json-without-content>' --arg-file content=<file>`. `--arg-file content=<path>` reads the file's raw bytes and injects them as the JSON `content` string, byte-exact — the CLI's built-in replacement for `json_encode_file`, so no helper is needed. `chorus mcp call` reads the same `CHORUS_URL` / `CHORUS_API_KEY` from the dsh process environment. See §3.6. **Requires chorus >= 0.17.0** (the `chorus mcp` subcommand was added then; an older CLI errors with "unknown command"); on any version or unknown-command failure, upgrade with `npm install -g @chorus-aidlc/chorus`.
- **Fallback — the package-local `chorus-mcp-call.mjs` wrapper (`$CHORUS_MCP_CALL`), when `chorus` is not on `PATH`:** build `$PAYLOAD` with the `json_encode_file` helper and call `"$CHORUS_MCP_CALL" <tool_name> "$PAYLOAD"` (availability note below). Defined in the §3.6 fallback block.

> **New to the `chorus` CLI?** See the **`chorus-cli`** skill for install, configuring agents (`chorus agents add|remove|list`), the connection env vars, and `chorus mcp` basics.

> **Acting identity — which agent the call acts as.** `chorus mcp call` resolves the agent from, in order: `CHORUS_AGENT_PROFILE` (a name or UUID) → `CHORUS_URL` + `CHORUS_API_KEY` in the environment → the single agent configured in `~/.chorus/daemon.json`. A daemon-woken session already has `CHORUS_AGENT_PROFILE` set. If a mirror call fails with `Multiple agents … specify --agent` (several agents configured and no profile/creds in the env), pass your own identity explicitly: `chorus mcp call <tool> … --agent <your-agentUuid>` — your UUID is in your `chorus_checkin` result, and `chorus agents` lists every configured name/UUID.

Reasons (they apply to both paths):

1. **Token cost.** Re-typing a multi-thousand-line markdown body through the LLM burns input + output tokens for every draft. Both the CLI's `--arg-file` and the fallback's `json_encode_file` (§3.6, Node `JSON.stringify`) stream the file's bytes into the JSON string — content never enters LLM context. A typical 3-doc proposal mirror costs roughly zero content-tokens this way; via direct MCP with a re-typed body it routinely costs 20k+.
2. **Byte-equality.** A file-fill path (CLI `--arg-file`, or the fallback's `JSON.stringify` of the file's UTF-8 bytes) is a byte-faithful encoder: backslashes, quotes, newlines, code-fence content, zero-width chars all survive. LLM re-emission has a non-zero failure rate on long markdown — table alignment drifts, fence escapes get "fixed", long URLs wrap. The byte-equality guarantee (modulo trailing `\n`) holds **only** on a file-fill path, never on LLM re-emission.
3. **Single source of truth.** With a file-fill mirror, the local `openspec/changes/<slug>/*.md` is authoritative and Chorus is a mirror. With agent re-typing, authority splits between local file and whatever the LLM happened to output — a future diff cannot tell which one is correct.

> **Wrapper availability on dsh (fallback path).** When you fall back to the wrapper, the npm bundle publishes its path in `CHORUS_MCP_CALL` at plugin load. Validate it before authoring:
>
> ```bash
> if [ -z "${CHORUS_MCP_CALL:-}" ] || [ ! -x "$CHORUS_MCP_CALL" ]; then
>   echo "ERROR: OpenSpec mirroring requires the package-local CHORUS_MCP_CALL wrapper; reload the Chorus dsh bundle." >&2
>   exit 1
> fi
> ```
>
> The wrapper reads `CHORUS_URL` and `CHORUS_API_KEY` from the dsh process environment (so does `chorus mcp call`). If both `chorus` and the wrapper are missing, halt visibly. Do not reproduce it ad hoc and do not retype document content through the model.

### Rule 2 — Halt on error via `chorus_check_response`

Every wrapper call must check three signals: wrapper exit code, `"error":` in body, empty body. Bare `RC=$?` is **insufficient** — the wrapper exits 0 on HTTP 401 (auth failure) with empty body, so a single-signal check silently misses the most common runtime failure. See §6 for the helper definition.

---

## §3. OpenSpec mode authoring

### 3.1 Pick a slug

`openspec/changes/<slug>/` is the local change folder. The slug must be:

- kebab-case (`add-export-csv`, not `addExportCsv` or `add_export_csv`),
- derived from the source Idea title,
- unique within `openspec/changes/`.

Record it for later steps:

```bash
SLUG="add-export-csv"
```

### 3.2 Scaffold the change folder

```bash
openspec new change "$SLUG" --description "<one-line idea summary>"
```

This creates `openspec/changes/$SLUG/` with `README.md` and `.openspec.yaml`. Then author by hand:

| Local file | Purpose | Mirror as `Document.type` |
|---|---|---|
| `proposal.md` | Why + What Changes + Capabilities + Impact | `prd` |
| `design.md` | Architecture, contracts, risks | `tech_design` |
| `specs/<capability>/spec.md` | Delta spec (`## ADDED Requirements` + Scenarios) | `spec` (one draft per capability) |
| `tasks.md` | OpenSpec tasks list | _(not mirrored — Chorus task drafts are source of truth)_ |

Use `openspec instructions <artifact> --change "$SLUG"` (artifacts: `proposal`, `specs`, `design`, `tasks`) for templates.

### 3.3 Spec file shape (verified against `openspec instructions specs`)

A delta spec lists one or more block headers — `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements` — and within each, `### Requirement:` entries. Mix freely in the same file; only include the blocks you actually need.

#### `## ADDED Requirements`

Append a brand-new Requirement to the long-term spec.

```
## ADDED Requirements

### Requirement: <name>
<requirement text — use SHALL / MUST for normative behavior>

#### Scenario: <name>
- **WHEN** <condition>
- **THEN** <expected outcome>
```

#### `## MODIFIED Requirements`

**Whole-block replacement, not merge.** Whatever you write here completely replaces the existing same-named Requirement in the long-term spec — title, description, and *all* scenarios. Half-writing it deletes the rest.

```
## MODIFIED Requirements

### Requirement: <existing name>
<full updated requirement text>

#### Scenario: <name>
- **WHEN** <condition>
- **THEN** <expected outcome>

#### Scenario: <other name>
- **WHEN** <condition>
- **THEN** <expected outcome>
```

Always include every scenario you want the post-archive spec to have, even ones that were already present and unchanged.

#### `## REMOVED Requirements`

Delete a Requirement from the long-term spec. The block under the heading is just the requirement name(s) you're removing — no scenarios needed.

```
## REMOVED Requirements

### Requirement: <existing name>
```

#### `## RENAMED Requirements`

Rename a Requirement's title. Body and scenarios are preserved as-is in the long-term spec; use `MODIFIED` instead if you need to change anything besides the title.

```
## RENAMED Requirements

### Requirement: <old name> -> <new name>
```

**Critical formatting rules (verified):**

- Scenarios MUST use **exactly 4 hashtags** (`#### Scenario:`). 3 hashtags or a bullet list silently fail validation.
- Every `### Requirement:` under `ADDED` or `MODIFIED` MUST have at least one `#### Scenario:`.
- `MODIFIED` blocks MUST include the **full updated content** — they overwrite, not patch.
- Use `SHALL` / `MUST` for normative requirements; avoid `should` / `may`.
- The merge into `openspec/specs/<capability>/spec.md` happens at `openspec archive` time (§3.9), not at proposal time. While the proposal is in flight, Chorus only sees the delta file as one `spec` Document — there is no half-merged state for the skill to reason about.

Optional:

```bash
openspec validate "$SLUG"
```

### 3.4 Filling the `content` field byte-exact

The document `content` must be inserted **byte-for-byte** from the local file — never re-typed by the LLM. Two mechanisms, preferred first:

- **Primary — `chorus mcp call … --arg-file content=<path>`** (§3.6). The CLI reads the file's raw bytes and injects them as the JSON `content` string. This is the byte-faithful replacement for `json_encode_file`, so on the CLI path **no helper is needed** — pass the base JSON without a `content` field and let `--arg-file` fill it.
- **Fallback — `json_encode_file`** (defined in the §3.6 fallback block, used only when `chorus` is not on `PATH`). It encodes the file into a byte-faithful JSON string with Node's `JSON.stringify` — Node is guaranteed present under dsh (it is the harness runtime), so no `jq` is required.

Round-trip: the Chorus backend appends a single `\n` to draft content on write, so server `content` is **byte-equal modulo a trailing newline**. Reviewers diffing local file vs server should ignore that one byte.

### 3.5 Create the proposal container with the slug provenance line

Use the regular `chorus_pm_create_proposal` MCP tool (no wrapper required for this single call — the description is short, the LLM-emitted version is fine). The description **must** carry exactly one line:

```
OpenSpec change slug: <slug>
```

- on its own line (no other text on that line),
- literal prefix `OpenSpec change slug: ` (capital O, capital S, single space after colon),
- no trailing punctuation,
- value matches the slug passed to `openspec new change`.

This line is machine-grep-able by future runs of this skill and by the §3.9 archive trigger.

### 3.6 Mirror each document draft (CLI primary, wrapper fallback)

> **Rule 1 reminder:** `content` comes from the file's bytes, never a hand-typed body. The agent must not retype the document body.

Define the halt-on-error helper from §6 once at the top. **Primary path — the `chorus` CLI:** pass the base JSON *without* a `content` field and let `--arg-file content=<file>` fill it byte-exact. One call per file:

```bash
# PRD draft — --arg-file fills content byte-exact from the file; no json_encode_file needed.
RESULT=$(chorus mcp call chorus_pm_add_document_draft \
  "{\"proposalUuid\":\"$PROPOSAL_UUID\",\"type\":\"prd\",\"title\":\"PRD: $HUMAN_TITLE\"}" \
  --arg-file content="openspec/changes/$SLUG/proposal.md")
RC=$?
chorus_check_response "chorus_pm_add_document_draft (prd)" "$RC" "$RESULT"
PRD_DRAFT_UUID=$(printf '%s' "$RESULT" | grep -o '"draftUuid"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
```

Repeat with `type: "tech_design"` for `design.md`, and one call per capability with `type: "spec"` for each `specs/<capability>/spec.md`. Do **not** mirror `tasks.md` — Chorus task drafts (created via the `chorus_pm_add_task_draft` MCP tool, no wrapper needed) are the source of truth for tasks.

> Why parsing uses `printf '%s' "$RESULT" | grep` not `echo "$RESULT" | jq`: `echo` interprets backslash sequences inside the captured JSON, turning embedded `\n` into a real newline. `jq` then aborts with `Invalid string: control characters from U+0000 through U+001F must be escaped`. `printf '%s'` emits the captured bytes verbatim. Same pattern applies to all wrapper-result parsing in this skill.

#### Fallback — when the `chorus` CLI is not on `PATH`

If `command -v chorus` fails, mirror through the package-local `chorus-mcp-call.mjs` wrapper resolved into `$CHORUS_MCP_CALL` (Rule 1). Define `json_encode_file` here (it is used **only** on this fallback path), then build `$PAYLOAD` with an embedded `content`. The `chorus_check_response` halt-on-error check applies exactly as on the primary path.

```bash
# Define once, fallback-only: byte-faithful file → JSON string via Node.
json_encode_file() {
  # Byte-faithful: JSON.stringify of the file's UTF-8 content — quotes,
  # backslashes, newlines, code-fence content, and control chars all survive.
  # No jq, no curl.
  node -e 'const fs=require("fs");process.stdout.write(JSON.stringify(fs.readFileSync(process.argv[1],"utf8")))' "$1"
}

# PRD draft
CONTENT=$(json_encode_file "openspec/changes/$SLUG/proposal.md")
PAYLOAD=$(cat <<JSON
{
  "proposalUuid": "$PROPOSAL_UUID",
  "type": "prd",
  "title": "PRD: $HUMAN_TITLE",
  "content": $CONTENT
}
JSON
)
RESULT=$("$CHORUS_MCP_CALL" chorus_pm_add_document_draft "$PAYLOAD")
RC=$?
chorus_check_response "chorus_pm_add_document_draft (prd)" "$RC" "$RESULT"
PRD_DRAFT_UUID=$(printf '%s' "$RESULT" | grep -o '"draftUuid"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
```

### 3.7 Editing a draft after the first mirror

Local file changes propagate via `chorus_pm_update_document_draft` — same primary/fallback split as §3.6, same halt check. Primary (CLI):

```bash
RESULT=$(chorus mcp call chorus_pm_update_document_draft \
  "{\"proposalUuid\":\"$PROPOSAL_UUID\",\"draftUuid\":\"$PRD_DRAFT_UUID\"}" \
  --arg-file content="openspec/changes/$SLUG/proposal.md")
RC=$?
chorus_check_response "chorus_pm_update_document_draft" "$RC" "$RESULT"
```

Fallback (no `chorus` on `PATH`) — `$CHORUS_MCP_CALL` from Rule 1, `json_encode_file` from the §3.6 fallback block:

```bash
CONTENT=$(json_encode_file "openspec/changes/$SLUG/proposal.md")
PAYLOAD=$(cat <<JSON
{
  "proposalUuid": "$PROPOSAL_UUID",
  "draftUuid": "$PRD_DRAFT_UUID",
  "content": $CONTENT
}
JSON
)
RESULT=$("$CHORUS_MCP_CALL" chorus_pm_update_document_draft "$PAYLOAD")
RC=$?
chorus_check_response "chorus_pm_update_document_draft" "$RC" "$RESULT"
```

### 3.8 Editing a Document after proposal approval

Once the proposal is approved, drafts materialize into Documents with their own UUIDs. To keep `openspec/changes/$SLUG/` and the Chorus Document in sync, mirror file edits via `chorus_pm_update_document`. Primary (CLI):

```bash
RESULT=$(chorus mcp call chorus_pm_update_document \
  "{\"documentUuid\":\"$SPEC_DOCUMENT_UUID\"}" \
  --arg-file content="openspec/changes/$SLUG/specs/<capability>/spec.md")
RC=$?
chorus_check_response "chorus_pm_update_document" "$RC" "$RESULT"
```

Fallback (no `chorus` on `PATH`) — `$CHORUS_MCP_CALL` from Rule 1, `json_encode_file` from the §3.6 fallback block:

```bash
CONTENT=$(json_encode_file "openspec/changes/$SLUG/specs/<capability>/spec.md")
PAYLOAD=$(cat <<JSON
{
  "documentUuid": "$SPEC_DOCUMENT_UUID",
  "content": $CONTENT
}
JSON
)
RESULT=$("$CHORUS_MCP_CALL" chorus_pm_update_document "$PAYLOAD")
RC=$?
chorus_check_response "chorus_pm_update_document" "$RC" "$RESULT"
```

To re-derive `$SPEC_DOCUMENT_UUID` from a fresh shell, look it up via `chorus_get_documents` for the proposal's project and match by `title` + `type`. Re-derive `$SLUG` by grepping the proposal's `description` for `^OpenSpec change slug: `.

### 3.9 Archive after the last task is verified

> **dsh difference:** the Claude Code plugin has a PostToolUse hook (`bin/on-post-verify-task.sh`) that fires after `chorus_admin_verify_task` and injects an `openspec archive <slug>` reminder. **dsh has no such hook.** You (the agent) must detect the trigger yourself: after each `chorus_admin_verify_task`, check whether the just-verified task was the **LAST** task of its OpenSpec-mode idea (every Task across every approved Proposal of that idea is now `done`/`closed`, and the proposal `description` carries an `OpenSpec change slug: <slug>` line). If so, run the archive flow below. If not, do nothing.

When the trigger holds, you perform the archive:

1. **Run archive locally.** Use `--yes` for non-interactive mode. Do NOT pass `--skip-specs` (defeats the mirror-back) or `--no-validate` (lets malformed deltas corrupt cumulative specs).

   ```bash
   openspec archive "$SLUG" --yes
   ```

   This moves `openspec/changes/$SLUG/` under `openspec/changes/archive/<date>-<slug>/` and emits/updates `openspec/specs/<capability>/spec.md` for each capability. (Run `openspec archive --help` against your installed version to confirm the current flag set — flags can drift between releases.)

2. **Mirror each updated `openspec/specs/<capability>/spec.md` back** to the matching post-approval Chorus Document (§3.8 contract). `chorus_get_documents` only supports `projectUuid` + `type` server-side filters; filter by title client-side. One `chorus_pm_update_document` call per capability.

3. **Halt on any error** from `openspec archive` or `chorus_pm_update_document`. Print stderr verbatim, post a comment on the proposal recording the failure (`chorus_add_comment` with `targetType: "proposal"`, `targetUuid: <proposalUuid>`), then stop. No retry. Matches §6 "no silent errors." (Comment on the proposal, not the idea: the failure is in archiving proposal-derived specs, and proposals can be `inputType: "document"` with no idea attached.)

4. **Confirm success.** List `openspec/specs/<capability>/spec.md` files and verify they round-trip byte-equal (modulo trailing newline) with their Chorus Document counterparts.

**Strict opt-in:** if the verified task is not the last of its idea, OR the proposal description carries no `OpenSpec change slug: <slug>` line, OR the local shell has no `openspec` CLI, do nothing — no archive. Existing free-form behavior is preserved.

---

## §4. Fallback authoring (OpenSpec not active)

When §1 shows OpenSpec is not active (no `CHORUS_OPENSPEC_ACTIVE=1`), this skill is a **no-op**. Return to the calling skill, which follows the resolved `CHORUS_SPEC_MODE` — **spec-lite** (the default when OpenSpec isn't usable; see the `spec-lite-chorus` skill) or free-form (`=off`). From this skill's side, regardless of which:

- No `openspec/changes/` folder is created or referenced.
- No `OpenSpec change slug: …` line is added to the proposal description.
- The §3.9 archive flow does nothing (no slug → no archive).

The two downstream modes differ, and this skill does not own either:

- **spec-lite** (`CHORUS_SPEC_MODE=lite`) → the caller loads `spec-lite-chorus`: a durable `.chorus/specs/<slug>/spec.md` (never mirrored) + dated per-change folders whose Chorus-typed docs **are** mirrored byte-exact via the same `--arg-file` transport + `chorus_check_response` helper as §3.6.
- **free-form** (`CHORUS_SPEC_MODE=off`) → document drafts are authored via direct MCP `chorus_pm_add_document_draft` calls with inline `content` — same as before this skill existed; Rule 1 (file-fill mirror) does not apply since there is no local file source of truth.

---

## §5. Document type mapping (reference table)

| Local file | Chorus `Document.type` | Mirrored? |
|---|---|---|
| `openspec/changes/<slug>/proposal.md` | `prd` | yes |
| `openspec/changes/<slug>/design.md` | `tech_design` | yes |
| `openspec/changes/<slug>/specs/<capability>/spec.md` | `spec` | yes (one draft per capability) |
| `openspec/changes/<slug>/tasks.md` | _(not mapped)_ | **no** — Chorus task drafts are source of truth |

`prd`, `tech_design`, `spec` are pre-existing valid `Document.type` values — no schema change required.

---

## §6. Failure visibility — the `chorus_check_response` helper

This helper guards **both** the primary CLI path and the fallback wrapper path. The Node wrapper exits non-zero on transport failures, HTTP 4xx/5xx, and JSON-RPC `error` bodies (e.g. a 401 from a bad `CHORUS_API_KEY` exits 2, a tool-level error exits 4); `chorus mcp call` likewise exits non-zero on tool/transport errors. Still check all three signals below as defense in depth: a bare `RC=$?` is fine for the common cases but this helper also catches a well-formed 200 body that nonetheless carries an `"error"` object, and an unexpectedly empty body.

Define this helper **once** at the top of the authoring session and use it after every mirror call (CLI or wrapper):

```bash
chorus_check_response() {
  local _tool="$1"
  local _rc="$2"
  local _body="$3"
  local _has_error=0
  local _is_empty=0

  local _trimmed
  _trimmed=$(printf '%s' "$_body" | tr -d ' \t\n\r')
  [ -z "$_trimmed" ] && _is_empty=1

  if [ "$_is_empty" -eq 0 ]; then
    # No jq required: a substring match on an "error" key is sufficient for the
    # mirror tools, whose success bodies carry no top-level "error" field.
    printf '%s' "$_body" | grep -qE '"error"[[:space:]]*:' && _has_error=1
  fi

  if [ "$_rc" -ne 0 ] || [ "$_has_error" -eq 1 ] || [ "$_is_empty" -eq 1 ]; then
    echo "ERROR: $_tool failed (exit=$_rc, error_in_body=$_has_error, empty_body=$_is_empty)" >&2
    echo "Output: $_body" >&2
    [ "$_rc" -ne 0 ] && exit "$_rc" || exit 1
  fi
}
```

**Anti-patterns** — do **not**:

- Collapse to `|| true`.
- Redirect stderr to `/dev/null`.
- Bury the wrapper call inside a pipeline (masks `$?`).
- Skip capturing `$RESULT` into a variable; the helper needs the body.
- Use only `if [ "$RC" -ne 0 ]; then ...` — that misses the HTTP-error path.

**Minimal call site shape (both paths):**

```bash
# Primary — chorus CLI:
RESULT=$(chorus mcp call <tool_name> '<json-without-content>' --arg-file content=<file>)
RC=$?
chorus_check_response "<tool_name>" "$RC" "$RESULT"

# Fallback — chorus-mcp-call.mjs via $CHORUS_MCP_CALL (chorus not on PATH):
RESULT=$("$CHORUS_MCP_CALL" <tool_name> "$PAYLOAD")
RC=$?
chorus_check_response "<tool_name>" "$RC" "$RESULT"
# ...if we reach here, the call succeeded; parse RESULT and continue.
```

This is project-wide policy: no silent errors.

---

## §7. Quick reference checklist

When invoked from a stage skill (proposal / develop / yolo):

1. **Read the mode the chorus-dsh bundle already resolved** (§1) — the `## Spec Mode` context, or the `CHORUS_OPENSPEC_ACTIVE` / `CHORUS_SPEC_MODE` env vars. Do NOT re-run detection or hand-roll an OpenSpec-only check. If that context is genuinely absent, use the §1 manual fallback.
2. If there's no `CHORUS_OPENSPEC_ACTIVE=1` → no-op; return to the caller, which follows the resolved `CHORUS_SPEC_MODE` (**spec-lite** via `spec-lite-chorus`, or free-form when `=off`) — see §4.
3. Otherwise:
   a. Pick `$SLUG` (§3.1).
   b. `openspec new change "$SLUG"` (§3.2).
   c. Author `proposal.md`, `design.md`, `specs/<capability>/spec.md` (§3.2–§3.3). Mix `ADDED` / `MODIFIED` / `REMOVED` / `RENAMED` blocks as needed; remember `MODIFIED` overwrites the whole Requirement.
   d. Optional: `openspec validate "$SLUG"`.
   e. `chorus_pm_create_proposal` (direct MCP) with the `OpenSpec change slug: $SLUG` line in description (§3.5).
   f. Define the `chorus_check_response` helper. Prefer `chorus mcp call … --arg-file content=<file>` for mirrors (§3.6) — no `json_encode_file` needed on that path; validate the executable path in `$CHORUS_MCP_CALL` and define `json_encode_file` only when falling back to the wrapper because `chorus` is not on `PATH`.
   g. For each row in §5 with "yes" — mirror via `chorus mcp call chorus_pm_add_document_draft … --arg-file content=<file>` (§3.6; fallback = `"$CHORUS_MCP_CALL" chorus_pm_add_document_draft`). Record each `$DRAFT_UUID`.
   h. On any failed `chorus_check_response` — halt, surface the error, do NOT proceed.
4. Edits before approval → §3.7. Edits after approval → §3.8.
5. Last task verified → detect the trigger yourself (no hook) → run §3.9 archive flow.

