feat: add interactive recipient composer
Some checks failed
tamq-ci / test (push) Failing after 6s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
tegwick 2026-08-24 23:24:18 +02:00
parent d7d92502b4
commit 8186550c9a
10 changed files with 379 additions and 33 deletions

View file

@ -77,17 +77,27 @@ The spelling without the trailing colon is equivalent:
@audit-core please review the auth boundary @audit-core please review the auth boundary
``` ```
After receiving a message, use the bare `@` command to answer its sender: Run bare `@` to open the inline composer. It shows the latest sender as the
recipient, and ordinary prose is read directly rather than parsed by the shell:
```bash ```text
@ Thanks, I will take a look. $ @
@audit-core: What's up?
``` ```
Bare `@` selects the sender of the latest durable inbound message for the Press Tab to cycle through the other repository windows while preserving the
current repository, including an already acknowledged message. Self-addressed draft, Enter to send, or Ctrl-C to cancel without queuing. The initial recipient
messages are ignored. If this window has no prior counterparty, the command is the sender of the latest durable inbound message, including an already
reports an error and queues nothing. The explicit equivalent is `tamq reply acknowledged message; without history, it is the first peer window.
MESSAGE...`. Self-addressed messages are ignored.
The one-line fast path remains available, but its text is parsed by the shell
and therefore follows ordinary shell quoting rules:
```bash
@ "What's up?"
tamq reply "What's up?"
```
These are tamq-owned executable commands beside the installed `tamq` command, These are tamq-owned executable commands beside the installed `tamq` command,
not shell-specific aliases. Set `TAMQ_COMMAND_DIR` before startup to select a not shell-specific aliases. Set `TAMQ_COMMAND_DIR` before startup to select a

View file

@ -18,7 +18,7 @@ tamq does not choose or infer them.
- Managed neutral-shell tmux lifecycle and explicit initial commands. - Managed neutral-shell tmux lifecycle and explicit initial commands.
- Durable manual send/inbox/acknowledgement with per-window repository identity, - Durable manual send/inbox/acknowledgement with per-window repository identity,
shell-native direct and latest-counterparty reply commands, comment-safe shell-native direct and latest-counterparty reply commands, comment-safe
display, and explicit pull-time filters. display, a recipient-aware inline composer, and explicit pull-time filters.
- Sanitized one-time output notifications through target tmux pane PTYs, with - Sanitized one-time output notifications through target tmux pane PTYs, with
inbox-only delivery as an explicit option and no foreground-process input. inbox-only delivery as an explicit option and no foreground-process input.
- Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY - Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
@ -48,7 +48,7 @@ transport.
| Intent capability | State | Evidence and remaining gap | | Intent capability | State | Evidence and remaining gap |
| --- | --- | --- | | --- | --- | --- |
| Direct repository addressing | Implemented for local alpha | Exact `gita` validation, per-session `@repo`/`@repo:` commands, bare `@` latest-counterparty replies, and long-form parsing are covered without modifying shell configuration. | | Direct repository addressing | Implemented for local alpha | Exact `gita` validation, per-session `@repo`/`@repo:` commands, a bare `@` composer with latest-counterparty default and Tab recipient cycling, and long-form parsing are covered without modifying shell configuration. |
| Durable, inspectable local queue | Implemented | SQLite history, manual inbox, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. | | Durable, inspectable local queue | Implemented | SQLite history, manual inbox, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. |
| Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. | | Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. |
| Neutral tmux session lifecycle | Implemented for local alpha | Repository-first startup opens untouched shells at exact gita paths, exports per-window identity, and runs no initial command unless `--command` is explicit. Stable reuse, service restart, and cleanup are covered by the installed-package test. | | Neutral tmux session lifecycle | Implemented for local alpha | Repository-first startup opens untouched shells at exact gita paths, exports per-window identity, and runs no initial command unless `--command` is explicit. Stable reuse, service restart, and cleanup are covered by the installed-package test. |
@ -87,7 +87,7 @@ Not yet suitable:
and stronger process-supervision evidence. and stronger process-supervision evidence.
- Cross-host messaging or use as a general-purpose broker. - Cross-host messaging or use as a general-purpose broker.
The suite currently has 100 passing tests and 76% statement coverage. Coverage The suite currently has 109 passing tests and 76% statement coverage. Coverage
is strongest in durable storage and registry handling, and weakest in the PTY is strongest in durable storage and registry handling, and weakest in the PTY
tap and CLI orchestration; PTY statement coverage increased from 23% to 33%, tap and CLI orchestration; PTY statement coverage increased from 23% to 33%,
while subprocess behavior is primarily proven by the real-tmux test. The while subprocess behavior is primarily proven by the real-tmux test. The

