0055 PR 1 — sticky ticket mode with phases #137

Merged
chad merged 41 commits from glen/0055-chat-modes into main 2026-10-21 11:18:09 -04:00
Collaborator

Implements PR 1 of docs/0055-chat-modes.md (approved plan v5). PR 2 (mode chip, usage control in the composer bar, header cleanup) follows separately.

What changes

  • Ticket is the one mode. conversations.skill = "ticket" is the mode (write-once → sticky); new conversations.mode_phase (migration 0058) holds plan → implement → review → fixup → ship.
  • lib/modes.ts (pure core in lib/agent/mode-ids.ts, db-free for workflow files): transitions — forward one, review→ship on a clean review, fixup⇄review loop, backward any step with a note. One gate: implement needs the latest plan approved (planState, durable sqlite fact).
  • Tools: enter_mode (approval-gated via the existing card; project chats only; refuses when already moded) writes skill + phase and returns the intro + plan section. advance_phase (no approval) validates and returns the next phase's ## section — just-in-time runbook. use_skill is list-only: the agent can no longer silently bind.
  • Owner entry: mode: "ticket" on a send / ?mode=ticket (?skill=ticket aliases it) appends a runEvent: "mode" row carrying the runbook — never the system prompt.
  • Prompt cache holds: mode skills skip the system prompt once a phase is set; the per-turn [thread state] mode: ticket · phase: implement (2/5) · next: review note is stored as user-row metadata and rebuilt in place by listTurnMessages (like [viewing]). Test proves turn N's model input is an exact prefix of turn N+1's history. Also fixes a pre-existing metadata clobber (runEvent overwrote viewing).
  • Skills: ticket rewritten orchestrator-shaped (intro + one section per phase, verification in implement); ship retired (seeded disabled version, restorable). Unedited git rows of ticket and the base prompt are upgraded; owner/agent-edited rows untouched (frozen exact-match constants, verified against main).
  • Legacy ticket threads (bound before this, null phase) keep their system-prompt behavior; advance_phase with a note adopts them into a phase (plan gate still applies).

Review

Fresh-eyes review (thread c_tmvhls7ro103cf68f38c3) verdict was "fix first"; all fixed:

  1. BLOCKING — 0058's journal when was earlier than 0057's, so drizzle skipped it on existing DBs → fixed; verified by migrating a main-schema db then this branch (column present).
  2. BLOCKING — runbook said review→ship, rule refused it → rule allows it; new test walks every advance_phase in the runbook against the rule.
  3. Legacy ticket threads dead-ended → adoption path above.
  4. enter_mode approve/deny now tested end-to-end through the real hook.
    5–7. Nits fixed: migration newline, [object Object] on answered gated cards, orphan conversation on a refused fresh mode send.

Checks: typecheck, lint (2 pre-existing warnings), 1016 tests pass.

