ship · diff
git:20261026.c5c88ff to git:20261028.afc8e19
355 added, 78 removed. Audit A to A.
---
name: ship
- description: Finalize and deliver a reviewed ticket — backup, tracker comment, chat draft, commit + PR — with a hard halt before any external post. Run after /review approves.
+ description: Finalize and deliver a reviewed ticket. It commits, opens the PR, merges after asking, backs up, comments on the tracker, and drafts the chat message, with a hard stop before any external post. Run it after /review approves.
---
- <!-- emitted by ticketwright install v4.3.0 — do not hand-edit; re-run `ticketwright install --runtime antigravity` to update. -->
+ <!-- emitted by ticketwright install v4.7.2 — do not hand-edit; re-run `ticketwright install --runtime antigravity` to update. -->
# /ship
Ships a ticket that has **passed `/review`** — and warns, waits, and records it when one has not. Split
into a safe Phase A (local, no approval) and a
gated Phase B (every external side effect), honoring `hard_halt_before_external_posts`. Reads
the **merged** config (`bin/effective_config.py`, never raw `stack.yaml` — a raw read misses every
personal and machine override); routes through the docstore / tracker / chat / vcs adapters, so it
works regardless of the underlying tools.
> **THE MODEL-INVOCATION CONFIRM GATE — `/ship` is model-invocable.** When the model reaches for
> `/ship` on its own initiative (the user did not explicitly invoke it), it must **stop and confirm
> before Phase A's first durable action.** Phase A finalizes *in the repo* — it tidies and overwrites
> deliverable files and refreshes the committed ticket-index entry — so a model-initiated run must not
> mutate the ticket before the human has said to ship it. Print the ticket and what Phase A will do,
- > then wait. An explicit user invocation authorizes Phase A as normal. This gate is separate from — and
+ > then ask it as one question, "Ship it" or "Not now", and wait. An explicit user invocation
+ > authorizes Phase A as normal, and so does a `ship` reply (or "Approve and ship") to `/review`'s
+ > sign-off question for this ticket, given as the answer to that question: the person asked for the
+ > ship. This gate is separate from — and
> earlier than — the Phase B external-post HARD HALT below, which always applies regardless of who
> invoked the skill; it is an instruction the agent follows, not a mechanical block.
+ ## How this skill asks the person something
+ <!-- ticketwright:ask-rule:begin -->
+ The kit's one question rule, the same in every skill that asks:
+ - Read `capabilities` from this skill's `kit_paths.py --json` probe the first time a question
+ comes up, and keep the result.
+ - `capabilities.structured_questions` is `yes` and `capabilities.question_tool` is not `none` →
+ **ask with that tool**, within `capabilities.question_limits` (when that says `unknown`: one
+ question per call, at most four options). Group up to the limit only questions that do not
+ depend on each other's answers; ask a dependent question after the one it depends on.
+ - Otherwise (`no` / `unknown`, or the probe failed) → ask in chat as a short numbered list, one
+ question at a time unless this skill says a set goes in one round, and wait for an explicit
+ answer.
+ - The question content is the same either way; only how it is shown changes. No skill names a
+ runtime's tool: the runtime's own adapter (`adapters/runtime/<name>.md`) does. A question tool
+ is not available inside a subagent, so the main session asks.
+ - **Never invent an option.** Options come from what this run found or the fixed choices a step
+ names. A recommended option goes first, marked recommended, with where it came from; the person
+ still picks. Each option says what choosing it changes, and free text is always possible (the
+ tool's own "Other", or "or tell me something else" in prose).
+ - Whatever the person needs to answer is already in chat before the question, so a picker never
+ hides it.
+ - A reply the kit asks for word for word (`ship unreviewed`, `go ahead`, the `yes` before
+ destructive SQL) stays typed: an unreviewed ship, an external send or an irreversible write is
+ never one click.
+ <!-- ticketwright:ask-rule:end -->
+
+ In this skill the questions are the confirm gate above, step 1's owner choice and pending sign-off
+ (asked through `/review` step ⑤.4), step 4(a)'s routing questions, step 6's merge question, and step 12's voice-profile proposal. Its
+ probe is `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" kit_paths.py --json`,
+ run the first time one of them comes up. `ship unreviewed` (step 1) and `go ahead` (Phase B) stay
+ typed.
+
## Phase A — Finalize (no separate approval when the user invoked `/ship`; internal to repo)
1. **Resolve WHO first** — `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" whoami.py`
and show its one-line "Working as …" display; the resolved person is the shipper. Then resolve
the **ticket locator**: `owner/id` is exact; a bare `<id>` resolves against the shipper's
`tickets/<owner>/` first, then across other owners — **two or more foreign owners sharing it is a
- hard stop listing the `owner/id` choices, never a pick**. Shipping a ticket whose owner isn't the
+ hard stop that asks which one, with the `owner/id` choices as the options, never a pick**. Shipping a ticket whose owner isn't the
shipper is allowed — say so out loud before continuing.
Then read the merged config (`bin/effective_config.py --json`) + the ticket README + the plan +
**the current verdict, read mechanically:**
`bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" review_verdict.py --ticket <ticket-dir> --json`
— it picks the `qc_queries/*_review_verdict.md` with the highest `<n>` (compared numerically:
`10_` beats `9_`; never a lexical sort or an mtime), reads its lowercase `verdict:` line, and
- returns `status`: `approve` · `request-changes` · `skipped` · `none` · `unreadable`, plus any
+ returns `status`: `approve` · `request-changes` · `awaiting-signoff` · `skipped` · `none` ·
+ `unreadable`, plus any
`legacy_candidates`. Branch on that `status`, never on your own reading of the folder. A review
report saved under another name by an earlier kit version still counts: read it,
and if it states APPROVE or REQUEST-CHANGES plainly, re-save it under the fixed name with the
- `verdict:` line first, then branch on it — never treat reviewed work as unreviewed. Four cases:
+ `verdict:` line first, then branch on it — never treat reviewed work as unreviewed. Five cases:
- **APPROVE** → proceed.
- **REQUEST-CHANGES** → **hard stop.** The review ran and found a defect (count mismatch, duplicate
gap, reconciliation break, a Critical anti-pattern). Send the user back to `/build <owner>/<id>`,
- which applies the remediation and re-reviews. Not overridable here.
+ which applies the remediation (or replans, when a finding is `kind: scope`) and re-reviews.
+ Not overridable here.
+ - **AWAITING-SIGNOFF** → the independent review approved, and the person's sign-off is the only
+ thing outstanding. **It is not approval:** never continue on it, never offer `ship unreviewed`,
+ and never write a `SKIPPED` record over it (that would bury the reviewer's approval). This
+ skill does not write the sign-off itself; `/review` step ⑤.4 does, the same way `/build` uses
+ it:
+ - When `/review`'s hand-off for this ticket, with its "Reply `ship`" line, is earlier in **this
+ same conversation** and the person's next message is `ship` or `/ship` for this ticket, that
+ message is the `ship` reply to ⑤.4. Do not ask again: apply ⑤.4's `ship` branch, which
+ rewrites the record.
+ - Otherwise (a new session, another person, or a different reply in between), follow
+ `/review <owner>/<id>`'s steps ⑤.1 to ⑤.4 for this ticket: open the files, print the
+ what-to-look-at list, then ask the sign-off with ⑤.4's own "Reply `ship`" line and options.
+ ⑤.4 answers a question and asks again, asks what to change before recording a change
+ request, and rewrites the record on an answer.
+ - **A sign-off taken in this run, either way, cancels `--go` for this run.** Phase B still
+ prints the delivery plan and waits for a typed `go ahead`, because the sign-off line told
+ the person it would. `--go` never answers the sign-off question itself.
+ Then re-read the verdict with `review_verdict.py --json` and branch again: `approve` after a
+ `ship` reply → continue to step 2 (in this run, ⑤.4's "route as Phase N says" means carrying on
+ here, not starting a second `/ship`); `approve` without one ("Approve, don't ship yet") → stop
+ and name `/ship <owner>/<id>` for later; `request-changes` → send the person to
+ `/build <owner>/<id>`. **Anything else, including `awaiting-signoff` still on file, stops
+ here and shows the record: never continue.**
- **No verdict file at all, or the newest one says `SKIPPED`** → **WARN, then stop and wait.** A
SKIPPED record never counts as approval; this gate re-fires on every ship until a real review
supersedes it. Print, verbatim in shape:
<!-- ticketwright:user-facing:begin -->
> Nobody has independently reviewed `<owner>/<id>` yet (last review: none | shipped
> unreviewed on <date>). That means the lint, counts, duplicates, reconciliation, independent
> re-run and your sign-off have not happened for these deliverables. Reply **`ship unreviewed`**
> to deliver them anyway, or run `/review <owner>/<id>` first.
<!-- ticketwright:user-facing:end -->
Only on that exact phrase: write `qc_queries/<n>_review_verdict.md` with `verdict: SKIPPED`,
`review_mode: none`, the date, the shipper (from whoami), and the user's instruction verbatim —
the record shows what review this ticket actually got, the same honesty rule `/review` applies
to an inline pass. Carry it forward: the PR body's QC section and the curated index summary
(step 3) say **"shipped without an independent review"**, and the tracker comment claims no QC
that did not happen. `--go` authorizes Phase B only — it never pre-answers this gate — and a
model-initiated ship meets the confirm gate above first, then this one.
- - **Anything else** — a `verdict:` value that is none of the three, a file with no `verdict:` line,
- a truncated report — is **the missing case above, never a pass.** The gate fails closed: an
+ - **Anything else** — a `verdict:` value that is none of the four above, a file with no
+ `verdict:` line, a truncated report — is **the missing case above, never a pass.** The gate fails closed: an
unreadable verdict is not a verdict.
Why this is a gate and not a wall: a hard stop here was routed around (work shipped through other
commands, leaving no record). `/build` runs the review itself, and the `review_verdict_guard` hook
asks the same question at `git push` / PR time — so a ship that never enters this skill still
meets the gate, and every path leaves a record.
2. If the stack has a warehouse seam, re-run the final deliverable queries once more; confirm
**byte-identical** output to the committed files (`deterministic_outputs` — explicit `ORDER BY`).
In a repo with no warehouse seam, re-verify each deliverable by its own check instead (the
document renders, the script runs clean, the numbers cited in the README match the files).
3. Tidy: remove redundant/version-sprawl files (overwrite, don't duplicate); confirm filenames
carry record counts; confirm the README tells the full business + methodology + QC story. Then
**refresh this ticket's index entry** so its `tickets/INDEX.md` row gets a curated one-line
summary: use **`/refresh index <owner>/<id>`** (the qualified locator — a bare id two owners
share is a hard stop there), which writes the record from this session — and when the verdict on file is `SKIPPED`, the
curated summary says so ("shipped without an independent review"). You have already
read this ticket — no second model call is needed, and this path works under every runtime.
The PostToolUse hook already keeps the row present; this upgrades it from auto-derived (`▱`) to
- curated.
+ curated. **Run its Phase 1b status read too** (`tracker.search` by `ids` for this ticket, then
+ `ingest_index_records.py --tracker-status -`), so the index starts from the tracker's status,
+ never from the README's creation-time Status line. When the tracker cannot be reached, say so in
+ one line and carry on: step 5 then has no fresh read to build on, and the index keeps what it had.
*Not in this flow:* `enrich_ticket.py` does the same job by handing the ticket README to a
**headless model**. That README is usually tracker-sourced — text someone outside the repo wrote —
and `/ship` is the flow that then posts externally, so a shipping run should not put attacker-
influenceable text in front of a fresh agent before its own approval gate. Run it deliberately via
`/refresh index` when you want it, on a runtime whose adapter declares a restricted
`model_sandbox`.
4. **Route the delivery FIRST, then draft the comms artifacts** (don't post yet).
**(a) Resolve routing before writing a word.** Who the message is for decides which recipient
list it carries, so it cannot be settled after drafting:
`bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" delivery_plan.py --plan <ticket-dir>/delivery-plan.yaml --seam chat`
(add `--override <name>` when the user passed `--chat <target>` — that flag is **chat only**, and
the CLI says so on the plan line when it contradicts the ticket; add `--self "<shipper>"` so an
- `include_self` target adds them). Same call with `--seam docstore`. Exit **7** = that slot isn't
+ `include_self` target adds them). Same call with `--seam docstore`, always with `--self "<shipper>"`:
+ each person's backups go into a folder named for them inside the team's folder, so the docstore
+ routing needs to know whose it is, and halts (exit 9) without it. Exit **7** = that slot isn't
configured → skip its artifact and name `/setup tool chat` / `/setup tool docstore` as the
enabler, exactly as before. Exit **0** → use the returned `target`, `destination`, `recipients`,
- `mode` and `sharing_scope` verbatim; they are the plan Phase B prints and executes.
+ `mode` and `sharing_scope` verbatim; they are the plan Phase B prints and executes. A chat exit
+ **0** carrying `skipped: declared` is the ticket's own `chat: none`: there is no chat message,
+ so draft none, print the plan's Chat line as "none, declared on the ticket" (with the returned
+ `skip_reason` when there is one), and leave out the chat step. It is a decision already made, so
+ nothing is asked.
**Any other exit is a HALT, and you resolve it by ASKING, never by choosing:**
- **9 (nothing declared)** — the plan declares nothing this slot can route on. Three shapes, and the
CLI's message names which:
- - *A `targets:` slot* has no `audience:`/`classification:`. Show the configured values the CLI
- lists and ask the user which one this ticket is.
+ - *A `targets:` slot* has no `audience:`/`classification:`. Ask the user which one this ticket
+ is, with the configured values the CLI lists as the options.
- *A tool-only chat seam* (the default shape — the stack names the tool, not a standing channel)
- has no `chat.channel:`/`chat.recipients:`. Ask the user (with the runtime's question tool when it has one, otherwise in chat) **where this goes and who
- it is for**: the destination (channel / DM / address) and the recipient list. This is the
+ has no `chat.channel:`/`chat.recipients:`. Ask the user, by the question rule above, **where
+ this goes and who it is for**: the destination (channel / DM / group DM / address) and the
+ recipient list. Offer as options only the audience suggestions below, with free text always
+ open, and ask for the recipients as a multi-select when the question tool allows one. This
+ is the
per-communication decision the tool-only shape exists for — who a result goes to depends on
who the analysis is for, so it is asked each ship, never configured once.
+ **Offer suggestions as options, never as answers** (see *Audience suggestions* below).
- *A per-ticket chat target* (the declared audience selected a target marked
`destination: per_ticket`, which the CLI's message names) has no `chat.channel:`/
`chat.recipients:`. Ask the same question as for a tool-only seam, for that target's tool:
**where this goes and who it is for**. If the person picks an audience whose target is
per-ticket, ask for its destination and recipients in the same turn rather than re-running
twice.
+ **"No chat message" is always one of the answers**, beside the destinations. When the person
+ picks it, write `chat: none` into the plan (or `chat:` with `none: true` and their `reason:`
+ when they gave one), never a comment: a comment is read by no tool, so the next run would ask
+ again and the record would not show the decision.
In each case, write the answer into `<ticket-dir>/delivery-plan.yaml` (schema: `adapters/README.md`
§ The delivery plan) — the `audience:` for a targets slot, the `chat:` block
- (`channel:` + a non-empty `recipients:`) for a tool-only seam, or both plus `chat.target:` naming
- the routed target for a per-ticket target — then re-run. **Never infer** the
+ (`channel:`, `kind:` and `recipients:`, which may be empty only for `kind: channel`) for a
+ tool-only seam, or both plus `chat.target:` naming the routed target for a per-ticket target —
+ then re-run. **Never infer** the
audience, the channel, OR the recipients from the ticket README, channel names, a label, or how
the work "feels" — an inferred destination is exactly how a result reaches the wrong room.
"I could not tell, so I picked the first one" is not available.
- - **8 (declared value matches nothing)** — a typo or a retired target. Show the declared value
- and the configured ones; ask which was meant. Never match it approximately.
+ **Audience suggestions.** The question may offer choices drawn from exactly two places, and
+ nowhere else: the `suggested_channels` the routing output returns, and the people in the
+ tracker's own structured fields for this ticket (`reporter`, `assignee`, `watchers` from
+ `tracker.fetch_ticket`). Resolve each person through the chat adapter's `lookup_user` verb
+ (or to an address for email) and label every option with where it came from: "Reported this
+ ticket: Sam (DM)", "Assigned: Jo (DM)", "A channel you post to: #analytics". Rules:
+ nothing is preselected, a suggestion is sent nowhere until the person picks it, an option that
+ could not be resolved is left out rather than guessed, and "Someone else" (typed) is always
+ there. Write the pick with `kind:` (`channel` from `lookup_channel`, `dm` or `group` from
+ `lookup_user`) and `chosen_from:` (`suggested_channels`, `tracker:reporter`,
+ `tracker:assignee`, `tracker:watcher` or `typed`); a suggestion they did not pick is dropped.
+ For the same reason, no audience, destination or recipient option is ever marked
+ recommended: the question rule's recommended-first option does not apply to routing, because
+ nothing in a run can know who a result is for.
+ - **8 (declared value matches nothing)** — a typo or a retired target. Show the declared value,
+ then ask which was meant, with the configured values as the options. Never match it
+ approximately.
- **3 (no plan file)** — same as 9: ask, write the file, re-run.
The declaration is a ticket artifact, not a session choice: it is committed with the ticket, so
the next person (or agent) can see who this went to and why.
**(b) Draft against the routed plan.** Render the tracker comment and the chat message from the
ticket facts. Tracker comment ≤ `word_limits.tracker_comment`; business-first; segmented with
counts/%/$. Chat ≤ `word_limits.chat`; carries **the routed target's own `recipients`** — never
another target's list, and never a slot-level one; **hyperlink everything**
- (`hyperlink_everything`). Then, in order:
+ (`hyperlink_everything`). The PR link and the backed-up file links do not exist yet: step 5
+ creates the first and step 7 the others. Write each as a named placeholder (`<link: PR>`,
+ `<link: final_deliverables/…>`) and fill it from what those steps return, before the comment
+ (step 8) and the chat message (step 9) go out. Never guess a URL. Then, in order:
- **Comms-lint the drafts first (the hard rails):** each is within its `word_limits.*` cap; ticket
- id(s), files, and PR are hyperlinked; the chat message carries every routed recipient. That last
+ id(s) are hyperlinked and every file and the PR has its placeholder; the chat message carries every routed recipient. That last
rail is mechanical, so run it rather than eyeballing it:
`… delivery_plan.py --plan <ticket-dir>/delivery-plan.yaml --seam chat [--override <name>] --check-draft <the draft file>`
— non-zero names each recipient the draft fails to carry. Fix any miss before continuing; these
rails always win.
- **Voice pass (only if a voice profile RESOLVES).** Resolve the shipper —
`bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" resolve_user.py --json` — and if it
returns a profile whose file exists, re-phrase the drafts to match that voice profile.
Gate on the RESOLUTION, never on where the config lives: the map moved to `people/<id>.yaml`
(tier 2), so a condition naming `project.voice_profiles` is permanently false in a migrated
repo and would silently switch this whole step off
**within the rails above** (it shapes phrasing only; it never bends a word limit, a link, or the
include-list). Empty output / no profile ⇒ leave the drafts as-is (fail open — behaves as today).
- **Persist the initial drafts** to the ticket's `comms/` (e.g. `comms/draft-tracker.initial.md`,
`comms/draft-chat.initial.md`). These are **immutable** — the record of what the plugin proposed,
for the voice-refine diff in Phase C. `comms/` is gitignored (unsent wording never rides the PR).
+ **(c) Read where the ticket can move, before the plan names a status.** A status typed from
+ memory fails at the very last step when the workflow does not have it (`No allowed transitions
+ found for given status`, on a board with no `In Review`). So ask the tracker: run
+ **`tracker.list_transitions`** for the ticket and save what it returns.
+ **Then settle where the ticket ends up.** Shipping does not always mean done: the work is often
+ shared for stakeholder feedback and changed again. Read the menu with
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" transition_plan.py --transitions <that file> --post-ship --id <id>`.
+ Its `default` is the team's `project.post_ship_status`:
+ - `done`: the ticket ends in its final status. No question.
+ - `review`: when `review_kind` is `tracker` with one `review_moves` entry, the ticket moves
+ there; with several, ask which one, offering each by its `to`. When `review_kind` is
+ `ticketwright`, the review is kept in Ticketwright only, and you say its `message` in one line.
+ - `ask` (unset): ask, by the question rule above, offering Done, then one answer per
+ `review_moves` entry, then the Ticketwright-only review:
+ <!-- ticketwright:user-facing:begin -->
+ > Where should `<id>` be after this ship?
+ > - **Done**: the work is finished, and `<id>` moves to `<done target>` once the pull request merges.
+ > - **In review, `<to>`**: shared for stakeholder feedback, and `<id>` moves to `<to>` once the pull request merges.
+ > - **In review, here only**: shared for stakeholder feedback. The ticket README and the index say In review, and the tracker stays at `<current>`.
+ <!-- ticketwright:user-facing:end -->
+ Print one "In review, `<to>`" answer per review move, and none when there are none; the
+ "here only" answer is always offered, since some teams do not track review in the tracker.
+ No answer is marked recommended: whether the work is finished is the person's call.
+ A Ticketwright-only review moves nothing: skip the rest of this step, and step 10 has nothing
+ to move. Otherwise pick the target for the chosen status with
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" transition_plan.py --transitions <that file> --id <id>`
+ (with `--want <the chosen review status>` for a review; without it, it reads
+ `project.terminal_status` from the merged config). Exit **0** → its `target` is the
+ status the plan names and step 10 moves to, through its `via`; `already_there` means step 10 has
+ nothing to do. Exit **8** → no single move reaches the configured status: ask the person which
+ status this ticket should end in, offering the `available` moves it lists (the `suggestion`
+ first when there is one, labelled as the one the tracker files under done), and use their pick
+ for this run. If they say it is the workflow's real final status, offer to set
+ `project.terminal_status` to it, as a separate change. An exit 8 whose `result` is `ambiguous`
+ means two moves reach that status: ask which one, offering each of its `options` by `name`, and
+ re-run with `--via <the picked option's via>`. Exit **7** → the moves could not be read (an
+ `unavailable` or `unsupported` reply), and exit **4** → the reply could not be parsed: in both,
+ the plan names the configured status and says it was not checked against the tracker, so nobody
+ reads it as checked. Skip this when the stack has no
+ tracker tool slot, or when the merge setting is off (step 10 then moves nothing).
+ Keep the outcome for step 5: the status is **checked** on an exit 0 that is a `move` or
+ `free_form`, and on a status the person picked from the `available` moves after an exit 8. It
+ is not checked on exit 7 or 4, and there is nothing to move on `already_there`. A ticket going
+ back for revisions after an earlier review ship asks the same way: pick Done this time when
+ the feedback is in.
+
## Phase B — External delivery (HARD HALT → requires `--go` or explicit "go ahead")
Print the **resolved delivery plan**, then **stop and wait** for the user — the human authorizes
- THE PLAN, not the word "ship". A docstore link is a shareable URL, so a wrong destination is a
+ THE PLAN, not the word "ship". `--go` does not count in a run where step 1 took the person's
+ review sign-off: then only a typed `go ahead` does. A docstore link is a shareable URL, so a wrong destination is a
disclosure, not an inconvenience. Resolve each posting seam with
`bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" effective_config.py --seam <name>`
and render one line per seam from the RESOLVED values, never from memory of the config. For **chat
and docstore, render the plan below from the routing resolved in step 4(a)**, the same resolution
- that steps 5 and 7 execute from; never print that JSON or its fingerprint. Preview and execution are one resolution, not two readings of it; re-deriving either
+ that steps 7 and 9 execute from; never print that JSON or its fingerprint. Preview and execution are one resolution, not two readings of it; re-deriving either
half by hand is how they drift apart. Every line names its resolved **target** (`target: single` on
- a single mapping — stating it is the point). The plan must show exactly what steps 5–9 will
+ a single mapping — stating it is the point). The plan must show exactly what steps 5–10 will
execute — nothing the steps don't do may appear in it:
- **docstore** — tool, target, the declared `classification` that selected it, the exact
- destination it will back up into, and the target's declared `sharing_scope`. Say plainly that
+ destination it will back up into (with per-person folders on, the routed `destination` already
+ ends in the shipper's own folder), whether that folder is created by this backup (the routed
+ `person_folder_new`), and the target's declared `sharing_scope`. Say plainly that
the scope is **declared, not verified**: the kit checks that the destination exists, never its
real sharing permissions (`adapters/README.md` § The delivery plan). Print it as
`Sharing settings: <declared scope> (declared, not checked)`. On a single mapping the line is
`Sharing settings: not checked (one destination).` (the plan has no sharing scope to show);
- **tracker** — tool, target, the exact ticket (`owner/id`) the comment lands on;
- **chat** — tool, target, the declared `audience` that selected it, channel, the full
recipient list (the routed target's own `recipients`, including the shipper when
`include_self`), the `sender` when the routed JSON carries one (an email target's sending
identity — WHO the message goes out as is part of what the human authorizes), and
draft-vs-send mode. When the target came from `--chat <target>` rather than the ticket's
declaration, say so on the line — the CLI returns that as a warning, and an override that the
ticket does not record is a fact the approver should see. For a tool-only seam or a
`destination: per_ticket` target the channel and recipients are the ones this ticket's plan
declares, routed in step 4(a): print them on the same line, never a placeholder, so the person
approves the exact destination and list;
- - **vcs** — tool, target, the branch, and that a PR opens;
+ - **vcs** — tool, target, the branch, that a PR opens, and how it merges: squash-merged by
+ `/ship` after asking once more (`ship_merges_pr: true`), or left for the team to merge;
- then the exact actions, in order (the numbered steps below).
Print the plan in this shape, one block, with the values filled in from the resolution (the
block is the text the person reads, so it names no internal field and shows no plan hash). Print
only the lines the plan actually delivers, one Documents line (with its sharing line) for each
routed classification, and one Chat line for each routed audience:
<!-- ticketwright:user-facing:begin -->
> Delivery plan for `<owner>/<id>`. Nothing has been sent yet.
> Documents: `<tool>`, target `<target>`, chosen by the classification `<classification>`, backed up to `<destination>`.
> Sharing settings: <declared scope> (declared, not checked) | not checked (one destination).
+ > New folder: this backup creates `<person folder>` (first backup for you).
+ > Your folder: `<person folder>` is created on this backup if it is not there yet.
> Ticket: `<tool>`, target `<target>`, comment on `<owner>/<id>`.
> Chat: `<tool>`, target `<target>`, chosen by the audience `<audience>`, in `<channel>`, to `<recipients>`, sent as `<sender>`, as a draft | sent.
- > Code: `<tool>`, target `<target>`, branch `<branch>`, and a pull request opens.
+ > Chat: none, declared on the ticket (<reason>).
+ > Code: `<tool>`, target `<target>`, branch `<branch>`, a pull request opens, and I merge it (squash) after asking you once more | you merge it by hand (the merge setting is off).
> This chat target was picked for this run, not declared on the ticket.
+ > This ticket declares no chat message, and this run sends one anyway because you picked the chat target.
> What happens, in this order:
- > 1. Scan the ticket folder for raw source material, then back it up to the documents destination above.
- > 2. Post the approved comment on the ticket.
- > 3. Draft the chat message for the recipients above, or send it only if you say "send it".
- > 4. Commit the ticket files (and a separate commit for any changed Ticketwright setup files, when there are any), push the branch, and open the pull request.
- > 5. Move the ticket toward its final status if that applies.
+ > 1. Commit the ticket files (and a separate commit for any changed Ticketwright setup files, when there are any), push the branch, and open the pull request.
+ > 2. Ask you once more, then squash-merge the pull request into `<default branch>`. If branch rules or checks block it, I stop and tell you why.
+ > 3. Scan the ticket folder for raw source material, then back it up to the documents destination above. Unsent drafts stay on this machine.
+ > 4. Post the approved comment on the ticket, with links to the backup and the pull request.
+ > 5. Draft the chat message for the recipients above, with the same links, or send it only if you say "send it".
+ > 6. Move `<id>` to `<status>` once the pull request is merged.
+ > 6. Leave `<id>` at `<current>` in the tracker. The ticket README and the index say In review, awaiting stakeholder feedback, from the commit in step 1.
+ > The ticket README and index in the pull request already show `<id>` as `<status>`, so they are right as soon as the move lands. If the move does not happen, I tell you and offer to set them back.
> Reply `go ahead` to deliver exactly this plan, or tell me what to change.
<!-- ticketwright:user-facing:end -->
+ Print the "New folder" line only when the routed docstore JSON says `person_folder_new: true`,
+ and the "Your folder" line only when it says `null` (a remote store that cannot be checked
+ ahead of time); print neither when it is `false` or when per-person folders are off. Fill
+ `<person folder>` with the routed `person_folder_label` verbatim (for example
+ `Tickets/Ada Lovelace/`).
Leave out the "picked for this run" line unless the chat target came from `--chat <target>`, and
- leave out the sender when the routed JSON carries none. The numbered list is the steps below
- (5 to 9), so drop any step the plan does not deliver (for example the documents backup when no
- documents tool slot is configured).
+ leave out the sender when the routed JSON carries none. When the routed chat JSON carries
+ `overrides_declared_none: true`, also print the "declares no chat message" line under it, so the
+ person sees they are sending what the ticket said it does not need. Print one of the two Chat lines: the
+ "none, declared" one only when routing returned `skipped: declared` (drop the parentheses when
+ no reason was given), and then leave out the chat step. Step 6 names the exact status from
+ step 4(c), and adds "(not checked against the tracker)" when the moves could not be read; when
+ the ticket is already there, it reads "`<id>` is already in `<status>`, so it stays there."
+ Print one of the two step 6 lines: "Leave `<id>` at `<current>`" only for a review kept in
+ Ticketwright only. The "README and index in the pull request" line appears only when step 5
+ records a planned status (the merge setting is on and step 4(c) checked the status); leave it
+ out otherwise. The numbered list is the steps below
+ (5 to 10), so drop any step the plan does not deliver (for example the documents backup when no
+ documents tool slot is configured, or the merge step when the merge setting is off).
Resolver exit 7 (tool slot not configured) = skip that line and tell the person:
<!-- ticketwright:user-facing:begin -->
> <tool slot> isn't set up yet; run `/setup tool <slot>`.
<!-- ticketwright:user-facing:end -->
For **tracker and vcs**, exit 8 or a resolution whose `target` is non-null (a `targets:`
block on either) = **halt** naming the configured targets: target routing for those two slots is
deliberately not built (identity and remote-binding work, see `adapters/README.md`), and
rendering a target the steps below would not deliver to is an authorization mismatch — never fall
back to another target or to slot-level values.
**Pin the plan the human just authorized.** Each routed line carries the target name AND its
`resolution_fingerprint`. Keep both from the resolution; the person is never shown the
fingerprint or any shortened form of it. Pass both back on **every**
- delivery_plan call in steps 5–7: `--expect-target <name> --expect-fingerprint <hex>`, with the same `--self` you routed
+ delivery_plan call in steps 7 and 9: `--expect-target <name> --expect-fingerprint <hex>`, with the same `--self` you routed
with. The name catches a swapped target; the fingerprint catches what a name check cannot — a
config or plan edit between approval and delivery that keeps the name while moving the channel,
the recipient list, or the declared scope. Either mismatch refuses instead of quietly delivering
a resolution nobody approved — that is what makes preview-equals-execution a mechanism rather
than a promise. It pins the *resolution*; the file contents shipped are whatever is in the
folder at delivery time, as always.
Only on explicit authorization, execute in order:
- 5. **Scan the source material first, then docstore.backup.** The backup copies the WHOLE ticket
- folder, so run
+ 5. **vcs.commit** — **first, isolate repo-setup / AI-layer files.** If any are dirty
+ (`.claude/settings.json`, `.claude/config/stack.yaml`, `.claude/statusline.sh`, `AGENTS.md`/
+ `CLAUDE.md`, `documentation/AI_LAYER_INDEX.md`, `.gitignore`) they belong to the repo's plugin
+ setup, not this ticket — give them a separate `chore(plugins): …` commit on this ticket's branch
+ (a distinct commit that rides the ticket's one PR — not a second PR, never folded into the ticket
+ commit); `.claude/settings.local.json` +
+ `.claude/worktrees/` are gitignored, leave them.
+ **Record the status the ticket ends on, in this commit**, in the ticket README's Status line and
+ the index together, so the README, `INDEX.md`, the graph note and `index_data.json` agree. The
+ tracker moves only in step 10, after the merge, so a status read now is out of date the moment
+ the pull request lands. When the merge setting is on and step 4(c) **checked** the status, write
+ it as planned (this also rewrites the README's Status line):
+ ```bash
+ echo '{"statuses":[{"id":"<id>","owner":"<owner>","status":"<the status step 4(c) named>"}]}' \
+ | bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" ingest_index_records.py --tracker-status - --planned
+ bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" build_ticket_index.py
+ ```
+ For a review kept in Ticketwright only, write that instead (whatever the merge setting, since
+ no tracker move waits on the merge):
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" ingest_index_records.py --in-review <owner>/<id> --tracker-now "<current>"`,
+ then `build_ticket_index.py`. Both commands name in plain words any README with no Status line.
+ A planned status is a promise the rest of the ship keeps or takes back: step 6 withdraws it when
+ the merge is left to the team, and step 10 checks it against the tracker after the move. Record
+ none when the status was not checked (the move may well fail, and the index then keeps the step 3
+ read until the next `/refresh index`; say so in one line), when the ticket is already there, or
+ when the merge setting is off.
+ Then stage this ticket's paths (deliverable files
+ included: they're committed by default so results live with the ticket and show in the PR) **plus
+ the whole index — `tickets/INDEX.md` + `tickets/OBJECTS.md` + `tickets/index_data.json`, and
+ `tickets/graph/` + `tickets/objects/` when `project.graph_notes` is on (the default)** (`--check`
+ gates the *rendered* ones — the two catalog files and the graph nodes, not the curated store — so
+ a rendered path left out is drift in CI; semantic message + Co-Authored-By). The render is
+ repo-wide, so review the index diff before committing: a row or node that moved for **another**
+ ticket is the renderer catching up, and belongs in the commit message rather than bundled in
+ silently. **Before staging, list the
+ `final_deliverables/` files that will be committed and confirm none carry PII/customer data that
+ shouldn't be in git** — if any do, have the user rename them `*.private.csv` (etc.) or move them
+ under a `private/` subfolder (both gitignored) first. **Then run the source-material scan**
+ (`scan_source_materials.py --ticket <ticket-dir>`, the same one step 7 runs before the backup) and halt on a non-zero exit:
+ `source_materials/` is committed too, and a raw transcript whose filename gives nothing away
+ (`notes.md`) is matched by no gitignore pattern. Here `private/` IS a valid remedy, alongside
+ trimming the file to the curated `YYYY-MM-DD-<slug>-meeting.md` form. Then **vcs.open_pr** (semantic title; body =
+ Business Impact / Deliverables / Technical Notes / QC — the QC section names the verdict on file,
+ and reads "shipped without an independent review" when it is `SKIPPED`). Keep the PR URL and the head commit sha you just pushed: step 6 merges exactly that sha, and steps
+ 8 and 9 link the PR. This step runs first on purpose. The comment and the chat message are the
+ posts people read, so they go out only once every link they carry exists, and a commit or push
+ that fails stops the ship before anything has been shared or anyone told it is done.
+ 6. **vcs.merge_pr, after asking** (policy `ship_merges_pr`). Read it from the merged config. A
+ missing value is `true`; an unparseable one is `false`, because unparseable config must never add
+ a write. When it is `false`, or the vcs adapter says it cannot merge, skip to step 7 and say in
+ one line that the pull request is open for the team to review and merge.
+ When it is `true`, the go-ahead covered the plan, and this step still **asks once more, right
+ before the merge** (`--go` never pre-answers this question, any more than it answers the review
+ gate), because a merge lands on the team's main branch and is the one step here that
+ is awkward to take back. Ask it by the question rule above (the runtime's question tool when it
+ has one, otherwise in chat), and offer exactly these two answers:
+ <!-- ticketwright:user-facing:begin -->
+ > The pull request is open: <PR link>. Merge it into `<default branch>` now? It is squash-merged into one commit, then the backup, the comment and the chat message follow.
+ > - **Merge now**
+ > - **Leave it open**: the team reviews and merges it later, and I won't move the ticket to its final status.
+ <!-- ticketwright:user-facing:end -->
+ Only on **Merge now**, run `vcs.merge_pr` with the PR and the sha from step 5. The adapter pins
+ that sha, so anything pushed after the person confirmed makes the merge refuse instead of landing
+ unseen work. Then confirm the merged state with the adapter's verify line. **If the verify line
+ does not show the pull request merged, it is blocked**, even when the merge command exited
+ cleanly: some code hosts queue a merge or switch on auto-merge instead of merging. When branch
+ protection, a required review, a check that failed or has not finished, or a conflict blocks
+ the merge, **stop the ship here** and tell the person what is blocking it, in the code host's own
+ words. Nothing has been backed up or posted yet, so stopping costs nothing outside the repo.
+ Never retry with a bypass, admin or auto-merge option: those skip or defer exactly the checks
+ the team set. The `review_verdict_guard` hook also asks before a merge of a ticket with no
+ approving verdict on file. On **Leave it open**, first take back the planned status from step 5,
+ on the same branch, so a teammate who merges later does not land a status the ticket is not in:
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" ingest_index_records.py --withdraw-planned <owner>/<id>`,
+ which also puts the README's Status line back, re-render with `build_ticket_index.py`, commit
+ the README and the index files (`chore(index): <id> keeps its
+ status until it merges`) and push. It is one more commit on the open pull request, never a second
+ one. A review kept in Ticketwright only stays: it is true however the merge goes, since the
+ work is still shared in steps 7 to 9. Then carry on with step 7 and leave the merge to the team.
+ When the merge is **blocked**, leave the planned status in place, since a push could restart the
+ checks or the review it is waiting on, and say so in the stop message:
+ <!-- ticketwright:user-facing:begin -->
+ > The pull request still says `<id>` is `<status>` in the ticket README and index. Run `/ship <owner>/<id>` again once it can merge, and it moves the ticket to match. If someone merges it another way, the index shows `<status>` before the tracker does, until `/refresh index` reads it back.
+ <!-- ticketwright:user-facing:end -->
+ 7. **Scan the source material first, then docstore.backup.** This runs after step 6, so what is
+ shared is the merged work. The backup copies the ticket folder except `comms/` (the unsent
+ drafts, which every docstore adapter's `backup` leaves out, since they are kept out of git for
+ the same reason), so run
`bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" scan_source_materials.py --ticket <ticket-dir>`
and **halt on a non-zero exit** — a raw meeting transcript is about to be copied out of the
repo. The remedy here is NOT the `private/` folder: `.gitignore` has no bearing on the
- adapter's `cp -r`, so `private/` protects git and nothing else. Remove the file from the folder,
+ adapter's copy, so `private/` protects git and nothing else. Remove the file from the folder,
or have the human approve the copy explicitly. (The `source_material_guard` hook asks about the
same command; this call is what makes the halt visible on runtimes whose hooks are not wired.
Neither reads MEANING — a curated summary quoting confidential material passes both.) Then
**docstore.backup** the ticket folder (full-title dest name) **into the routed target's
- destination from step 4(a)** — never another target's, and never a slot-level path. Then record
+ destination from step 4(a)** — never another target's, never a slot-level path, and never the
+ team folder when the routed destination names a person folder. When the plan said the person
+ folder is new, the backup creates it; say so in the output, on its own line:
+ <!-- ticketwright:user-facing:begin -->
+ > Created your folder `<person folder>` (first backup for you).
+ <!-- ticketwright:user-facing:end -->
+ Then record
each delivered file against the target it actually went to:
- `… delivery_plan.py --plan <ticket-dir>/delivery-plan.yaml --seam docstore --record-delivered <path relative to the ticket> --url <shareable URL> --expect-target <name from the resolution> --expect-fingerprint <full fingerprint from the resolution>`
+ `… delivery_plan.py --plan <ticket-dir>/delivery-plan.yaml --seam docstore --self "<shipper>" --record-delivered <path relative to the ticket> --url <shareable URL> --expect-target <name from the resolution> --expect-fingerprint <full fingerprint from the resolution>`
and call `docstore.link_for` **against that same recorded target**. The recorded row is what
makes a link provably come from the store the copy went into, rather than from whichever store
was resolved a step later. Skip when the docstore seam isn't configured.
**Routing is per deliverable here.** If the plan's `deliverables:` gives a file its own
`classification:` (a client-facing summary among internal working files), that file routes to
*its* target: resolve it with `--file <path>` and back that file up separately, rather than
assuming the whole folder shares one destination. For that file pass the full
fingerprint the resolver returned for its line, kept from the resolution, never anything printed to the person.
Anything the approved plan did not
show, you do not deliver. **The plan-level classification is folder-wide** — the backup carries
`qc_queries/` and everything else not split out by a row, so an external plan-level
classification must be a deliberate statement about the whole folder, and the approval line
should be read that way.
- 6. **tracker.comment** — post via the adapter's rich path (smart-link cards for the docstore
- files). Never before this point (no tracker comments without human review). Always write the
+ 8. **tracker.comment** — post via the adapter's rich path (smart-link cards for the docstore
+ files), with the backup links from step 7 and the PR link from step 5 filled into their
+ placeholders. Never before this point (no tracker comments without human review). Always write the
exact posted text to `comms/draft-tracker.approved.md` — edited or not — so Phase C can diff it
(an unedited ship just yields `initial == approved` and proposes nothing).
- 7. **chat.draft** to **the routed target's destination from step 4(a)** — its channel, its
+ 9. **chat.draft** to **the routed target's destination from step 4(a)** — its channel, its
recipients, its adapter (policy `chat_default_draft` — the human clicks send unless they said
- "send it", in which case `chat.send`). Re-run `--check-draft` on the final text first: it is the
+ "send it", in which case `chat.send`). Fill the link placeholders as in step 8, then re-run
+ `--check-draft` on the final text: it is the
last mechanical point at which a message can be caught carrying the wrong audience's list, and it
costs one command. A routing failure here is a **stop**, never a send to another target. Smart
links for ticket id(s), files, PR. Write the final drafted text to `comms/draft-chat.approved.md`.
- Skip when the chat seam isn't configured.
- 8. **vcs.commit** — **first, isolate repo-setup / AI-layer files.** If any are dirty
- (`.claude/settings.json`, `.claude/config/stack.yaml`, `.claude/statusline.sh`, `AGENTS.md`/
- `CLAUDE.md`, `documentation/AI_LAYER_INDEX.md`, `.gitignore`) they belong to the repo's plugin
- setup, not this ticket — give them a separate `chore(plugins): …` commit on this ticket's branch
- (a distinct commit that rides the ticket's one PR — not a second PR, never folded into the ticket
- commit); `.claude/settings.local.json` +
- `.claude/worktrees/` are gitignored, leave them. Then stage this ticket's paths (deliverable files
- included: they're committed by default so results live with the ticket and show in the PR) **plus
- the whole index — `tickets/INDEX.md` + `tickets/OBJECTS.md` + `tickets/index_data.json`, and
- `tickets/graph/` + `tickets/objects/` when `project.graph_notes` is on (the default)** (`--check`
- gates the *rendered* ones — the two catalog files and the graph nodes, not the curated store — so
- a rendered path left out is drift in CI; semantic message + Co-Authored-By). The render is
- repo-wide, so review the index diff before committing: a row or node that moved for **another**
- ticket is the renderer catching up, and belongs in the commit message rather than bundled in
- silently. **Before staging, list the
- `final_deliverables/` files that will be committed and confirm none carry PII/customer data that
- shouldn't be in git** — if any do, have the user rename them `*.private.csv` (etc.) or move them
- under a `private/` subfolder (both gitignored) first. **Then run the same source-material scan
- as step 5** (`scan_source_materials.py --ticket <ticket-dir>`) and halt on a non-zero exit:
- `source_materials/` is committed too, and a raw transcript whose filename gives nothing away
- (`notes.md`) is matched by no gitignore pattern. Here `private/` IS a valid remedy, alongside
- trimming the file to the curated `YYYY-MM-DD-<slug>-meeting.md` form. Then **vcs.open_pr** (semantic title; body =
- Business Impact / Deliverables / Technical Notes / QC — the QC section names the verdict on file,
- and reads "shipped without an independent review" when it is `SKIPPED`).
- 9. **transition** the ticket toward `project.terminal_status` if appropriate.
+ Skip when the chat seam isn't configured, or when the ticket declares `chat: none`.
+ 10. **transition** the ticket to the status the approved plan named (step 4(c): its `target`, and
+ its `via` where the adapter's `transition` section says the call takes one), never another one,
+ **and only when
+ step 6 merged the pull request.** A move that fails anyway (the workflow changed since step
+ 4(c)) is reported in the tracker's own words; do not try other statuses.
+ **Then read the status back** when step 5 recorded a planned one, success or failure: run
+ `tracker.search` by `ids` for this ticket, save what it returns (`{"statuses":[]}` when the
+ search fails), and run
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" ingest_index_records.py --confirm-planned <that file> --for <owner>/<id>`.
+ It writes nothing. Exit **0**: the tracker shows the status the merged index already says, so
+ there is nothing to land and no second pull request; say it in one line. Exit **8**: the merged
+ index says one status and the tracker another (the move failed, or landed somewhere else). Say
+ so, then ask by the question rule above, offering exactly these two answers:
+ <!-- ticketwright:user-facing:begin -->
+ > The ticket README and index on `<default branch>` say `<id>` is `<planned>`, but the tracker shows `<read>`. Fix them now?
+ > - **Fix it now**: I open a small pull request that sets the README and the index to `<read>`.
+ > - **Leave it**: the next `/refresh index` corrects it.
+ <!-- ticketwright:user-facing:end -->
+ On **Fix it now**, on a new branch off `<default branch>` (`vcs.branch`, named `<id>-index`),
+ record the read-back with `ingest_index_records.py --tracker-status <that file> --set-readme`
+ (a read replaces the plan, and the README's Status line follows it), re-render, and open that pull request through `vcs.commit` and
+ `vcs.open_pr`; it is the only case where a ship opens a second one. Exit **7**: the status could
+ not be read back (the tracker stopped answering): say the index shows `<planned>` and that it is
+ not confirmed, and that the next `/refresh index` reads it.
+ For a review kept in Ticketwright only there is no move: say in one line that the tracker
+ stays at `<current>` and the README and index say In review.
+ When the merge was left to the team (the person chose
+ **Leave it open**, or `ship_merges_pr` is `false`), leave the status where it is and say so: a
+ ticket marked done with its work unmerged tells the team something that is not true yet.
## Phase C — Post-ship (always, even on success)
- 10. **System-evolution retro** (policy `system_evolution`). Reflect briefly: did anything go wrong or
+ 11. **System-evolution retro** (policy `system_evolution`). Reflect briefly: did anything go wrong or
get re-done this ticket? If so, **which layer was insufficient** — a global rule, the context
- pack, a skill, or an adapter? Propose the concrete fix to *that* artifact and note it. A repeated
- manual step is a signal to `/skillify` it. Fixing the layer, not just the ticket, is what
- compounds. *(A stylistic tweak to a comms draft is **not** a layer failure — that's step 11, not
+ pack, a skill, or an adapter? Propose the concrete fix to *that* artifact and note it. Fixing
+ the layer, not just the ticket, is what compounds.
+ **Then check for a repeat, every time.** Run
+ `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/bin/tw" recall.py --for <owner>/<id> --repeats --json`
+ (the ticket's SQL now exists, so the answer is `confirmed`) and follow the four steps in
+ [repeats.md](../skillify/repeats.md). When they end in a
+ suggestion, ask it by the question rule above, offering **Skillify it now** and **Not now**:
+ <!-- ticketwright:user-facing:begin -->
+ > This repeats dana/TEST-5. Skillify it?
+ <!-- ticketwright:user-facing:end -->
+ **Skillify it now** starts `/skillify` with the pattern and the `built_from` list (the earlier
+ tickets plus this one, each `<owner>/<id>`). When a skill already does this work, say so
+ instead: "Next time, `/<skill>` does this." Never start `/skillify` without the yes. *(A stylistic tweak to a comms draft is **not** a layer failure — that's step 12, not
this retro.)*
- 11. **Voice refine** (only if a voice profile resolved for the shipper). Diff `comms/draft-<kind>.initial.md` against `comms/draft-<kind>.approved.md`. If they
+ 12. **Voice refine** (only if a voice profile resolved for the shipper). Diff `comms/draft-<kind>.initial.md` against `comms/draft-<kind>.approved.md`. If they
differ beyond whitespace, the edits are voice signal: **propose** a small append/edit to the
person's `voices/<id>.md` capturing the pattern (e.g. "drops the greeting", "prefers 'net:' over
- 'in summary'") and **wait for confirmation before writing** — never silent. If `initial` ==
+ 'in summary'") and **ask before writing**, as one question: "Add it to my voice profile" or
+ "Skip". Never write it silently. If `initial` ==
`approved` (no edits), or the feature is off, do nothing.