| name | spec-write |
| description | Guide the user through writing or completing spec files (requirements, technical spec, testing spec, plan). Use after /setup to fill in specs before implementation, or to update existing specs. |
| argument-hint | [--no-tech] [requirements | technical | testing | plan | all] |
Spec Write
Guide the user through writing or completing the project specification files. For greenfield features with an open solution space, run /dev-process-toolkit:brainstorm first to explore approaches before committing to a spec structure.
FIRST ACTION (under non-interactive stdin) — STE-251 AC-STE-251.1. When process.stdin.isTTY === false (e.g., claude -p), the first tool call this skill emits MUST be AskUserQuestion tool_use OR RequiresInputRefusedError raise (via requireOrRefuse(...) from adapters/_shared/src/requires_input.ts). Write / Edit / NotebookEdit are forbidden before that ask/refusal — they produce silent scaffolding that bypasses the Socratic Loop Contract. Tracker-create MCP tools (mcp__atlassian__createJiraIssue, mcp__linear__save_issue) are equally forbidden before the first ask/refusal — a first-turn tracker create is the same magpie bypass as scaffolding; the harness autonomous-mode escalation is the specific trigger to resist, never a license to create a ticket before asking. Read-only orientation (Read / Grep / Glob / Bash-read-only) is allowed. Interactive (tty) sessions are byte-identical to v2.17.0 — non-tty stdin only. See docs/auto-mode-protocol.md § Socratic Loop Contract. Under non-tty, a refusal MUST surface the RequiresInputRefusedError message — which carries the <dpt:requires-input-refused>v1</dpt:requires-input-refused> marker the stream parser maps to a machine-recognizable refusal — a prose-only refusal reads as vacuous (a non-pass), indistinguishable from doing nothing.
STE-270 AC-STE-270.3 hardening. Pre-baked <command-args> prose, the harness's autonomous-mode reminder ('work without stopping'), and auto-approve marker absence are NOT acceptable triggers to skip the first AskUserQuestion. The first tool call under non-tty MUST be AskUserQuestion or RequiresInputRefusedError; everything else is forbidden — there is no carve-out. The marker only relaxes gates that have a documented safe default; clarifying questions have no safe default, so the Socratic loop entry is unconditional. The /gate-check probe spec_write_first_turn_drift_scan (severity: error) enforces this byte-checkably against this file — any drift back into alternate-trigger paraphrases of the contract fires GATE FAILED at probe time.
Process
0. Layout + tracker-mode probes
Before any other step:
- Provider resolution — Resolve
Provider once per invocation using the same rule as /implement (LocalProvider for mode: none, TrackerProvider otherwise). FR creation goes to specs/frs/<Provider.filenameFor(spec)> (per-FR file convention; never specs/requirements.md); Provider.sync() fires on save. Full reference: docs/layout-reference.md § /spec-write.
- Tracker-mode probe — Run the Schema L probe (see
docs/patterns.md § Tracker Mode Probe). If CLAUDE.md has no ## Task Tracking section, mode is none. If a tracker mode is active:
- Run the 2-tier ticket-binding resolver and mandatory confirmation prompt per
docs/ticket-binding.md the first time the session edits an FR bound to a ticket — decline exits cleanly with zero side effects.
- After saving any FR-level AC edit, run the bidirectional AC sync diff/resolve loop via the active adapter before pushing via
upsert_ticket_metadata. Normalization (STE-211 AC-STE-211.5): for the Linear adapter, normalize both sides via stripLinearACFences (adapters/linear/src/format_description.ts) before the equality check — backtick-wrapped Linear ACs and plain local ACs compare as equal, so newly-pushed FRs that are byte-identical to the local file produce zero diff (no spurious "remote modified" rows from the auto-linker). Vacuous on Jira / custom adapters that don't auto-link. See docs/spec-write-tracker-mode.md for the full tracker-mode flow.
0.5 Tracker-local reconciliation
Tracker mode only (mode-none vacuous). Call reconcileTrackerLocal to surface drift between local specs/frs/ and the tracker: tracker-only orphans auto-import via importFromTracker (guarded by existsSync per STE-135 to avoid clobbering local edits); local-only orphans and milestone mismatches prompt the user to resolve before continuing.
0a. Resolver entry
After the layout gate and before any FR write:
- Call
buildResolverConfig(claudeMdPath, adaptersDir) from adapters/_shared/src/resolver_config.ts once at entry, then pass the returned ResolverConfig to resolveFRArgument($ARGUMENTS, config) from adapters/_shared/src/resolve.ts. The builder reads CLAUDE.md ## Task Tracking + each active adapter's Schema W resolver: block — never hand-assemble the config inline. Malformed adapter metadata surfaces as MalformedAdapterMetadataError → NFR-10 canonical refusal.
- Route by
kind:
ulid → open the FR whose frontmatter id: equals <ulid> — located via Provider.filenameFor(spec). Skip step 0b below.
tracker-id / url → branch on mode:
- Tracker mode → call
findFRPathByTrackerRef(specsDir, trackerKey, trackerId) (path-returning, no id: requirement; tracker-mode frontmatter has no id: line — findFRByTrackerRef's id:-driven lookup would silently miss every existing FR and fall through to importFromTracker, overwriting local edits).
mode: none → call findFRByTrackerRef(specsDir, trackerKey, trackerId) (ULID-returning; mode-none has id:).
- Hit (either helper) → open that FR for editing; no import, no tracker network call beyond resolve. Skip 0b.
- Miss → run the shared import helper
importFromTracker(trackerKey, trackerId, provider, specsDir, promptMilestone) from adapters/_shared/src/import.ts. Tracker ACs are auto-accepted — never run the bidirectional per-AC prompt loop here; the local side is empty so there is nothing to diff against. Empty-AC tickets get a TODO marker in the new FR's ## Acceptance Criteria section.
fallthrough → continue with free-form-argument handling (step 1 below). NFR-18 requires byte-identical behavior for all, requirements, technical, testing, .
Full decision table and edge cases: docs/resolver-entry.md. Subsequent /spec-write calls on the same tracker ID run the normal bidirectional diff/resolve flow because both sides are now populated. STE-227 auto-resume on needs_technical_review: true (AC-STE-227.5): when the resolved FR's frontmatter carries this flag and the invocation is unflagged, skip the requirement + AC interview (already filled by the prior --no-tech run) and run only the technical-design + testing-spec interviews. On save, the needs_technical_review key is removed from frontmatter entirely (absent ≡ false; never write false); the needs-technical-review tracker label is removed on the same Provider.sync / upsertTicketMetadata call that pushes the technical content. mode: none is vacuous (no tracker, no labels — the frontmatter key removal is the sole signal).
0b. FR creation path
Creating a new FR means:
STE-227 --no-tech flag (AC-STE-227.1, AC-STE-227.3). When the invocation carries --no-tech (parsed in resolver-entry preamble before § 0a; flag does not affect resolver kind routing), skip the technical-design + testing-spec interviews entirely on this new-FR creation path. Write the placeholder line [needs technical review — run /spec-write <FR-id> to complete] (substitute the literal <FR-id> with the real ID at write time — the tracker ID returned by Provider.sync(spec) in tracker mode, or the short-ULID stem in mode: none) into the body of ## Technical Design and ## Testing. Frontmatter: needs_technical_review: true via buildFRFrontmatter(spec, trackerBinding, { needsTechnicalReview: true }); auto-resume per § 0a closes the gap. Provider.sync(spec) still fires (the FR lands on the tracker as usual, just with an extra label). In tracker mode with label-push capability (Linear, Jira), append needs-technical-review to defaultLabels when populated, or seed a single-element array when absent — upsertTicketMetadata(null, { …, labels: [...(defaultLabels ?? []), "needs-technical-review"] }). Adapters without label-push capability surface a needs_technical_review_label_unsupported capability row; the frontmatter flag remains the canonical signal. mode: none is vacuous (no tracker, no labels). Without the flag (default), the full interview runs unchanged.
Draft with placeholder. When drafting a new tracker-bound FR, use <tracker-id> (or the adapter-specific rendering — STE-<N> for Linear, PROJ-<N> for Jira, etc.) as the tracker-ID placeholder throughout the draft: AC prefixes (AC-<tracker-id>.1), filename (<tracker-id>.md), plan-file table row, and every prose cross-reference. Never guess the next sequential number — the tracker allocator decides, not the implementer, and a guess that clashes with a cancelled/renumbered ticket ships misaligned with its own binding. The real ID is known only after Provider.sync(spec) / upsertTicketMetadata(null, …) returns. Substitute the placeholder globally once the ID is assigned, then write the FR file. This rule applies equally to Linear (which skips cancelled numbers), Jira, and custom trackers. Mode: none is exempt — the short-ULID tail is minted locally and is never subject to race conditions with a tracker allocator.
-
mode: none only: Mint a ULID via Provider.mintId() — always local, offline-safe. Tracker mode skips this step: TrackerProvider does not implement IdentityMinter (capability boundary — mintId() on a Provider-typed value is a TypeScript error by design), and buildFRFrontmatter(spec, trackerBinding) rejects id: alongside trackerBinding (the bimodal-identity invariant). The tracker ID returned by step 4 is the canonical identity in tracker mode.
-
Build canonical frontmatter via buildFRFrontmatter(spec, trackerBinding?) from adapters/_shared/src/fr_frontmatter.ts — never author YAML by hand. The helper enforces the bimodal shape (mode-none id: block; tracker-mode compact tracker: block) and rejects the verbose { key, id, url } form (InvalidTrackerShapeError). Hand-rolled YAML is the regression source the earlier prose flip didn't catch. Then write the FR file to specs/frs/<Provider.filenameFor(spec)> (per-FR file convention). Provider.filenameFor(spec) returns <tracker-id>.md in tracker mode (e.g., <TKR>-NN.md) and <short-ULID>.md in mode: none (e.g., VDTAF4.md, matching the AC-prefix convention). Never hard-code fr_<ULID>.md — the ULID lives in frontmatter id:, not in the filename. Every new-FR create passes the changelog category (STE-381 AC-STE-381.2): call buildFRFrontmatter(spec, trackerBinding?, { changelogCategory }) with a value from the closed Keep-a-Changelog set {Added, Changed, Deprecated, Removed, Fixed, Security} — chosen by the drafting session, shown in the draft-gate preview, default Added — and the helper emits changelog_category: <value> after created_at: (the shipped M102-era file shape; when needsTechnicalReview is also set the key order stays tracker → needs_technical_review → created_at → changelog_category).
Helper output — do not author by hand. ## Summary — authored on every new-FR create. Write as the first body section, immediately after the H1 and before : 3–6 non-empty lines of plain prose for human reviewers, under the altitude rule — zero backtick characters; no AC-ID tokens; no path-like tokens (slash + dot-extension in one whitespace-delimited token); no code fences (subsumed by zero backticks). Seed it from the drafting session's approved-decision recap; it appears in the step 4 draft-gate preview with the rest of the draft. The five required body sections follow in order: , , , , . Reference shapes (illustrative only):
- ≥ 1 candidate returned ⇒ MUST emit
spec_research_invoked (literal, backticked).
- empty-fallback bullets / zero topic matches ⇒ MUST emit
spec_research_no_matches (literal, backticked).
- shape violation (sections / line cap / banner) ⇒ MUST emit
spec_research_shape_violation (literal, backticked). The byte-checkable token is the structural signal /gate-check's closing_summary_capability_keys probe greps for; narrative prose alone is insufficient (STE-220 lesson re-applies). Deps-research seed (STE-301 AC-STE-301.14) — step 2.5b. Immediately after the spec-research seed above and before writing AC lines (step 3 below), check whether specs/deps.yaml exists and contains ≥ 1 entry; when both conditions hold, invoke /dev-process-toolkit:deps-research <topic> (forked, <topic> = FR title + 1-line summary). The forked skill returns a banner-fenced block of dependency hits drawn from the curated specs/deps.yaml manifest; inject it into context to seed the ## Technical Design interview alongside the spec-research block. After the fork returns, persist the raw returned block to .dpt/scratch/<ulid>/deps-research-result.txt (the deterministic gate reads it — the deps_research_result_shape probe, not the LLM, validates its shape). Read-only context; never copy verbatim into the FR body. Vacuous path: specs/deps.yaml absent or empty (zero entries) ⇒ skip the invocation entirely (no fork, no tokens), step 3 proceeds as today. Skipped under --no-tech (no ## Technical Design interview to seed; vacuous regardless of manifest state). Shape-violation fallback: block dropped silently, parent continues without the seed (enrichment, not load-bearing — identical to the manifest-absent path). The closing summary at Step 7 MUST emit exactly one literal token (verbatim, no paraphrase) whenever the deps-research subagent fires: ≥ 1 match returned ⇒ MUST emit deps_research_invoked (literal, backticked); zero matches across N entries ⇒ MUST emit deps_research_no_matches (literal, backticked); shape violation ⇒ MUST emit deps_research_shape_violation (literal, backticked). The two legal-skip dispositions complete the set: specs/deps.yaml absent or empty ⇒ ; a run ⇒ . Exactly one of these five literal, backticked tokens emits per invocation — the first three when the fork ran, the two skip tokens when it was legally not invoked; when carries ≥ 1 entry and the run is not , the fork MUST be invoked. There is NO legal token for "previously believed compromised," "injected," or "disabled." a deps-research shape violation drops THIS seed and continues; it never disables the fork for future invocations. This skill holds no "fork compromised" state and carries no cross-invocation belief about fork health — each invocation re-invokes the fork per the manifest gate, independent of any prior run's outcome.
-
AC prefix — every AC line in the new file uses the shape - AC-<PREFIX>.<N>: <body>, where <PREFIX> is derived via acPrefix(spec) from adapters/_shared/src/ac_prefix.ts: in tracker mode it's the bound tracker ID (e.g., AC-<TKR>-NN.1); in mode: none it's spec.id.slice(23, 29) — the last 6 chars of the ULID's random portion (e.g., AC-VDTAF4.1). Tracker mode requires the tracker: block to be populated before ACs are written (i.e., bind the ticket first, then author ACs). In mode: none, before writing the file, call scanShortUlidCollision(specsDir, spec) from the same module; it throws ShortUlidCollisionError (NFR-10-shape) if another FR already uses the same short-ULID tail. The same short-ULID doubles as the mode-none filename stem, so the collision scan also guards against filename collisions. Never emit literal AC-<digit>.<N> shape — the requirements template carries the placeholder AC-<tracker-id>.<N>; substitute via acPrefix(spec) before writing every AC line.
-
Draft acceptance gate (run BEFORE Provider.sync below). Present the FR draft to the user and ask Approve and proceed? Reply 'y' to create the tracker issue and write the files, 'n' to cancel, 'edit' to revise. Review surface: the human approves the Summary, Requirement, and Acceptance Criteria; Technical Design and Testing are implementation-facing and are verified by the deterministic gates and the TDD audit.
Marker-detection (STE-262 AC-STE-262.2 — deterministic, runtime byte-grep). The literal byte-string <dpt:auto-approve>v1</dpt:auto-approve> is the marker the runtime helper detects. Before evaluating the draft-gate auto-apply branch, write the verbatim first user message that invoked /spec-write to /tmp/dpt-spec-write-prompt-body.txt (Bash heredoc, FIRST step of draft-gate evaluation), then run:
bun run plugins/dev-process-toolkit/adapters/_shared/src/check_marker_runtime.ts /tmp/dpt-spec-write-prompt-body.txt
The script prints PRESENT or ABSENT on a single line. Branch strictly on the script's stdout — the script's output is the single deterministic gate decision; no LLM inference, no autonomous-mode reminder, no "work without stopping" framing influences the auto-apply branch.
1. Assess current state
Check which spec files exist in specs/ and how complete they are:
- Read each file and determine: empty template, partially filled, or complete
- Report status to the user
If specs/ doesn't exist, suggest running /dev-process-toolkit:setup first.
2. Determine scope
If $ARGUMENTS specifies a file (requirements, technical, testing, plan), work on that one. If $ARGUMENTS is "all" or empty, work through all files in precedence order:
requirements.md → technical-spec.md → testing-spec.md → plan.md
This order matters because each spec builds on the previous one.
3. For each spec file
requirements.md (WHAT to build — cross-cutting only)
Scope: cross-cutting only. specs/requirements.md captures concerns that span multiple FRs — auth scheme, observability surface, tenancy model, accessibility posture, etc. Per-FR detail lives in specs/frs/<id>.md exclusively. When /spec-write is invoked on a per-FR feature, route the work straight to specs/frs/ (per § 0b) and do NOT touch specs/requirements.md. The requirements-md-no-placeholder gate probe (gate-check #29) flags any ### FR-N: [Feature Name] heading that survives in requirements.md as drift. STE-227 --no-tech: the flag does not affect this cross-cutting flow; only the per-FR technical-spec.md + testing-spec.md interviews below are skipped (the FR body's ## Technical Design and ## Testing sections are written as placeholder lines per § 0b's preamble; the technical reviewer fills them via /spec-write <FR-id>).
Only fire the per-section flow below when the user is filling in genuinely cross-cutting requirements — typically the first time /spec-write runs on a new project, or when an architectural concern emerges that affects multiple FRs.
Ask the questions in this order, one at a time. Wait for each answer before asking the next — do not bundle them into a single turn even when the user is responsive.
Ask what this project is, who it is for, and what problem it solves. Wait for the answer. Then ask what cross-cutting functional requirements exist (auth, observability, accessibility, tenancy — anything that spans multiple FRs). Wait for the answer. Then ask what is explicitly out of scope (project-wide, not per-FR). Wait for the answer. Then ask whether there are any non-functional requirements (performance, security, accessibility).
Per-feature acceptance criteria are NOT collected here — those go in specs/frs/<id>.md via § 0b's FR creation path. If the user names a specific feature, branch into the FR-creation flow rather than expanding § 2 in requirements.md.
Write the answers into the spec using the template structure with per-AC prefixes derived via acPrefix() (tracker ID in tracker mode, short-ULID tail in mode: none). AC lines take the shape AC-<PREFIX>.<N>: ....
Stable anchor IDs: Every ### <PREFIX>: heading you generate or edit must carry its {#<PREFIX>} anchor on the same line, matching the template form ### <tracker-id>: User login {#<tracker-id>} (tracker mode) or ### VDTAF4: User login {#VDTAF4} (mode: none). Same rule applies in plan.md, whose milestone headings you emit as ## M{N} — <Title> {#M{N}} (em-dash separator, never a colon) — the {#M{N}} anchor must be present. These anchors are the pointer targets for archival and for cross-references in the traceability matrix, so they must survive heading renames. If you encounter any milestone or FR heading without its anchor, flag it as a warning in the report (step 7) and offer to add it — never silently edit around it.
technical-spec.md (HOW to build it)
Read requirements.md first to understand what needs building. Then ask the questions below in order, one at a time — wait for each answer before asking the next.
Ask what the high-level architecture looks like (read existing code if any). Wait for the answer. Then ask what the key design decisions are and their rationale. Wait for the answer. Then ask what the data model is (schemas, types, database tables). Wait for the answer. Then ask what APIs or interfaces are needed. Wait for the answer. Then ask what the key patterns are (state management, error handling, etc.).
Pre-fill what you can from the codebase and CLAUDE.md. Ask the user to confirm or correct.
testing-spec.md (HOW to test it)
Read requirements.md and technical-spec.md. Then pre-fill the test framework, mocking approach, and file conventions from CLAUDE.md, and identify what NOT to test (generated code, third-party internals).
For the two items that need user input, ask them in order, one at a time — wait for each answer before asking the next. Ask about coverage targets per layer. Wait for the answer. Then ask about the test data strategy (factories, fixtures, seeds, frozen times).
Most of this can be inferred — present your best guess and let the user correct.
plan.md (WHEN to build it)
Read all other specs. Then work through the steps below in order, one at a time — if any step surfaces a question for the user, ask it, wait for the answer, and only then move to the next step. Do not bundle the step-questions into a single turn.
First, break the requirements into milestones (each independently gatable). Then order the milestones by dependency. Then, for each milestone, list tasks in dependency order, acceptance criteria, and gate commands. Set the plan frontmatter's migration: key deliberately at drafting time — the literal none when the milestone introduces no consumer-artifact convention change, or a migration-registry entry id when the plan is bound to that registry entry. Finally, draw the milestone dependency graph.
Milestone-number allocation guard. Before claiming any new milestone identity, make exactly ONE call — resolveMilestoneIdentity({ specsDir, mode, epicKey, changelogPath, provider, branchScanner }) from adapters/_shared/src/resolve_milestone_identity.ts, before any plan or FR file is written. It routes on the ## Task Tracking mode and returns { milestoneId, id? } — id on the tracker-less branch alone — and throws on an unknown mode rather than falling through to the sequential allocator. Never trust a partial ls or LLM memory: the dispatcher is the single source of truth, and only its Linear route scans — the Jira and tracker-less routes bypass nextFreeMilestoneNumber entirely. Linear sequential branch. It delegates to nextFreeMilestoneNumber (adapters/_shared/src/next_free_milestone_number.ts). It runs a five-way scan — active plans, archived plans (specs/plan/archive/), CHANGELOG M<N> refs, tracker milestones (provider.listMilestones()), and cross-branch plan files (sources.branches) — returning { next, sources } with next = max(union) + 1; forward every optional scan input, since dropping one demotes the five-way scan to a two-way one. The branches leg catches an M<N>.md committed on an unmerged branch, worktree, or remote-tracking ref; build it as branchScanner = { listBranchMilestones: () => scanBranchMilestones(repoRoot, { fetch }) } (from adapters/_shared/src/branch_milestone_scan.ts) — best-effort and mode-agnostic (any git failure ⇒ []; runs on every path that reaches this scan). Fetch precedence: --no-fetch > --fetch > the standalone CLAUDE.md milestone_scan_fetch: true|false line (read regardless of mode — not a ## Task Tracking Schema-L key, which is a closed set) > default false; --fetch / milestone_scan_fetch: true runs a best-effort git fetch --all --quiet first, the default does no network, and /gate-check never fetches. If the user explicitly typed an M<N> that appears in any of the five source sets, showing all five breakdowns ( / / / / ) + the proposed next free number. When says and a NEW milestone is needed, mint the milestone Epic first — the Epic before FR tickets, so each FR Task can to it — by making exactly ONE call, from ): the IN-PROCESS call is what creates, with ALONE — its find-before-create retry means a create that lands then times out is reused, never duplicated. The command form CREATES NOTHING: it takes a key the tracker has already allocated and shows you the summary that would be sent, the derived id and the plan path, so the order can be checked without a write — the canonical name is not knowable until the create returns — then reads the allocated key back and derives the id, returning , and a key that will not sanitize refuses there rather than yielding a malformed id. Pass the returned on as ; the route derives through (), e.g. → . The epic-binding attach () surfaces the Epic key on its result for exactly this derivation. The key-derived id is collision-free by construction (no scan, no lock, no retry) — then write the plan file at and bind each FR with frontmatter. When says and a NEW milestone is needed, the route mints the identity locally instead of allocating a number, delegating to , which puts through the collision-guarded minter () — the predicate receives the full minted id and reports a hit when a plan file for the derived id is already on disk, active archived — and derives the milestone id from the 6-char tail as . It is the only route with no scan, no fetch, no five-source collision refusal — what replaces those is , not prevention by construction: the minter's predicate detects a derived-id clash against the active and archived plan trees and re-mints (bounded retries), and the plan-identity gate probe detects a duplicate derived token spanning both trees; two working trees that cannot see each other can still mint the same tail and only find out when they meet. Write the plan file at , (the plan-frontmatter identity probe requires it in this mode, and the filename derives from it), and bind each FR with frontmatter. before creating that plan file, call from (its locates the ACTIVE plan declaring , whatever its filename). It renames that scaffold in place onto , rewrites , records the minted , DROPS the line, and carries the body through byte-for-byte — step 8 pre-fills real paths, gate commands and the live FR row, so consuming keeps that content instead of stranding it as a second active plan; write a fresh plan at the minted path only when the call reports , and never delete-and-rewrite (same file count, lost content). Consumption is gated on the minted , which only this branch has, so a tracker-mode scaffold cannot be renamed no matter how the call is threaded. Linear keeps the sequential five-way scan unchanged, Jira keeps Epic-first, and no Epic is ever created off the Jira path.
Milestone-allocation gate (marker/refusal routing, not a prose no-op). The milestone-binding decision for a new FR — which M<N> it targets, including the drift/collision case a fresh project hits — has a safe default (the milestoneId the dispatcher above returned), so route it through requireOrRefuse(...) (adapters/_shared/src/requires_input.ts) with that one value as defaultValue — milestoneAllocationGateSpec(input) from the dispatcher module returns exactly that { gateSite, defaultValue } pair, so read it from there rather than hand-assembling it per mode (Linear's is the sequential M<N>, the Jira Epic-first branch's is the Epic-derived M_<epic-key> id, and the tracker-less minted branch's is the locally minted M_<short-ULID> — same gate, same gate site on all three), decided by the byte-grep at adapters/_shared/src/check_marker_runtime.ts (the § 0b / § 7a decider): marker PRESENT ⇒ default-applied, bind the recommended M<N>, MUST emit milestone_allocation_default_applied; marker ABSENT + non-tty ⇒ RequiresInputRefusedError (NFR-10 shape, gate site milestone-allocation); ABSENT + tty ⇒ the interactive choice. A prose-ask-then-end-turn is forbidden under non-tty here and at every clarifying-question surface — the only non-tty outcomes are default-apply or RequiresInputRefusedError; a prose ask that ends the turn is a silent no-op under claude -p (the 2026-07-19 conformance F1). See § Rules for the full NOT-a-trigger set (the marker is the SOLE auto-apply trigger). Sanctioned interview-answer source — this gate AND every clarifying question in § 1–§ 6. resolveInterviewAnswer(promptBody, key) from adapters/_shared/src/auto_answers.ts is called once per question, keyed by that question's own answer key: at the milestone gate above, and identically at each § 1–§ 6 FR-content prompt (Summary, Requirement, Acceptance Criteria, Technical Design, Testing, cross-cutting scope, out-of-scope, NFRs, risks). A clarifying question has no safe default and therefore never default-applies; under non-tty its one legitimate answer is an operator-authored, byte-fenced block that the resolver lifts out of the <dpt:answers>v1 … </dpt:answers> region and hands to this same requireOrRefuse(...) call in its preBakedValue slot — a hit resolves as , so the question is still posed and is ANSWERED from the block rather than bypassed — when the ask tool is registered it is posed as the call § Rules mandates, and in a session where that tool is absent the question is still put and resolved, never silently dropped — and a miss returns , leaving the refusal above to fire untouched. The block treats the marker as a hard precondition ( byte-greps for it first), so a block in an unmarked body — or one whose delimiters are absent or out of order — is inert and answers nothing. The marker alone never answers a clarifying question; only an explicit operator-authored block does, and only when the marker sits in the same prompt body.
Task Sizing: generated tasks must follow the Task Sizing guidance in templates/spec-templates/plan.md.template — each task ≈ one commit's worth of work, written as a 2-line entry (action line + indented verify: line). If you can't name a single verification step, split the task. See the template's anti-pattern callout for examples of tasks that are too large.
Present the plan and ask for approval.
4. Review and confirm
After completing each spec file: show the user what was written, ask for approval before saving, note any open questions or decisions that need human input. Review surface: the human approves the Summary, Requirement, and Acceptance Criteria; Technical Design and Testing are implementation-facing and are verified by the deterministic gates and the TDD audit.
Draft + commit acceptance gates — marker-driven default-apply (STE-213, STE-220, STE-226). Two gates live in this skill: the FR-draft Approve and proceed? gate at § 0b step 4 (gates tracker create + FR file write), and the commit Apply commit? gate at § 7a (gates the spec commit). Both default-apply y when the prompt body contains the literal line <dpt:auto-approve>v1</dpt:auto-approve> (byte-grep, no inference) — MUST emit spec_write_draft_default_applied and MUST emit spec_write_commit_default_applied respectively (literal tokens, not paraphrased — STE-238 AC.1; the two rows are co-located in the static map at § 7). Interactive n decline at either gate MUST emit the corresponding _declined row (literal token). The marker contract (paste, not paraphrase) is byte-repeated at all three gate sites — § 0b step 4, here, and § 7a — so the LLM reading any single site has the full contract in view. STE-226 replaces STE-213 + STE-220's prose-only detection (legacy Auto Mode Active system-reminder match + claude -p non-interactive inference, both falsified end-to-end across four smoke runs) with this byte-checkable marker; legacy detection is removed, the marker is the single deterministic mechanism, and callers without the marker get interactive gating (no backward-compat shim per project_no_users_yet). Universal pre-commit branch gate (STE-228): § 7a additionally calls requireCommittableBranch({...}) from adapters/_shared/src/require_committable_branch.ts with the name from branchNameFor(...) at skills/spec-write/branch_name_for.ts (new-FR delegates to buildBranchProposal — the template comes from canonicalBranchTemplate({ milestone }) in adapters/_shared/src/branch_proposal.ts with {N} resolved from the FR's milestone: frontmatter, so a milestone-bound FR proposes {type}/m{N}-{slug} and a milestone-less FR falls back to {type}/{ticket-id}-{slug}; derive type deterministically via branchTypeFor({ changelogCategory, noTech }) from adapters/_shared/src/branch_type_for.ts, where changelogCategory is the FR's changelog_category frontmatter value and is this run's flag, so a run proposes ; the LLM pass is rescoped to only, and keeps its shape — callers compute the helper result and pass it through as ; cross-cutting-only → ); outcomes / → ; → rollback + non-zero exit; for off-trunk OR ; auto-mode default-apply via the same marker; capability rows per STE-228 § Branch-name canonical table. Each outcome MUST emit the matching literal token (backticked) into the Step 7 closing summary:
- gate creates branch interactively ⇒ MUST emit
branch_gate_created.
- edited proposal accepted ⇒ MUST emit
branch_gate_edited.
- declined ⇒ MUST emit
branch_gate_declined.
- marker-driven auto-apply with branch creation ⇒ MUST emit
branch_gate_default_applied.
- gate short-circuits because operator was already off-trunk ⇒ MUST emit
branch_gate_skipped_already_non_main (NEW token added to the static map at § 7 under STE-238 AC.6).
- remote-probe failure ⇒ MUST emit
branch_gate_remote_probe_skipped. The literal token is the byte-checkable signal /gate-check's closing_summary_capability_keys probe greps for; narrative prose like "the gate auto-created the branch" is insufficient.
5. Cross-check consistency
After saving any spec file, automatically check all other existing specs for consistency. Each spec builds on the ones before it, so changes can ripple.
What to check
- requirements.md changed: Check that
technical-spec.md covers all functional requirements (architecture, data model, APIs). Check that testing-spec.md has test strategies for all ACs. Check that plan.md milestones cover all requirements and no milestone references removed/renamed FRs.
- technical-spec.md changed: Check that implementation details are consistent with
requirements.md scope (no undocumented features, no missing requirements). Check that testing-spec.md mocking and test strategies match the chosen architecture. Check that plan.md task breakdowns match the technical approach.
- testing-spec.md changed: Check that test coverage targets and strategies align with
requirements.md ACs and technical-spec.md module boundaries.
- plan.md changed: Check that milestones reference valid FRs/ACs from
requirements.md and that task descriptions match technical-spec.md.
How to report
For each inconsistency found, report:
| Spec file | Issue | Suggestion |
|---|
| technical-spec.md | <tracker-id> has no architecture section | Add data model for user preferences |
| plan.md | M2 references removed AC-<tracker-id>.1 | Remove or replace with AC-<tracker-id>.2 |
- If no inconsistencies found, report "All specs are consistent" and move on.
- If inconsistencies found, offer to fix them right now by updating the affected specs. Walk the user through each change and get approval before saving, just like step 4.
6. Risk scan
Before handing off to implementation, do a structured risk scan. Read all specs and the existing codebase, then assess risks using this table:
| Category | Risk | Severity | Mitigation |
|---|
| External dependencies | | high / medium / low | |
| Breaking changes | | high / medium / low | |
| Security surface | | high / medium / low | |
| Performance impact | | high / medium / low | |
| Data migration | | high / medium / low | |
| Unclear acceptance criteria | | high / medium / low | |
Severity levels:
- high — Could block release or cause data loss/security breach
- medium — Significant effort to fix if discovered late
- low — Minor inconvenience, easy to address
Any high-severity risk must be resolved or explicitly accepted before proceeding to implementation.
For each risk found, add it to the relevant spec:
- Technical risks →
specs/technical-spec.md (risks/considerations section)
- Unclear ACs →
specs/requirements.md (flag the specific AC with a note)
- Testing risks →
specs/testing-spec.md (note what's hard to test and the strategy)
If no significant risks found, report "No major risks identified" and move on. Don't invent risks — only flag things that would genuinely surprise someone during implementation.
7a. Stage spec changes and prompt for commit
After Steps 0–6 settle and before Step 7 emits the closing summary, /spec-write stages every file it wrote under specs/ and produces one commit per /spec-write invocation — STE-179 closes the gap that widened setup-bootstrap-committed to toolkit-bootstrap-committed. Subjects (Conventional Commits): new-FR run ⇒ chore(specs): write FR <tracker-id> (tracker mode) or short-ULID stem (mode: none); cross-cutting-only run (pure requirements/technical-spec/testing-spec/plan/M<N>.md edits, no new FR) ⇒ docs(specs): edit cross-cutting specs; hybrid ⇒ new-FR shape (cross-cutting edits land in the same commit). Procedure: stage explicit path list (never git add -A) → diff preview → prompt Apply commit "<subject>"? [y / n / edit]. Branch creation is a PRELUDE to this commit, never a substitute for it — the branch gate's created / edited / default_applied outcomes hand control straight back to this commit gate, and a run that creates the branch and then stops with spec files staged has NOT completed the trunk-invocation path; before Step 7 emits, call classifyTrunkInvocationRun({...}) from adapters/_shared/src/spec_write_trunk_invocation_path.ts over observeTrunkGitState(repoRoot, trunk) and treat any terminalState other than committed or one of the two sanctioned staged-but-uncommitted terminals as a hard stop. Neither staged exit may leave tracker state unsaid — tracker items are created back in Steps 2–3, so both staged-but-uncommitted terminals can exit with live Epic / Task / issue ids that no commit records; such a run MUST close with the literal line Tracker state created but not committed: <comma-separated ids>, which is exactly what trackerStateDisclosed(assistantText, createdIds) from the same module scores — mid-run narration naming the ids does NOT count, only the closing line does, and a run that committed owes nothing extra because its commit is the record.
Marker-detection (STE-262 AC-STE-262.2 — deterministic, runtime byte-grep). The literal byte-string <dpt:auto-approve>v1</dpt:auto-approve> is the marker the runtime helper detects. Before evaluating the commit-gate auto-apply branch, write the verbatim first user message that invoked /spec-write to /tmp/dpt-spec-write-prompt-body.txt (Bash heredoc, FIRST step of commit-gate evaluation; if the file already exists from the § 0b step 4 draft gate of the same invocation, reuse it as-is — same body, same decision), then run:
bun run plugins/dev-process-toolkit/adapters/_shared/src/check_marker_runtime.ts /tmp/dpt-spec-write-prompt-body.txt
The script prints PRESENT or ABSENT on a single line. Branch strictly on the script's stdout — the script's output is the single deterministic gate decision; no LLM inference, no autonomous-mode reminder, no "work without stopping" framing influences the auto-apply branch.
PRESENT → call requireOrRefuse(..., markerPresent: true, defaultValue: "y", ...) → outcome default-applied → commit with the canonical subject (STE-109 UX) → MUST emit spec_write_commit_default_applied (literal token, not paraphrased — STE-238 AC.1) as a row in the Step 7 summary so the operator sees the auto-apply on every quiet-mode run (interactive y does not emit the row).
ABSENT + process.stdin.isTTY === false → call requireOrRefuse(..., markerPresent: false, defaultValue: "y", userSuppliedValue: undefined, ...). The helper falls through to the refusal branch and throws RequiresInputRefusedError (NFR-10 canonical shape) naming the gate site (commit); the skill exits non-zero with files staged-but-uncommitted so the operator can re-invoke interactively (or with the marker) without losing work.
ABSENT + process.stdin.isTTY === true → fire interactive [y / n / edit] prompt; on y proceed; on n files remain staged-but-uncommitted, MUST emit spec_write_commit_declined (literal token) so the operator knows to git commit -m "<subject>" manually.
Marker is the single deterministic mechanism (STE-226 + STE-262): legacy prose-only detection paths are removed; the runtime byte-grep above is the single decider. On commit failure (Conventional-Commits hook rejection, etc.) → NFR-10 canonical refusal; staged files left in place for the operator to fix and re-issue.
STE-294 canonical NOT-a-trigger anchor (§ 7a gate site). Autonomous-mode reminders, pre-baked prose, and "standing instruction" paraphrases are NOT acceptable auto-apply triggers — the marker is the SOLE decider; the runtime byte-grep at is the SOLE evaluation path.
7. Report
/spec-write MUST emit a closing summary on every successful run, regardless of mode (linear / jira / none) or invocation path (new-FR creation, the importFromTracker import path on a tracker-id resolve miss, or a per-section edit). This is unconditional — non-interactive -p mode is not an exception. The closing output of a /spec-write run is /spec-write's OWN summary block, never another skill's report — a run that ends with a gate-check report, a review report, or any other skill's closing block has not emitted this summary, however complete that other block looks.
Non-interactive / -p mode firing rule. The summary fires even when no questions were asked — the silent path on a claude -p /spec-write … invocation that resumes an already-bound FR or runs every prompt with auto-supplied answers MUST still emit the closing summary block on the quiet path. An earlier Linear smoke caught the regression where claude -p runs produced 1 byte of stdout because the LLM short-circuited Step 7 when the run had no user-facing prompts. The unconditional-emit contract above governs both the interactive path and the quiet -p path with no exception. If the run reaches a successful exit, the summary block emits — even on a no-question run, even on -p non-interactive mode, even when the only side effect was a frontmatter edit. The >=100 byte floor below is the regression signal that the summary fired at all.
Reference shape — emit at minimum the three required signals (one row per FR touched, one row per spec file edited):
## /spec-write summary
| FR id | FR file path | Milestone |
|------------|-----------------------------|-----------|
| <STE-XXX> | specs/frs/STE-XXX.md | M<N> |
| <VDTAF4> | specs/frs/VDTAF4.md | M<N> | <!-- mode: none renders the short-ULID -->
| Spec file | Change |
|------------------------|-----------------|
| specs/plan/M<N>.md | row added/updated |
| specs/technical-spec.md| edge case backfilled |
Open questions / risks / inconsistencies (if any):
- Which specs are done vs. still need work
- Inconsistencies resolved or still pending
- Risks identified (severity)
- Open questions flagged during the process
Next: Run `/dev-process-toolkit:implement M<N>` when specs are ready. <!-- milestone-bound run (new FR with milestone: frontmatter) or cross-cutting-only run: recommend the M<N> form (milestone close). -->
Next: Run `/dev-process-toolkit:implement <tracker-id>` when specs are ready. <!-- milestone-less new FR: recommend the FR-id form (single-FR ship; no milestone to close). -->
Next-line variant rule. The discriminator is milestone binding, not new-FR presence. When the run wrote ≥ 1 new FR whose frontmatter carries milestone: M<N>, recommend the milestone form (Run /dev-process-toolkit:implement M<N>) — the milestone is the unit of shipping, and the M-form runs the Phase 5 close the FR-id form silent-skips (skills/implement/SKILL.md § Invocation forms). Render one Next: line per distinct milestone when a run's new FRs span several. When a new FR carries no milestone: binding, recommend the FR-id form (Run /dev-process-toolkit:implement <tracker-id>) for that FR. Cross-cutting-only runs (no new FR file written) keep the M form. Hybrid runs (new FR + cross-cutting edit) follow the new FR's milestone binding.
Capability-gap rendering. The "Open questions / risks / inconsistencies" block must render every capability gap as plain prose, drawn from the static plain-language map below — never as a literal AC-<tracker-id>.<N> reference into this toolkit's own internal spec set. The toolkit's AC IDs are opaque jargon to project owners running /spec-write on their own repo (a 2026-04-28 smoke caught the regression: a toolkit-internal AC identifier for the milestone-attach capability surfaced as the rendered description of the gap, replacing what should have been plain prose). Echoing such an identifier inside this section's instructions is itself a regression risk — the LLM may copy it back into the rendered summary; describe failure modes by capability name only.
Static plain-language map (capability key ⇒ rendered prose):
| Capability key | Rendered prose |
|---|
milestone_attach_skipped_adapter_limit | tracker adapter does not support project_milestone — milestone-attach skipped |
milestone_create_required / milestone_attach_failed / milestone_epic_unsupported / milestone_label_asserted_at_archive / milestone_label_archive_refused | create_required: tracker project had zero milestones; created <milestone-name> and attached the FR. attach_failed (severity: warning — loud row, never plain info): milestone attach permanently failed (retries exhausted or non-transient binding mismatch) — ticket <id> is NOT bound to milestone <name>; FR file kept (spec is the source of truth). Bind manually in the tracker, then re-run the attach to verify. asserted_at_archive: archival-time milestone-binding assertion passed for ticket <id> — binding present, or missing-then-attached. archive_refused: archival-time milestone-binding assertion refused the archive for ticket <id> — FR skipped, still active followed by the refusal's OWN remedy, quoted verbatim from its detail. The gate refuses on six distinct causes and "attach did not land" is only one of them; a generic backfill-or-attach line is wrong on three and invites a duplicate write. The archival surfaces (/spec-archive + /implement § Milestone Archival) MUST emit milestone_label_asserted_at_archive per archived FR whose binding was present or missing-then-attached, and MUST emit milestone_label_archive_refused per FR refused at the archival boundary — exactly one literal backticked token per archived FR; narrative paraphrase is insufficient. epic_unsupported (informational, never a failure): tracker project's issue-type metadata lacks the Epic type (or the project cannot set parent) — epic milestone binding degraded to the milestone-<M-token> label path; FR still attached via the label; when the attach helper returns this capability the closing summary MUST emit milestone_epic_unsupported as the literal backticked token. |
workspace_binding_deferred | tracker workspace binding deferred — <MCP> unregistered at /setup time; run /setup --resume-tracker-binding after authenticating |
tracker_skipped | tracker step skipped (--code-only flag or pre-flight probe failed) — re-run without flag once MCP loadable to sync |