Implements PR 1 of docs/0055-chat-modes.md (approved plan v5). PR 2 (mode chip, usage control in the composer bar, header cleanup) follows separately. ## What changes - **Ticket is the one mode.** `conversations.skill = "ticket"` is the mode (write-once → sticky); new `conversations.mode_phase` (migration 0058) holds `plan → implement → review → fixup → ship`. - **`lib/modes.ts`** (pure core in `lib/agent/mode-ids.ts`, db-free for workflow files): transitions — forward one, review→ship on a clean review, fixup⇄review loop, backward any step with a note. One gate: `implement` needs the latest plan approved (`planState`, durable sqlite fact). - **Tools:** `enter_mode` (approval-gated via the existing card; project chats only; refuses when already moded) writes skill + phase and returns the intro + `plan` section. `advance_phase` (no approval) validates and returns the next phase's `##` section — just-in-time runbook. `use_skill` is list-only: the agent can no longer silently bind. - **Owner entry:** `mode: "ticket"` on a send / `?mode=ticket` (`?skill=ticket` aliases it) appends a `runEvent: "mode"` row carrying the runbook — never the system prompt. - **Prompt cache holds:** mode skills skip the system prompt once a phase is set; the per-turn `[thread state] mode: ticket · phase: implement (2/5) · next: review` note is stored as user-row metadata and rebuilt in place by `listTurnMessages` (like `[viewing]`). Test proves turn N's model input is an exact prefix of turn N+1's history. Also fixes a pre-existing metadata clobber (runEvent overwrote viewing). - **Skills:** `ticket` rewritten orchestrator-shaped (intro + one section per phase, verification in implement); `ship` retired (seeded `disabled` version, restorable). Unedited git rows of `ticket` and the base prompt are upgraded; owner/agent-edited rows untouched (frozen exact-match constants, verified against main). - **Legacy ticket threads** (bound before this, null phase) keep their system-prompt behavior; `advance_phase` with a note adopts them into a phase (plan gate still applies). ## Review Fresh-eyes review (thread c_tmvhls7ro103cf68f38c3) verdict was "fix first"; all fixed: 1. BLOCKING — 0058's journal `when` was earlier than 0057's, so drizzle skipped it on existing DBs → fixed; verified by migrating a main-schema db then this branch (column present). 2. BLOCKING — runbook said review→ship, rule refused it → rule allows it; new test walks every `advance_phase` in the runbook against the rule. 3. Legacy ticket threads dead-ended → adoption path above. 4. enter_mode approve/deny now tested end-to-end through the real hook. 5–7. Nits fixed: migration newline, `[object Object]` on answered gated cards, orphan conversation on a refused fresh mode send. Checks: typecheck, lint (2 pre-existing warnings), 1016 tests pass.
glen added 16 commits 2026-10-21 08:15:24 -04:00
New nullable column tracking a sticky mode's current phase (null = no
mode, or a legacy ticket-skill thread bound before 0055). Adds the
read column to getConversation, setConversationMode/setConversationPhase
writers, and a "mode" runEvent kind for the owner-entry chip — all
unused until the following commits wire them up.
lib/agent/mode-ids.ts holds the pure core (ids, phase order, the
transition rule — forward one step or the fixup<->review loop need no
note, backward any step needs one, same-phase refuses — the per-turn
note text, and ##-section extraction), kept dependency-free so
workflow-body files can import it without pulling in a db import (same
posture as lib/usage/pricing.ts). lib/modes.ts adds the one db-touching
piece — the plan-approval gate on entering "implement", read from
db/plans.ts's durable planState fact, never a transcript scan — and
re-exports the rest as the one door everything else should use.
skills/ticket/SKILL.md is now a short intro (keep your own context
small, delegate real work to a harness or a child thread, call
advance_phase) plus one `## <phase>` section per plan/implement/review/
fixup/ship, replacing the old single-pass numbered-steps runbook. ship
is retired: its front matter gains `status: disabled` (a seeded,
restorable tombstone, not a delete) — lib/skill-files.ts parses the new
field, db/prompt-settings.ts's seed path honors it for brand-new
installs, and resetToGitDefault follows the shipped status instead of
forcing "active".

Existing, never-edited rows upgrade in place: repairRetiredSkill flips
an unedited active ship seed to disabled, repairTicketUpgrade rewrites
an unedited pre-0055 ticket body to the new one — both as a new git
version, both leaving a user/agent-edited row untouched (same idiom as
the owner-placeholder repair). The base prompt's Skills paragraph drops
"bind ship or ticket with use_skill" (ticket is a mode now, entered
only via enter_mode) and gains the fixed "[thread state]" line;
buildInstructions skips the skill section once a mode skill's phase is
set. A matching guarded upgrade (PRE_0055_BASE_PLACEHOLDER) chains with
the existing owner-placeholder repair so an untouched base row lands on
the current default either way.
enter_mode asks through the same approval mechanism ask_owner/create_pr
use (pendingStep + chatApprovalHook, no new card kind —
GatedApprovalCard renders it); approved, it writes skill=ticket and
phase=plan and hands back the runbook's intro + plan section.
advance_phase is not approval-gated (the owner already approved the
plan, not each phase move) — lib/modes.ts's transition rule plus the
implement gate are the whole check, and it returns the target phase's
own section. use_skill drops its id parameter entirely: binding a
skill mid-thread is gone, the tool only lists the catalog now.

The per-turn [thread state] note (workflows/chat/turn.ts) rides the
same injection point as the [viewing] note — rebuilt fresh every turn
from the live mode_phase column, never persisted, present only for a
moded conversation with a phase set (a legacy ticket-skill thread with
a null phase gets none, same as a plain chat).
The chat send route accepts mode: "ticket" on a send (fresh or an
existing chat, project-scoped — unlike `skill`, not creation-only; the
legacy `?skill=ticket` means the same thing now that ticket is a mode).
lib/chat/mode-entry.ts writes skill + phase directly with no
self-approval (this is the owner's own action, not the agent's) and
appends a runEvent "mode" row carrying the rendered plan-phase runbook
BEFORE the owner's own send — a transcript chip ("Entered ticket
mode"), never the system prompt. `?mode=ticket` on the fresh composer
URL reuses the existing skill-id plumbing (the route already treats
skill: "ticket" as mode entry), so no new client wiring was needed
beyond page.tsx reading the query param.
AGENTS.md's Skills bullet drops the "ship, ticket" catalog description
(ship is retired, ticket is a mode now) and gains a Ticket mode bullet
stating the current contract: the mode_phase column, lib/modes.ts and
lib/agent/mode-ids.ts, enter_mode/advance_phase, owner entry, the
[thread state] note, and what's deferred to a later PR. docs/agent/
projects.md's project-setup pointer now says "enter ticket mode" rather
than "a real ticket".
The per-turn [thread state] note was injected fresh every turn from
live state but never stored, unlike the [viewing] note — so it
vanished from the rebuilt history on the next turn, shifting every
earlier turn's messages and invalidating the prompt cache for the
whole prefix each time.

Same fix as viewing: the turn's phase now rides as metadata.threadState
on its user row (recordUserMessage, loaded via loadSkillStep BEFORE the
row is written so it has the phase to stamp), and listTurnMessages
reinserts threadStateNote(phase) before that row on every later turn —
byte-identical to what the turn actually sent the model, so history
never shifts underneath an earlier turn.

Also fixes a real data-loss bug in the same function: viewing and
runEvent were written as two separate `{ metadata: {...} }` spreads,
so the second silently clobbered the first whenever a send carried
both. All three (viewing, runEvent, threadState) now merge into one
metadata object.

Added a test proving turn N's model input is a byte-identical prefix of
turn N+1's rebuilt history, and that all three metadata fields survive
together on one row. Updated turn.ts's comments and the AGENTS.md
ticket-mode bullet to describe the note as rebuilt from row metadata
(like viewing), not "never persisted".
The journal timestamp for 0058_mode_phase was earlier than 0057's,
so drizzle's migrator would have applied it out of order (or skipped
it) on a database that already had 0057. Also restores the trailing
newline the other migration files have.
review→ship was wrongly refused as a "skip" — a clean review should be
able to go straight to ship without a pointless detour through fixup.
validateTransition now allows it explicitly, alongside the existing
fixup⇄review loop and forward-one-step rule.

