| name | upgrade |
| description | Bring an already-bootstrapped project up to current toolkit conventions. Walks the version-ordered migration registry, previews every change, and applies the scripted fixes in one approved commit. |
| user-invocable | false |
Upgrade
Heal consumer-artifact debt in a project bootstrapped under older toolkit conventions: retired state folders, orphaned marker files, and dead settings entries that current releases no longer read. Roughly half of the toolkit's convention changes heal on an idempotent /setup re-run; the rest shipped forward-only and leave live debt behind. This skill is the reversal.
This skill owns no migration list of its own. It walks the shared migration registry at adapters/_shared/src/migrations/ — a version-ordered list of entries, each pairing a pure detector with either a scripted fix (kind: "script") or an operator-guided transform (kind: "assisted"). Adding a migration means adding a registry entry, never editing this file. Registry contract + entry-authoring guide: docs/upgrade-reference.md.
This skill is commit-producing — call requireCommittableBranch before staging anything, exactly as every other commit-producing skill does (STE-228 § Branch-name canonical table is the anchor; don't duplicate it here). The step 4 batch approval is a real commit, so it never lands on trunk unchecked.
Rails
Deliberately lean. There is no dry-run flag, no backup tag, and no state-marker file: the script entries are cheap, previewable file operations, and idempotency comes from detection, not bookkeeping. What guards the run instead:
- Clean-tree refusal — a dirty tree makes "what did the upgrade change?" unanswerable.
- Diff preview before any approval — nothing is committed sight-unseen.
- One approval commit for the whole script batch.
- Detection-based idempotency — re-running on a current tree exits with
Nothing to do.
Idempotency is a property of the tree, not of the toolkit install. Nothing here freezes the project after a run: code still running against it — a stale editor session, a divergent tool, a script wired to the old layout — can re-create the very state a migration just cleaned. That is survivable by design. Detection is cheap and stateless, so the next run simply finds the debt again and offers the same scripted fix.
Run the steps in order. Each step's refusal is terminal: exit without mutating anything.
Step 0 — never-bootstrapped probe
Read the project's CLAUDE.md and decide whether the tree was ever bootstrapped. It was, when the file exists and carries toolkit-managed sections — the <!-- generated by /dev-process-toolkit:setup --> marker, or managed sections such as ## Task Tracking / ## Docs. When no toolkit-managed section is present, the tree was never bootstrapped: there is no legacy state to migrate, only an absent install. Route the operator to /setup and exit before walking a single detector — /setup writes current conventions directly, so a freshly bootstrapped tree needs no migration afterwards.
Before printing the refusal, run the read-only CLI bun run ${CLAUDE_PLUGIN_ROOT}/adapters/_shared/src/upgrade_staleness.ts <projectRoot> to catch the one exception the plain never-bootstrapped verdict would otherwise miss: a pre-toolkit tree still carrying the retired monolithic specs/requirements.md. The CLI applies no detector and writes nothing — it runs the shared staleness probe in-process and prints its NOTES block, so the skill and /gate-check share one byte-pinned verdict instead of re-deriving staleness in prose. On an ordinary unmanaged tree its stdout is empty; on a monolithic one it prints the monolith-split row followed by the legacy-monolith hint. The exit stays terminal and read-only either way.
When the CLI's stdout is empty, print the refusal block byte-for-byte as below:
Nothing to migrate: this project has never been bootstrapped (no toolkit-managed CLAUDE.md sections).
Remedy: run `/dev-process-toolkit:setup` first — it writes today's conventions directly, so no migration is needed afterwards.
Context: mode=upgrade, ticket=unbound, skill=upgrade
When the stdout is non-empty, print that same block and then append the CLI's lines verbatim after the Context: line — the monolith-split row and the hint route the operator to /setup first, then a spec split — so the tail reads:
Context: mode=upgrade, ticket=unbound, skill=upgrade
monolith-split (1.16.0): <evidence from the sniff>
legacy monolithic specs detected — run /dev-process-toolkit:setup to bootstrap, then /dev-process-toolkit:upgrade to split specs/requirements.md into per-FR files.
Either way the exit is terminal: nothing is migrated and no detector runs. A hand-written CLAUDE.md with no marker and no managed sections takes this same path. Never migrate a file the toolkit does not own.
Step 1 — preamble advisory (warn-only)
The advisory below is warn-only: emit the line, then continue the run. It never refuses, and it never changes what the detector walk finds.
Blanket .dpt/ ignore. Check the consumer's root .gitignore for a blanket rule covering the whole .dpt/ tree (a bare .dpt/ or .dpt line). The toolkit ships a nested .dpt/.gitignore that keeps ledger and scratch state out of commits while leaving the lock namespace tracked; a blanket root rule overrides it and hides locks from git entirely. Confirm with git check-ignore -v against a path inside the lock namespace — the rule that wins is the one it reports. Warn, name the offending .gitignore line, and continue.
Advisory: root .gitignore line <n> (`<rule>`) blanket-ignores the .dpt/ tree.
The toolkit's nested .dpt/.gitignore keeps locks tracked; this rule overrides it. Remove the blanket rule.
Step 2 — clean-tree gate
Run git status --porcelain. Any output at all — modified, added, deleted, or untracked — refuses the run: this skill rewrites files in place and batches them into one commit, so a dirty tree makes the upgrade's diff unreadable and an abort unrecoverable. Refuse in the NFR-10 canonical shape and list every offender, not just the first — a gate that reports one at a time turns a cleanup into N round-trips.
Refusing: working tree is dirty — <n> uncommitted change(s)
Remedy: commit or stash the offenders below, then re-run `/dev-process-toolkit:upgrade`
Context: mode=upgrade, ticket=unbound, skill=upgrade
Offenders:
<XY> <path> ← one line per `git status --porcelain` entry, verbatim
Not a git repository? Refuse the same way — the one approval commit has nowhere to land, and without git there is no undo.
Step 3 — detector walk
Import MIGRATIONS from the registry and call each entry's detect(projectRoot) in registry order. Detectors are pure, synchronous, filesystem-only, and network-free — the walk mutates nothing, so it is safe to run before any approval has been asked for. Collect every entry whose result is applies: true, with its evidence, into the detected set.
When the detected set is empty the tree is already current. Print the literal line and exit — no commit, no prompt, no further steps:
Nothing to do.
That line means every registry entry declined — not that every gate will pass. The registry's verification-run-keys entry seeds run_cmd: none only into a project that is NOT runnable; a runnable tree keeps its absent run_cmd, because a migration must never invent a run command it cannot verify, so /gate-check probe #80 (runnability_declared) is the surface that fails that tree and asks the operator for the real command.
Otherwise present the detected set before asking for anything, in registry order, so the operator sees the whole scope at once:
| id | introduced_in | kind | title | evidence |
|---|
<entry.id> | <entry.introduced_in> | script / assisted | <entry.title> | <evidence[0]> |
Render one row per evidence entry — repeat the row for <evidence[1]>, <evidence[2]>, … leaving the first four cells blank on the repeats. Never truncate the list and never collapse it to the first entry with an ellipsis: detectors emit one evidence row per affected file precisely so the operator approves a scope they have seen in full, and an entry requiring explicit approval cannot be approved per-file if the files are hidden behind a ….
A detector that throws is a bug, not a detection: surface the throw in NFR-10 shape naming the entry id, and refuse the run rather than silently treating the entry as not-applicable.
Step 4 — script entries: diff preview, then ONE approval commit
Handle every kind: "script" entry in the detected set together, in registry order — version order is the order the conventions actually changed in, so an older entry never undoes a newer one.
Call each entry's apply(projectRoot), collecting the returned {changed, summary}. Then show one aggregated diff preview for the batch (git diff plus git status --porcelain for newly untracked paths), grouped by entry id with each entry's summary as its heading. Every changed path appears in the preview; nothing is committed sight-unseen.
Ask once. That single approval commit covers the whole batch — the entries are independent file operations against a tree that was clean a moment ago, so per-entry commits would only fragment one logical upgrade across N reviews. On approval, stage exactly the changed set and commit:
chore(upgrade): apply toolkit migrations <comma-separated ids>
Body: one line per applied entry — <id> (<introduced_in>): <summary>. Do not push. On decline, restore the tree to its pre-apply state (git checkout -- . plus removal of any newly created untracked paths) and exit — a declined upgrade leaves no residue.
The permission-shapes entry NEVER auto-applies. It carries requires_explicit_approval: true because it rewrites the user's security configuration — the permissions.allow allowlist and MCP server entries. Ask for its own explicit per-entry approval, in its own prompt naming exactly what it rewrites, even when the auto-approve marker <dpt:auto-approve>v1</dpt:auto-approve> is present: the marker pre-authorizes the batch commit, but it never relaxes this entry. This is the same principle by which the marker is read but never relaxes a requires-input: gate. Declining drops that one entry from the batch and leaves the rest intact; the run continues.
The mode-none-sequential-milestone entry NEVER auto-applies either. It renames milestone plan files and rewrites the milestone: binding of every FR pointing at them, and its detector is filesystem-only by registry contract — so it cannot read git provenance and cannot tell a genuinely legacy plan from a mis-named fresh one. It offers to repair both, deliberately wider than the set the git-keyed plan-identity gate probe fails on. Ask for its own explicit approval with every affected plan listed, so the operator declines the ones they want kept; a project that prefers its sequential plans declines and declares kind: legacy on them instead.
Treat requires_explicit_approval: true as the general rail, not a special case for the two entries carrying it today: any future entry carrying the flag gets its own prompt on the same terms.
Step 5 — assisted entries
An assisted entry carries no apply — its transform needs operator judgment, so it cannot ride the batch's one approval. Route each assisted entry in the detected set to its own documented flow, named in the entry's registry module and in docs/upgrade-reference.md, and run them one at a time after the script batch has been committed, so each starts from a clean tree and lands its own commit. Assisted entries never join the batch commit.
The routing table — entry id → flow:
| Entry | Flow |
|---|
monolith-split | The monolithic-specs split, below. Walkthrough: docs/upgrade-reference.md. |
When the detected set is assisted-only, there is no batch: skip step 4 entirely rather than committing an empty change.
The monolithic-specs split
The flow the monolith-split entry routes to. Five steps, run in order — backup → triage → split → freeze → commit. Each step's refusal is terminal, exactly as the run's own steps are.
Split step 1 — back up specs/
Call backupSpecsTree(projectRoot) from the entry's registry module first — before the operator is prompted for anything, and before a single file is moved, written, or rewritten. It copies the whole specs/ tree, nested paths included, to a timestamped sibling directory at the project root, and returns that path plus the file list it copied.
The backup is mandatory regardless of VCS state. It is the one piece of bookkeeping this skill keeps, and the Rails section's "no backup tag" does not license waiving it: that line is about the script batch, whose undo is the clean-tree gate plus git checkout -- .. That undo reaches a tracked specs/ tree only. An ignored specs/ tree has no index entry, no diff, and no undo at all — and this flow rewrites the operator's specs in place. A copy on disk is the only net that holds either way, so it is never optional, never flag-gated, and never conditional on the tree being a repo.
The directory name is collision-suffixed: specs-backup-<stamp> resolves only to the second, so a second backup taken inside the same second appends -2, -3, … rather than clobbering the first. No backup ever overwrites another.
Copy failure aborts, pre-mutation, in the NFR-10 canonical shape. Every source file is read before any byte is written, so a failed backup leaves specs/ untouched and no half-copied directory behind — there is nothing to clean up, and nothing to mistake for a real backup. Refuse and exit; never proceed to the next step on the reasoning that the copy mostly worked.
Refusing: could not read the specs/ tree — <path>: <reason>
Remedy: fix the unreadable path (permissions, broken symlink), then re-run `/dev-process-toolkit:upgrade`
Context: mode=upgrade, entry=monolith-split, skill=upgrade
The closing summary names the backup directory verbatim, whatever the outcome — it is the operator's restore path, and a summary that omits it strands them.
Split step 2 — triage the legacy FRs with the operator
Derive every legacy FR's disposition mechanically, through the entry's registry module: parseMonolithFRSections reads the monolith's live FR heading blocks, extractPlanCheckboxState reads the flat plan's checkbox rows, and classifyFRs pairs them into one verdict per FR with the evidence that decided it. Never eyeball the dispositions instead — a hand-read of a long monolith is exactly the step that quietly misfiles an FR.
Present the whole derived table, in section order, and present it as a proposal:
| FR | Title | Derived | Evidence |
|---|
FR-<n> | <title> | open / shipped | <evidence> — the deciding AC, or that the plan never mentions it |
Then put it to the operator with AskUserQuestion, with a per-FR override on every row: the operator can flip any row's disposition either way before the flow acts on it. There is no silent classification — the derived verdict is a starting point they confirm, never a decision the flow makes on their behalf. That confirmation is the Socratic contract every judgment call in this toolkit is held to.
Why the derived state is not trusted on its own: the pre-pivot layout is split-brain by construction. The plan's checkboxes and the monolith's AC bullets are two sources that drift, and a checkbox can be stale, wrong, or never ticked for work that shipped years ago. The classifier is deliberately conservative — an FR the plan never mentions reads open — because the two errors are not symmetric: burying work that cannot be proven done costs far more than carrying one already-done FR through the split, which the operator can archive with a single command.
Triage completes before anything moves: no file is split, relocated, or frozen until the table is confirmed. Declining is terminal — the flow exits with specs/ exactly as it was, and step 1's backup still on disk.
Split step 3 — split the surviving open FRs into specs/frs/
Write one per-FR file for every FR the operator confirmed open, through the canonical helpers and nothing else: buildFRFrontmatter builds the frontmatter, acPrefix derives the AC-prefix segment, and Provider.filenameFor names the file. Never hand-roll the YAML, the prefix, or the filename. These are the same three helpers /spec-write § 0b calls on every new FR, and a split that hand-rolls any of them mints files the rest of the toolkit reads differently from the ones it writes itself — the shape drift a migration exists to end, re-introduced by the migration.
Which identity each FR gets is mode-dependent, on exactly the rules the /spec-write creation path already follows:
mode: none mints ULIDs locally. Mint one per open FR via Provider.mintId() — always local, offline-safe, no allocator to race. acPrefix takes the short-ULID tail from it, and that same tail is both the AC prefix (AC-<short-ULID>.<n>) and the filename stem Provider.filenameFor returns. Run the short-ULID collision scan before each write, exactly as a hand-authored FR would.
- Tracker mode routes each open FR through the
/spec-write § 0b creation path instead. There is no local mint on this side: the tracker's allocator owns the number, so create the ticket down that path and let the bound id be the identity, the AC prefix, and the filename stem. No claim on create — a freshly created ticket lands in the tracker's default state, unassigned. Claiming belongs to /implement, never to a migration: a ticket sitting in In Progress because an upgrade touched it misreports the board and trips the drift probes that read that state as active development.
Legacy dotted AC ids are re-keyed, never carried across. Pass each section's AC lines through rewriteAcPrefix(lines, prefix) from the entry's registry module: it rewrites every legacy AC-<fr>.<n> to the derived prefix, re-keys same-FR cross-references in AC prose along with them, and preserves the dotted suffix and the AC order. No legacy AC id survives into a split file.
Each split file carries a one-line provenance note naming the legacy FR number it was cut from; rewriteAcPrefix appends it below the ACs. Once the ids are re-keyed, nothing else in the file remembers its origin — the note is the operator's only thread from a per-FR file back to the monolith section it came from, and to the archive the freeze puts that monolith in.
Milestone bindings retain the legacy M-numbers verbatim. An FR bound to M<n> before the pivot stays bound to M<n> — nothing is renumbered, re-sequenced, or re-pointed at a current milestone. Those numbers are what the freeze mints plan stubs against, and they are load-bearing in the archived monolith too, so renumbering here would break both ends at once to buy nothing.
Split step 4 — freeze the monolith
Call freezeMonolith(projectRoot, openMilestones) from the entry's registry module, passing exactly the milestone set step 2's triage left with surviving open work. It relocates the monolithic requirements and plan into the specs archive as read-only legacy documents — git mv when the file is tracked, so the relocation lands staged for the commit leg, and a plain filesystem move when it is not, because a git-ignored specs/ tree has no index entry to move. Content is preserved byte-for-byte either way: both mechanisms relocate the file itself, so nothing is read back, reflowed, or re-serialized on the way, and the frozen bytes are the ones the operator wrote.
Both documents land together in one legacy/ folder nested inside the FR archive. Together, because the pre-pivot pair is one document in two files — the plan's checkboxes are the only thing that explains the requirements' own AC state, so filing them into separate archives strands each half from the evidence that reads it. Nested one level, because every scanner that would read a file here as a conforming archived FR lists *.md directly under its archive directory and recurses into nothing: a subfolder is invisible to all of those, and that invisibility is what lets the bytes stay pristine. A loose drop would be scanned as a conforming archived FR, and the frontmatter it would need to pass that scan is precisely the edit byte-for-byte forbids. (The design-reference probe does recurse, but it only resolves a link convention no pre-pivot monolith carries, so it passes without asking for an edit.)
A fresh cross-cutting specs/requirements.md is then scaffolded from the shipped requirements.md.template — taken verbatim, the same file /setup writes into a tree bootstrapped today, which is what leaves the migrated tree indistinguishable from a fresh one. Never salvage the monolith's own overview into it instead: that carries the FR sections' framing forward into the one file whose defining property is that it has none.
Spliced under its title is a pointer line naming the legacy archive the history moved to, path verbatim. It is the operator's only thread from the live file back to the sections it used to carry — once the monolith is archived and its ACs re-keyed, nothing else in the tree remembers where any of it went.
Plan stubs are minted for the open-work milestones only: one active specs/plan/M<N>.md per milestone the triage left at least one surviving open FR in, carrying that milestone's remaining rows — unchecked and [~] partial alike — verbatim under its legacy M-number. Rows arrive with their legacy AC ids still on them; the freeze cannot re-key them, because the id mapping is minted per FR by step 3 and is not the freeze's to reconstruct. The stub is a starting point the operator finishes, and those ids are the thread back to the archived plan that explains each row. A fully-shipped milestone gets no stub at all — it stays frozen in the archive, and an active plan file claiming work that finished years ago is exactly what this tree's own probes would go on to read as live development.
Freeze-everything is legal, not an error path. An operator whose triage confirms every AC really did ship freezes the whole monolith and splits nothing. An empty open set is a legitimate, expected outcome — zero per-FR files, zero plan stubs, the history archived and the fresh scaffold in place — and the flow reports it as success. Never read it as a failure to find something to split, and never go hunting for work to keep active to avoid it.
Split step 5 — commit the split, or raise the ignored-specs advisory
Which rail this last step takes depends on one fact, so establish it before staging anything: run git check-ignore -v against a path inside the specs tree and let the rule it reports decide, the same way step 1's blanket-.dpt/ advisory resolves its own question. A tracked tree takes the commit leg; an ignored tree takes the advisory instead. Never infer the answer from the presence of a rule that looks close enough — a negation elsewhere in the file may already be un-ignoring the tree.
Tracked specs/ — the standard rail. Everything the flow produced is one logical change: the per-FR files, the legacy pair the freeze relocated with git mv, the fresh scaffold, and the minted plan stubs. So it lands the way the script batch lands — one aggregated diff preview, then one approval, then one commit covering the whole split. Ask once, stage exactly the split's changed set, and commit. Do not push; the operator reviews and pushes.
chore(upgrade): split monolithic specs into specs/frs/
Body: one line per split FR (FR-<n> → <new id>), plus the archived legacy pair and every minted plan stub. On decline, restore the tree to its pre-split state and exit — a declined split leaves no residue, and step 1's backup is still on disk either way.
Ignored specs/ — advisory, and no commit leg. An ignored tree has no index entry, so there is nothing to stage and the commit leg is skipped entirely rather than asked for or committed empty. Say so loudly rather than exiting quietly on a split the operator has no commit to review: current conventions treat committed specs as the source of truth — they are what the toolkit's probes read, what a reviewer diffs an FR against, and what makes a spec answerable to someone other than the machine that wrote it. Recommend removing the ignore rule and committing the split by hand, and name the backup so the run stays undoable.
Advisory: specs/ is git-ignored (<file>:<n> — `<rule>`), so the split was NOT committed.
Current conventions treat committed specs as the source of truth. Remove the ignore rule, then commit specs/ by hand.
Backup: <backup directory>
The advisory never edits the consumer's .gitignore. It names the offending file and line and stops there. Ignoring the specs tree was the operator's own decision about their own repo, and a migration that silently un-ignores what they deliberately kept out of git mutates their configuration to win an argument it was only licensed to raise. Warn, leave the rule exactly as it stands, and let them act on it.
Step 6 — closing summary
Report what happened, in this order: entries applied (id + summary), entries declined, assisted entries routed and their outcome, the backup directory of any assisted entry that took one — named verbatim, whatever the outcome, and any advisories raised in step 1 that still stand. The backup row is not optional bookkeeping: it is the operator's only restore path for a tree git cannot undo, and a summary that omits it strands them. Then re-run every applied entry's detect and confirm each now returns applies: false — a migration that still detects after applying is a bug in the entry, and the summary must say so loudly rather than reporting success.
Recommend /gate-check as the follow-up when anything was applied.
Rules
- Registry-driven, always. Never hard-code a migration in this file. A new migration is a new registry entry with a detector and a fix.
- Detectors never mutate. The walk in step 3 runs before any approval; anything that writes belongs in
apply.
- Refusals are terminal and side-effect-free. No partial application, no "fixed some of it".
- Never migrate an untracked tree. Steps 0 and 2 are the two gates that make every later step reversible.
- Idempotency comes from detection. Re-running is always safe and always free — that is why there is no marker file.
- One commit, one approval, no push. The operator reviews and pushes.