diff --git a/docs/council.md b/docs/council.md index ab3843f..1ad7271 100644 --- a/docs/council.md +++ b/docs/council.md @@ -353,6 +353,17 @@ and command authority to every seat. Both refuse while a turn is in flight, and kills anything: seats move on their next turn. Only the bare word is a command, so `/write a test for this` never changes the posture. +**`/retry` sends the last brief again, to the seats that owe an answer.** A turn where four +seats replied and one failed, was cut with `x`, or fell over leaves you retyping the brief and +the mentions to finish it. Type `/retry` instead: the brief comes back into the composer +addressed to exactly the seats that did not answer — `@codex @agy ` — and **enter is +still what sends it**, so the footer prices the re-send before you pay for it and you can edit +the draft first. A seat that answered is never re-sent to, and neither is one that sat the turn +out, so the bill never grows past the turn you are finishing. A seat that finished and streamed +nothing counts as having answered: that is a measured zero, not a missing reply. It refuses +while a turn is in flight, when nothing has been dispatched yet, and when every seat answered. +The text goes out unchanged — `/retry` re-sends, it does not re-brief. + **A slash the room does not know is refused, not sent.** A draft that opens with `/` and names no room command used to go to the vendors as a brief, so a mistyped verb cost every seated seat a turn. Now nothing spawns, the draft stays in the composer, and the notice names diff --git a/docs/design.md b/docs/design.md index 591f5c9..5cd407d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -10271,6 +10271,76 @@ the honest shape rather than only a saving: the two take one argument in one voc only in direction, so a reader who finds either has found both. "times" paid for the width — the row is a list of controls, and `/trace ` is unambiguous without the verb. +#### Amendment, 2026-08-17: `/retry` sends the last brief again, to the seats that owe an answer + +**The gap.** §9.37's 2026-08-17 amendment gave the operator a way to stop ONE seat of an ordinary +turn, and it argued from the room's most probable live failure: five seats on an `@all` turn, four +answers, one vendor that fails or stalls. The room can now end that turn. It has no way to finish +the brief. The only act left is to retype the brief and retype the mentions — arithmetic at the +keyboard, on the one line where getting it wrong bills seats that already answered. That is the +same complaint `-@` and `/unseat` were built for, one turn later. + +**`/retry` puts the last dispatched brief back in the composer, addressed to the seats that did not +answer it.** The brief comes from the columns' own per-turn record (`Column.Prompt`), unchanged. The +mentions are the seats that owe an answer, so the draft reads `@codex @agy ` — a draft the +operator could have typed, in the grammar that already exists. + +**It arms; it does not dispatch, and enter is still what spends the money.** The verb writes a +draft, `setDraft` re-derives the route from it, and the footer prices that route through the same +`State.SeatsIn` intersection dispatch gates on (§9.21). So the operator reads the bill before paying +it, and can edit the draft — drop a seat off the front, fix a word — because it is an ordinary +draft. A verb that spawned on the spot would spend up to five quotas on a keystroke that named none +of them, and the room would have no surface left on which to say which ones. + +**What counts as an ANSWER is defined against the four endings**, and it is narrower than "the +column looks empty": + +- **`PhaseDone` answered**, including the seat whose body reads `[Turn completed with 0 text chunks + streamed]`. That is a measured zero, not a missing reply (§4a.1), and re-sending on it would be + the room overruling a vendor's honest empty answer — and billing a seat that did the work. +- **`PhaseFailed` and `PhaseCancelled` did not.** Cancelled covers `ctrl+c` and the per-seat + give-up together, deliberately: a seat the operator cut is the case this verb exists for, and + after the turn the room holds one cancelled phase for both. +- **A seat that SAT THE TURN OUT is not a candidate at all.** `dispatch` never calls `startTurn` on + it, so its `Column.TurnN` still names the last turn it took and the scan skips it. That is + load-bearing rather than incidental: without it, a `/retry` after an `@codex` turn would widen the + bill to four seats the operator deliberately did not address. + +**Bare-only, on `/read` and `/write`'s rule.** The verb takes no argument, and "/retry the failing +test" is a sentence someone types. A verb that swallowed that argument would run a re-send and +discard the brief — §9.17's vanishing-brief failure. The bare draft is the command; anything longer +is refused with the space escape named, which costs nothing. + +**Three refusals, three sentences, and only the first keeps the draft.** *A turn in flight*: the +phases this verb reads are not settled, so any list it produced would be a claim about a turn that +has not ended — and the operator still wants the verb one turn later, which is `postureCommand`'s +own reason for holding the draft. *No brief on record*: turn 0 and the degenerate turn whose brief +sanitized away to nothing are one sentence, because they are one fact. *Every seat answered*: there +is nothing to re-send, and saying so beats a composer the operator has to clear by hand. The verb +sits in `roomVerbs` like every other, so §9.31's walked refusal teaches it for free — no second copy +of the vocabulary was added, and the notice fits the reference width with one cell to spare. + +**`--brief` stays unfiled, and this verb is careful not to file it.** It re-sends the brief +UNCHANGED: no re-briefing, no edit, no automatic second attempt, and it never reads `Model.brief`. +§9.17's sweep left `--brief` out on the ruling that first-turn context is a different feature from +re-briefing; that question is still open and still to be decided on its own. + +**After a race it re-sends the race's brief as an ORDINARY turn.** `Column.Prompt` holds the brief +the racers were given and never the `/arena` draft that wrapped it, and this verb invents no grammar +to put the wrapper back. The composer shows exactly what will be sent before enter, which is where +the operator reads that the worktrees are not part of it; `/arena ` races again. + +**The help panel does not name it**, on `/adopt` and `/arena drop`'s precedent: the room-controls +row is at its budget (§9.20), and the verb is taught by the slash refusal and by this block. + +Verified offline only. `roomcmd_test.go` pins the four endings producing the right seat list, the +measured zero counting as an answer, the three refusals, the bare-word rule, the verb's appearance +in the walked refusal table, and — at the dispatch level, with `countSpawns` — that the re-send +spawns one process per seat owing an answer and none for a seat that already replied. The +`slash-refusal` goldens and their `--ascii` twins carry the new word. No test here spawns a vendor. +A live `/retry` on the Windows reference box is not owed as a separate payment: nothing here is a +claim about vendor behaviour, and every process the verb can cause is an ordinary turn's spawn. + ### 9.32 the room remembered where it was and forgot who was in it diff --git a/internal/council/roomcmd.go b/internal/council/roomcmd.go index eeff080..997ffb8 100644 --- a/internal/council/roomcmd.go +++ b/internal/council/roomcmd.go @@ -86,6 +86,13 @@ func roomVerbs() []roomVerb { {verb: "/cd", run: (*Model).cdCommand}, {verb: "/flow"}, {verb: "/read", bare: true, run: func(m *Model, _ string) bool { return m.postureCommand(false) }}, + // Bare-only, for /read and /write's reason rather than for symmetry: it + // takes no argument, and "/retry the failing test" is a sentence someone + // types. A verb that swallowed that argument would run a re-send and + // discard the brief the user meant — §9.17's vanishing-brief failure. The + // bare form is the command; anything after it is refused with the space + // escape named, which costs nothing. + {verb: "/retry", bare: true, run: (*Model).retryCommand}, {verb: "/seat", run: (*Model).seatCommand}, {verb: "/trace", run: (*Model).traceCommand}, {verb: "/unseat", run: (*Model).unseatCommand}, @@ -880,6 +887,135 @@ func (m *Model) applyPosture(write bool) { } } +// retryCommand puts the last dispatched brief back in the composer, addressed +// to the seats that did not answer it. +// +// IT DOES NOT DISPATCH, and that is the shape of the control rather than a +// caution. The seats a re-send would bill are read off a turn that is already +// over, so the operator can see the bill before paying it: setDraft re-derives +// the route from the draft this builds, and the footer prices it through the +// same State.SeatsIn arithmetic dispatch itself gates on (§9.21). Enter sends +// it, the way enter sends any other brief. A verb that spawned on the spot +// would spend up to five quotas on a keystroke that named none of them, and the +// room would have no surface on which to say which ones. +// +// THE DRAFT IS A MENTION LIST AND A BRIEF, which is why this adds no grammar. +// "@codex @agy " is what a user types by hand, so ParseRoute reads it, +// the footer renders it, and dispatch intersects it with the roster exactly as +// it does for typed text. The draft is also editable, because it is an ordinary +// draft — the operator can drop a seat off the front before pressing enter. +// +// WHAT COUNTS AS AN ANSWER is defined against the four endings §9.37's +// 2026-08-17 amendment names, and it is narrower than "the column looks empty": +// +// - PhaseDone ANSWERED. That includes the seat whose body reads "[Turn +// completed with 0 text chunks streamed]": it is a measured zero, not a +// missing reply (§4a.1), and re-sending on it would be the room overruling a +// vendor's honest empty answer. +// - PhaseFailed and PhaseCancelled did not answer. Cancelled covers ctrl+c and +// the per-seat give-up (`x`) together, deliberately: a seat the operator cut +// is the case a re-send exists for, and after the turn the room holds one +// cancelled phase for both. +// - A seat that SAT THE TURN OUT is not a candidate at all. dispatch never +// calls startTurn on it, so its Column.TurnN still names the last turn it +// took and the scan below skips it. That is load-bearing: without it, a +// /retry after an "@codex" turn would widen the bill to four seats the +// operator deliberately did not address. +// +// It re-sends the brief UNCHANGED and does nothing else. No re-briefing, no +// edit, no automatic second attempt. That is what keeps §9.17's `--brief` +// question unfiled: `--brief` is first-turn context by definition, re-briefing +// is a separate feature to be decided on its own, and this verb never reads +// Model.brief. +// +// AFTER A RACE it re-sends the race's brief as an ORDINARY turn. Column.Prompt +// holds the brief the racers were given and never the "/arena" draft that +// wrapped it, and this verb invents no grammar to put the wrapper back. The +// composer shows exactly what will be sent before enter, which is where the +// operator reads that the worktrees are not part of it; "/arena " races +// again. +// +// THREE REFUSALS, THREE SENTENCES, and only the first keeps the draft. A turn in +// flight: the phases this verb reads are not settled, so any list it produced +// would be a claim about a turn that has not ended — and the operator still +// wants the verb one turn later, which is postureCommand's own reason for +// holding the draft. No brief on record: turn 0 and the degenerate turn whose +// brief sanitized away to nothing are one sentence, because they are one fact. +// Every seat answered: there is nothing to re-send, and saying so is better than +// a composer the operator has to clear by hand. +func (m *Model) retryCommand(_ string) bool { + if m.turn != nil { + m.st.Notice = "a turn is in flight — /retry re-sends between turns" + return true + } + + brief, seats := m.lastTurnUnanswered() + if brief == "" { + m.st.Notice = "no brief to re-send — /retry sends the last one again to the seats that did not answer it" + m.setDraft("") + return true + } + if len(seats) == 0 { + m.st.Notice = "every seat answered turn " + itoa(m.st.Turn) + " — /retry has nothing to re-send" + m.setDraft("") + return true + } + + var draft strings.Builder + names := make([]string, 0, len(seats)) + for _, v := range seats { + draft.WriteString("@" + string(v) + " ") + names = append(names, string(v)) + } + draft.WriteString(brief) + m.setDraft(draft.String()) + // ", " joins the names, because Route.label joins them that way and the + // footer is about to print the same set one line below this notice. Two + // spellings of one list would read as two lists. + m.st.Notice = strings.Join(names, ", ") + " did not answer turn " + itoa(m.st.Turn) + + " — enter re-sends that brief to them, and to no other seat" + return true +} + +// lastTurnUnanswered reads the previous turn off the columns: the brief it +// carried, and the seats it left without an answer. +// +// THE COLUMNS ARE THE RECORD HERE, not Column.History, and that is not a +// shortcut. startTurn files a turn into History when the NEXT one is dispatched, +// so between turns the finished turn is still the live one on the column; +// reading History would answer about the turn before last. +// +// The brief is taken from the first column that took the turn, and any of them +// would do — dispatch sanitizes ONE echo for the whole turn and hands that same +// string to every seat it starts. It is read WITHOUT the seating filter, so a +// seat unseated since the turn can still supply the text it was asked. The seat +// list applies the filter, because a mention of an unseated seat prices nothing: +// State.SeatsIn intersects the route with the roster, and dispatch drops the +// same seat for the same reason. +// +// An empty brief with seats behind it is possible and is left to the caller: a +// draft of nothing but control characters passes dispatch's own empty check and +// sanitizes to "", so the turn is on record with no text to re-send. +func (m *Model) lastTurnUnanswered() (brief string, seats []model.VendorID) { + last := m.st.Turn + if last == 0 { + return "", nil + } + for _, c := range m.st.Columns { + if c.TurnN != last { + continue + } + if brief == "" { + brief = c.Prompt + } + if c.Phase == PhaseDone || !m.st.seats(c) { + continue + } + seats = append(seats, c.Vendor) + } + return brief, seats +} + // plural is the one-word difference between "1 turn" and "2 turns". func plural(n int, word string) string { if n == 1 { diff --git a/internal/council/roomcmd_test.go b/internal/council/roomcmd_test.go index acd302a..b762cc6 100644 --- a/internal/council/roomcmd_test.go +++ b/internal/council/roomcmd_test.go @@ -399,6 +399,279 @@ func TestRunRejectsAMissingCdDirectory(t *testing.T) { } } +// --- /retry: the last brief, sent again to the seats that owe an answer ----- +// +// Every assertion below is about a COST: what the composer is about to bill, +// and what actually spawned. The verb's whole shape is that it arms and does +// not dispatch, so "nothing spawned" is the claim on the arming half and "only +// these seats spawned" is the claim on the sending half. + +// retryRoom is a four-seat room whose last turn is OVER, carrying one column +// for each of the four ways a seat ends a turn (§9.37's 2026-08-17 amendment): +// answered, failed, given up, and sat out. +// +// The sat-out seat keeps the turn number of the last turn it TOOK, because that +// is what dispatch leaves behind for a seat it never started — startTurn is not +// called on it. A fixture that stamped turn 3 on it would be testing a state +// the room cannot produce. +func retryRoom(t *testing.T) *Model { + t.Helper() + m := flowRoom(t, true) + m.st.Turn = 3 + const brief = "the brief that half landed" + for i := range m.st.Columns { + c := &m.st.Columns[i] + switch c.Vendor { + case model.VendorClaude: + c.TurnN, c.Phase, c.Prompt, c.Body = 3, PhaseDone, brief, "an answer" + case model.VendorCodex: + c.TurnN, c.Phase, c.Prompt = 3, PhaseFailed, brief + c.Note = "exit status 1" + case model.VendorAntigravity: + c.TurnN, c.Phase, c.Prompt = 3, PhaseCancelled, brief + c.Note = "given up after 4m12s — nothing had arrived, and its process is dead" + case model.VendorCursor: + c.TurnN, c.Phase, c.Prompt, c.Skipped = 2, PhaseDone, "an earlier brief", true + c.Note = "not addressed in turn 3" + } + } + return m +} + +// TestRetryAddressesOnlyTheSeatsThatDidNotAnswer is the verb's contract in one +// test: the brief comes back unchanged, the mentions name the failed and the +// given-up seat, and the two that have nothing to re-send — the one that +// answered and the one that sat the turn out — are not billed. +func TestRetryAddressesOnlyTheSeatsThatDidNotAnswer(t *testing.T) { + log := countSpawns(t) + m := retryRoom(t) + m.setDraft("/retry") + + if !m.roomCommand() { + t.Fatal("/retry was not recognised as a room command") + } + if log.n() != 0 { + t.Fatalf("/retry spawned %d process(es) — it arms the composer, it does not dispatch", log.n()) + } + if m.st.Turn != 3 { + t.Errorf("/retry counted itself as a turn: %d", m.st.Turn) + } + if want := "@codex @agy the brief that half landed"; m.st.Draft != want { + t.Errorf("draft = %q, want %q", m.st.Draft, want) + } + // The bill the footer is about to print, read through the arithmetic + // dispatch itself gates on rather than by counting the words in the draft. + if n := m.st.SeatsIn(m.st.Route); n != 2 { + t.Errorf("the route prices %d seats, want the 2 that did not answer", n) + } + for _, v := range []model.VendorID{model.VendorClaude, model.VendorCursor} { + if m.st.Route.addresses(v) { + t.Errorf("%s has no answer owing and the re-send addresses it anyway", v) + } + } + if !strings.Contains(m.st.Notice, "codex, agy") || !strings.Contains(m.st.Notice, "turn 3") { + t.Errorf("the notice does not say who owes an answer, and for which turn: %q", m.st.Notice) + } +} + +// TestAMeasuredZeroCountsAsAnAnswer. A seat that finished and streamed nothing +// says so — "[Turn completed with 0 text chunks streamed]" under `done` — and +// that is a MEASURED zero, not a missing reply (§4a.1). Re-sending on it would +// be the room overruling a vendor's honest empty answer, and it would bill a +// seat that already did the work. +func TestAMeasuredZeroCountsAsAnAnswer(t *testing.T) { + m := retryRoom(t) + for i := range m.st.Columns { + if m.st.Columns[i].Vendor == model.VendorClaude { + m.st.Columns[i].Body = "[Turn completed with 0 text chunks streamed]" + } + } + m.setDraft("/retry") + m.roomCommand() + + if m.st.Route.addresses(model.VendorClaude) { + t.Errorf("a measured empty answer was re-sent as though the seat had not answered: %q", m.st.Draft) + } +} + +// TestRetryRefusesWithNoBriefOnRecord: turn 0. Nothing has been dispatched, so +// there is nothing to send again, and the refusal says which of the two it is +// rather than arming an empty composer. +func TestRetryRefusesWithNoBriefOnRecord(t *testing.T) { + m := flowRoom(t, true) + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "no brief to re-send") { + t.Errorf("the refusal does not say what is missing: %q", m.st.Notice) + } + if m.st.Draft != "" { + t.Errorf("a refusal that only reports left the verb in the composer: %q", m.st.Draft) + } + if m.st.Route.addresses(model.VendorCodex) { + t.Error("a refused /retry routed a draft anyway") + } +} + +// TestRetryRefusesWhenEverySeatAnswered. The other end of the same question: +// the turn is on record and every seat in it replied, so a re-send would bill +// the room for answers it already has. +func TestRetryRefusesWhenEverySeatAnswered(t *testing.T) { + m := retryRoom(t) + for i := range m.st.Columns { + if m.st.Columns[i].TurnN == 3 { + m.st.Columns[i].Phase = PhaseDone + } + } + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "every seat answered turn 3") { + t.Errorf("the refusal does not say why there is nothing to send: %q", m.st.Notice) + } + if m.st.Draft != "" { + t.Errorf("a refusal that only reports left the verb in the composer: %q", m.st.Draft) + } +} + +// TestRetryRefusedMidTurnKeepsTheDraft. The phases this verb reads are not +// settled while a turn runs, so it refuses — and it KEEPS the draft, which is +// postureCommand's rule: the command is still what the operator wants, one turn +// later. +func TestRetryRefusedMidTurnKeepsTheDraft(t *testing.T) { + m := retryRoom(t) + m.turn = &turnState{cancel: func() {}, live: map[model.VendorID]bool{}} + m.setDraft("/retry") + m.roomCommand() + + if !strings.Contains(m.st.Notice, "in flight") { + t.Errorf("the refusal does not say why: %q", m.st.Notice) + } + if m.st.Draft != "/retry" { + t.Errorf("the draft was thrown away on a refusal the next turn undoes: %q", m.st.Draft) + } +} + +// TestRetryIsOnlyABareCommand is §9.17's bare-word rule reaching this verb: +// "/retry the failing test" is a brief, and a verb that took it as an argument +// would run a re-send and discard the sentence the operator typed. It is +// refused instead, which costs nothing and names the escape. +func TestRetryIsOnlyABareCommand(t *testing.T) { + log := countSpawns(t) + m := retryRoom(t) + m.setDraft("/retry the failing test") + + if !m.roomCommand() { + t.Fatal("a slash-leading draft was neither run nor refused") + } + if !strings.Contains(m.st.Notice, "no room command") { + t.Errorf("/retry took an argument it does not have: %q", m.st.Notice) + } + if m.st.Draft != "/retry the failing test" { + t.Errorf("the refused draft was rewritten: %q", m.st.Draft) + } + if log.n() != 0 { + t.Errorf("a refused draft spawned %d process(es)", log.n()) + } +} + +// TestRetryIsNamedInTheWalkedCommandTable. The refusal reads roomVerbs and +// §9.31's rule is that the table is walked rather than copied into a string — +// so the claim here is registration: a verb missing from that table is a verb +// the room refuses, and one present in it is taught by the refusal for free. +func TestRetryIsNamedInTheWalkedCommandTable(t *testing.T) { + registered := false + for _, rc := range roomVerbs() { + if rc.verb == "/retry" { + registered = true + } + } + if !registered { + t.Fatal("/retry is not in roomVerbs, so the room refuses its own word") + } + + m := flowRoom(t, true) + m.setDraft("/nosuchverb") + m.roomCommand() + if !strings.Contains(m.st.Notice, "/retry") { + t.Errorf("the refusal does not teach /retry: %q", m.st.Notice) + } +} + +// TestTheReSendSpawnsOnlyForTheSeatsThatOweAnAnswer is the dispatch-level half, +// asserted on what SPAWNED rather than on the draft the verb built. +// +// The fixture derives the two sets rather than naming vendors: which seats the +// registry drives as one-shot processes and which as long-lived ones is not this +// test's claim, and hardcoding it would make a registry change look like a +// /retry bug. One batch seat is cut with `x`, the rest fail, and every +// persistent seat answers — so the seats owing an answer are exactly the batch +// seats, and exactly they should spawn. +// +// No vendor is started: countSpawns stubs all three spawn vars, per CLAUDE.md's +// council-test rule. +func TestTheReSendSpawnsOnlyForTheSeatsThatOweAnAnswer(t *testing.T) { + m, oneShots, live := ordinaryTurn(t) + + cut := oneOf(t, oneShots, "batch seat") + focusSeatOn(t, m, cut) + m.key(key("x")) + m.key(key("y")) + for v := range oneShots { + if v == cut { + continue + } + m.applyEvents([]runner.Event{{ + Vendor: v, Kind: runner.KindError, Note: "exit status 1", ExitCode: 1, + }}) + } + for v := range live { + m.applyEvents([]runner.Event{{ + Vendor: v, Kind: runner.KindMeta, EndsTurn: true, Text: "an answer", + }}) + } + if m.turn != nil { + t.Fatal("fixture: the turn never ended, so there is no finished turn to re-send") + } + + // Counted from here, so the first turn's own spawns are not in the number. + log := countSpawns(t) + m.setDraft("/retry") + enter(m) + if log.n() != 0 { + t.Fatalf("/retry spawned %d process(es) before enter was pressed", log.n()) + } + enter(m) + + if log.n() != len(oneShots) { + t.Fatalf("the re-send spawned %d process(es), want %d — one per seat owing an answer: %+v", + log.n(), len(oneShots), log.specs) + } + for _, spec := range log.specs { + if _, owed := oneShots[spec.Vendor]; !owed { + t.Errorf("the re-send spawned %s, which already answered turn 1", spec.Vendor) + } + } + for v := range live { + c := m.column(v) + if c.TurnN != 1 || !c.Skipped { + t.Errorf("%s answered turn 1 and was billed again: turn %d, skipped %v", v, c.TurnN, c.Skipped) + } + } + for v := range oneShots { + if c := m.column(v); c.TurnN != 2 { + t.Errorf("%s owed an answer and the re-send missed it: turn %d", v, c.TurnN) + } + } + // The text is the same brief, unchanged — the property that keeps this verb + // clear of §9.17's unfiled re-briefing question. + for v := range oneShots { + if got := m.column(v).Prompt; got != "an ordinary brief" { + t.Errorf("%s was re-sent %q, not the brief it was given", v, got) + } + } +} + // TestAnUnmovedRoomKeepsItsProcess is the other side: seatProcess must not // respawn a seat whose directory still matches — that would pay a session // init per turn and undo the sixth amendment. diff --git a/internal/council/testdata/golden/slash-refusal-ascii.txt b/internal/council/testdata/golden/slash-refusal-ascii.txt index 846ae83..0839552 100644 --- a/internal/council/testdata/golden/slash-refusal-ascii.txt +++ b/internal/council/testdata/golden/slash-refusal-ascii.txt @@ -21,4 +21,4 @@ +------------------------------------------------------------------------------------------------------------------+ | : /unseet codex_ | +------------------------------------------------------------------------------------------------------ COMPOSE -+ - ! no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /seat /trace /unseat /write \ No newline at end of file + ! no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /retry /seat /trace /unseat /write \ No newline at end of file diff --git a/internal/council/testdata/golden/slash-refusal.txt b/internal/council/testdata/golden/slash-refusal.txt index 66c07d4..c37f52a 100644 --- a/internal/council/testdata/golden/slash-refusal.txt +++ b/internal/council/testdata/golden/slash-refusal.txt @@ -21,4 +21,4 @@ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › /unseet codex_ │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────── COMPOSE ─╯ - ⚠ no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /seat /trace /unseat /write \ No newline at end of file + ⚠ no room command /unseet, a leading space sends · /adopt /arena /cd /flow /read /retry /seat /trace /unseat /write \ No newline at end of file