View file

@ -17,7 +17,7 @@
| workplan | TAMQ-WP-0006 | finished | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | | workplan | TAMQ-WP-0006 | finished | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md |
| workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md |
| workplan | TAMQ-WP-0008 | finished | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | | workplan | TAMQ-WP-0008 | finished | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |
| workplan | TAMQ-WP-0009 | active | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md | | workplan | TAMQ-WP-0009 | finished | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
| task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md | | task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md |
| task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md | | task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
| task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md | | task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
@ -52,6 +52,6 @@
| task | TAMQ-WP-0008-T02 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | | task | TAMQ-WP-0008-T02 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |
| task | TAMQ-WP-0008-T03 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | | task | TAMQ-WP-0008-T03 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |
| task | TAMQ-WP-0008-T04 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | | task | TAMQ-WP-0008-T04 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |
| task | TAMQ-WP-0009-T01 | progress | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md | | task | TAMQ-WP-0009-T01 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
| task | TAMQ-WP-0009-T02 | todo | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md | | task | TAMQ-WP-0009-T02 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
| task | TAMQ-WP-0009-T03 | todo | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md | | task | TAMQ-WP-0009-T03 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |

View file

@ -24,6 +24,7 @@ from .tmux import TmuxError, TmuxManager
from .broker import BrokerIdentity, InputBroker from .broker import BrokerIdentity, InputBroker
from .ptytap import PtyTap from .ptytap import PtyTap
from .control import ControlModeClient from .control import ControlModeClient
from .composer import ComposerError, compose_message, recipient_order
from .diagnostics import configure from .diagnostics import configure
from .policy import load_profile from .policy import load_profile
from .registry import RegistryError, validate_targets from .registry import RegistryError, validate_targets
@ -162,7 +163,8 @@ def build_parser() -> argparse.ArgumentParser:
manual messaging from a managed shell: manual messaging from a managed shell:
@TARGET: MESSAGE... @TARGET: MESSAGE...
@ MESSAGE... reply to the latest sender for this window @ compose with a visible, Tab-selectable recipient
@ MESSAGE... fast reply (ordinary shell quoting applies)
tamq inbox [--filter COMMAND] tamq inbox [--filter COMMAND]
""", """,
) )
@ -208,7 +210,7 @@ manual messaging from a managed shell:
send.add_argument("--endpoint-id") send.add_argument("--endpoint-id")
send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)") send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)")
reply = subparsers.add_parser("reply", help="reply to the latest sender for the current repository") reply = subparsers.add_parser("reply", help="reply to the latest sender for the current repository")
reply.add_argument("body", nargs="+", help="message body") reply.add_argument("body", nargs="*", help="message body; omit to open the interactive composer")
export = subparsers.add_parser("export", help="export history as JSONL") export = subparsers.add_parser("export", help="export history as JSONL")
export.add_argument("--output", required=True) export.add_argument("--output", required=True)
export.add_argument("--repo", dest="target_repo") export.add_argument("--repo", dest="target_repo")
@ -383,11 +385,33 @@ def main(argv: list[str] | None = None) -> int:
if not sender: if not sender:
print("tamq reply requires a managed window with TAMQ_REPO", file=sys.stderr) print("tamq reply requires a managed window with TAMQ_REPO", file=sys.stderr)
return 2 return 2
body = " ".join(args.body).strip() latest = store.latest_counterparty(sender)
target = store.latest_counterparty(sender) if args.body:
if target is None: body = " ".join(args.body).strip()
print(f"tamq: no counterparty has sent a message to {sender}", file=sys.stderr) target = latest
return 2 if target is None:
print(f"tamq: no counterparty has sent a message to {sender}", file=sys.stderr)
return 2
else:
try:
encoded_recipients = json.loads(os.environ.get("TAMQ_RECIPIENTS", "[]"))
except json.JSONDecodeError:
encoded_recipients = []
session_repos = (
encoded_recipients
if isinstance(encoded_recipients, list)
and all(isinstance(repo, str) for repo in encoded_recipients)
else []
)
recipients = recipient_order(sender, latest, session_repos)
try:
composed = compose_message(recipients)
except ComposerError as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
if composed is None:
return 130
target, body = composed
else: else:
text = " ".join([args.address, *args.body]).strip() text = " ".join([args.address, *args.body]).strip()
if not text.startswith("@") or ":" not in text: if not text.startswith("@") or ":" not in text:

131
src/tamq/composer.py Normal file
View file

@ -0,0 +1,131 @@
from __future__ import annotations
import os
import re
import sys
import termios
import tty
from collections.abc import Sequence
class ComposerError(RuntimeError):
pass
def recipient_order(
current_repo: str,
latest_counterparty: str | None,
session_repos: Sequence[str],
) -> tuple[str, ...]:
"""Put the latest sender first, then the remaining peer repositories."""
ordered: list[str] = []
for repo in ([latest_counterparty] if latest_counterparty else []):
if (
repo != current_repo
and repo not in ordered
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is not None
):
ordered.append(repo)
for repo in session_repos:
if (
repo != current_repo
and repo not in ordered
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is not None
):
ordered.append(repo)
return tuple(ordered)
def _remove_last_character(value: bytearray) -> None:
if not value:
return
removed = value.pop()
if removed & 0xC0 == 0x80:
while value and value[-1] & 0xC0 == 0x80:
value.pop()
if value and value[-1] & 0xC0 == 0xC0:
value.pop()
def compose_message(
recipients: Sequence[str],
*,
input_fd: int | None = None,
output_fd: int | None = None,
) -> tuple[str, str] | None:
"""Read one literal terminal line while Tab cycles the visible recipient."""
if not recipients:
raise ComposerError("no recipient is available")
try:
source = sys.stdin.fileno() if input_fd is None else input_fd
destination = sys.stderr.fileno() if output_fd is None else output_fd
except (AttributeError, OSError) as exc:
raise ComposerError("interactive composition requires a terminal") from exc
if not os.isatty(source) or not os.isatty(destination):
raise ComposerError("interactive composition requires a terminal")
saved = termios.tcgetattr(source)
draft = bytearray()
recipient_index = 0
escape_state = 0
def write(value: bytes) -> None:
offset = 0
while offset < len(value):
offset += os.write(destination, value[offset:])
def render() -> None:
prompt = f"@{recipients[recipient_index]}: ".encode("utf-8")
write(b"\r\x1b[2K" + prompt + draft)
try:
tty.setraw(source)
render()
while True:
value = os.read(source, 1)
if not value:
write(b"\r\n")
return None
byte = value[0]
if escape_state == 1:
escape_state = 2 if byte in (ord("["), ord("O")) else 0
continue
if escape_state == 2:
if 0x40 <= byte <= 0x7E:
escape_state = 0
continue
if byte == 0x1B:
escape_state = 1
elif byte == 0x09:
recipient_index = (recipient_index + 1) % len(recipients)
render()
elif byte in (0x0A, 0x0D):
try:
body = draft.decode("utf-8")
except UnicodeDecodeError:
write(b"\a")
continue
if not body.strip():
write(b"\a")
continue
write(b"\r\n")
return recipients[recipient_index], body
elif byte == 0x03:
write(b"^C\r\n")
return None
elif byte == 0x04 and not draft:
write(b"\r\n")
return None
elif byte in (0x08, 0x7F):
_remove_last_character(draft)
render()
elif byte == 0x15:
draft.clear()
render()
elif byte >= 0x20:
draft.extend(value)
write(value)
else:
write(b"\a")
finally:
termios.tcsetattr(source, termios.TCSADRAIN, saved)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import shlex import shlex
@ -78,13 +79,7 @@ class TmuxManager:
self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700) self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if not os.access(self.command_dir, os.W_OK | os.X_OK): if not os.access(self.command_dir, os.W_OK | os.X_OK):
raise TmuxError(f"address command directory is not writable: {self.command_dir}") raise TmuxError(f"address command directory is not writable: {self.command_dir}")
scripts = { scripts: dict[str, str] = {}
"@": (
"#!/bin/sh\n"
"# tamq-address-command v1\n"
f"exec {shell_join(list(self.tamq_command))} reply -- \"$@\"\n"
)
}
for repo in repos: for repo in repos:
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
raise TmuxError( raise TmuxError(
@ -98,6 +93,14 @@ class TmuxManager:
) )
for name in (f"@{repo}", target): for name in (f"@{repo}", target):
scripts[name] = script scripts[name] = script
recipient_data = shlex.quote(json.dumps(list(repos), separators=(",", ":")))
scripts["@"] = (
"#!/bin/sh\n"
"# tamq-address-command v1\n"
f"TAMQ_RECIPIENTS={recipient_data}\n"
"export TAMQ_RECIPIENTS\n"
f"exec {shell_join(list(self.tamq_command))} reply -- \"$@\"\n"
)
for name in scripts: for name in scripts:
destination = self.command_dir / name destination = self.command_dir / name
if destination.exists() or destination.is_symlink(): if destination.exists() or destination.is_symlink():

