Add a Spec to a Linear Ticket
Close the gap between "ticket groomed" and "ticket with a reviewable spec": draft the spec and attach it to the ticket as a file. The ticket's state is never touched — advancing it is /spec-to-symphony's job, and it advances it only after proving the spec is on the remote.
Two modes. Draft (the default) writes the spec and attaches it. Refresh re-uploads the local spec file over the existing attachment after /spec-review-codex has hardened it. Refresh skips Steps 4–5 entirely and uploads what is on disk; everything else — both gates, the burst, the read-back proof — is identical.
Sibling skills, and the leg each one owns:
| Skill | Owns |
|---|---|
/linear-groom-ticket |
before: makes the ticket coherent |
| this skill | the attachment, in both directions. No state change, no git writes |
/spec-review-codex |
after: hardens the spec on disk — this skill never runs it |
/spec-to-symphony |
after that: puts the spec on the remote, then advances the state |
/spec-to-symphony used to be the near-twin that created a ticket for an existing spec. It no longer creates anything — it continues this skill's ticket. Nothing here creates a ticket either; that is /to-linear.
The transport is the Linear MCP — the same server every sibling skill uses. Every Linear read and write here is an MCP tool call (get_issue, list_issue_statuses, prepare_attachment_upload, create_attachment_from_upload, delete_attachment); load them with ToolSearch if they are deferred. The one thing that is not a tool call is the signed PUT of the spec's bytes, because no tool ships bytes — that stays a curl. There is no orca command here and no hand-rolled GraphQL; both were transports of earlier revisions and both were removed by decision.
Why the MCP, since it used to be GraphQL. Revision 3 moved this skill onto Linear's GraphQL API with a personal LINEAR_API_KEY for one reason: the MCP had no attachment-upload tool. It has one now — prepare_attachment_upload → signed PUT → create_attachment_from_upload is the identical three-step burst, and delete_attachment retires the old copy. So the API key bought nothing except a second credential that could bind to a different workspace than the OAuth session. A later revision removed it. One credential, one workspace binding, shared with every sibling skill — and if a future Linear release drops attachment upload from the MCP again, that is a decision to re-open, not a reason to quietly hand-roll GraphQL mid-run.
One channel: a file attachment. The spec is uploaded to the ticket as <IDENT>-design.md. The ticket's description is never touched, its state is never touched, nothing is committed, nothing is pushed, and no snapshot comment is posted. A local copy at docs/superpowers/specs/<IDENT>-design.md is left behind, uncommitted, and three things read it: the upload needs bytes on disk, /spec-review-codex reviews a file rather than an attachment, and /spec-to-symphony is what eventually commits and pushes it. This skill still makes no git writes — it leaves the file where the next leg expects to find it.
Read this before the first gate
Whether a Symphony stage agent can read this attachment is a property of the deployment, not of Symphony. Do not carry either answer over as a general fact:
- A build whose
Tracker.Issuecarries anattachmentsfield can read it. There, the stage prompt renders the attachment list, an attachment-fetch tool downloads one, and a first stage so equipped writes the attached<IDENT>-design.mdinto the repo and treats it as outranking the description. - A build without that field cannot. No attachments field, no fetch tool, nothing rendered —
issue.descriptionis the only channel that reaches the agent. Linear comments reach no stage on any deployment.
Which one you are on is a runtime read, not a remembered fact. The two markers are an issue.attachments loop in the stage prompt body and an attachment-fetch tool in allowed_tools; /spec-to-symphony Step 1 resolves them from the governing config and prints the answer. From this skill you usually cannot see the config at all — so assume not readable, say so at the gate, and never let an assumed capability be the reason the branch push is skipped.
Symphony has no git code on either: the clone is the config's after_create hook, which runs once, so a spec absent from the remote at first dispatch never arrives by that path either. This skill pushes nothing, so on a deployment that cannot read attachments the spec reaches the pipeline only through /spec-to-symphony's branch push.
None of that changes what this skill does, and it does not move the ticket. The reason it does not has changed, so state the current one rather than the retired one: arming is a single confirmed sequence that also has to set the ticket's project and verify its branch, and /spec-to-symphony Step 9 is where that sequence lives. A ticket armed with no project is dispatched in no state — Symphony filters project before state — so a transition from here can be a silent no-op even where the attachment is perfectly readable. It would also arm an autonomous run from behind a gate whose text promises the opposite.
So two things are true at once: the attachment is the human-readable artifact on the ticket, and arming belongs to the next leg. Do not push, do not write the description, do not transition.
The Job
- Preflight — a git repo, the Linear MCP, the ticket, and which mode
- Sanity-check the ticket's current state — read it, never write it
- Establish which repo this ticket is about
- Run
/blind-spotover the task (draft mode only) - Draft the spec to disk (draft mode only)
- Gate 1 — the caller approves the spec
- Gate 2 — the caller approves the Linear write
- Upload burst → prove → retire the old attachment
- Report, naming the next step
Both gates precede step 8, and that is forced, not stylistic. prepare_attachment_upload returns a signed URL that is short-lived (Linear's upload URLs expire in about 60 seconds). Prepare → PUT → finalize is one uninterrupted burst; a gate inside it burns the fuse.
Inside step 8, proving precedes retiring. The new attachment is proven on the ticket before the old one is deleted, so a failed burst leaves the previous spec in place rather than a ticket with no spec at all. Nothing enforces that order but you.
Refresh exists because /spec-review-codex rewrites the spec file in place and nothing else re-uploads it. Without this mode the ticket's attachment stays the unreviewed draft for the life of the ticket, while the line map's next station is called Spec hardened.
The caller may be an agent
"The caller" is whoever invoked this skill — a person at a terminal, or another agent. The gates do not assume a person, but they are still gates: stop, present, and wait for an explicit affirmative. An agent caller's affirmative is its explicit yes, not your inference that it would probably agree. There is no --dry-run to inspect with; the earlier revision had one and the API offers no equivalent, so the gate text is the only thing standing between a draft and a live write.
FORBIDDEN
- Never call
save_issueat all. Not withdescription, not withstate, not withpatch, not with anything. This skill's only Linear write is the attachment. Moving the ticket belongs to/spec-to-symphony, which arms only after setting the ticket's project and proving the spec on the remote — a transition from here skips both and can dispatch nothing at all. - Never report success, and never delete the previous attachment, before a read-back has shown the new one on the ticket. A successful
create_attachment_from_uploadis not proof; the read-back is. - Never re-draft in refresh mode. Refresh uploads the bytes on disk. Re-drafting there discards whatever
/spec-review-codexjust fixed, which is the one thing refresh exists to preserve. - Never put a gate, a question, or any unrelated call between
prepare_attachment_uploadandcreate_attachment_from_upload. The signed URL expires in about a minute. - Never modify, re-case, drop or add to the headers
prepare_attachment_uploadreturns. They are part of the signed request; any change returns HTTP 403. - Never base64-encode the spec, and never embed it as a
data:URL. Useprepare_attachment_upload+ directPUT+create_attachment_from_upload. - Never leave two spec attachments on the ticket. In refresh mode the old one is deleted in Step 8.6, after the new one is proven — never before, and never not at all. In draft mode a pre-existing attachment is the caller's decision (Step 1.3).
- Never commit, branch or push. No git writes at all — the local spec file stays uncommitted.
- Never use
orca, and never hand-roll GraphQL againstapi.linear.app. The transport is the Linear MCP. A missing tool is a STOP that names the tool, not a licence to reach for another wire mid-run. - Never invent a second delivery channel. No description rewrite, no snapshot comment, no push. The attachment is the channel; where a deployment cannot read attachments,
/spec-to-symphony's branch push is the one that reaches it. Name it as the next step and stop. - Never glob for the blind-spot report and never rebuild it from the subagent's final message.
- Never run
/spec-review-codex. It is the caller's next step.
Step 1: Preflight
git rev-parse --show-toplevel >/dev/null || { echo "not a git repo"; exit 1; }
The Linear MCP is the other half of the preflight: the five tools named above must be available. If the only Linear tools visible are authenticate/complete_authentication, the connector is not authenticated — ask the user to run /mcp, select the Linear connector, and retry. There is no second transport: no orca, no API key, no GraphQL. No usable MCP ⇒ STOP, naming the tool that was missing.
There is no workspace parameter. The OAuth session binds to exactly one workspace, which you can neither select nor assert, so the only check available is reading the identifier back (1.1) and confirming it is the one you asked for.
1.0 — which mode
Refresh when the caller says so — refresh, re-upload, "the spec has been reviewed", "update the attachment" — or when the caller reaches Step 1.3's collision question and chooses replace. Draft otherwise. The mode decides one thing only: whether Steps 4–5 run.
Refresh has one extra precondition: docs/superpowers/specs/<IDENT>-design.md must exist on disk. Absent ⇒ STOP — there is nothing to re-upload, and drafting a fresh spec is not a substitute for the hardened one that went missing. Say which path was empty and let the caller decide whether to re-run in draft mode.
1.1 — resolve the ticket
Resolve the identifier from the invocation's argument, else from the worktree (its branch name or its linked ticket). Then two reads, which is everything Steps 1.3 and 2 need:
get_issue(id: "<IDENT>") -> identifier, title, url, description,
status, team, attachments
list_issue_statuses(team: "<team from get_issue>") -> the team's workflow states
get_issue's id accepts the human identifier (NBS-123) directly. No issue UUID is needed anywhere in this skill — prepare_attachment_upload takes the identifier too, which is one fewer thing to carry through the burst than the GraphQL revision needed.
get_issueerrors or returns nothing ⇒ STOP. Nothing else in this skill is safe without it.- The returned
identifiermust equal the one you asked for. The session binds to one workspace and you can neither select nor assert it. This read-back is the only check that you are writing where you think you are — a ticket in another workspace simply does not resolve. - An empty description ⇒ STOP. This skill's premise is a groomed ticket; there is nothing to draft from.
1.2 — the description is read, not written
Do not capture it as a baseline, do not diff it later, do not snapshot it. It is input to the drafting pass and nothing else. The previous revision overwrote it and needed all three of those; this one does not.
1.3 — attachment collision
Look for an attachment titled <IDENT>-design.md in get_issue's attachments from 1.1.
Each entry must carry its own id — Step 8.6 deletes by that id and there is no other way to address an attachment. An entry with a title but no id ⇒ say so and treat the collision as unresolvable: continue only in draft mode with the caller's explicit go-ahead to leave both copies on the ticket, and name the leftover in the report.
In refresh mode its presence is expected: keep its id for Step 8.6 and continue.
In draft mode, present ⇒ STOP and ask the caller, showing the existing attachment's title and creation date:
- replace — continue in refresh mode if the local hardened spec is what they want uploaded, or in draft mode if they want a new spec drafted. Either way keep the old attachment id for Step 8.6; or
- abort — stop here.
Either way the delete happens in Step 8.6, after the new attachment is proven — never up front, with delete_attachment(id: "<old attachment id>").
Deleting first would mean a failed burst leaves the ticket with no spec at all, which is worse than a stale one. Never a silent second copy either: two attachments named alike leave the next reader guessing which spec is current. An attachment from an earlier run holds that run's spec, so its presence means decide, not done.
Step 2: Sanity-Check the Current State — Read Only
There is no target state to resolve, because there is no transition. What is left is one read-only look at where the ticket already sits, using get_issue's status and list_issue_statuses' names from Step 1.1:
- Expected initial state: default
To Do. In draft mode, if the ticket is in any other state, say so and ask before continuing — a ticket already inIn ProgressorDoneprobably should not be re-specced. In refresh mode this is looser: a ticket mid-flight is the normal case, so report the state and continue. - If the ticket already sits in an active-looking state —
Ready for Spec Review,Ready for Planning, anything past the backlog — say so at Gate 2. A Symphony poller may already be running, and on a deployment that reads attachments an upload now changes the artifact a stage that has not run yet will fetch. You cannot know any deployment's intake state from here, so do not try to work it out, do not move the ticket back, and do not treat it as a blocker: report it and let the caller decide.
Never resolve a state UUID. Needing one means you are about to call save_issue, which this skill does not do. list_issue_statuses is called only so the check above can print real names instead of guesses.
Step 3: Establish the Repo
The drafting pass reads the codebase, so it must read the right one, and Linear has no field naming the repo a ticket belongs to. Two honest cases:
- Invoked with no identifier (the worktree's own ticket): the checkout is the answer. Continue.
- Invoked with an explicit identifier: the repo cannot be derived. Print
git remote get-url originnext to the identifier at Gate 1 and let the caller confirm it. Declined ⇒ STOP.
A spec drafted against the wrong clone describes the wrong system, and the caller reading two lines is the only check available.
Step 4: Blind-Spot Pass — Draft Mode Only
Refresh mode skips this step and Step 5 entirely: go straight to Gate 1 with the file on disk. Re-running a blind-spot pass over an already-hardened spec buys nothing and costs a subagent.
Resolve the report path into a variable, before dispatch, and read that exact path afterwards:
REPORT_PATH="${TMPDIR:-/tmp}/linear-spec-blind-spot-$(date +%s).md"
Dispatch one Agent subagent following /blind-spot's contract: read-only tools plus a single Write carve-out for $REPORT_PATH, given the ticket description and the repo. Substitute the expanded path into the prompt — a subagent cannot expand ${CLAUDE_PLUGIN_ROOT} or a shell variable.
Then read $REPORT_PATH. Do not glob *blind-spot*.md: a glob silently adopts a stale report from a different task. Do not reconstruct the report from the subagent's final message: a subagent that finishes its pass and idles sends none. Missing file ⇒ say so and STOP; never re-dispatch and never re-ask.
Step 5: Draft the Spec — Draft Mode Only
Dispatch a second, fresh-context Agent subagent. Give it, as expanded absolute paths or inline content: the ticket description, $REPORT_PATH, and the repo root.
Its instructions:
- Write a design spec for this ticket, in the language the ticket is written in.
- Every high-impact unknown from the blind-spot report must appear either as a resolved decision or as an explicitly deferred one that says what was deferred and why. Silence about an unknown is a defect.
- Sections: purpose, decisions taken, the approach, failure modes, non-goals, definition of done, known gaps.
- Return the spec as markdown. Nothing else.
Write the returned markdown to docs/superpowers/specs/<IDENT>-design.md.
Gate the output hard before it goes anywhere:
| Condition | Meaning |
|---|---|
| empty | the step went wrong |
contains </invoke>, <invoke, </content> or antml |
tool-call markup leaked; a wave-2 editor once leaked its own closing tags into a live ticket |
no ## heading anywhere |
not a spec |
Any of these ⇒ re-dispatch once. A second failure ⇒ STOP with nothing written to Linear. Never strip the contamination and continue: contaminated output means the step went wrong, not that the text needs cleaning.
In refresh mode, run the same three checks against the file on disk — /spec-review-codex edits it in place, so a leak or a truncation is possible there too. There is nothing to re-dispatch, so any failure is an immediate STOP: fix the file, then re-run.
Leave the file uncommitted. This skill makes no git writes; /spec-to-symphony is what commits it, onto the ticket branch.
Step 6: Gate 1 — the Spec
Show the caller: the mode, the spec path, its headings, the repo origin next to the ticket identifier (Step 3), and — in draft mode — the blind-spot report path. In refresh mode say plainly that the file on disk is being uploaded as it stands and name the attachment it will replace, with that attachment's creation date, so the caller can tell a hardened spec from a stale one. Ask whether to proceed. Declined ⇒ STOP; the spec stays on disk and re-running is cheap.
Step 7: Gate 2 — the Linear Writes
This is the only thing that stops an unapproved Linear write. There is no dry run. Present this and wait for an explicit yes:
linear-spec-ticket — ready to write
-----------------------------------
Mode: draft | refresh
Ticket: <IDENT> — <title>
<url>
Team: <team> (the workspace your Linear MCP session is bound to)
Repo: <owner/repo> (origin)
Spec: docs/superpowers/specs/<IDENT>-design.md (local, uncommitted)
Attach: <IDENT>-design.md (text/markdown, <N> bytes)
[refresh: replaces the attachment created <date>, after the new one is proven]
State: <current> -> unchanged
Writes: one file attachment. [refresh: and one delete of the old one.] Nothing else.
The ticket state is NOT changed, the description is NOT modified, and nothing is
committed or pushed — so this write arms nothing, and nothing here starts a run.
On a deployment whose Symphony build renders attachments, a stage agent CAN read
this attachment and treats it as outranking the description; on a build without
that support the spec reaches the pipeline only via the branch push.
Either way the ticket is armed by /spec-to-symphony, which sets the project and
proves the spec first — moving the ticket yourself starts an autonomous run and
may dispatch nothing at all. Proceed? [y/N]
If Step 2 found the ticket already in an active-looking state, add one line above the question: Note: <IDENT> is already in <state> — a pipeline may already be running against a different spec.
Declined ⇒ STOP. The spec stays on disk; re-running this step later is all that is needed.
Step 8: Upload, Prove, Retire
Everything from here to the end of the burst runs without interruption.
8.1 — measure
prepare_attachment_upload turns this number into a signed x-goog-content-length-range: <N>,<N> header, exact on both bounds. A spec with any non-ASCII character in it — an accent, an em dash — has more bytes than characters, so a size measured with wc -m returns 403 on the PUT. Use the byte count:
SPEC="docs/superpowers/specs/<IDENT>-design.md"
wc -c < "$SPEC" # exact byte SIZE — never wc -m
8.2 — request the signed upload URL
prepare_attachment_upload(
issue: "<IDENT>",
filename: "<IDENT>-design.md",
contentType: "text/markdown",
size: <exact bytes from 8.1>,
title: "<IDENT>-design.md",
subtitle: "Design spec — /linear-spec-ticket"
)
Keep assetUrl, uploadRequest.url and uploadRequest.headers. The clock starts now — the signed URL is good for about 60 seconds, so do nothing between here and 8.4 but the PUT. An error here ⇒ the burst never started and the ticket is untouched; fix the input and re-issue.
One file, one burst. Never prepare a second upload before this one is finalized — the first signed URL expires while the second is being prepared.
8.3 — PUT the raw bytes
One -H per header in uploadRequest.headers, verbatim, casing included — that set already carries the content type, so add nothing to it. Send no other headers, in particular no Authorization: this is a signed storage URL, not the API. Drop none, re-case none. Do not transform the file: no base64, no re-encoding.
Send every returned header even though not all are signed. A live run returned several headers while X-Goog-SignedHeaders listed only a subset, so the rest were almost certainly droppable. Do not start dropping them. Which headers Google signs is Google's decision and can change between runs; sending exactly what the response returned costs nothing and never needs revisiting.
curl -sS -f -X PUT --data-binary @"$SPEC" \
-H "<header-1-name>: <header-1-value>" \
-H "<header-2-name>: <header-2-value>" \
... # one per returned header, verbatim
"<uploadRequest.url>"
Non-zero exit or a 403 ⇒ the burst failed. An unused upload URL creates nothing on the ticket, so the ticket is untouched: go back to 8.2 and repeat the burst. Do not proceed.
8.4 — create the attachment
create_attachment_from_upload(
issue: "<IDENT>",
assetUrl: "<assetUrl from 8.2>",
title: "<IDENT>-design.md",
subtitle: "Design spec — /linear-spec-ticket"
)
Keep the returned attachment id. Step 8.5 proves that id, not the title — in refresh mode the old attachment carries the same title and is still on the ticket at that moment, so a title match would be satisfied by the very attachment you are about to delete.
An error here ⇒ the asset is uploaded but unreferenced, so nothing is visible on the ticket. Retry this call with the same assetUrl; if that fails too, repeat the whole burst from 8.2.
8.5 — prove it
Re-run get_issue(id: "<IDENT>") and look at its attachments.
The attachment id returned by 8.4 must appear in that list. Absent ⇒ STOP. Do not delete anything, do not report success. A create_attachment_from_upload that returned without error is not proof — this read is.
8.6 — retire the previous attachment
Only if Step 1.3 found one and the caller chose replace. Now that 8.5 has proven the new attachment, delete the old one by the id kept in Step 1.3:
delete_attachment(id: "<old attachment id>")
This is the last write, and it is the only order that is safe. Deleting before the burst risks a ticket with no spec at all; skipping it leaves two attachments with the same title and no way for a reader to tell which is current.
Fails ⇒ report it plainly. The ticket then carries both, the new one is the later of the two, and deleting the old one by hand is a ten-second fix. Do not retry in a loop and do not delete the new one to "restore consistency".
8.7 — the state stays where it is
There is no transition. If the caller's workflow tracks a "spec attached" state, moving the ticket is theirs to do; if the build is going to a Symphony pipeline, /spec-to-symphony moves it after proving the spec is on the remote. Say which in the report — a ticket that silently never advances is its own kind of drift.
Step 9: Report
Draft mode:
linear-spec-ticket complete (draft)
-----------------------------------
Ticket: <IDENT> <url> state unchanged: <state>
Attach: <IDENT>-design.md (on the ticket)
Spec: docs/superpowers/specs/<IDENT>-design.md (local, uncommitted)
Next: /spec-review-codex docs/superpowers/specs/<IDENT>-design.md
then /linear-spec-ticket <IDENT> refresh — re-upload the hardened spec
then /spec-to-symphony <IDENT> — push it to the remote and arm
Nothing was armed: the ticket state is unchanged. If your workflow tracks a
"spec attached" state, move it by hand — no skill on the local build path does.
Refresh mode: same block headed (refresh), with Attach: reading <IDENT>-design.md (replaced the copy from <date>) and Next: reading /spec-to-symphony <IDENT> alone.
Pass the path explicitly on that Next line. /spec-review-codex auto-discovers specs by date-prefixed filename, and this one is identifier-keyed, so its scan will not find it on its own.
Name the refresh step every time, in draft mode. /spec-review-codex rewrites the file in place, so without it the attachment on the ticket is the draft nobody reviewed — and the ticket looks complete either way.
If command -v codex fails, add one line saying that next step needs codex on PATH and authenticated. This skill itself never invokes codex.
Red Flags — STOP
- "The spec is attached, so I'll move it to
Ready for Spec Reviewwhile I'm here" — that is a real deployment's intake state, and nothing has verified the ticket's project./spec-to-symphonymoves it, after that check and the push proof - "It's only a state change, not a real write" — it is the one write that can start an autonomous run
- "
create_attachment_from_uploadreturned without an error, so the spec is on the ticket" — only the read-back proves it - "The read-back shows an attachment with the right title, so the new one landed" — in refresh mode that title belongs to the old one too. Prove the id 8.4 returned
- "I'll delete the old attachment first so there's only ever one" — a failed burst then leaves the ticket with no spec at all. Delete in 8.6, after the proof
- "Refresh mode, so I'll re-draft the spec to be safe" — that discards exactly what
/spec-review-codexjust fixed - "The hardened spec is on disk, so the ticket is up to date" — the ticket holds whatever was uploaded. Run refresh
- "I'll ask the caller to confirm before I PUT" — the signed URL dies in about a minute; both gates are behind you by then
- "I'll add a
Content-Lengthheader to be safe" — the header set is signed as returned; adding one returns 403 - "I'll put
Authorizationon the PUT too" — the PUT goes to a signed storage URL, not the API. There is no key to send anyway, and an extra header breaks the signature - "
wc -mgives me the size" — it gives characters, not bytes. Any accent makes the two differ and the signed range is exact on both bounds, so thePUTreturns 403 - "
orcaor a raw GraphQL call would be easier here" — the MCP carries the whole burst, and it is the transport by decision. A tool that is missing is a STOP naming it, not a second wire - "There's already a spec attachment, I'll just upload the new one alongside" — two specs, and no reader can tell which is current
- "There's already a spec attachment, so this ticket is done" — an attachment holds its own run's spec. Its presence means decide (Step 1.3), not done
- "Symphony might not see the attachment, so I'll also push the spec / also write the description" — a second channel here is the removed revision, whichever way the deployment reads.
/spec-to-symphonyowns the push, and it is the next step, not a gap to patch here - "I'll commit the spec so it isn't lost" — this skill makes no git writes
- "The caller is an agent, so the gate is a formality" — the gate is the only thing between a draft and a live write, and there is no dry run
- "I'll glob for the blind-spot report" — a glob adopts a stale report from another task
- "I'll just run
/spec-review-codextoo while I'm here" — out of scope, by decision