Grooming a Linear ticket
Analyse one Linear ticket, fix it, or mark it a deletion candidate. Nothing is written to Linear before you approve it. This skill issues no workflow-state command — it never transitions a ticket. It can create a missing label, but only as a write shown at the approval gate like any other.
Transport: the Linear MCP. Every Linear read and write is an MCP tool call
on the same server /to-linear uses (get_issue, list_issue_labels,
list_issue_statuses, list_issues, save_issue, save_comment,
create_issue_label, …); load them with ToolSearch if they are deferred.
save_issue is one tool for create and update — every call here passes id,
because omitting it files a new ticket, which this skill never does. The MCP is agent-only, so you
make every call — there is no script that talks to Linear, by design, because a
script cannot reach the MCP. The deterministic scripts here do only the offline
work (template lint, wave-1 analysis, plan synthesis); the Linear I/O around
them is yours.
A duplicate is a comment by default — a policy, not a missing tool. The
MCP can write the relation: save_issue takes duplicateOf, blocks,
blockedBy and relatedTo, all append-only. This skill still records a
duplicate as a comment naming the canonical ticket and its URL, plus the triage
label, and the reason is specific to duplicateOf: that write was observed to
move the ticket's state on its own, when the removed local pipeline's
relation add silently transitioned an observed ticket into a Duplicate-type state with
nothing in the activity log. Linear has a Duplicate state and no other
relation type has one to move to. That collides with the one promise this skill
makes — it never changes a ticket's workflow state — so the comment keeps the
promise true.
duplicate-of is also the only relation type 40-synthesize.py ever puts in
a plan, so in practice this policy governs every relation the skill writes. The
non-duplicate types were checked and are state-safe: a blocks write on
A verified cross-team pair (2026-08-20) added the relation on both sides and left both
tickets' stateHistory untouched.
The native relation is therefore opt-in, not unavailable: if the caller explicitly asks for it at the approval gate, write it and read the state back (step 7). Never write it silently.
Announce at start: "Using linear-groom-ticket to groom <TICKET>."
Prerequisites — check first, fail loudly
command -v codex || echo MISSING_CODEX
python3 -c "import jsonschema" 2>&1
And the Linear MCP: the tools 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. Never fall back to a local CLI or hand-rolled GraphQL; there is no shared
API key.
Any missing prerequisite: stop and tell the user exactly which one. Do not improvise a substitute — the safeguards live in these tools.
The one rule
You never write to Linear without explicit approval in this conversation.
Every write is an MCP call, made only after the approval gate, and only for
what the approved $RD/plan.json contains — the plan is the output of the
deletion safeguard and the editor gate, so writing anything not in it (or
writing before the gate) bypasses every safeguard the earlier steps built (the
repo/ticket plausibility check, the deletion-evidence rule, the staleness
check, the pre-overwrite snapshot). Step 7 is the only place writes happen, and
it follows the plan exactly.
And never hand-edit $RD/plan.json. It is what drives those writes, so
editing it is the same bypass by an easier route: a hand-written verdict,
reason or safeguard condemns a ticket on your say-so rather than on the
analysis. If the plan is wrong, fix the input and re-run 40-synthesize.py.
Steps
Let $T be the ticket identifier (like NBS-238) — if you were handed a
Linear URL, resolve it to the identifier first, because it names the run
directory. Resolve this skill's directory into $S; every step below uses it:
S="${CLAUDE_PLUGIN_ROOT}/skills/linear-groom-ticket"
echo "$S"
Run that echo and keep the printed absolute path. You need the literal
path, not the variable, the moment you hand a file to a subagent in step 4: a
subagent's Read tool does not expand ${CLAUDE_PLUGIN_ROOT}, so an editor
dispatched with the unexpanded form cannot open its own prompt.
1. Fetch and gate on the repo
Compute the run directory and fetch the ticket over the MCP into it, in the shape the offline scripts expect:
RD="${XDG_STATE_HOME:-$HOME/.local/state}/linear-groom/$T"
mkdir -p "$RD/wave1"
echo "$RD"
Then, through the Linear MCP:
get_issue(id: "$T")→ write$RD/ticket.json. Read the team key/name, the state, the labels, and the full body (comments, relations, children, attachments, activity) from the response and lay them out exactly like this — the--fullextras sit besideissue, underresult:{"result": { "issue": { "identifier": "NBS-238", "title": "…", "description": "…markdown…", "team": {"key": "NBS", "name": "Nimbus"}, "state": {"name": "To Do", "type": "unstarted"}, "labels": [{"name": "Story"}], "url": "https://linear.app/…" }, "comments": [], "children": [], "attachments": [], "relations": [], "activity": [] }}list_issue_labels(team: "<key>")→$RD/labels.jsonas{"result": {"labels": [{"name": "Bug"}, …]}}list_issue_statuses(team: "<key>")→$RD/states.jsonas{"result": {"states": [{"name": "To Do", "type": "unstarted"}, …]}}
Then run the repo-plausibility gate — a Linear ticket does not record its repository, and grooming from the wrong repo makes the veracity analyst read the wrong codebase and falsely condemn a good ticket:
python3 "$S/scripts/lib/repocheck.py" --ticket "$RD/ticket.json" --repo "$PWD"
Exit 0: plausible, carry on. Exit 3: the ticket does not plausibly belong
to $PWD — this is the gate doing its job, not a crash. Show the user the
signals from its stdout JSON and ask what to do; do not waive it on your own
initiative. Any other exit: the check could not reach a verdict — stop, do not
proceed with the gate in an unknown state.
2. Lint and gather candidates
python3 "$S/scripts/10-lint.py" --ticket "$RD/ticket.json" --out "$RD/gaps.json"
10-lint.py measures the ticket against the template the to-linear skill owns
(skills/to-linear/templates/) — bug.md when the ticket carries a Bug type
label, story.md otherwise. One source of truth on purpose: /to-linear
files tickets against those templates, so grooming lints the shape the ticket was
filed with. Its stderr names which template it used, and gaps.json carries
template and template_path. Override with --template <file> only to lint
against a shape deliberately different from the filed one.
Then assemble the duplicate candidate set. The MCP reads are yours; the assembly
is deterministic and offline. Derive the query, then gather parts into
$RD/.candidate-parts/:
QUERY="$(python3 "$S/scripts/lib/keywords.py" --ticket "$RD/ticket.json" --max 6 | tr '\n' ' ')"
mkdir -p "$RD/.candidate-parts"
list_issues(query: "$QUERY", limit: 25)→$RD/.candidate-parts/search.jsonas{"result": {"issues": [{"identifier","title","state":{"name"},"url"}, …]}}- For each open state (every state in
states.jsonwhosetypeisbacklog,unstartedorstarted),list_issuesfiltered to that state →$RD/.candidate-parts/open-<N>.json, one file per state, same shape. - For the first
LINEAR_GROOM_BODY_LIMIT(default 8) candidates,get_issue(id)→$RD/.candidate-parts/body-<IDENT>.jsonas{"result": {"issue": {"description": "…"}}}. If a body read fails, write one line to$RD/.candidate-parts/bodyerr-<IDENT>.txtinstead — not fatal, it degrades one candidate's evidence.
A failed candidate-LIST call (list_issues) is fatal — stop and retry. A
silently short candidate set can hide a duplicate entirely, and nothing
downstream can tell the set was ever short. A failed body read is not. Then:
python3 "$S/scripts/20-candidates.py" --run-dir "$RD" --ticket "$T"
Read its stderr: it reports how many candidates are unique, how many bodies were read, how many were skipped by the cap, and which could not be read. Bodies are what let the duplicates analyst find a shared identifier (same Actions run, same commit, same URL) instead of guessing from titles.
3. Wave 1 — three codex analysts in parallel
bash "$S/scripts/30-wave1.sh" "$T"
This takes a few minutes: three gpt-5.6-luna processes at high reasoning
effort, one per dimension (veracity, duplicates, feasibility). Read what it
prints to stderr carefully — there are two different messages and they mean
different things:
wave1: N/3 dimensions available— the normal summary line. IfN < 3, read the matching$RD/wave1/<dim>.UNAVAILABLEmarker: the model never produced a valid answer after a retry, orcodexitself failed. An unavailable dimension blocks any deletion verdict, and the user should know which one and why.- A
wave1: FATAL —banner, printed before that summary line — this meanspython3'sjsonschemamodule could not be imported, so the validator itself could not run for at least one dimension. This is an environment problem, not a verdict on the ticket. Do not read theN/3line that follows it as "the models couldn't answer" — nothing was asked. Tell the user to fix the Python environment (e.g.pip install jsonschema) and re-run30-wave1.sh; do not proceed to synthesis on a FATAL run.
Before any of that, the script checks the pinned model and effort against
$CODEX_MODELS_CACHE (default ~/.codex/models_cache.json) and refuses to
start if this account cannot run that pair — exit 1, zero codex calls, and
the message says so. Read it as an entitlement problem and nothing else: it is
not a FATAL validator banner and it is not a verdict on the ticket. Fix the
entitlement or override with CODEX_MODEL_BUILD / CODEX_EFFORT_BUILD. A
missing cache file skips the gate rather than failing it.
Only after confirming there is no FATAL banner, read $RD/wave1/*.json for
the dimensions that succeeded.
4. Wave 2 — the editor (Claude Sonnet)
Dispatch one subagent with the Agent tool, model: "sonnet". Its prompt
is the contents of $S/prompts/editor.md, followed by, clearly labelled:
- the template
10-lint.pyselected — readtemplate_pathout of$RD/gaps.json(jq -r .template_path "$RD/gaps.json"), which is an absolute path already $RD/ticket.json$RD/gaps.json- every
$RD/wave1/*.json
Write out every one of those paths in full, expanded. $S and $RD are
your shell's variables, not the subagent's, and $S expands from
${CLAUDE_PLUGIN_ROOT}, which a subagent's Read tool leaves untouched. Paste
the absolute paths you printed in step 1 — or paste the file contents inline. A
subagent handed $S/prompts/editor.md reports the file as missing and then
drafts a description with no instructions and no template, which reads as a
plausible draft and is not one.
Instruct it to return only the description markdown. Write its reply verbatim to
$RD/draft.md — do not edit it yourself. If it returns prose around the
markdown, dispatch it once more saying so; do not hand-clean the output, because
then nobody reviewed what actually reaches the ticket.
This wave runs after wave 1 by design: the editor cannot write an honest description before knowing whether the work is already done.
5. Synthesise the plan
python3 "$S/scripts/40-synthesize.py" --ticket-id "$T" --draft "$RD/draft.md" \
--run-dir "$RD" --triage-label needs-triage --out "$RD/plan.json"
Pass --run-dir "$RD" as shown. Without it the script derives the run
directory from $T, and a differently-cased id would miss the directory you
created.
The verdict and the deletion safeguard are computed here, in code — do not
second-guess them. Exit 1 means every dimension was unavailable; no plan is
written, and you must go back to 30-wave1.sh rather than inventing a verdict
yourself.
Exit 2 means the editor's draft is not fit to write to a ticket and no plan
was written either. The draft becomes the ticket description verbatim, so it is
gated hard: it is refused when empty or whitespace-only, when it contains agent
tool-call markup (</invoke>, <invoke, </content>, antml — this is not
hypothetical, a wave-2 editor leaked its own closing tags into a live ticket
once), or when it has no ## headings at all although every template defines
them. The error names the offending token and line. Do not clean the draft up
and re-run — nothing is stripped on purpose, because a contaminated draft
means the editor step went wrong. Re-dispatch the wave-2 editor (step 4) and
read what it returns.
Read $RD/plan.json's safeguard object before showing anything to the user:
safeguard.deletion_allowed/safeguard.blocked_by: ifblocked_byis non-empty, deletion was proposed and deliberately downgraded — that is the design working, and the user must see every reason listed.safeguard.analysis_incomplete:truewhenever any dimension is unavailable, regardless of verdict, including on aREADYverdict. When it istrue, do not present the result as a complete audit. Say the ticket looks ready (or fixable, or a delete-candidate) on the dimensions that ran, and name every dimension inunavailable_dimensionsthat did not run.safeguard.incoherent_dimensions: dimensions whoseverdictwasdelete-candidatebut whose answer does not hold together — either thedeletion_stancewas notsupports, or there was nodelete_reasonat all (a condemnation with nothing machine-readable behind it). Both are treated identically. These are never counted insafeguard.proposersand they always block deletion for the whole run, even if another dimension proposed deletion cleanly.blocked_bynames which of the two it was.- If
safeguard.incoherent_dimensionsis non-empty andsafeguard.proposersalso contains an entry, say this to the user in one explicit sentence — do not make them infer it from two separate lines: dimension X proposed deletion with qualifying evidence, but dimension Y contradicted itself (said delete-candidate while not supporting deletion), so the tool declined to delete and a human decides. Name the actual X and Y fromsafeguard.proposersandsafeguard.incoherent_dimensions.
Check $RD/labels.json yourself: if needs-triage does not exist on the team,
either pass --triage-label with one that does, or plan to create it — the MCP
has create_issue_label, so a missing label is a write to approve, not a dead
end. 40-synthesize.py does not verify the label; it writes whatever you pass
into the plan. Checking here is what lets the approval gate name the label
creation up front instead of surprising the user with it in step 7.
6. The approval gate
Show the user, in this order:
- The verdict (
READY,FIXABLE, orDELETE-CANDIDATE) and, forDELETE-CANDIDATE, the reason and its evidence. - Per dimension: verdict, confidence, stance, and the one-line findings.
Name every
UNAVAILABLEdimension explicitly. Ifanalysis_incompleteis true, say so in plain words (see step 5). - The safeguard adjacency sentence from step 5 whenever it applies (incoherent dimension blocking a different dimension's qualifying proposal).
- The description diff — run
diffbetween the original and the draft and show it, not two full documents:python3 -c " import json,sys p=json.load(open('$RD/plan.json')) open('$RD/orig.md','w').write(p['original_description']) " && diff -u "$RD/orig.md" "$RD/draft.md" | head -120 - The exact writes, read straight from the plan — never a paraphrase:
Then describe, in plain words, what step 7 will do with each: the description overwrite (preceded by a snapshot comment holding the original), the label adds — naming any label that does not yet exist on the team and will be created first — the audit comment, and, for anypython3 -c " import json p=json.load(open('$RD/plan.json')) print('description overwrite:', 'yes' if p.get('description_new') else 'no') print('labels to add:', p['labels_add']) print('relations:', [(r['type'], r['related']) for r in p['relations']]) print('comments:', len(p['comments'])) "duplicate-ofrelation, a comment naming the canonical ticket plus the triage label rather than a native relation. Say why that last one is a comment: the relation write is available (save_issue(duplicateOf:)) but was seen to transition a ticket's state by itself, and this skill promises not to move state. Offer the native relation as an explicit opt-in. Say that all of these are additive, that the snapshot makes the description overwrite reversible by hand, and that nothing here changes the ticket's workflow state unless the caller opts into the relation.
Then ask whether to apply. Stop and wait. A READY verdict proposes nothing
by default; ask whether the user even wants the audit comment.
7. Apply
Only after an explicit yes. Read $RD/plan.json and perform its writes through
the Linear MCP, in this order, reading each one back to confirm it landed.
Write nothing that is not in the approved plan, and issue no state transition.
If the plan writes a new description (description_new is non-null):
- Staleness check.
get_issue(id: "$T")and compare the live description againstplan.original_description, ignoring Linear's own markdown re-normalisation — it rewrites-bullets to*, turns bare URLs into autolinks, moves**bold**markers and re-flows whitespace, so the live text is never byte-identical. Compare with those stripped. If it still differs, someone edited the ticket since grooming — STOP, nothing written. Recover by re-fetching (step 1) and re-synthesising (step 5); that costs nocodexcalls, because wave-1 findings are cached. But those cached findings describe the text before the edit: if the edit was substantive, re-runbash "$S/scripts/30-wave1.sh" "$T" --forcefirst so the verdict is computed against the ticket you are about to groom. Show the user the diff and let them decide; do not decide silently. - Snapshot. Post a comment holding
plan.original_descriptionverbatim, prefixed with a marker line (e.g.<!-- linear-groom:snapshot -->), so the overwrite is reversible by hand. - Overwrite.
save_issue(id: "$T", description: "<plan.description_new>")— the whole-field replace is what is wanted here, since the draft is the new body. (Do not usepatch; the draft is not a delta.) - Read back.
get_issue(id: "$T")and confirm the stored description matches what you sent (tolerant of the re-normalisation above). If it does not, STOP and report — the snapshot comment holds the original.
Labels (any verdict): for each entry in plan.labels_add, check
$RD/labels.json. A label that is missing is created, not a STOP:
create_issue_label(name: "<label>", teamId: "<the ticket's team UUID>"), but
only if the approval gate named that creation — an unapproved label is an
unapproved write like any other. Then add it via save_issue(id: "$T", labels: [<every existing label> + <new>]) and read the issue back to confirm.
labelsreplaces the full set. Unlike the relation parameters, it is not append-only: any label you omit is removed. Send the ticket's current labels from step 1'sget_issueplus the additions, never the additions alone.
Relations (plan.relations): record each one as a comment — for
duplicate-of, "Duplicate of <related>: <its url>"; for the others, a
one-line statement of the relationship. save_issue can write the real
relation (duplicateOf, blocks, blockedBy, relatedTo, all append-only),
and the comment is a deliberate policy rather than a missing tool: a
duplicateOf write was observed to transition a ticket's state with no
activity-log entry, and this skill promises not to move state. Since
duplicate-of is the only type a plan ever carries, that covers every relation
this skill would write. Tell the user in the report that it was recorded as a
comment and why.
If the caller explicitly opted into the native relation at the approval
gate, write it — save_issue(id: "$T", duplicateOf: "<related>") — then
get_issue(id: "$T") and compare the state to what step 1 recorded. Read
stateHistory, not just the current state: a transition Linear makes for you
shows up there whether or not it reaches the activity log. If the state moved,
say so in the report as a state change the caller caused; do not try to move
it back, and never write a relation that was not opted into.
Comments (plan.comments): post each body as a comment. The audit comment
is last.
If any write fails, report which steps completed and stop; the snapshot comment (posted before the overwrite) holds the original description. Re-running the same approved plan is the supported recovery — the staleness check will recognise a description you already wrote (tolerant of re-normalisation) and let you repeat the remaining additive steps, which are safe to repeat.
Re-running
Wave 1 caches: a valid $RD/wave1/<dim>.json is not regenerated. Pass --force
to 30-wave1.sh to re-run the analysis. Everything else is cheap to repeat.
The cache is keyed on nothing but the file's own validity — it does not
notice that ticket.json, candidates.json or gaps.json changed underneath
it. So after a re-fetch that brought in edited ticket text, the cached findings
still describe the previous text, and re-running only 40-synthesize.py
produces a verdict from findings computed against text that no longer exists.
Cheap and correct are different questions: if the ticket changed
substantively, --force.
Reporting honestly
- If a dimension is
UNAVAILABLE, say so — do not present a two-dimension analysis as if it were complete. - If a
FATALbanner appeared in30-wave1.sh, say the environment is broken, not that the ticket has a problem. - If the analysts disagree, show the disagreement rather than picking a side.
- If the verdict is
READY, say the ticket needed nothing. That is a real result, not a failure to find work. DELETE-CANDIDATEmay be structurally rare — possibly rarer than it should be. Condition 3 of the safeguard blocks deletion on anyopposesvote, and wave 1 is parallel by design, so the feasibility and duplicates analysts never learn what veracity found. A ticket describing work that is already merged still reads as perfectly implementable, so an analyst answering "could this be built?" can veto the verdict of the analyst who checked "has this already been built?". If you never see aDELETE-CANDIDATEacross many groomings, do not conclude the tickets are all healthy — suspect this, and say so.
Tests
bash "$S/scripts/tests/run.sh"
The suite runs with no tokens and no network: it exercises the offline scripts
only (template lint, candidate assembly, plan synthesis, plan-schema
validation, the repo-plausibility gate). $LINEAR_GROOM_CODEX points at a codex
test double; the Linear I/O is the agent's job and is not scripted, so there is
nothing to fake. Run it after touching any script.
Configuration
| Variable | Default | Purpose |
|---|---|---|
CODEX_MODEL_BUILD |
gpt-5.6-luna |
Wave-1 model (pinned, not inherited from ~/.codex/config.toml) |
CODEX_EFFORT_BUILD |
high |
Wave-1 reasoning effort |
CODEX_MODELS_CACHE |
~/.codex/models_cache.json |
Where the entitlement gate looks. Absent file → gate skipped, not failed. Exists but unparseable → the run is refused, because "cannot confirm" is not "fine" |
LINEAR_GROOM_CODEX |
codex |
Codex CLI, overridden by the test suite |
LINEAR_GROOM_BODY_LIMIT |
8 |
How many candidate descriptions 20-candidates.py reads (one get_issue call each). The cap and the number skipped by it are always printed to stderr |
LINEAR_GROOM_EXCERPT_CHARS |
400 |
Length of the per-candidate excerpt. The full length is reported separately as description_chars |
XDG_STATE_HOME |
~/.local/state |
Parent of the run directory |