101
tests/test_composer.py Normal file
View file

@ -0,0 +1,101 @@
import os
import pty
import select
import termios
import threading
import time
import pytest
from tamq.composer import ComposerError, compose_message, recipient_order
def _read_until(master: int, marker: bytes, timeout: float = 2) -> bytes:
output = bytearray()
deadline = time.monotonic() + timeout
while marker not in output and time.monotonic() < deadline:
readable, _, _ = select.select([master], [], [], 0.05)
if readable:
output.extend(os.read(master, 4096))
return bytes(output)
def _compose_in_pty(recipients, typed):
master, slave = pty.openpty()
original = termios.tcgetattr(slave)
result = {}
def run():
try:
result["value"] = compose_message(
recipients, input_fd=slave, output_fd=slave
)
except Exception as exc: # surfaced in the test thread
result["error"] = exc
thread = threading.Thread(target=run)
try:
thread.start()
output = bytearray(_read_until(master, b": "))
os.write(master, typed)
thread.join(timeout=2)
assert not thread.is_alive()
output.extend(_read_until(master, b"\r\n", timeout=0.2))
assert "error" not in result
assert termios.tcgetattr(slave) == original
return result["value"], bytes(output)
finally:
os.close(master)
os.close(slave)
def test_recipient_order_prefers_latest_then_session_peers():
assert recipient_order(
"audit-core",
"flex-auth",
["audit-core", "railiance-platform", "flex-auth", "railiance-platform"],
) == ("flex-auth", "railiance-platform")
assert recipient_order(
"audit-core", None, ["audit-core", "railiance-platform"]
) == ("railiance-platform",)
assert recipient_order("audit-core", "bad\x1btarget", ["../bad"]) == ()
def test_composer_accepts_literal_prose_and_cycles_recipient_with_tab():
result, output = _compose_in_pty(
("flex-auth", "railiance-platform"),
b"What's up?\t Really?\r",
)
assert result == ("railiance-platform", "What's up? Really?")
assert b"@flex-auth: " in output
assert b"@railiance-platform: What's up?" in output
def test_composer_supports_unicode_backspace_and_ctrl_u():
result, _ = _compose_in_pty(
("flex-auth",),
"caf\u00e9x\b!\x15final\r".encode(),
)
assert result == ("flex-auth", "final")
def test_composer_ctrl_c_cancels_and_restores_terminal():
result, output = _compose_in_pty(("flex-auth",), b"draft\x03")
assert result is None
assert b"^C\r\n" in output
def test_composer_requires_a_terminal():
read_fd, write_fd = os.pipe()
try:
with pytest.raises(ComposerError, match="requires a terminal"):
compose_message(("flex-auth",), input_fd=read_fd, output_fd=write_fd)
finally:
os.close(read_fd)
os.close(write_fd)
def test_composer_requires_a_recipient():
with pytest.raises(ComposerError, match="no recipient"):
compose_message(())

View file

