agentleFS
kyle-chalmerskyle-chalmers/ticketwright/.claude/skills/ship/SKILL.md

ship

Skill4 starsChanged 19 days agoMITView on GitHub

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.

  • Commits and pushes

194 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use ios-sync, open-gstack-browser and 10 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • Saqlain-811Skill

    ios-sync

    Regenerate the iOS debug bridge against the latest upstream gstack templates. (gstack)

    Also in 6 projects that use this

  • seek888Skill

    open-gstack-browser

    Launch GStack Browser — AI-controlled Chromium with the sidebar extension baked in. Opens a visible browser window where you can watch every action in real time. The sidebar shows a live activity feed and chat. Anti-bot stealth built in. Use when asked to "open gstack browser", "launch browser", "connect chrome", "open chrome", "real browser", "launch chrome", "side panel", or "control my browser". Voice triggers (speech-to-text aliases): "show me the browser".

    Also in 10 projects that use this

  • ayudaySkill

    setup-deploy

    Configure deployment settings for /land-and-deploy. Detects your deploy platform (Fly.io, Render, Vercel, Netlify, Heroku, GitHub Actions, custom), production URL, health check endpoints, and deploy status commands. Writes the configuration to CLAUDE.md so all future deploys are automatic. Use when: "setup deploy", "configure deployment", "set up land-and-deploy", "how do I deploy with gstack", "add deploy config".

    Also in 19 projects that use this

  • ShreyRaviSkill

    landing-report

    Read-only queue dashboard for workspace-aware ship. Shows which VERSION slots are currently claimed by open PRs, which sibling Conductor workspaces have WIP work likely to ship soon, and what slot /ship would pick next. No mutations — just a snapshot. Use when asked to "landing report", "what's in the queue", "show me open PRs", or "which version do I claim next". (gstack)

    Also in 9 projects that use this

  • zebbernSkill

    debug-fix

    Find and fix a bug or issue — from any source (GitHub issue, error message, user report, or observed behavior)

    Also in 4 projects that use this

  • ramybarsoumSkill

    document-release

    Post-ship documentation update. Reads all project docs, cross-references the diff, updates README/ARCHITECTURE/CONTRIBUTING/CLAUDE.md to match what shipped, polishes CHANGELOG voice, cleans up TODOS, and optionally bumps VERSION. Use when asked to "update the docs", "sync documentation", or "post-ship docs". Proactively suggest after a PR is merged or code is shipped.

    Also in 23 projects that use this

The file

Show its rules and text

We split this skill into the rules it gives an agent

18 rules. 0 are common, 5 are distinctive, and 13 are very specific to this project.

  • 0

    Common

    Most of the 180 edited versions we read keep these.

  • 5

    Distinctive

    This author's own choices. Worth a look.

  • 13

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read capabilities from this skill's kit_paths.py --json probe the first time a question comes up, and keep the result.

    line 32
  2. Very specific:

    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 …

    line 91
  3. Very specific:

    Only on that exact phrase: write qc_queries/<n>_review_verdict.md with verdict: SKIPPED, review_mode: none, the date, the shipper (from …

    line 118
  4. Distinctive:

    If the stack has a warehouse seam, re-run the final deliverable queries once more.

    line 132
  5. Distinctive:

    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 …

    line 189
  6. Very specific:

    No answer is marked recommended: whether the work is finished is the person's call.

    line 273
  7. Very specific:

    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 …

    line 291
  8. Distinctive:

    "Go ahead, and send the chat message": only when the plan's Chat line says draft (delivers the plan with that one change, which the option …

    line 360
  9. Very specific:

    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 …

    line 371
  10. Distinctive:

    Only on explicit authorization, execute in order:

    line 405
  11. Very specific:

    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 …

    line 457
  12. Very specific:

    Only on Merge now, run vcs.merge_pr with the PR and the sha from step 5.

    line 467
  13. Very specific:

    Never retry with a bypass, admin or auto-merge option: those skip or defer exactly the checks the team set.

    line 475
  14. Very specific:

    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 …

    line 485
  15. Very specific:

    Routing is per deliverable here: If the plan's deliverables: gives a file its own classification: (a client-facing summary among internal …

    line 514
  16. Very specific:

    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 …

    line 542
  17. Distinctive:

    When the merge was left to the team (the person chose

    line 563
  18. Very specific:

    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> …

    line 572

“Common” is measured: kept by at least 6 in 10 of the 180 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

/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 (tools/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 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, the yes before destructive SQL) stays typed: an unreviewed ship or an irreversible write is never one click. The external-send gate (go ahead) is asked like any other question once the full delivery plan is in chat: its first option is "Go ahead", which delivers exactly the printed plan, and with no question tool the person types go ahead.
<!-- 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, Phase B's go-ahead, 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) stays typed; go ahead (Phase B) is asked as a question, with the delivery plan already in chat.

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 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 (tools/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 · 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. 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 (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 asks its go-ahead question, 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 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. 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, 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. 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:. 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, 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:, 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. 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). 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) 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 the person's go-ahead answer)

Print the resolved delivery plan, then stop and wait for the user — the human authorizes 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 the person's own answer to the go-ahead question 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 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–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 (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, 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. 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. 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.
  7. 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.
<!-- ticketwright:user-facing:end -->

Then ask the go-ahead as one question under the ask rule, never as a request to type a phrase: "Deliver this plan?", with the options "Go ahead" (delivers exactly the printed plan), "Go ahead, and send the chat message" only when the plan's Chat line says draft (delivers the plan with that one change, which the option says), and "Change something" (the person says what; reprint the plan after the change and ask again). No option is marked recommended. With no question tool, end the plan with "Reply go ahead to deliver exactly this plan, or tell me what to change." Any answer other than a go-ahead option or that phrase is a change request, never approval. 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. 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 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. 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):

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 -->
  1. 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 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, 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 --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.
  2. 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).
  3. 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). 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, or when the ticket declares chat: none.
  4. 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)

  1. 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. 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. 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.)
  2. 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 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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.