Also fixes a dead end for legacy ticket-skill threads (skill="ticket",
mode_phase null, bound before this column existed): advance_phase used
to refuse them "not-moded" while the upgraded runbook tells them to
call it, and enter_mode correctly refuses re-entering a skill that's
already bound, leaving no way in. checkLegacyAdopt (lib/modes.ts) lets
advance_phase adopt such a thread directly: a note is required (there's
no prior phase to validate a transition from), and anything past "plan"
still needs the conversation's latest plan approved — the same gate
that protects a fresh ticket's implement phase.

Added: review→ship allowed (still refusing other same-distance skips
like plan→review, implement→ship); a test that walks every
advance_phase({to}) call inside skills/ticket/SKILL.md's phase sections
and checks validateTransition agrees, so the runbook and the rule can
never silently disagree again; checkLegacyAdopt coverage (adopt with a
note, refuse without one, refuse implement-or-later without an approved
plan, accept it once approved).
The project's test command and visual (agent-browser + screenshot)
verification belonged in implement, before advancing to review — review
should see already-verified work, not catch missing verification.
fixup now just re-verifies what changed, the same way, instead of
duplicating the full verification paragraph. Each section still stands
alone when returned by itself (## implement's own verification
paragraph is self-contained; the runbook-upgrade repair in
db/prompt-settings.ts compares against the frozen pre-0055 body and
writes the current git default, so no new constant is needed here).
Adds the "enter-mode" scripted mock and a turn-level test driving
enter_mode through the real approval hook: approved writes skill +
phase and the turn's history carries the rendered plan-phase runbook;
denied with a comment writes nothing and the comment comes back as the
tool's message. Same pattern as the existing approve/create-pr tests.
GatedApprovalCard's answered state called String(part.output) directly,
which stringifies a plain object to "[object Object]" — enter_mode's
denial ({ ok: false, message }) and similar object-shaped results all
rendered that way. Prefer output.message when present, else a clipped
JSON dump, same 300-char clip as before.
fix(chat): refuse ticket mode entry before creating an orphan conversation row
All checks were successful
deploy / checks (pull_request) Successful in 44s
deploy / deploy (pull_request) Has been skipped
76f38d6cf0
A fresh send with mode: "ticket" and no project ran ensureConversation
before enterTicketModeOnSend's own "needs a project" refusal, so a
rejected request still left a bare conversation row behind. The project
check now runs first, matching how the pre-existing skill/projectId
validations already avoid creating anything on a 400. The existing
"creating nothing" test now actually asserts that.
docs(0055): v7 approved — owner-defined modes, markdown phases, phase handoff
All checks were successful
deploy / checks (pull_request) Successful in 45s
deploy / deploy (pull_request) Has been skipped
25327a6600
A skill can be flagged as a MODE (0055 v7) — versioned on both
skills and skill_versions, like thread/status. Adds a regression
test asserting the migration journal's `when` strictly increases
(0058 shipped with an out-of-order `when`; this would have caught it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the hardcoded ticket phase list and fixed transition rules
with a generic parser (parseModeSkill: brief/shared/phases from
`## Phase: <Name>` and `## Shared` headings) and a generic transition
rule over whatever phases THIS skill declares — no plan gate, no
fixed review->ship or fixup<->review exceptions. Pure, no db import,
so workflow-body files can import it directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`mode: true` in SKILL.md front matter (implies thread) flows through
parseFrontMatter, lib/skills.ts's Skill type, every db/prompt-settings.ts
write/version/restore/reset/repair path, and propose_prompt_change's
patch. db/chat.ts stores the per-turn [thread state] note as the
already-rendered string (not {mode, phase}) so listTurnMessages stays
db-free and byte-stable on replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
enter_mode and the owner send path now take any skill id (existence,
mode flag, and active status all validated live) instead of a
hardcoded "ticket" literal; mode_phase is "" for a phaseless mode
entry, null for never-entered/legacy. advance_phase validates `to`
against the mode's CURRENT body alone; the plan gate (checkPhaseGate)
and the fixed review/ship exceptions are gone — a mode's own phase
order is all there is. lib/modes.ts deleted (nothing left to re-export
once the gate was removed); callers import lib/agent/mode-ids.ts
directly. ?mode=<id> and ?skill=<id> (only when that skill is a mode)
both reuse the existing skill-id plumbing on the fresh composer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Beside "Bindable to a new thread" — checking it auto-checks thread too
(mode implies thread). Wired into isDifferent/patch/Changes alongside
the other skill fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
front matter mode: true, ## Shared, and five ## Phase: <Name> sections
(Plan, Implement, Review, Fixup, Ship) — same substance as before,
restructured for the generic parser. Each phase section is written
standalone for whoever runs it (orchestrator or, from a later PR,
a phase worker).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
parseModeSkill edge cases (no phases, no Shared, ### inside a phase,
odd names, duplicate ids, case-insensitivity, brief collection),
flag-gated mode-ness (phase headings alone don't make a mode),
validateTransition/checkLegacyAdopt/checkEnterMode over arbitrary
phase lists, disabled-mode-entry refusal (tool + send path), entry
content, and the runbook<->rule agreement test against the rewritten
ticket skill. Updates the cache-stability and repair tests for the
new threadState string shape and the mode flag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs(agents): update the modes/skills contract to v7
All checks were successful
deploy / checks (pull_request) Successful in 45s
deploy / deploy (pull_request) Has been skipped
d69df4a738
Owner-defined modes, markdown phases, no plan gate — replaces the
"Ticket mode" bullet describing v5's hardcoded ids and fixed gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
0055 v7 part B: the phase (display name) a harness call or start_thread
child was spawned to do — distinct from a chat's own sticky mode_phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
renderPhaseHandoff (lib/agent/mode-ids.ts, pure) builds the runtime-
attached block a phase worker gets: the phase framing, Shared (if
any), just that phase's body, and the orchestrator's task last —
never the brief, never other phases. resolvePhaseHandoff
(lib/chat/phase-handoff.ts) is the db-touching half: resolves the
chat's current phase or an explicit override against the mode
skill's CURRENT body, refusing value-shaped for a non-moded chat or
an unknown phase; a stale/phaseless/legacy phase just means no
wrapping, same as a plain chat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every harness tool (pi/claude/cursor/codex) and start_thread gain an
optional `phase` arg (id or name — overrides the chat's current phase,
refused value-shaped when unknown or the chat isn't moded). The
workflow prompt (harness) / child's first turn (start_thread) is
wrapped with the handoff when a phase resolves; harness_calls.task
keeps the orchestrator's original text so the card stays readable —
only the actual model/child prompt carries the handoff. The resolved
phase name is recorded on harness_calls.phase and, for a child,
conversations.work_phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Child-status (GET /api/x/status/<id>), the sidebar's synthetic harness
rows and child conversation rows, and inspect's harness-call/chat
output all surface the recorded phase. ChildMeta's existing label row
shows it alongside the thread's skill, no new UI chrome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A read-only panel under a mode skill's editor, derived from
parseModeSkill(draft.body) on every keystroke — the same parser that
drives entry/advance, so the phase list, Shared presence, and brief
length shown are exactly what the mode will do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
renderPhaseHandoff (content, ordering, no brief/other phases, no
Shared paragraph when absent); startHarnessCall and startThread
wrapping the workflow prompt / child's first turn under a current or
explicit phase, keeping the stored task/original prompt and recording
the phase, refusing value-shaped on an unknown phase or a non-moded
chat, and leaving a non-moded/phaseless/legacy chat byte-identical to
before; the phase surfacing on childStatus, the sidebar's harness and
child rows, and inspect's harness-call/chat output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs(agents): document the phase handoff contract (0055 v7 part B)
All checks were successful
deploy / checks (pull_request) Successful in 45s
deploy / deploy (pull_request) Has been skipped
fe250cd18c
Replaces the "deferred" note with the shipped contract: handoff
content/resolution, where the phase is recorded and exposed, the
Settings live preview. One line on the harness/start_thread phase arg
in docs/agent/sandboxes.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs(0055): card shows the task + phase, handoff lives in the prompt
All checks were successful
deploy / checks (pull_request) Successful in 44s
deploy / deploy (pull_request) Has been skipped
4f1a43f032
action=default omitted mode and status, so a mode skill like ticket
showed a permanent "Differs from shipped default" (isDifferent saw
mode: true vs. the default's implicit false) and the reset dialog
forced status: "active" into the preview even for a shipped-disabled
skill. The reset button itself (resetToGitDefault) was already
correct — only the payload/preview lied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A fresh send with an unknown/deleted/disabled/non-mode mode id went
through ensureConversation before enterModeOnSend's own validation
caught it, leaving an orphan conversation row on every refusal.
validateModeTarget (lib/chat/mode-entry.ts) factors out the
project-independent checks (exists, is a mode, active) so the route
can run them before creating anything; the already-moded check still
needs a real row, so it stays in enterModeOnSend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PromptChangeCard's read-model type (lib/read-models/attention.ts,
duplicated from the workflow step's own type) was missing mode, so a
proposal that only flips a skill's mode flag showed no field diff on
the approval card. Add it to the type and to PromptChangeDetails'
rendered fields list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GatedApprovalCard's headline and RunEventChip's mode label both said
"ticket mode" unconditionally — use the proposed/entered mode's own id
(enter_mode's input.mode / the runEvent's id) instead, now that any
skill can be a mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A harness call's card/task column shows only the orchestrator's
original task + phase — the handoff is never stored, only the
workflow prompt carries it. A start_thread child's first turn IS its
prompt, so the handoff lives in the child's own first message
(visible when you open the child, durable across its later turns);
the PARENT's card still shows the orchestrator's unwrapped prompt (the
tool call's own argument) either way. Verified lib/agent/tools/
start-thread.ts's result and StartThreadCard already behave this way
— no code change needed, AGENTS.md and the design doc just described
it wrong.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A `## Phase: <Name>` whose name slugs to "" (punctuation only, e.g.
"!@#") collided with the phaseless-mode sentinel ("" meaning "entered,
no phases" on mode_phase); parseModeSkill now treats it as ordinary
brief content instead of a declared phase, matching any other
non-phase `##` heading. threadStateNote no longer claims a bogus
"(0/n) · next: done" when the stored phase isn't declared by the
CURRENT body (a heading renamed/removed mid-run) — it says so
plainly and lists the current phases, so the agent knows to
advance_phase rather than reading it as finished.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
\`## Phase : Name\` (whitespace before the colon) now matches, same as
\`## Phase: Name\`. \`##\` headings inside a fenced code block (\`\`\` or
~~~) no longer split sections — a skill author showing example
advance_phase calls or a sample heading inside a code fence doesn't
accidentally declare a phase or end one; a \`\`\` fence isn't closed by
a ~~~ line, matching CommonMark.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feat(skills): include mode on the composer picker's /api/skills data
All checks were successful
deploy / checks (pull_request) Successful in 45s
deploy / deploy (pull_request) Has been skipped
6fd156633e
So a later UI (PR 2) can distinguish modes from plain thread skills;
no visual change yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chad merged commit 62c793bbdb into main 2026-10-21 11:18:09 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chad/sweatshop-legacy!137
No description provided.