OpenSpec-aware Authoring (Pi plugin)
This skill is a shared sub-procedure invoked by the Chorus stage skills (proposal, develop, yolo) when the resolved spec mode is a usable OpenSpec — spec-driven authoring through the OpenSpec CLI:
- Activates when the resolved spec mode is a usable OpenSpec (see §1):
CHORUS_SPEC_MODE=openspecor unset, andCHORUS_OPENSPEC_MODEnotoff, anopenspec/directory exists at the project root, and theopenspecCLI is onPATH. - Otherwise the calling skill follows the resolved mode — spec-lite (the default when OpenSpec isn't usable) or free-form (
CHORUS_SPEC_MODE=off).
See also —
spec-lite(the lightweight fallback): OpenSpec (this skill) stays the default whenever usable. When OpenSpec is absent or disabled — orCHORUS_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-filetransport. See/skill:spec-lite.
When you reach a point in proposal / develop / yolo where this skill is referenced, read the resolved mode from the ## Spec Mode section (see §1) and branch on it. Do not re-derive the detection — the session_start handler has already resolved it once for this session.
§1. Spec mode — already resolved at session_start
The chorus-pi extension's session_start handler resolves the spec mode once when the session opens (via the TS resolveSpecMode, the single source of truth — the reimplementation of the canonical bash resolver) and writes a ## Spec Mode section into the injected context stating CHORUS_SPEC_MODE=<lite|openspec|off>; when the resolved mode is a usable OpenSpec it also carries a CHORUS_OPENSPEC_ACTIVE=1 line. That line is present only when CHORUS_SPEC_MODE is openspec or unset, and all three of these hold:
- OpenSpec is not disabled (
CHORUS_OPENSPEC_MODEnotoff,enableOpenSpecnotfalse). - The project root contains an
openspec/directory (i.e. someone ranopenspec inithere). - The
openspecCLI is onPATH.
Both signals (2) and (3) are required because the OpenSpec authoring path needs the working directory and the CLI. If signal (2) holds but (3) does not, the ## Spec Mode section carries an install hint (npm i -g @fission-ai/openspec); pass it through if asked.
How to read the value
You should already see something like this in your context (look for the ## Spec Mode section near the top of the conversation):
## 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=1line present → follow §3 (OpenSpec authoring).- No
CHORUS_OPENSPEC_ACTIVE=1line → this skill is a no-op; return to the caller, which follows the resolvedCHORUS_SPEC_MODE(spec-lite or free-form). Do not scaffoldopenspec/changes/. Do not add the slug line to the proposal description.
If the ## Spec Mode section shows an explicit CHORUS_SPEC_MODE=openspec that cannot be honored (OpenSpec disabled or not installed — it carries a config-conflict / not-installed reason and no CHORUS_OPENSPEC_ACTIVE=1 line), the caller MUST halt and surface it — do not silently fall back.
Manual fallback
The mode is resolved by the extension in TypeScript (resolveSpecMode) at session_start — there is no separate bash resolver to source, and you must not hand-roll the rule (a hand-rolled OpenSpec-only check ignores CHORUS_SPEC_MODE and the lite/off cases). If the ## Spec Mode section is genuinely absent (e.g. the checkin/connection failed, or you are a forwarded sub-agent whose parent context was not carried), set CHORUS_SPEC_MODE explicitly (lite | openspec | off) and relaunch so the extension re-resolves it — rather than guessing.
§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
chorusCLI: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 JSONcontentstring, byte-exact — the CLI's built-in replacement forjson_encode_file, so no helper is needed. See §3.6. Requires chorus >= 0.17.0 (thechorus mcpsubcommand was added then; an older CLI errors with "unknown command"); on any version or unknown-command failure, upgrade withnpm install -g @chorus-aidlc/chorus.Fallback — the bundled
chorus-mcp-call.shwrapper, whenchorusis not onPATH: build$PAYLOADwith thejson_encode_filehelper and call"$CHORUS_BIN" <tool_name> "$PAYLOAD".chorus-mcp-call.shships with thechorus-pipackage (declared as abin); invoke it via thebashtool. Resolve$CHORUS_BINfirst — prefer the path the extension injects in the Quick Reference (line- **OpenSpec wrapper**: … is at <PATH>):CHORUS_BIN="<injected path from the Quick Reference>" # copy from the `- **OpenSpec wrapper**` line # …or if it wasn't injected, resolve it once: CHORUS_BIN=$(find ~/.pi/agent/npm -path '*chorus-pi/bin/chorus-mcp-call.sh' -type f 2>/dev/null | head -1) # for local-path installs (pi install ./packages/chorus-pi) the script lives next to the package: CHORUS_BIN="$(dirname "$(realpath packages/chorus-pi/bin/chorus-mcp-call.sh 2>/dev/null)")/chorus-mcp-call.sh"The bare command
chorus-mcp-call.shis only onPATHfor npm/git installs — for a local-path install (pi install ./packages/chorus-pi) it is NOT linked, so always use the resolved$CHORUS_BIN. Thejson_encode_filehelper is defined in the §3.6 fallback block.
New to the
chorusCLI? See thechorus-cliskill for install, configuring agents (chorus agents add|remove|list), the connection env vars, andchorus mcpbasics.
Acting identity — which agent the call acts as.
chorus mcp callresolves the agent from, in order:CHORUS_AGENT_PROFILE(a name or UUID) →CHORUS_URL+CHORUS_API_KEYin the environment → the single agent configured in~/.chorus/daemon.json. A daemon-woken session already hasCHORUS_AGENT_PROFILEset. If a mirror call fails withMultiple 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 yourchorus_checkinresult, andchorus agentslists every configured name/UUID.
Reasons (they apply to both paths):
- 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-fileand the fallback'sjson_encode_filestream 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+. - Byte-equality. A file-fill path (CLI
--arg-file, or the fallback'sjq -Rs '.') 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 exact byte-equality guarantee holds only on a file-fill path, never on LLM re-emission. - Single source of truth. With a file-fill mirror, the local
openspec/changes/<slug>/*.mdis 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.
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, notaddExportCsvoradd_export_csv), - derived from the source Idea title,
- unique within
openspec/changes/.
Record it for later steps:
SLUG="add-export-csv"
3.2 Scaffold the change folder
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:underADDEDorMODIFIEDMUST have at least one#### Scenario:. MODIFIEDblocks MUST include the full updated content — they overwrite, not patch.- Use
SHALL/MUSTfor normative requirements; avoidshould/may. - The merge into
openspec/specs/<capability>/spec.mdhappens atopenspec archivetime (§3.9), not at proposal time. While the proposal is in flight, Chorus only sees the delta file as onespecDocument — there is no half-merged state for the skill to reason about.
Optional:
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 JSONcontentstring. This is the byte-faithful replacement forjson_encode_file, so on the CLI path no helper is needed — pass the base JSON without acontentfield and let--arg-filefill it. - Fallback —
json_encode_file(defined in the §3.6 fallback block, used only whenchorusis not onPATH). Withjqavailable it streams the file throughjq -Rs '.'; the pure-shell branch matcheschorus-mcp-call.sh's own escaping whenjqis missing.
Round-trip verification is exact: a trailing newline difference is real drift and MUST NOT be normalized or ignored.
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:
contentcomes 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:
# 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" | grepnotecho "$RESULT" | jq:echointerprets backslash sequences inside the captured JSON, turning embedded\ninto a real newline.jqthen aborts withInvalid 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 bundled chorus-mcp-call.sh wrapper (resolved into $CHORUS_BIN, see §2). 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.
# Define once, fallback-only: byte-faithful file → JSON string.
json_encode_file() {
local _path="$1"
if command -v jq >/dev/null 2>&1; then
jq -Rs '.' < "$_path"
else
local _content
_content=$(cat "$_path")
_content=${_content//\\/\\\\}
_content=${_content//\"/\\\"}
_content=${_content//$'\n'/\\n}
printf '"%s"' "$_content"
fi
}
# chorus-mcp-call.sh ships with chorus-pi; resolve CHORUS_BIN once (see §2), then call via the bash tool.
# 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_BIN" 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):
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_BIN from §2, json_encode_file from the §3.6 fallback block:
CONTENT=$(json_encode_file "openspec/changes/$SLUG/proposal.md")
PAYLOAD=$(cat <<JSON
{
"proposalUuid": "$PROPOSAL_UUID",
"draftUuid": "$PRD_DRAFT_UUID",
"content": $CONTENT
}
JSON
)
RESULT=$("$CHORUS_BIN" 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):
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_BIN from §2, json_encode_file from the §3.6 fallback block:
CONTENT=$(json_encode_file "openspec/changes/$SLUG/specs/<capability>/spec.md")
PAYLOAD=$(cat <<JSON
{
"documentUuid": "$SPEC_DOCUMENT_UUID",
"content": $CONTENT
}
JSON
)
RESULT=$("$CHORUS_BIN" 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
When the LAST task of an OpenSpec-mode idea is admin-verified via chorus_admin_verify_task, the extension (the tool_execution_end handler) injects an additionalContext reminder containing the literal substring openspec archive <slug> so you can act without re-reading the slug.
The hook is read-only; you (the agent) perform the archive:
Run archive locally. Use
--yesfor non-interactive mode. Do NOT pass--skip-specs(defeats the mirror-back) or--no-validate(lets malformed deltas corrupt cumulative specs).openspec archive "$SLUG" --yesThis moves
openspec/changes/$SLUG/underopenspec/changes/archive/<date>-<slug>/and emits/updatesopenspec/specs/<capability>/spec.mdfor each capability. (Runopenspec archive --helpagainst your installed version to confirm the current flag set — flags can drift between releases.)Mirror each updated
openspec/specs/<capability>/spec.mdback to the matching post-approval Chorus Document (§3.8 contract).chorus_get_documentsonly supportsprojectUuid+typeserver-side filters; filter by title client-side. Onechorus_pm_update_documentcall per capability.Halt on any error from
openspec archiveorchorus_pm_update_document. Print stderr verbatim, post a comment on the proposal recording the failure (chorus_add_commentwithtargetType: "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 beinputType: "document"with no idea attached.)Confirm success. For each capability, verify that
openspec/specs/<capability>/spec.mdround-trips byte-equal (modulo a single trailing\n) with its Chorus Document. The--arg-filemirror path already guarantees this, so a spot check is enough: fetch the Documentcontentandcmpit against the local file. Do NOT usejq -rto extract the content — it appends a trailing newline and manufactures a phantom 1-byte drift (a spec that mirrored correctly will read as 1 byte longer on the server). Usejq -j(no trailing newline) orcmpdirectly, and never rely on recursivejq,head, command substitution, or newline normalization. (Unlike the plugin-style Chorus surfaces, thechorus-pipackage ships noverify-document-roundtrip.sh— the--arg-filebyte-equality guarantee makes it unnecessary.)
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, the hook exits 0 silently and no archive reminder is injected. Existing free-form behavior is preserved.
§4. When OpenSpec is not the resolved mode
When the resolved mode is not a usable OpenSpec (no CHORUS_OPENSPEC_ACTIVE=1 line), 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) or free-form (=off). From this skill's side:
- No
openspec/changes/folder is created or referenced. - No
OpenSpec change slug: …line is added to the proposal description. - In spec-lite, the caller follows
/skill:spec-lite(durable.chorus/specs/<slug>/spec.md+ dated per-change folders mirrored via--arg-file); in free-form, document drafts are authored via direct MCPchorus_pm_add_document_draftcalls with inlinecontent— same as before this skill existed. - Rule 1 (wrapper-only mirror) still applies in spec-lite (mirror from the local file); in free-form there is no local file source of truth.
- The §3.9 archive hook does nothing (no slug → silent exit); spec-lite has no archive step at all.
§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. On the fallback path there is a known wrapper edge case: when the server returns HTTP 4xx (e.g. 401 from a bad CHORUS_API_KEY), chorus-mcp-call.sh captures the JSON-RPC error body internally, pipes it through a .result.content[]? jq filter that produces no output when .result is absent, and exits 0 with empty stdout. A bare RC=$? check would not halt on this — the most common runtime failure mode would be invisible. (chorus mcp call exits non-zero on tool/transport errors, so RC is reliable on the primary path — but run the same three-signal check on both, as defense in depth.)
Define this helper once at the top of the authoring session and use it after every mirror call (CLI or wrapper):
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
if command -v jq >/dev/null 2>&1; then
if printf '%s' "$_body" | jq -e 'try ([.. | objects | has("error")] | any) catch false' >/dev/null 2>&1; then
_has_error=1
fi
else
printf '%s' "$_body" | grep -qE '"error"[[:space:]]*:' && _has_error=1
fi
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
$RESULTinto 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):
# 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.sh via $CHORUS_BIN (chorus not on PATH):
RESULT=$("$CHORUS_BIN" <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):
- Read the
## Spec Modesection in the session_start context (§1) — proceed only if it carries theCHORUS_OPENSPEC_ACTIVE=1line. If the section is absent, use the fallback in §1 (setCHORUS_SPEC_MODEexplicitly + relaunch). - No
CHORUS_OPENSPEC_ACTIVE=1line → no-op; return to the caller per the resolvedCHORUS_SPEC_MODE(spec-lite or free-form) — see §4. - Otherwise:
a. Pick
$SLUG(§3.1). b.openspec new change "$SLUG"(§3.2). c. Authorproposal.md,design.md,specs/<capability>/spec.md(§3.2–§3.3). MixADDED/MODIFIED/REMOVED/RENAMEDblocks as needed; rememberMODIFIEDoverwrites the whole Requirement. d. Optional:openspec validate "$SLUG". e.chorus_pm_create_proposal(direct MCP) with theOpenSpec change slug: $SLUGline in description (§3.5). f. Define thechorus_check_responsehelper. Preferchorus mcp call … --arg-file content=<file>for mirrors (§3.6) — nojson_encode_fileneeded on that path; definejson_encode_fileand resolveCHORUS_BINto the bundledchorus-mcp-call.sh(see §2) only when falling back to the wrapper becausechorusis not onPATH. g. For each row in §5 with "yes" — mirror viachorus mcp call chorus_pm_add_document_draft … --arg-file content=<file>(§3.6; fallback ="$CHORUS_BIN" chorus_pm_add_document_draft). Record each$DRAFT_UUID. h. On any failedchorus_check_response— halt, surface the error, do NOT proceed. - Edits before approval → §3.7. Edits after approval → §3.8.
- Last task verified → hook fires → run §3.9 archive flow.