@ -55,6 +55,66 @@ def test_bare_reply_targets_latest_inbound_sender(tmp_path, monkeypatch, capsys)
store.close() store.close()
def test_interactive_reply_shows_latest_and_cycles_session_recipients(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("TAMQ_REPO", "audit-core")
monkeypatch.setenv(
"TAMQ_RECIPIENTS", '["audit-core","railiance-platform","flex-auth"]'
)
monkeypatch.setattr("tamq.cli.ping", service_is_down)
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
store = Store(tmp_path / "state" / "tamq.sqlite3")
store.add("flex-auth", "audit-core", "latest inbound")
store.close()
seen = []
def compose(recipients):
seen.append(recipients)
return "railiance-platform", "What's up?"
monkeypatch.setattr("tamq.cli.compose_message", compose)
assert main(["reply"]) == 0
message_id = capsys.readouterr().out.strip()
assert seen == [("flex-auth", "railiance-platform")]
store = Store(tmp_path / "state" / "tamq.sqlite3")
row = next(row for row in store.list() if row["message_id"] == message_id)
assert (row["sender_repo"], row["target_repo"], row["body"]) == (
"audit-core", "railiance-platform", "What's up?"
)
store.close()
def test_interactive_reply_uses_first_peer_without_history(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("TAMQ_REPO", "audit-core")
monkeypatch.setenv("TAMQ_RECIPIENTS", '["audit-core","flex-auth"]')
monkeypatch.setattr("tamq.cli.ping", service_is_down)
monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None)
monkeypatch.setattr(
"tamq.cli.compose_message", lambda recipients: (recipients[0], "hello")
)
assert main(["reply"]) == 0
message_id = capsys.readouterr().out.strip()
store = Store(tmp_path / "state" / "tamq.sqlite3")
row = next(row for row in store.list() if row["message_id"] == message_id)
assert row["target_repo"] == "flex-auth"
store.close()
def test_interactive_reply_cancellation_queues_nothing(tmp_path, monkeypatch):
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("TAMQ_REPO", "audit-core")
monkeypatch.setenv("TAMQ_RECIPIENTS", '["audit-core","flex-auth"]')
monkeypatch.setattr("tamq.cli.compose_message", lambda recipients: None)
assert main(["reply"]) == 130
store = Store(tmp_path / "state" / "tamq.sqlite3")
assert store.list() == []
store.close()
def test_bare_reply_requires_managed_identity_and_prior_sender(tmp_path, monkeypatch, capsys): def test_bare_reply_requires_managed_identity_and_prior_sender(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setattr("tamq.cli.ping", service_is_down) monkeypatch.setattr("tamq.cli.ping", service_is_down)

View file

@ -181,6 +181,9 @@ def test_address_commands_preserve_message_arguments(tmp_path):
check=True, check=True,
) )
assert result.stdout == "reply -- Some reply! $value --literal\n" assert result.stdout == "reply -- Some reply! $value --literal\n"
generic = (command_dir / "@").read_text(encoding="utf-8")
assert 'TAMQ_RECIPIENTS=' in generic
assert '["audit-core"]' in generic
def test_address_commands_reject_unsafe_repository_names(tmp_path): def test_address_commands_reject_unsafe_repository_names(tmp_path):

View file

@ -4,7 +4,7 @@ type: workplan
title: "Interactive recipient-aware message composer" title: "Interactive recipient-aware message composer"
domain: communication domain: communication
repo: tmux-amq repo: tmux-amq
status: active status: finished
owner: codex owner: codex
topic_slug: coulomb-social topic_slug: coulomb-social
planning_priority: P0 planning_priority: P0
@ -36,7 +36,7 @@ retains normal shell quoting rules.
```task ```task
id: TAMQ-WP-0009-T01 id: TAMQ-WP-0009-T01
status: progress status: done
priority: high priority: high
state_hub_task_id: "320e4c7c-9c44-5023-8c60-7b270a0276f5" state_hub_task_id: "320e4c7c-9c44-5023-8c60-7b270a0276f5"
``` ```
@ -49,7 +49,7 @@ Exclude the current repository from the interactive recipient cycle.
```task ```task
id: TAMQ-WP-0009-T02 id: TAMQ-WP-0009-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "6973e046-f8b3-50d1-958b-4216af656624" state_hub_task_id: "6973e046-f8b3-50d1-958b-4216af656624"
``` ```
@ -63,7 +63,7 @@ exit path. Ctrl-C cancels without queuing a message.
```task ```task
id: TAMQ-WP-0009-T03 id: TAMQ-WP-0009-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "85b8bb9f-cf0d-5d95-9598-1cec8449ac17" state_hub_task_id: "85b8bb9f-cf0d-5d95-9598-1cec8449ac17"
``` ```
@ -72,3 +72,17 @@ Cover recipient ordering, shim metadata, non-TTY rejection, punctuation,
editing and cancellation with pseudo-terminal tests. Run the full checks, editing and cancellation with pseudo-terminal tests. Run the full checks,
install the build, exercise the composer in the live two-window session, and install the build, exercise the composer in the live two-window session, and
update README and SCOPE. update README and SCOPE.
## Completion evidence
- `make check`: 109 tests passed, including pseudo-terminal coverage for
literal apostrophes, recipient cycling with draft preservation, UTF-8-aware
backspace, Ctrl-U, Ctrl-C, and terminal restoration.
- Coverage: 76% overall and 76% for the new composer module.
- `make install`: installed `tmux-amq==0.1.0` and refreshed the live session's
tamq-owned command shims.
- Live `flex-auth` composer: delivered `What's up? WP0009-live-smoke` without
shell quoting, then retained `cycle-draft` while Tab changed the visible
recipient from `@audit-core: ` to `@railiance-platform: `. Ctrl-C cancelled
that draft. The controlled smoke message was acknowledged; existing user
messages were not.