Files
boxmaker/docs/plans/M5c1/04-bxctl-speaker.md
T
kyleandClaude Opus 5.5 5454d7ea1b M5c-1 plan: task 04, bxctl chat --speaker NAME, opt-in, no default
The speaker goes on every turn of the chat: interactive, --say and --say's retry. Without the
flag it is None. A name that is empty or holds whitespace or a control character (Unicode), or a
second --speaker, is a usage error: the usage text on stderr and exit status 2. The usage text
gains the flag and one line explaining it. Given: chat_speaker.rs (new) and new versions of
chat.rs, chat_approvals.rs and cli.rs. Task 01 now copies chat.rs-task01, the older chat.rs.

Checks: every before block found once at ref 03; replayed from the task text on a fresh ref-03
worktree with its own build directory, byte-identical to the reference, gate ok with 1158 tests
(1148 + 10); /tmp unchanged around the reference's and the replay's gate runs; 20 of 20
mutations caught; the new tests passed 40 of 40 runs under CPU load; given files pass fmt and
the 500-line check; no chat-template tag in the plan or the given files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 01:04:27 -07:00

419 lines
17 KiB
Markdown

# M5c-1 task 04: `bxctl chat --speaker NAME`
**Branch:** `m5c1` (run `git switch m5c1`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `bxctl: chat --speaker NAME, the speaker on every turn of the chat`
**Log row:** the Task cell is `M5c1/04-bxctl-speaker`.
## Goal
Task 01 gave `Turn` a field `speaker: Option<String>`, task 02 made `loopd` log it, and task 03
made `gatewayd` set it. `memoryd` will send a turn to Honcho only when it has a speaker (M5c spec,
sections 4 and 11). `bxctl chat`, the owner's console, still sends `speaker: None` on every turn.
This task gives it a flag, `--speaker NAME`.
The owner's rule: **the speaker is opt-in, with no default.** Console turns are mostly tests, and
Honcho cannot forget a turn once it has it. So:
| The command line | `Turn.speaker` on the chat's turns |
|---|---|
| `bxctl chat --speaker kyle` (interactive) | `Some("kyle")` on **every** turn, not only the first |
| `bxctl chat --speaker kyle --say hi` | `Some("kyle")`, also on the turn sent again when a named session did not exist |
| `bxctl chat --speaker first.last` | `Some("first.last")`: the name as typed, a `.` is **not** changed |
| `bxctl chat` or `bxctl chat --say hi`, no flag | `None`, as today |
No default, no environment variable, no configuration: only the flag.
**The name's rule, exactly.** `bxctl` refuses only what cannot be a name at all. The value is
refused when it is **empty**, or when **any** character in it is whitespace
(`char::is_whitespace`, which includes the tab, the line feed, the no-break space U+00A0 and the
other Unicode spaces) or a control character (`char::is_control`: U+0000 to U+001F, U+007F and
U+0080 to U+009F). Anything else is kept exactly as typed: letters outside ASCII, `.`, a leading
`-`. `memoryd` checks the name against Honcho's own pattern in a later task. A second `--speaker`
is refused too, even with the same name: one of two names would be the wrong person.
**A refused value is a usage error**, like every other bad argument of `bxctl`: `parse` returns
`UsageError`, and `main` (unchanged) prints the usage text on stderr and exits with status 2,
before it connects to anything. So nothing is sent.
**The usage text** gains the flag at the end of the chat line and one line under "Talk to loopd."
that says what it does. Every explanation line of the usage text is indented by exactly six
spaces, every command line by exactly two; a given test checks every line.
## Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
| `crates/bxctl/src/cli.rs`, `USAGE` | the flag in the chat line, one line explaining it (edit C1) |
| `crates/bxctl/src/cli.rs`, `ChatOptions` and `ChatOptions::default` | new field `speaker`, `None` by default (edits C2, C3) |
| `crates/bxctl/src/cli.rs`, `parse_chat`, and a new `parse_speaker` | the flag, its check, a second one refused (edits C4, C5) |
| `crates/bxctl/src/chat.rs`, `run_turn` | a new parameter `speaker: Option<&str>`, after `resume`, put on the `Turn` (edits R1, R2) |
| `crates/bxctl/src/chat.rs`, `stream_turn` | the same parameter, passed to `run_turn` (edit R3) |
| `crates/bxctl/src/main.rs`, `run_turn_twice` and `run_interactive` | pass `opts.speaker.as_deref()` at all three calls of `stream_turn` (edits M1 to M3) |
| `crates/bxctl/src/main.rs`, `main` | **no change**: a `UsageError` already prints the usage text and exits 2 |
| tests that build a `ChatOptions` or call `run_turn` or `stream_turn` | new versions are given |
## Files
- Copy, from `docs/plans/M5c1/files/` (the same path under it):
- new: `crates/bxctl/tests/chat_speaker.rs`
- **new versions** of (they replace the files in the tree): `crates/bxctl/tests/chat.rs`,
`chat_approvals.rs` and `cli.rs`. Do not copy `chat.rs-task01`: it is the older version.
- Modify: `crates/bxctl/src/cli.rs`, `crates/bxctl/src/chat.rs`, `crates/bxctl/src/main.rs`,
`docs/implementer-log.md`.
- Do not touch: any other source file (`main`'s handling of a usage error in particular), any
file of another crate, any `Cargo.toml`, `crates/bxctl/tests/support/`.
## Facts you need
- **How `parse_chat` reads a flag with a value.** Its loop matches `args[i]`. An arm like
`"--say" => opts.say = Some(value_at(args, i)?)` takes the value, and the `i += 2` after the
`match` steps over the flag and its value. `value_at(args, i)` is `args[i + 1]`, or `UsageError`
when there is none; it takes the next argument even when it looks like a flag, so
`--speaker --json` is the name `--json` (a given test says so). The new arm is a block, because
it first refuses a second `--speaker`, and it falls through to the same `i += 2`: no `continue`.
- **Why the parameter is `Option<&str>`.** `ChatOptions` owns the name as `Option<String>`;
`opts.speaker.as_deref()` lends it as `Option<&str>`, and `run_turn` makes the `Turn`'s own
copy with `speaker.map(str::to_string)`, as `loopd` does since task 02.
- **The three calls in `main.rs`.** `run_turn_twice` (the `--say` path) calls `stream_turn`
twice: once, and again with `resume` false when `loopd` answers that the named session does not
exist. `run_interactive` calls it once per line. All three pass the speaker.
- **Seven arguments.** `stream_turn` goes from six parameters to seven. Clippy's
`too_many_arguments` fires only above seven, so there is no warning and no `#[allow]`.
- **The usage line is long.** The chat line of `USAGE` is already over 100 characters, inside a
string; `cargo fmt` does not touch a string's contents, and the line-length check of the gate
counts lines per file, not their width. Copy C1 exactly: its spaces are checked.
- **The given tests' processes and paths.** `chat_speaker.rs` starts `bxctl` with a 20-second
deadline, and every socket and directory it makes is removed by a guard (`support/temp.rs`).
- The calls the edits use:
| Call | Signature, and what it gives |
|---|---|
| `value_at` | `fn value_at(args: &[String], i: usize) -> Result<String, UsageError>` (exists) |
| `parse_speaker` | `fn parse_speaker(value: &str) -> Result<String, UsageError>` (new, C5) |
| `str::is_empty` | `value.is_empty() -> bool` |
| `str::chars`, `Iterator::any` | `value.chars().any(\|c\| …) -> bool`: true when one character matches |
| `char::is_whitespace`, `char::is_control` | `c.is_whitespace() -> bool`, `c.is_control() -> bool` (Unicode, not only ASCII) |
| `Option::as_deref` | `opts.speaker.as_deref()`: `Option<String>` to `Option<&str>` |
| `Option::map` | `speaker.map(str::to_string)`: `Option<&str>` to `Option<String>` |
| `run_turn` | `pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, speaker: Option<&str>, on_event: &mut dyn FnMut(&TurnEvent)) -> Result<TurnDone, ChatError>` (R1) |
| `stream_turn` | `pub fn stream_turn(socket: &Path, session: &SessionId, text: &str, resume: bool, speaker: Option<&str>, approvals: &Approvals<'_>, io: &mut TurnIo<'_>) -> std::io::Result<Result<TurnDone, ChatError>>` (R3) |
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
### `crates/bxctl/src/cli.rs`
**C1.** In `USAGE`, the lines
```rust
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json]
Talk to loopd.
```
become
```rust
bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json] [--speaker NAME]
Talk to loopd.
With --speaker NAME (no spaces), the turns are NAME's and are synced to Honcho as that person.
```
The chat line starts with two spaces; each of the two lines under it with six.
**C2.** In `struct ChatOptions`, the lines
```rust
pub say: Option<String>,
pub json: bool,
}
```
become
```rust
pub say: Option<String>,
pub json: bool,
/// Who is typing, from `--speaker`, sent on every turn (M5c spec, section 11). There is no
/// default: without the flag it is `None`, and a turn without a speaker is not synced.
pub speaker: Option<String>,
}
```
**C3.** In `ChatOptions::default`, the lines
```rust
say: None,
json: false,
}
```
become
```rust
say: None,
json: false,
speaker: None,
}
```
**C4.** In `parse_chat`, the lines
```rust
"--say" => opts.say = Some(value_at(args, i)?),
"--no-thinking" => {
```
become
```rust
"--say" => opts.say = Some(value_at(args, i)?),
// Given twice, it is refused rather than one of them chosen: the turns would be synced
// as the wrong person.
"--speaker" => {
if opts.speaker.is_some() {
return Err(UsageError);
}
opts.speaker = Some(parse_speaker(&value_at(args, i)?)?);
}
"--no-thinking" => {
```
**C5.** The line
```rust
// A refuse reason must be present; its content is unvalidated on the client.
```
becomes
```rust
// A speaker must be a name: not empty, and no whitespace or control character anywhere. The rest,
// Honcho's pattern, is `memoryd`'s to check (M5c spec, section 3); a `.` is kept as it is.
fn parse_speaker(value: &str) -> Result<String, UsageError> {
if value.is_empty() || value.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(UsageError);
}
Ok(value.to_string())
}
// A refuse reason must be present; its content is unvalidated on the client.
```
### `crates/bxctl/src/chat.rs`
**R1.** The lines
```rust
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives.
pub fn run_turn(
socket: &Path,
session: &SessionId,
content: &str,
resume: bool,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {
```
become
```rust
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives. `speaker`
/// is `--speaker`'s value, sent as it is; `None` sends none.
pub fn run_turn(
socket: &Path,
session: &SessionId,
content: &str,
resume: bool,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Result<TurnDone, ChatError> {
```
**R2.** In `run_turn`, the lines
```rust
// Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker.
speaker: None,
```
become
```rust
speaker: speaker.map(str::to_string),
```
**R3.** In `stream_turn`, the lines
```rust
resume: bool,
approvals: &Approvals<'_>,
io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, &mut |event| {
```
become
```rust
resume: bool,
speaker: Option<&str>,
approvals: &Approvals<'_>,
io: &mut TurnIo<'_>,
) -> std::io::Result<Result<TurnDone, ChatError>> {
let mut err = None;
let outcome = run_turn(socket, session, text, resume, speaker, &mut |event| {
```
### `crates/bxctl/src/main.rs`
**M1.** In `run_turn_twice`, the line
```rust
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
```
becomes
```rust
match stream_turn(
&opts.socket,
session,
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
**M2.** In `run_turn_twice`, the line
```rust
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
```
becomes
```rust
match stream_turn(
&opts.socket,
session,
text,
false,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
(M1 and M2 are how `cargo fmt` lays the calls out once they have seven arguments.)
**M3.** In `run_interactive`, the lines
```rust
text,
resume,
&approvals,
&mut io,
) {
```
become
```rust
text,
resume,
opts.speaker.as_deref(),
&approvals,
&mut io,
) {
```
## Steps
- [ ] **1. Copy the given files.** From the repository root, four commands:
```sh
cp docs/plans/M5c1/files/crates/bxctl/tests/chat_speaker.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/chat_approvals.rs crates/bxctl/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/cli.rs crates/bxctl/tests/
```
Then `git status --short` lists 3 modified files and 1 new one, all under
`crates/bxctl/tests/`.
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p bxctl`: four test targets do not
compile: `chat`, `chat_approvals`, `chat_speaker` and `cli`. Every error is about a name this
task adds or changes: `E0061` (``this function takes 5 arguments but 6 arguments were
supplied``, for `run_turn`; ``this function takes 6 arguments but 7 arguments were supplied``,
for `stream_turn`), `E0282` (``type annotations needed``, once, in `chat.rs`, on the closure
passed to `run_turn`), `E0560` (``struct `ChatOptions` has no field named `speaker` ``) or
`E0609` (``no field `speaker` on type `ChatOptions` ``).
- [ ] **3. `cli.rs`.** Make edits C1 to C5. `cargo check -p bxctl`: no error. Then
`cargo test -p bxctl --test cli`: 15 passed.
- [ ] **4. `chat.rs`.** Make edits R1, R2 and R3. `cargo check -p bxctl` now fails in `main.rs`
only, with three errors, one at each call of `stream_turn`: `E0061` (``this function takes 7
arguments but 6 arguments were supplied``). The notes under them also point into `chat.rs`, at
`stream_turn`'s definition; that is where the call is compared, not a second error.
- [ ] **5. `main.rs`.** Make edits M1, M2 and M3. `cargo check -p bxctl`: no error and no
warning.
- [ ] **6. See them pass.** `cargo test -p bxctl --test chat_speaker`: 7 passed. Then
`cargo test -p bxctl`: every suite passes. The new tests:
| Test | What it checks |
|---|---|
| `chat_speaker_is_the_value_after_the_flag` (`cli.rs`) | C4, C5: the name as typed (`first.last`, a name outside ASCII, `--json`) |
| `a_bad_speaker_is_a_usage_error` (`cli.rs`) | C4, C5: empty, whitespace or a control character anywhere, no value, a second `--speaker` |
| `usage_explains_the_speaker_flag` (`cli.rs`) | C1: the chat lines exactly, and the indentation of every line |
| `run_turn_puts_the_speaker_on_the_turn` | R1, R2: `Some` and `None` on the `Turn` |
| `stream_turn_passes_the_speaker_on` | R3 |
| `say_sends_the_speaker_as_it_was_typed` | M1 end to end: `first.last`, and the text unchanged |
| `without_the_flag_no_turn_has_a_speaker` | C3: `--say` and interactive, `None` on every turn |
| `the_retried_turn_keeps_the_speaker` | M2: both turns of a `--say` on a session that does not exist |
| `every_interactive_turn_has_the_speaker` | M3: four turns, one of them failed, all with the speaker |
| `a_bad_speaker_is_a_usage_error_and_sends_nothing` | the binary: status 2, the usage text on stderr, nothing on stdout, no turn sent |
`chat_defaults_come_from_home` and `chat_takes_every_flag_in_any_order` in `cli.rs` also check
`speaker`: `None` by default, and the flag among all the others.
- [ ] **7. Check.** Three commands:
- `grep -rn 'speaker: None' crates/bxctl/src` prints one line, the default in
`ChatOptions::default` (C3): the placeholder in `chat.rs` is gone.
- `git diff --stat -- crates/bxctl/src` lists exactly `chat.rs`, `cli.rs` and `main.rs`.
- `git diff --stat -- crates/bxcore crates/loopd crates/memoryd crates/brokerd crates/gatewayd`
prints nothing.
- [ ] **8. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`, with 1158 tests: 1148 before this task, plus 10 new ones (three
in `cli.rs`, seven in `chat_speaker.rs`; the new versions of `chat.rs` and `chat_approvals.rs`
keep their counts).
- [ ] **9. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5c1/04-bxctl-speaker`. `git status --short` lists the three sources, the three replaced test
files and your log as modified, and `chat_speaker.rs` as new. Stage with
`git add crates/bxctl docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
`Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1158 tests.
- `git diff HEAD~1 --stat` lists only the three source files of "Modify", the four copied files
and `docs/implementer-log.md`.
## Stop and report if
- A "before" block of any edit is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: other test targets, or an error of another kind.
- Step 3's `cargo check` fails, or step 4's fails anywhere other than the three calls of
`stream_turn` in `main.rs`, or with other errors.
- After step 3 or 6, a test fails. Do not change a test: stop, and give the failing tests' names
and their messages.
- The fix seems to need a change anywhere else: in `main`, in another crate, a default speaker, a
speaker from the environment or a configuration, a change to the name (such as `.` to `_`), or
a new kind of error with its own message. It does not; stop and say why you think so.
- A test in `inferproxy`, `toolkit` or `memoryd` fails in the gate: check once whether it fails
without your change, then stop and report both results.
- The gate passes with a count other than 1158.
- A comment in an edit does not fit the code under it. Quote it and stop.