**Rebased onto main after #2614 landed this file.** That PR's conversion
already took the tracked count for `register-task-workflow-routes.ts` to
**0**, so this PR does **not** move your number and I am not claiming it
does.
| file | before | after |
|---|---:|---:|
| `packages/dashboard/src/routes/register-task-workflow-routes.ts` | 0
(post-#2614) | 0 |
What it fixes is a **live 400** that #2614 left in place. Measured on
current main: **9 of this file's 14 retry tests fail** without the
change below.
## The defect
`POST /api/tasks/:id/retry` must answer *"does this card sit where its
workflow **plans**?"*, because the yes-branch is **destructive** — it
stamps `needs-replan` **and deletes PROMPT.md**. Two predicates stood in
for that question and neither answered it:
- **#2614** resolved the **intake** column. Correct for the merged
lineage; wrong wherever intake and the planning column differ.
- The older arm asked `!workflowHasColumn(ir, "triage")`.
**Measured across all 12 builtins:** *not one* plans in `triage`, while
**seven** still declare that column. So for the five that declare
`triage` **and** run every plan node in `todo` — `quick-fix`,
`review-heavy`, `compound-engineering`, `design`, `legacy-coding` — the
predicate is `false` and a `planning`/`needs-replan` card sitting in
**its own planning column** is refused outright:
```
400 — "Task is not in a retryable state (current status: needs-replan)"
```
The operator has no button at all on a card parked mid-planning. The
mirror-image fault is destructive rather than obstructive: a workflow
that plans anywhere other than `todo` had a `todo` card's PROMPT.md
deleted for a re-plan nobody asked for.
## Fix
`workflowPlansInColumn` asks the graph. Planning nodes are recognised by
the **semantic markers** the builtins carry — `config.seam ===
"planning"` and an **exact** `workflowAction` set (measured vocabulary:
`plan-replan`, `code-review`, `pre-merge-remediation`) — with node ids
as a backstop.
Deliberately **not** a `startsWith("plan")` prefix. That was my first
attempt and greptile was right to kill it: it matched in the
**destructive** direction, classifying a custom `plan-execute` column as
a planning column, which deletes a specification. An unlisted planning
action costs a replan (recoverable, card stays retryable); a
wrongly-listed one costs a spec (not). Hence opt-in.
### Second concern, split out
Narrowing the destructive branch must not narrow **retryability** —
those were one boolean and are two questions. A card parked outside its
planning column would otherwise fail the gate and answer 400: that
trades *a card which loses its spec* for *a card nothing can rescue*. It
stays retryable via the non-destructive branch, scoped to pre-WIP
columns so no `in-progress`/`in-review` status gains a path it lacked.
A **v1 IR** declares neither columns nor nodes, so placement is
**unanswerable** rather than answered "no".
`workflowDeclaresColumnModel` distinguishes the two — reading that
silence as "past planning" is exactly what 400'd a v1 planning card.
## Verification
All three greptile P1s on the earlier revision were real and are fixed
with revert-proof tests (bespoke planning-node ids; the v1 regression I
introduced; my own loose action prefix).
`pnpm lint` clean · dashboard `tsc` clean · `pnpm test:gate` green (132
+ 10 + 482 + 71) · core 11/11 · dashboard 119/119
(`retry-planning-column` + `stale-merge-status` + `routes-tasks`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>