name: openspec-aware
description: Opt-in OpenSpec-mode authoring for Chorus PM workflows in Codex. Detects the local openspec CLI, scaffolds openspec/changes/<slug>/ on disk, and mirrors Markdown files into Chorus document drafts via the chorus-mcp-call.sh wrapper. Required reading for the proposal, develop, and yolo skills whenever the user has the openspec CLI installed.
license: AGPL-3.0
metadata:
author: chorus
version: "0.10.0"
category: project-management
mcp_server: chorus
OpenSpec-aware Authoring (Codex plugin)
This skill is a shared sub-procedure invoked by the Chorus stage skills (proposal, develop, yolo) whenever the user wants spec-driven authoring through the OpenSpec CLI. It is opt-in:
- Activates when all three signals hold (see §1):
CHORUS_OPENSPEC_MODEis notoff, anopenspec/directory exists at the project root, and theopenspecCLI is onPATH. - Otherwise the calling skill falls back to its existing free-form behavior.
When you reach a point in proposal / develop / yolo where this skill is referenced, read the value of CHORUS_OPENSPEC_ACTIVE from the SessionStart context (see §1) and branch on it. Do not re-run the detection block — the SessionStart hook has already done it once for this session.
Codex specifics: Codex's stateless MCP wrapper is
chorus-mcp-call.sh, located at$CHORUS_PLUGIN_DIR/hooks/chorus-mcp-call.shafter resolving$CHORUS_PLUGIN_DIRwith §2.1. It is invoked aschorus-mcp-call.sh <TOOL_NAME> '<JSON_ARGUMENTS>'— nomcp-toolsubcommand (unlike the Claude Code variant). The Codex port has no on-disk session state; every call is self-contained.The helper snippets below are Bash snippets. Codex executes shell commands through the user's configured shell, which may be
zsh; run multi-line helper blocks underbash -lc(or save them as a.shscript with a Bash shebang) before usingjson_encode_file/chorus_check_response.
§1. Detection — already done at SessionStart
The Chorus plugin's SessionStart hook (hooks/on-session-start.sh) computes CHORUS_OPENSPEC_ACTIVE once when the session opens and writes a ## OpenSpec Mode section into the developer-message context. The value of CHORUS_OPENSPEC_ACTIVE is 1 only when all three of these hold:
CHORUS_OPENSPEC_MODEis not set tooff(explicit opt-out wins).- 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 — having one without the other leaves the workflow unrunnable. If signal (2) holds but (3) does not, the SessionStart hook surfaces a "OpenSpec repo detected — install with: npm i -g @fission-ai/openspec" hint to the user; the agent should pass this through if asked rather than silently choosing free-form.
How to read the value
You should already see something like this in your context (look for the ## OpenSpec Mode section near the top of the developer message):
## OpenSpec Mode
CHORUS_OPENSPEC_ACTIVE=1 (openspec/ directory + openspec CLI both present)
or:
## OpenSpec Mode
CHORUS_OPENSPEC_ACTIVE=0 (no openspec/ directory at /path/to/repo/openspec)
Branch:
CHORUS_OPENSPEC_ACTIVE=1→ follow §3 (OpenSpec authoring).CHORUS_OPENSPEC_ACTIVE=0→ return to the calling skill's free-form path. Do not scaffoldopenspec/changes/. Do not add the slug line to the proposal description.
Manual fallback
If you're in a sub-agent that did not see the SessionStart context (e.g. spawned mid-session with the parent's context not forwarded), reconstruct the value yourself with the same three checks — Codex hooks run from $PWD, so use that as the project root probe:
if [ "${CHORUS_OPENSPEC_MODE:-}" = "off" ]; then
CHORUS_OPENSPEC_ACTIVE=0
elif [ ! -d "$PWD/openspec" ]; then
CHORUS_OPENSPEC_ACTIVE=0
elif ! openspec --version >/dev/null 2>&1; then
CHORUS_OPENSPEC_ACTIVE=0
else
CHORUS_OPENSPEC_ACTIVE=1
fi
Use this only when SessionStart context is genuinely unavailable — duplicating the detection is wasteful when the hook already computed it.
§2. Wrapper setup and non-negotiable rules
Both are enforced at review time. Both have caused incidents in past releases.
2.1 Resolve the Codex wrapper path
Define this once before the first wrapper call. Do not require the user to add plugin scripts to PATH; Codex does not do that automatically.
resolve_chorus_plugin_dir() {
if [[ -n "${CHORUS_PLUGIN_DIR:-}" && -x "$CHORUS_PLUGIN_DIR/hooks/chorus-mcp-call.sh" ]]; then
printf '%s\n' "$CHORUS_PLUGIN_DIR"
return 0
fi
if [[ -n "${PLUGIN_ROOT:-}" && -x "$PLUGIN_ROOT/hooks/chorus-mcp-call.sh" ]]; then
printf '%s\n' "$PLUGIN_ROOT"
return 0
fi
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && -x "$CLAUDE_PLUGIN_ROOT/hooks/chorus-mcp-call.sh" ]]; then
printf '%s\n' "$CLAUDE_PLUGIN_ROOT"
return 0
fi
local _codex_home="${CODEX_HOME:-$HOME/.codex}"
local _candidate
_candidate=$(
find "$_codex_home/plugins/cache" -path '*/hooks/chorus-mcp-call.sh' -type f 2>/dev/null | sort | tail -n 1
)
if [[ -n "$_candidate" ]]; then
dirname "$(dirname "$_candidate")"
return 0
fi
return 1
}
CHORUS_PLUGIN_DIR="$(resolve_chorus_plugin_dir)" || {
echo "Unable to locate Chorus Codex plugin root; cannot call chorus-mcp-call.sh" >&2
exit 1
}
API="$CHORUS_PLUGIN_DIR/hooks/chorus-mcp-call.sh"
Rule 1 — Mirror via the wrapper, 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 go through:
"$API" <TOOL_NAME> "$PAYLOAD"
with $PAYLOAD built using json_encode_file (defined in §3.4). Calling these tools directly from Codex's MCP harness with a hand-typed content field is a protocol violation for OpenSpec mode and will fail review. Reasons:
- Token cost. Re-typing a multi-thousand-line markdown body through the model burns input + output tokens for every draft. The wrapper streams bytes through
jq -Rs '.'— content never enters model context. A typical 3-doc proposal mirror via the script costs roughly zero content-tokens; via direct MCP it routinely costs 20k+. - Byte-equality.
jq -Rs '.'is a byte-faithful encoder: backslashes, quotes, newlines, code-fence content, zero-width chars all survive. Model 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 the wrapper path. - Single source of truth. With the wrapper, the local
openspec/changes/<slug>/*.mdis authoritative and Chorus is a mirror. With agent re-typing, authority splits between local file and whatever the model 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 for the same wrapper-bug reason described in §6 — keep using the helper even though Codex's chorus-mcp-call.sh differs slightly in implementation from Claude Code's chorus-api.sh.
§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 Helper: json_encode_file
Define once at the top of the authoring session. With jq available it streams the file into a JSON string; the fallback matches the Codex wrapper's escaping when jq is missing.
json_encode_file() {
local _file_path="$1"
if command -v jq >/dev/null 2>&1; then
jq -Rs '.' < "$_file_path"
else
local _content
_content=$(cat "$_file_path")
_content=${_content//\\/\\\\}
_content=${_content//\"/\\\"}
_content=${_content//$'\n'/\\n}
printf '"%s"' "$_content"
fi
}
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 model-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 via the wrapper
Rule 1 reminder: these calls go through
chorus-mcp-call.sh, not direct MCP. The agent must not retype the document body.
Define the wrapper path from §2.1 and the halt-on-error helper from §6 once at the top, then run one call per file. Note the two-arg Codex wrapper signature: <TOOL_NAME> <JSON_ARGUMENTS> — there is no mcp-tool subcommand.
# 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=$("$API" 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/')
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.
3.7 Editing a draft after the first mirror
Local file changes propagate via chorus_pm_update_document_draft — same wrapper, same json_encode_file, same halt check.
CONTENT=$(json_encode_file "openspec/changes/$SLUG/proposal.md")
PAYLOAD=$(cat <<JSON
{
"proposalUuid": "$PROPOSAL_UUID",
"draftUuid": "$PRD_DRAFT_UUID",
"content": $CONTENT
}
JSON
)
RESULT=$("$API" 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:
CONTENT=$(json_encode_file "openspec/changes/$SLUG/specs/<capability>/spec.md")
PAYLOAD=$(cat <<JSON
{
"documentUuid": "$SPEC_DOCUMENT_UUID",
"content": $CONTENT
}
JSON
)
RESULT=$("$API" 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 plugin's PostToolUse hook (hooks/on-post-verify-task.sh) 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. List
openspec/specs/<capability>/spec.mdfiles 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, the hook exits 0 silently and no archive reminder is injected. Existing free-form behavior is preserved.
§4. Fallback authoring (no openspec)
When detection puts the agent in fallback mode (CHORUS_OPENSPEC_ACTIVE=0), this skill is a no-op. Return to the calling skill's free-form path:
- No
openspec/changes/folder is created or referenced. - No
OpenSpec change slug: …line is added to the proposal description. - Document drafts are authored via direct MCP
chorus_pm_add_document_draftcalls with inlinecontent— same as before this skill existed. - Rule 1 (wrapper-only mirror) does not apply — there is no local file source of truth.
- The §3.9 archive hook does nothing (no slug → silent exit).
§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
There is a known wrapper edge case shared with the Claude Code variant: when the server returns HTTP 4xx (e.g. 401 from a bad CHORUS_API_KEY), the wrapper's internal jq filter can produce empty stdout and exit 0. A bare RC=$? check would not halt on this — the most common runtime failure mode would be invisible.
Define this helper once at the top of the authoring session and use it after every wrapper call:
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 (Codex):
RESULT=$("$API" <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
CHORUS_OPENSPEC_ACTIVEfrom the## OpenSpec Modesection in the SessionStart developer-message context (§1). If it isn't there, fall back to the manual probe in §1. - If
CHORUS_OPENSPEC_ACTIVE=0→ return to caller's free-form path (§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$API,json_encode_file,chorus_check_responsehelpers. g. For each row in §5 with "yes" — mirror via"$API" chorus_pm_add_document_draft(§3.6). 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.