From ac38dba529ff6cb4838f825b5c3c9594af36b7d1 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 18 Aug 2026 01:36:16 -0600 Subject: [PATCH 1/4] Fix: watch was executing the flags it did not recognise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects that surfaced during the last triage but were out of that PR's scope. Each verified before being planned. watch had no unknown-flag guard: anything flag-shaped it did not recognise stayed in the argv and became the command. My first probe of this was worthless — a non-tty run refuses on "stdin is not a TTY" before parsing anything, so the refusal was the TTY check and proved nothing. Driven through a real PTY.spawn, `rune watch --timeout 5 -- echo hi` exited 127 with the child never running. run has guarded this since it grew flags; watch never did, which made it the worse of the two, because run at least says something. The guard now lives in Command.flag_error and is shared rather than copied: the two had already drifted once, with run growing the inline-value branch and watch having no guard to grow it in. --grep ignored --since. `filter` was handed the sliced text and then called transcript.grep, which searched the whole transcript, so a read from a cursor recorded after the first line still returned that line and grep_matches counted it. A caller paging with --since= got the whole history on every page under a count that looked like it had filtered. --max-output did not bound clean_stdout/clean_stderr at all: a 200-byte budget returned 10,506 bytes across four fields. Now 1,012. An adversarial pass found two merge-blockers in this work and was right about both. First, the watch guard shipped with NO test — reverting all three guard files left the suite fully green, so CI could not tell the fix from its absence, including its own headline case. Six tests now cover it, including a drift guard and the case it must not lose, a child's own flags surviving. Second, moving unknown_flag_error and INLINE_VALUE_ERROR out of RunCommand left the pty_runner spec documenting them and added six exports nothing documented; specsync reported two hard errors, both now gone. It also caught a false claim in my own comment: watch's VALUE_FLAGS said it "cannot drift from the parser" while --log is appended by hand, because its pattern is inline in scan_head. Corrected to what is true. One reported loss was checked and deliberately left: the guard covers the leading position only, so `rune watch echo hi --log=/tmp/x` still writes rune's own log to the child's path. Measured byte-identical before and after, so it is pre-existing and recorded in invariant 20 rather than fixed here. Note an API change: RunCommand::INLINE_VALUE_ERROR still resolves through inheritance but now carries a second placeholder, so format(..., name:) alone raises KeyError. It has never appeared in a tagged release. 584 examples, 0 failures. Controls: watch guard reverted fails 4 of 33, grep reverted fails 1 of 2, stream bounds reverted fails 1 of 3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rf59AtQmJcodUJ6vXDZNY --- .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 2 +- .../context.md | 0 .../deltas/parsers.md | 0 .../docs.md | 0 .../plan.md | 0 .../requirements.md | 0 .../state.json | 44 + .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 2 +- .../context.md | 0 .../deltas/parsers.md | 0 .../docs.md | 0 .../plan.md | 0 .../requirements.md | 0 .../state.json | 39 + .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 2 +- .../context.md | 0 .../deltas/pty_runner.md | 0 .../deltas/session.md | 0 .../docs.md | 0 .../plan.md | 0 .../requirements.md | 0 .../state.json | 43 + .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 2 +- .../context.md | 0 .../deltas/pty_runner.md | 0 .../deltas/session.md | 0 .../docs.md | 0 .../plan.md | 0 .../requirements.md | 0 .../state.json | 44 + .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .../accepted-state.json} | 0 .../approvals.json | 0 .../change.md | 2 +- .../context.md | 0 .../deltas/pty_runner.md | 0 .../deltas/session.md | 0 .../docs.md | 0 .../plan.md | 0 .../requirements.md | 0 .../state.json | 48 + .../tasks.md | 0 .../testing.md | 0 .../verification-attempts.json | 0 .../verification.json | 0 .specsync/change-sequence.json | 4 +- .../approvals.json | 33 + .../change.md | 27 + .../context.md | 26 + .../deltas/cli.md | 59 ++ .../deltas/pty_runner.md | 177 ++++ .../deltas/session.md | 885 ++++++++++++++++++ .../deltas/watch.md | 160 ++++ .../docs.md | 29 + .../plan.md | 18 + .../requirements.md | 13 + .../state.json | 53 ++ .../tasks.md | 14 + .../testing.md | 35 + .../verification-attempts.json | 35 + .../verification.json | 216 +++++ lib/rune/command.rb | 30 + lib/rune/commands/run_command.rb | 21 +- lib/rune/commands/session_command.rb | 4 +- lib/rune/commands/watch_command.rb | 29 +- lib/rune/pty_runner.rb | 26 +- lib/rune/session/transcript.rb | 14 +- spec/rune/commands/watch_command_spec.rb | 52 + spec/rune/pty_runner_spec.rb | 43 + spec/rune/session_spec.rb | 31 + specs/cli/cli.spec.md | 6 +- specs/pty_runner/pty_runner.spec.md | 19 +- specs/session/session.spec.md | 12 +- specs/watch/watch.spec.md | 23 +- 97 files changed, 2279 insertions(+), 43 deletions(-) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/accepted-state.json} (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/approvals.json (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/change.md (98%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/context.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/deltas/parsers.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/docs.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/plan.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/requirements.md (100%) create mode 100644 .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/tasks.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/testing.md (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th => archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th}/verification.json (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/accepted-state.json} (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/approvals.json (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/change.md (98%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/context.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/deltas/parsers.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/docs.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/plan.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/requirements.md (100%) create mode 100644 .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/tasks.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/testing.md (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the => archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the}/verification.json (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/accepted-state.json} (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/approvals.json (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/change.md (98%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/context.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/deltas/pty_runner.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/deltas/session.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/docs.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/plan.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/requirements.md (100%) create mode 100644 .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/tasks.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/testing.md (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the => archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the}/verification.json (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/accepted-state.json} (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/approvals.json (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/change.md (98%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/context.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/deltas/pty_runner.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/deltas/session.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/docs.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/plan.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/requirements.md (100%) create mode 100644 .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/tasks.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/testing.md (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg => archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg}/verification.json (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/accepted-state.json} (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/approvals.json (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/change.md (98%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/context.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/deltas/pty_runner.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/deltas/session.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/docs.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/plan.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/requirements.md (100%) create mode 100644 .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/tasks.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/testing.md (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/verification-attempts.json (100%) rename .specsync/{changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun => archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun}/verification.json (100%) create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/change.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/context.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/cli.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/pty_runner.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/session.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/watch.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/docs.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/plan.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/requirements.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/tasks.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/testing.md create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json create mode 100644 .specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/accepted-state.json diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/approvals.json b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/approvals.json similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/approvals.json rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/approvals.json diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md similarity index 98% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md index 2ba8a04..d1737af 100644 --- a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md +++ b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/change.md @@ -1,6 +1,6 @@ --- id: CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th -state: accepted +state: archived type: feature base_commit: cc8bb3c25e807fe07de3cf66687302d97f1ddddb --- diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/context.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/context.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/context.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/context.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/deltas/parsers.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/deltas/parsers.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/deltas/parsers.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/deltas/parsers.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/docs.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/docs.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/docs.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/docs.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/plan.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/plan.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/plan.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/plan.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/requirements.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/requirements.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/requirements.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/requirements.md diff --git a/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json new file mode 100644 index 0000000..ff65504 --- /dev/null +++ b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/state.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "id": "CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th", + "slug": "honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th", + "title": "Honour the modes and charsets that decide what the screen contains, and strip the escapes the sanitizer missed", + "description": "Honour the modes and charsets that decide what the screen contains, and strip the escapes the sanitizer missed", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "cc8bb3c25e807fe07de3cf66687302d97f1ddddb", + "created_at": 1787006901, + "updated_at": 1787032773, + "affected_specs": [ + "parsers" + ], + "affected_paths": [ + "lib/rune/parsers/screen.rb", + "lib/rune/parsers/screen_renderer.rb", + "lib/rune/parsers/text_sanitizer.rb", + "spec/rune/parsers/screen_renderer_spec.rb", + "spec/rune/parsers/text_sanitizer_spec.rb", + "specs/parsers/parsers.spec.md", + "harnesses/renderer_gaps.rb", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "The renderer honours the alternate screen buffer (1049, 1047, 47), DECAWM, IRM and DEC Special Graphics charset designation with SO/SI, and TextSanitizer strips the two-byte escapes and the full charset-designation set. Four of the five renderer gaps ROADMAP listed as open are closed; double-width characters remain, recorded as a limitation with its measurement. Every fix is verified by a harness that measures against ECMA-48/xterm behaviour rather than a reference emulator, and every new test was falsified against deliberately unfixed code." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/tasks.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/tasks.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/tasks.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/tasks.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/testing.md b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/testing.md similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/testing.md rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/testing.md diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification-attempts.json b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification-attempts.json rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification-attempts.json diff --git a/.specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification.json b/.specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification.json similarity index 100% rename from .specsync/changes/CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification.json rename to .specsync/archive/changes/2026-08-18-CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th/verification.json diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/accepted-state.json diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/approvals.json b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/approvals.json similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/approvals.json rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/approvals.json diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md similarity index 98% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md index 5719e00..361a54b 100644 --- a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md +++ b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/change.md @@ -1,6 +1,6 @@ --- id: CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the -state: accepted +state: archived type: feature base_commit: 1749093b9563a42e245ceeb6d6f7eb59fb23fb63 --- diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/context.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/context.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/context.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/context.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/deltas/parsers.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/deltas/parsers.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/deltas/parsers.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/deltas/parsers.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/docs.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/docs.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/docs.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/docs.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/plan.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/plan.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/plan.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/plan.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/requirements.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/requirements.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/requirements.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/requirements.md diff --git a/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json new file mode 100644 index 0000000..36a2553 --- /dev/null +++ b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/state.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "id": "CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the", + "slug": "record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the", + "title": "Record that the wide-character cell model was built and measured worse than the gap", + "description": "Record that the wide-character cell model was built and measured worse than the gap", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "1749093b9563a42e245ceeb6d6f7eb59fb23fb63", + "created_at": 1787009342, + "updated_at": 1787032785, + "affected_specs": [ + "parsers" + ], + "affected_paths": [ + "specs/parsers/parsers.spec.md", + "harnesses/renderer_gaps.rb", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "parsers.spec.md invariant 17 records that a wide-character cell model was implemented and reverted, with the live-output comparison that killed it and the two synthetic probes that reproduce it. The claim is limited to the cases that actually differ; a probe identical in both is labelled baseline rather than evidence. harnesses/renderer_gaps.rb carries the reproduction and says which cases the cell model changed. No production behaviour changes." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "no" + } +} diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/tasks.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/tasks.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/tasks.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/tasks.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/testing.md b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/testing.md similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/testing.md rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/testing.md diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification-attempts.json b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification-attempts.json rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification-attempts.json diff --git a/.specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification.json b/.specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification.json similarity index 100% rename from .specsync/changes/CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification.json rename to .specsync/archive/changes/2026-08-18-CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the/verification.json diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/accepted-state.json diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/approvals.json b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/approvals.json similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/approvals.json rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/approvals.json diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md similarity index 98% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md index 11854e3..04a5f02 100644 --- a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md +++ b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/change.md @@ -1,6 +1,6 @@ --- id: CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the -state: accepted +state: archived type: feature base_commit: 4abd95c8461be099fc5030a740ea456ec7d242d1 --- diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/context.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/context.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/context.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/context.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/pty_runner.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/pty_runner.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/pty_runner.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/pty_runner.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/session.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/session.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/session.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/deltas/session.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/docs.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/docs.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/docs.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/docs.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/plan.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/plan.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/plan.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/plan.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/requirements.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/requirements.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/requirements.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/requirements.md diff --git a/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json new file mode 100644 index 0000000..d50244b --- /dev/null +++ b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/state.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "id": "CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the", + "slug": "stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the", + "title": "Stop a read mid-escape: withhold an unterminated sequence from the text and the cursor", + "description": "Stop a read mid-escape: withhold an unterminated sequence from the text and the cursor", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "4abd95c8461be099fc5030a740ea456ec7d242d1", + "created_at": 1787015317, + "updated_at": 1787032795, + "affected_specs": [ + "session", + "pty_runner" + ], + "affected_paths": [ + "lib/rune/commands/session_command.rb", + "lib/rune/output_limiter.rb", + "spec/rune/session_spec.rb", + "specs/session/session.spec.md", + "specs/pty_runner/pty_runner.spec.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "A read stops at the last complete escape sequence and its cursor stops there too, so a sequence split across two pty reads is never delivered as visible text and never left headless for the next read. list's last_line is summarised from the reassembled tail rather than one event. Verified on the exact reported reproduction: clean_output, screen and last_line all agree the child printed RED, where previously clean_output said 1mRED and screen said RED in the same reply. Three regression tests, each falsified against deliberately unfixed code." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/tasks.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/tasks.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/tasks.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/tasks.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/testing.md b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/testing.md similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/testing.md rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/testing.md diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification-attempts.json b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification-attempts.json rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification-attempts.json diff --git a/.specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification.json b/.specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification.json similarity index 100% rename from .specsync/changes/CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification.json rename to .specsync/archive/changes/2026-08-18-CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the/verification.json diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/accepted-state.json diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/approvals.json b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/approvals.json similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/approvals.json rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/approvals.json diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md similarity index 98% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md index 9b06728..a84dd68 100644 --- a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md +++ b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/change.md @@ -1,6 +1,6 @@ --- id: CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg -state: accepted +state: archived type: feature base_commit: 8c9055be7093551caf654679a1f8ebb51357de50 --- diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/context.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/context.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/context.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/context.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/pty_runner.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/pty_runner.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/pty_runner.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/pty_runner.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/session.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/session.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/session.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/deltas/session.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/docs.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/docs.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/docs.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/docs.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/plan.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/plan.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/plan.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/plan.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/requirements.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/requirements.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/requirements.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/requirements.md diff --git a/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json new file mode 100644 index 0000000..c3c27af --- /dev/null +++ b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/state.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "id": "CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg", + "slug": "make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg", + "title": "Make --tail count a carriage return as a line break, and report matched on a regex send's timeout", + "description": "Make --tail count a carriage return as a line break, and report matched on a regex send's timeout", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "8c9055be7093551caf654679a1f8ebb51357de50", + "created_at": 1787020810, + "updated_at": 1787032803, + "affected_specs": [ + "session", + "pty_runner" + ], + "affected_paths": [ + "lib/rune/output_limiter.rb", + "lib/rune/session/pending_send.rb", + "lib/rune/commands/session_command.rb", + "spec/rune/session_spec.rb", + "specs/session/session.spec.md", + "docs/sessions.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "The tail bound counts CR, LF and CRLF as line breaks, so it bounds a full-screen TUI's repaint output instead of silently returning everything with truncated and omitted_lines absent. rune run bounds raw_output as well as clean_output, which it did not when the raw stream had no LFs. A regex send reports matched false when it times out, as its own comment always claimed. The documentation saying a regex send races the settle window is corrected in the spec, in docs/sessions.md and in help, and settled is defined as the wait being answered rather than the child going quiet. Four regression tests, each falsified against deliberately unfixed code." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/tasks.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/tasks.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/tasks.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/tasks.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/testing.md b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/testing.md similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/testing.md rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/testing.md diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification-attempts.json b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification-attempts.json rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification-attempts.json diff --git a/.specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification.json b/.specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification.json similarity index 100% rename from .specsync/changes/CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification.json rename to .specsync/archive/changes/2026-08-18-CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg/verification.json diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/accepted-state.json diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/approvals.json b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/approvals.json similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/approvals.json rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/approvals.json diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md similarity index 98% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md index 42a70a0..297bd4e 100644 --- a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md +++ b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/change.md @@ -1,6 +1,6 @@ --- id: CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun -state: accepted +state: archived type: feature base_commit: 023f4078ec28548d58aaa35bfd985e5a843781e9 --- diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/context.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/context.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/context.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/context.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/pty_runner.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/pty_runner.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/pty_runner.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/pty_runner.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/session.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/session.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/session.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/deltas/session.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/docs.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/docs.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/docs.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/docs.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/plan.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/plan.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/plan.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/plan.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/requirements.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/requirements.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/requirements.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/requirements.md diff --git a/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json new file mode 100644 index 0000000..03391a3 --- /dev/null +++ b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/state.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "id": "CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun", + "slug": "correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun", + "title": "Correct the flag message run gets wrong, and the five contracts the dogfood found documented wrong", + "description": "Correct the flag message run gets wrong, and the five contracts the dogfood found documented wrong", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "023f4078ec28548d58aaa35bfd985e5a843781e9", + "created_at": 1787029711, + "updated_at": 1787032810, + "affected_specs": [ + "pty_runner", + "session" + ], + "affected_paths": [ + "lib/rune/commands/run_command.rb", + "lib/rune/commands/session_command.rb", + "lib/rune/output_limiter.rb", + "spec/rune/commands/run_command_spec.rb", + "specs/pty_runner/pty_runner.spec.md", + "specs/session/session.spec.md", + "docs/sessions.md", + "docs/getting_started.md", + "docs/pty_architecture.md", + "ROADMAP.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "A flag rune run owns, spelled correctly but given a space-separated value, gets a message naming the real problem instead of Unknown option plus a remedy that hands the flag to the child. The known-flag set is derived from the parser so it cannot drift. Four further findings are confirmed as documentation defects and corrected where each is stated: the two run output fields describe different windows under max-output, omitted_bytes reconciles only on ASCII, grep matches the cleaned transcript rather than the rendered screen, and screen is bounded by geometry rather than by the read filters. Every code path keeps its current behaviour except the one message." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/tasks.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/tasks.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/tasks.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/tasks.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/testing.md b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/testing.md similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/testing.md rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/testing.md diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification-attempts.json b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification-attempts.json rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification-attempts.json diff --git a/.specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification.json b/.specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification.json similarity index 100% rename from .specsync/changes/CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification.json rename to .specsync/archive/changes/2026-08-18-CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun/verification.json diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 5356609..5baec85 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 68, - "id": "CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun", + "sequence": 69, + "id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json new file mode 100644 index 0000000..655c3de --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json @@ -0,0 +1,33 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1787038084, + "digest": "4c75a772348974c4e7436b1c62048c69df496e193c04980909b93f0cbd30bfe8", + "note": null + }, + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1787038105, + "digest": "33e699eb9baeed95ea42d9f3f590c6fe5b311af1ccb06cb4d932b44e146830a8", + "note": null + }, + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1787038144, + "digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787038297, + "digest": "351fbb5c5a60597a54463c7905c1ac128cd4751a189c81949945d300897a4f65", + "note": null + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/change.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/change.md new file mode 100644 index 0000000..aab9487 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/change.md @@ -0,0 +1,27 @@ +--- +id: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +state: accepted +type: feature +base_commit: ad76e2237bb8215f77d4cd7bb8358cc6083a61f2 +--- + +# Guard the flags watch was executing, and bound the two fields max-output was not + +## Intent + +Guard the flags watch was executing, and bound the two fields max-output was not + +## Affected Canonical Specs + +- `watch` +- `pty_runner` +- `session` +- `cli` + +## Acceptance Criteria + +- rune watch refuses a flag-shaped token it does not own instead of executing it as the command, sharing run's guard rather than copying it. rune session read honours --since when --grep is given, searching the slice rather than the whole transcript. max-output and tail bound clean_stdout and clean_stderr as well as the merged fields. Each fix has tests that fail against deliberately reverted code, including a drift guard for watch's flag list. Export documentation follows the two constants and one method that moved from RunCommand to Command. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/context.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/context.md new file mode 100644 index 0000000..2f367a6 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/context.md @@ -0,0 +1,26 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: context +--- + +# Context + +Three defects that surfaced during the triage of the translation dogfood but were +out of that PR`s scope. Each was verified here before being planned. + +**watch executed the flags it did not recognise.** Anything flag-shaped stayed in +the argv and became the command. My first probe of this was worthless: a non-tty +run refuses on "stdin is not a TTY" before parsing anything, so the refusal was +the TTY check and proved nothing. Driven through a real `PTY.spawn`, +`rune watch --timeout 5 -- echo hi` exited **127 with the child never running**. +`run` has guarded this since it grew flags. watch never did, which made it the +worse of the two — run at least says something. + +**`--grep` ignored `--since`.** `filter` was handed the sliced text and then +called `transcript.grep`, which searched `@text`. Measured: a read from a cursor +recorded after the first line still returned that line, and `grep_matches` +counted it. + +**`--max-output` did not bound the separate streams.** A 200-byte budget returned +10,506 bytes across four fields, because only the merged pair went through +`apply_output_limit`. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/cli.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/cli.md new file mode 100644 index 0000000..fdf5f85 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/cli.md @@ -0,0 +1,59 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `CLI` | class | CLI router. Class methods: `run(argv)`, `register(command_class)`, `commands`. Instance: `run(argv)`. | +| `Command` | class | Base class for commands. DSL: `name(n)`, `summary(s)`, `usage(text)`, `flag(spec, description)`. Override: `call(args, options)`, `human_render(data, io)`. Shared: `flag_shaped?(token)`. | +| `FLAG_SHAPED` | constant | Matches a token shaped like one of rune's own long flags — `--`, a letter, flag characters, optional `=value`. Deliberately narrow: the same argv position also carries input text and wrapped-command argv, so `---`, `--- section ---` and any token with a space are not flags. | +| `flag_shaped?` | class method | Whether a token looks like a long flag rune could have meant to own. Commands use it to tell a mistyped flag from their own operands, so an unrecognized `--flag` is refused rather than exec'd or typed at a child. | +| `Result` | class | Structured result. Class methods: `.success(data, exit_code: nil)`, `.failure(error, data: nil, exit_code: nil)`. Instance: `#success?`, `#failure?`, `#to_h`, `#exit_code`. | +| `Renderer` | class | Output formatter. `#agent_mode?`, `#render(result, human_block:)`. Supports JSON and NDJSON modes. | +| `Error` | class | Base exception reserved for rune-specific library errors. | +| `Rune` | module | Top-level namespace for the library and CLI framework. | +| `register` | class method | Registers a command subclass immediately after its DSL name is declared. | +| `run` | method | Dispatches an argv array, renders its `Result`, and exits with `Result#exit_code`. | +| `commands` | reader | Returns the registered command-name-to-class mapping. | +| `name` | class method | With an argument, declares and registers the CLI name for a `Command` subclass; without one, returns the normal Ruby class name. | +| `summary` | class method | Declares the help summary for a `Command` subclass. | +| `call` | instance method | Command execution contract; subclasses must return a `Result`. | +| `human_render` | instance method | Optional command-specific terminal renderer. | +| `command_name` | reader | Returns the subclass's declared CLI name. | +| `command_summary` | reader | Returns the subclass's declared help summary. | +| `success?` | predicate | Reports whether a `Result` has `:ok` status. | +| `failure?` | predicate | Reports whether a `Result` has `:error` status. | +| `success` | class method | Constructs a successful `Result`, optionally with a process exit override. | +| `failure` | class method | Constructs an error `Result`, optionally with data and a process exit override. | +| `to_h` | instance method | Serializes the stable status/data/error envelope. | +| `exit_code` | instance method | Returns the explicit process exit override or the status-derived default. | +| `status` | reader | Returns the symbolic result status. | +| `data` | reader | Returns the result payload, if any. | +| `error` | reader | Returns the result error message, if any. | +| `agent_mode?` | predicate | Selects structured output for explicit JSON modes or non-TTY output. | +| `render` | instance method | Renders one `Result` in NDJSON, JSON, or human form. | +| `render_event` | instance method | Emits and flushes a named NDJSON event when NDJSON mode is active. | +| `io` | reader | Returns the renderer's output stream. | +| `json_mode` | reader | Reports whether explicit JSON rendering is enabled. | +| `ndjson_mode` | reader | Reports whether NDJSON envelope rendering is enabled. | +| `VERSION` | constant | Current rune release version. | +| `VersionCommand` | class | Returns rune, Ruby, platform, and optional-tool version information. | +| `Commands` | module | Namespace containing concrete CLI command implementations. | +| `usage` | class method | Declares the one-line invocation shape shown by `rune --help`. | +| `flag` | class method | Declares one command-specific flag (spec + description) for command help. | +| `command_usage` | reader | Returns the subclass's declared usage line, or nil. | +| `command_flags` | reader | Returns the subclass's declared flags, defaulting to an empty array. | +| `subcommand` | class method | Declares one subcommand (name + summary) for command help. | +| `command_subcommands` | reader | Returns the subclass's declared subcommands, defaulting to an empty array. | +| `flag_error` | class method | Rejects a flag-shaped token that reached the wrapped command's argv, shared by `run` and `watch`. | +| `INLINE_VALUE_ERROR` | constant | Message template for a flag the command owns whose value was space-separated. | +| `UNKNOWN_FLAG_ERROR` | constant | Message template for a flag-shaped token the command does not own. | +| `Help` | class | Builds and renders `rune --help`, `rune --help`, and `rune help [cmd]`. Class method: `.extract_flag!(args)`. Instance: `#overview`, `#for_command(name)`, `#render(data, io)`. | +| `FLAGS` | constant | Tokens (`--help`, `-h`) recognized as a help request before the first `--`. | +| `GLOBAL_FLAGS` | constant | Flags that apply to every command, rendered under "Global flags" and returned in every help payload. | +| `extract_flag!` | class method | Removes every help alias from the pre-separator argv in place and reports whether any were present. | +| `overview` | instance method | Builds the all-commands help `Result`. | +| `for_command` | instance method | Builds one command's help `Result`, or a structured failure for an unknown name. | +| `render_command` | internal method | Renders one command's usage and flag list for a terminal. | +| `render_flags` | internal method | Renders an aligned flag/description list. | + diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/pty_runner.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/pty_runner.md new file mode 100644 index 0000000..b6acabf --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/pty_runner.md @@ -0,0 +1,177 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `PTYRunner` | class | Spawns command in PTY. Constructor: `(command, input: nil, script: nil, timeout_seconds: 30, max_output_bytes: nil, tail_lines: nil, separate_streams: false, &on_output)`. Method: `#run` returns `Result`. Class method: `.pty_available?` reports whether the `pty` stdlib loaded successfully. | +| `RunCommand` | class | Subcommand `rune run [--timeout=SECONDS] [--max-output=BYTES] [--tail=N] [--separate-streams] ` exposing PTY process runner to humans and agents. `--timeout` overrides the default 30s PTYRunner timeout. `--max-output` bounds `clean_output`/`raw_output` to BYTES each, keeping head+tail. `--tail` keeps only the last N lines of each. `--max-output` and `--tail` are mutually exclusive. `--separate-streams` adds `clean_stdout`/`clean_stderr` to the result alongside the existing merged view. All four are only recognized before a `--` separator; a malformed value fails with a clear error instead of leaking the raw flag into the executed command. Declared via the `usage`/`flag` DSL, so `rune run --help` renders them without constructing a PTY runner. | +| `Script` | class | Interactive step DSL passed to `PTYRunner.new(script:)`. Constructor: `Script.new(&block)` (or `Script.define(&block)`, an alias) evaluates the block via `instance_eval`; no I/O happens until `PTYRunner#run` executes the declared steps. | +| `Rune` | module | Top-level rune namespace. | +| `pty_available?` | class predicate | Reports whether Ruby's PTY stdlib loaded successfully. | +| `ExecArgv` | module | Turns a caller's command into arguments spawn will treat the way the caller meant. | +| `for_spawn` | module function | Forces an argv array to exec directly; leaves a String command line alone. | +| `run` | instance method | Executes, captures, sanitizes, bounds (if requested), and returns one PTY-backed command result. | +| `detect_prompt?` | instance predicate | Delegates prompt recognition to `PromptDetector`. | +| `spawn_and_stream` | internal method | Spawns the PTY and coordinates input, output, signals, and child reaping for the default single-stream mode. | +| `spawn_for_mode` | internal method | Dispatches to `spawn_and_stream` or `spawn_and_stream_separate` depending on `separate_streams`. | +| `spawn_and_stream_separate` | internal method | Spawns stdout on a real pty and stderr on a plain pipe for `separate_streams: true`, reusing the same signal/input/reap machinery as the default mode. | +| `spawn_with_separated_stderr` | internal method | Runs `Process.spawn` with stdout/stdin on the pty slave and stderr on the pipe's write end. | +| `read_separate_streams` | internal method | Multiplexes the stdout pty and stderr pipe with `IO.select`, decoding each independently and appending to its own buffer plus the shared merged `raw_output`. | +| `poll_ready_streams` | internal method | Runs one `IO.select` pass and consumes every stream that became readable. | +| `consume_stream_chunk` | internal method | Reads and decodes one chunk from a single stream, or finalizes it on EOF. | +| `append_decoded_chunk` | internal method | Appends one decoded chunk to a stream's own buffer and the shared `raw_output`. | +| `timeout_hint` | internal method | An extra sentence on a timeout that captured nothing, naming the stdin shape that usually causes it. | +| `prompt_detected_in?` | internal predicate | Checks whether the last non-blank line of a finished text buffer looks like an interactive prompt. | +| `kill_orphaned_child` | internal method | Kills and reaps a timed-out direct child. | +| `wait_for_process` | internal method | Reaps the child and normalizes exit or signal status. | +| `write_input` | internal method | Performs a bounded non-blocking PTY input write. | +| `read_pty_stream` | internal method | Polls the PTY, incrementally decodes output, and drives script steps. | +| `consume_output_chunk` | internal method | Appends one decoded chunk and drives script steps. | +| `process_script_steps` | internal method | Advances ready script steps. | +| `PTY_LOAD_ERROR` | constant | Captured `LoadError` when PTY support is unavailable. | +| `PTY_ALLOCATION_ERRORS` | constant | OS errors treated as rune-level PTY allocation failures. | +| `command` | reader | Shell-escaped display string. | +| `input` | reader | Optional eager input written after spawn. | +| `script` | reader | Optional interactive `Script`. | +| `timeout_seconds` | reader | Maximum execution duration. | +| `max_output_bytes` | reader | `--max-output` byte budget, or `nil` if unset. | +| `on_output` | reader | Optional decoded-output callback. | +| `OutputLimiter` | class | Bounds captured text without corrupting UTF-8 or splicing a half-escape-sequence at the cut boundary. Stateless; all entry points are class methods. | +| `truncate_middle` | class method | `(text, max_bytes)` returns `[bounded_text, omitted_bytes]`; keeps head and tail with a marker between. `omitted_bytes` is measured in offsets into the original text, which is not the same as "every byte absent from the result" once a cut splits a character. | +| `apply_output_limit` | internal method | Applies `--max-output` or `--tail` to the merged clean/raw pair. | +| `execute_pty` | internal method | Runs the command in a pty and collects its output. | +| `dangling_suffix` | class method | The trailing bytes of an escape sequence still waiting for its terminator, or empty. | +| `LINE_WITH_TERMINATOR` | constant | One line plus its terminator, where a line ends at CR, LF or CRLF. | +| `elision_marker` | class method | `(omitted)` returns the newline-delimited `[rune] ==== N bytes omitted by --max-output ====` line spliced between head and tail. Not charged against `max_bytes`: it is rune's annotation of the cut, not the child's output. | +| `ELISION_PATTERN` | constant | Matches an elision marker line and captures its byte count, for callers that need to find or verify the join. | +| `DANGLING_ESCAPE` | constant | Matches an escape sequence left without its terminator, anchored at the last ESC before a cut. | +| `COMPLETE_ESCAPE` | constant | Matches the same shapes complete, used to find where the remainder of a split sequence ends inside the tail. | +| `STRING_BODY` | constant | The body of an OSC/DCS control string: any byte but BEL, ESC, CR or LF. Excluding CR and LF is what stops a stray introducer from making a multi-line run of plain text look like one unterminated string. | +| `RESYNC_WINDOW_BYTES` | constant | How far either side of a cut is examined for the sequence that straddles it, and therefore the most either boundary adjustment can remove (512). | +| `tail_lines` | class method | `(text, n)` returns `[bounded_text, omitted_lines]`; keeps only the last `n` lines. Also the name of the matching `PTYRunner` reader holding the `--tail` line budget, or `nil` if unset. | +| `Commands` | module | Namespace containing concrete CLI command implementations. | +| `call` | instance method | Validates CLI arguments and delegates to `PTYRunner`. | +| `human_render` | instance method | Prints a concise command summary and captured clean output. | +| `FLAG_PATTERNS` | constant | Maps each `PTYRunner` value-taking keyword option (`--timeout`, `--max-output`, `--tail`) to its argv pattern, flag name, and error-message value description. `--separate-streams` takes no value, so it is matched separately rather than via this table. | +| `matching_flag` | internal method | Matches one argv token against `FLAG_PATTERNS`, returning the matched option key and `MatchData`, or `[nil, nil]`. | +| `VALUE_FLAGS` | constant | The `run` flags that take a value, derived from `FLAG_PATTERNS` so it cannot drift from the parser. The guard itself moved to `Command.flag_error`, shared with `watch`. | +| `parse_flags` | internal method | Parses every raw `--timeout`/`--max-output`/`--tail` value, stopping at the first invalid one, then checks mutual exclusion. | +| `both_output_limits?` | internal predicate | True when both `--max-output` and `--tail` were given. | +| `parse_positive_int` | internal method | Accepts a positive integer value for `--timeout`/`--max-output`/`--tail` and rejects every other value. | +| `wait_for` | DSL method | Appends an output-pattern wait step. | +| `send_keys` | DSL method | Appends a PTY input step. | +| `pause` | DSL method | Appends a timed delay step. | +| `define` | class method | Constructs a `Script` from the DSL block. | +| `Step` | data type | Immutable step record containing `type` and `payload`. | +| `steps` | reader | Ordered script steps. | +| `SignalHandler` | class | Temporarily traps INT/TERM, forwards every one of them to a child process, and escalates a repeated signal into stopping `rune` itself. | +| `with_traps` | class method | `(pid, burst_window:, abort_after:)` installs traps for a block and yields a polling forward callable. | +| `reap` | class method | `(pid, grace_seconds:, &drain)` gives a signalled child a bounded grace period, then SIGKILLs it, then waits a bounded time for it to become reapable, running `drain` on every poll. Returns its status, or `nil` if it never became reapable inside the bounds. | +| `Aborted` | error class | Raised out of the caller's polling loop once a repeated INT/TERM means `rune` must stop too. | +| `signal_name` | reader | The INT/TERM that triggered the abort. | +| `exit_code` | instance method | The conventional `128 + signo` status for the aborting signal (130 for INT, 143 for TERM). | +| `BURST_WINDOW_SECONDS` | constant | Seconds within which successive signals count as one escalating burst (5.0). | +| `ABORT_AFTER` | constant | Signals within one burst tolerated before `rune` stops itself (2). | +| `ABORT_GRACE_SECONDS` | constant | Grace a just-signalled child gets to leave on its own before SIGKILL (1.0). | +| `POST_KILL_SECONDS` | constant | Bound on waiting for a SIGKILLed child to become reapable (2.0). | +| `POLL_SECONDS` | constant | Reap-loop poll interval (0.02). | +| `drain_available` | internal method | One bounded, best-effort pty read used while tearing an aborted run down; appends to the capture and fires `on_output` without driving script steps. | +| `drain` | internal method | Forwards every signal queued since the last poll, in order, then raises `Aborted` at the burst threshold. | +| `next_signal` | internal method | Pops one queued signal name, or `nil` when the queue is empty. | +| `record_burst` | internal method | Returns the signal's position within the current burst, restarting the count once the burst window has lapsed. | +| `forward` | internal method | Sends one signal to the child, treating an already-dead or permission-denied target as handled. | +| `trap_signal` | internal method | Installs one trap, swallowing an unsupported signal name instead of raising. | +| `restore_signal` | internal method | Restores one signal's previous disposition, defaulting to `DEFAULT`. | +| `interrupted_capture` | internal method | Reaps and builds the capture tuple for a run ended by a repeated signal. | +| `UTF8StreamDecoder` | class | Incrementally decodes chunks while retaining incomplete UTF-8 suffix bytes. | +| `decode` | instance method | Returns complete scrubbed UTF-8 text and buffers an incomplete suffix. | +| `finish` | instance method | Flushes a final incomplete suffix using replacement-character semantics. | +| `sequence_length` | internal method | Maps a valid leading byte to its UTF-8 sequence length. | +| `continuation_bytes?` | internal predicate | Validates UTF-8 continuation bytes. | +| `scrub` | internal method | Force-encodes bytes as UTF-8 and replaces invalid sequences. | + + +### SPEC SECTION Invariants + +22. `data[:prompt_detected]` reflects only the *last* non-blank line of the finished output + buffer (ANSI stripped), not whether any line anywhere in the run ever matched a prompt + pattern. `rune run`'s result is only ever read after the wrapped process has already exited or + been killed by `--timeout`, so this is the question that's actually useful: "was the last + thing on screen a prompt, with nothing after it" — the signature of a process genuinely stuck + waiting for input, since by definition nothing else arrives after that line. A prompt-shaped + line that appears mid-run as ordinary TUI chrome, followed by further real output, does not + set `prompt_detected` (found via real dogfooding driving a long-running third-party TUI + sub-agent, where the old "any line ever" semantics made the field `true` on every run and + therefore useless — issue #30). This holds identically across a natural exit, a + `PTY::ChildExited` short-circuit, and a `--timeout` kill: the last-line check runs against + whatever `raw_output` was captured up to the point execution stopped, in every case. +23. No output at all, or output consisting only of blank/whitespace lines, yields + `data[:prompt_detected]: false` — never a crash from an absent "last line". +24. No trapped signal is ever swallowed. Every INT/TERM caught while a child is running is + forwarded to that child, in arrival order, for as long as the run lasts. The forward callable + used to latch after its first successful forward, so signals two onward reached neither the + child nor `rune` itself: measured as a `rune run` absorbing 4x SIGINT + 2x SIGTERM over three + seconds and leaving only when its own `--timeout` fired 15s later, and as a `rune watch` + (which has no default timeout) surviving 5x SIGINT + 5x SIGTERM and needing SIGKILL. Signals + are queued rather than held in a single slot, so two arriving inside one 0.2s poll interval + are both delivered instead of overwriting each other. +25. The second INT/TERM within `SignalHandler::BURST_WINDOW_SECONDS` ends the run, the same + escalation `timeout`, `docker run`, and `ssh` use: it is forwarded to the child *first* — a + child whose second Ctrl-C interrupts a turn still receives it — and only then is + `SignalHandler::Aborted` raised out of the polling loop. `rune` unwinds to a well-formed + result rather than dying mid-render: the child is reaped, the capture keeps everything it + printed on the way out, `[rune] Interrupted by SIG` is appended, and the reported exit + code is the conventional `128 + signo` (130 for INT, 143 for TERM). A single signal is still + the child's alone — it is forwarded and `rune` keeps waiting, so the traps continue to do what + they were installed for instead of `rune` dying instantly and orphaning the child. Signals + further apart than the burst window are independent first signals, so a long-lived session + legitimately interrupted once now and once ten minutes later is not torn down by the second. + Once `rune` has aborted, INT/TERM are restored to their default dispositions, so a third + signal during teardown kills `rune` outright — deliberately, as the last escape hatch. +26. Every wait on a signalled child is bounded, and the child's pty is drained while it dies. + Both are load-bearing on macOS rather than defensive: a pty child SIGKILLed while bytes it + wrote are still sitting unread in the pty buffer wedges *permanently* in the kernel's exit + path (`ps` reports `?Es`), and from there it is never reapable again — a blocking + `Process.wait2` never returns, `WNOHANG` polling never succeeds, and waiting minutes does not + help; only reading the pty master clears it. This is the ordinary shape of an abort, because + the last thing a child does on its way out is usually to print something, and it hung the real + CLI for over three minutes on a 20-second `--timeout` before the drain existed. The abort path + therefore reaps from inside the read loop, where the reader is still open. `--timeout`'s kill + path is bounded for the same reason but cannot drain — Ruby's internal timeout exception is + not a `StandardError`, so it cannot be caught while the reader is still in scope — so it gives + up on a wedged child rather than blocking forever. + +27. `--max-output` bounds `clean_output` and `raw_output` **independently, each to the same + budget**. That is the stated contract — the flag says "BYTES each" — and it is what a caller + sizing a context window wants, since both fields land under the cap. The consequence is not + stated anywhere and surprised a reporter: because a pty turns every `\n` into `\r\n` and + `raw_output` also keeps its escapes, the same budget lands at different points in the child's + output, so under this flag **`clean_output` is not `strip_ansi(raw_output)`**, and `raw_output` + carries its own marker with a different count. Measured on a 5,200-byte ASCII fixture at + `--max-output=200`: metadata `omitted_bytes: 5000`, raw's own marker `5070`, and with colour a + whole line of the child's output was present in one field and absent from the other. + + `omitted_bytes` is `clean_output`'s count. Deriving `clean_output` from the bounded raw instead — + which is what `session read` does, and correctly for its own contract — was rejected here: it + would cut the readable payload by the ANSI fraction on every colour-emitting child, against the + flag's stated purpose. A separate additive `raw_omitted_bytes` is a plausible future change. + +28. `omitted_bytes` reconciles exactly with the source on ASCII and does not on multi-byte + text. It is measured in offsets into the original, so when a cut splits a character the orphaned + fragment is in neither the result nor the count, and `scrub` may replace it with a longer + U+FFFD. Measured drift: 0-6 bytes on Hangul, up to 7 on 4-byte emoji, and 2-byte Latin-1 at 121 + of 245 budgets — not a CJK curiosity. A caller cannot verify how much was dropped by arithmetic + on non-ASCII input. Making it reconcile would mean discarding the split character's fragment, + which contradicts the scrub invariant, and redefining it would change the marker's rendered + length and could flip `truncated` for callers who changed nothing. + +29. `--max-output` and `--tail` bound `clean_stdout` and `clean_stderr` as well as the merged + fields. They were not bounded at all: a 200-byte budget returned 10,506 bytes across the four + fields, because only `clean_output` and `raw_output` went through `apply_output_limit`. A caller + sets the flag to cap what comes back, and adding `--separate-streams` — which surfaces the same + output twice more — should not silently uncap it. Measured after: 1,012 bytes for the same + budget. Each field is bounded to the same budget, which is the "BYTES each" contract already + stated, and their omitted counts are not surfaced for the same reason `raw_output`'s is not: + one reply carries one count and it is `clean_output`'s. With neither flag set the fields are + byte-for-byte unchanged. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/session.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/session.md new file mode 100644 index 0000000..122ef44 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/session.md @@ -0,0 +1,885 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `Session` | module | Namespace for persistent session support. | +| `Rune` | module | Top-level rune namespace. | +| `Commands` | module | Namespace containing concrete CLI command implementations. | +| `Store` | class | Per-session state on disk: `RUNE_HOME` resolution, owner-only dirs/files, `meta.json` read/write, liveness. | +| `Supervisor` | class | The detached process owning one session's PTY master and serving its control socket. | +| `Client` | class | One request/reply exchange against a session's control socket. | +| `Unavailable` | class | Raised when a control socket is missing or refuses a connection — how a dead supervisor presents. | +| `PromptScanner` | module | Reports whether the last non-blank line of text looks like an interactive prompt. | +| `Transcript` | class | One session's durable transcript: reconstruction, cursors across rotation, search and rendering. | +| `load` | class method | Reads a transcript log, returning the retained text and where in it the stream is not contiguous. | +| `record_gap` | class method | Records one dropped region at a retained offset, merging it with one already recorded there. | +| `text` | reader | The output the log still holds. | +| `dropped` | reader | Bytes of earlier output that was discarded, by rotation or by a write that failed. | +| `gaps` | reader | Each dropped region as the retained offset it sits at and the total dropped up to and including it. | +| `cursor` | instance method | Total bytes the child has produced, including everything discarded. | +| `from` | instance method | Everything from an absolute cursor onwards, as far as the retained text reaches. | +| `retained_offset` | instance method | Where an absolute cursor lands in the retained text, walking past each dropped region rather than subtracting one total. | +| `screen` | instance method | What a terminal of a given size would be showing. | +| `grep` | instance method | Lines matching a pattern with surrounding context, and how many matched. | +| `grep_text` | class method | Greps a given stretch of transcript, so a `--since` slice is searched rather than the whole of it. | +| `filter` | internal method | Applies `--grep` to a read, or fails the filter closed and reports why. | +| `Echo` | class | The pty's echo of one send, and where it ends in what has arrived back. | +| `ESCAPE_SEQUENCE` | constant | Escape forms removed when condensing text for echo location. | +| `PRINTED` | constant | A run of characters that survives condensing. | +| `ECHO_SEARCH_LIMIT` | constant | Ceiling on the slice searched for the echo, bounding per-tick cost. | +| `ECHO_COPY_MARGIN` | constant | How far around a match to look for a repainted copy of the input. | +| `REPAINT_MARGIN_FLOOR` | constant | Smallest window the repaint veto will consider, for a short echo. | +| `condense` | class method | Drops escapes and whitespace, so a transformed echo still matches what was sent. | +| `empty?` | instance predicate | Whether anything was sent to echo back. | +| `ends_at` | instance method | The character offset just past the echo, or nil while it is unlocated. | +| `repaint?` | instance method | Whether a repainted copy of the input covers a candidate match. | +| `PendingSend` | class | One in-flight `send` and the decision of when it has been answered. | +| `absorb` | instance method | Folds the bytes that arrived since the last tick into everything the send decides on. | +| `outcome` | instance method | The outcome for this tick, or nil to keep waiting. | +| `matchable` | instance method | The bounded text a `--wait-for-regex` pattern is matched against this tick. | +| `busy_at_send` | reader | Whether the child was still producing output when this send landed. | +| `client` | reader | The control connection waiting on this send. | +| `cursor` | reader | Transcript offset taken when the send was written, so the reply holds only its own output. | +| `compile_regex` | class method | Compiles `--wait-for-regex` with a bounded match budget, returning nil when absent or invalid. | +| `supports_regex_timeout?` | class predicate | Whether this Ruby can bound a single regex match. | +| `ECHO_GRACE_SECONDS` | constant | How long a prefix of the input may still be assumed to be the pty's echo. | +| `MATCH_WINDOW_BYTES` | constant | How much recent post-echo output a `--wait-for-regex` pattern is matched against. | +| `MATCH_WINDOW_SLACK` | constant | How far past that window output may accumulate before it is trimmed back. | +| `MATCH_SPAN` | constant | How far each scan resumes behind the last, and so the longest match always found. | +| `BLANK_CHARACTER` | constant | The first character that counts as the child having produced output of its own. | +| `UTF8_CONTINUATION` | constant | Bytes a window trim must not cut on, so the window stays valid UTF-8. | +| `REGEX_MATCH_TIMEOUT` | constant | How long one `--wait-for-regex` match may run before the pattern is abandoned. | +| `REGEX_TIMEOUT_ERROR` | constant | The regex-timeout error class, or an unraised stand-in on Ruby without one. | +| `DEFAULT_TIMEOUT_MS` | constant | Hard cap on a whole send when the caller does not set one. | +| `SessionCommand` | class | Subcommand `rune session `. | +| `home` | reader | Returns the resolved session-state root for this store. | +| `default_home` | class method | Resolves `RUNE_HOME`, treating an empty value as unset, else `~/.rune`. | +| `valid_name?` | class predicate | Accepts only session names safe to use as a directory component. | +| `alive?` | class predicate | Asks the OS whether a pid exists; `EPERM` counts as alive, a bad value as dead. | +| `process_start_times` | class method | Start times for the pids that still exist, keyed by pid, read from `ps` under `LC_ALL=C`. | +| `process_start_time` | class method | Start time for one pid, or nil when it is gone. | +| `parse_start_times` | class method | Parses `ps -o pid=,lstart=` output into a pid-to-start-time map. | +| `positive_pid` | class method | Coerces a value to a positive pid, or nil when it is not one. | +| `with_bindable_path` | class method | Runs a bind/connect against a path short enough for `sockaddr_un`, chdir-ing into the session directory when the absolute path is too long. | +| `sessions_dir` | instance method | Returns the directory holding every session. | +| `session_dir` | instance method | Returns one session's directory. | +| `meta_path` | instance method | Returns one session's `meta.json` path. | +| `MAX_LOG_BYTES` | constant | Ceiling on a session's transcript file before it is rotated. | +| `LOG_KEEP_BYTES` | constant | How much recent output a rotation keeps. | +| `rotate_output` | instance method | Rewrites the transcript keeping its recent tail, recording what was dropped. | +| `prepare_rotation` | instance method | Writes the replacement transcript to a temp path, without touching the caller's open handle. | +| `output_bytes` | instance method | Output bytes carried by one transcript line. | +| `tail_offset` | instance method | Byte offset of the first whole line within the keep bound of the end. | +| `output_bytes_from` | instance method | Stream bytes the region being kept accounts for — its output and any gap it already records — counting only whole records, without parsing them. | +| `whole_record?` | instance predicate | Whether a transcript line is a record the reader will parse, decided on its last byte; a line with no trailing newline is parsed instead. | +| `parseable?` | instance predicate | Whether one line parses as JSON. | +| `NEWLINE_BYTE` | constant | The byte `whole_record?` treats as a line terminator. | +| `CLOSE_BRACE_BYTE` | constant | The byte a whole NDJSON record ends on. | +| `output_size` | instance method | Current size of a session's transcript file. | +| `rotate_log` | internal method | Rotates the transcript once it reaches the ceiling, backing off rather than retrying an attempt that failed. | +| `HARD_LOG_CEILING` | constant | The size past which recording stops rather than growing, when rotation cannot succeed. | +| `ROTATE_RETRY_SECONDS` | constant | How long a failed rotation waits before it is attempted again. | +| `output_path` | instance method | Returns one session's NDJSON transcript path. | +| `socket_path` | instance method | Returns one session's control-socket path. | +| `exist?` | instance predicate | Reports whether a session directory exists. | +| `names` | instance method | Lists known session names in sorted order. | +| `create` | instance method | Creates a session directory and forces owner-only permissions. | +| `remove` | instance method | Deletes a session directory and its contents. | +| `write_meta` | instance method | Replaces `meta.json` atomically, with owner-only permissions. | +| `read_meta` | instance method | Reads `meta.json`, returning nil when absent or unparseable. | +| `update_meta` | instance method | Merges fields into existing metadata, or nil when the session is unknown. | +| `open_output` | instance method | Opens the append-only transcript for the supervisor's lifetime, owner-only and unbuffered. | +| `DEFAULT_DIR_NAME` | constant | Directory name used under the home directory when `RUNE_HOME` is unset. | +| `DIR_MODE` | constant | Owner-only directory mode for session state. | +| `FILE_MODE` | constant | Owner-only file mode for session state. | +| `NAME_PATTERN` | constant | Pattern a session name must match to be usable as a directory component. | +| `SOCKET_PATH_LIMIT` | constant | Path length beyond which socket bind/connect switches to a session-relative path. | +| `run` | instance method | Runs one supervised session: detach, bind the socket, spawn the child, serve until it ends. | +| `pump` | internal method | Reads and decodes one chunk from the pty, marking the child finished at EOF. | +| `append` | internal method | Appends decoded output to the transcript, records activity, and logs an event. | +| `handle_request` | internal method | Reads one JSON request line from a client and dispatches it. | +| `dispatch` | internal method | Routes one control request to its handler. | +| `handle_send` | internal method | Writes input to the child and either replies immediately or begins a pending settle. | +| `write_to_child` | internal method | Writes the request text to the pty and schedules the terminating carriage return as a separate write. | +| `schedule_submit` | internal method | Records when the terminating carriage return becomes due. | +| `deliver_submit` | internal method | Writes the terminator once its delay has passed and the text has drained. | +| `flush_submit` | internal method | Writes any outstanding terminator immediately, preserving order against a new send. | +| `transcript_bytes` | internal method | Total bytes the child has ever produced, which is what cursors count. | +| `slice_from` | internal method | Everything from an absolute cursor onwards, as far as the held window reaches. | +| `trim_transcript` | internal method | Drops output older than the attach backlog and older than any in-flight send. | +| `pending_text?` | internal predicate | True while a send's text is still queued for the pty master. | +| `undelivered_input?` | internal predicate | True while a previous send's text is queued and its terminator still owed. | +| `exit_status` | internal method | Normalizes a Process::Status into an exit code, mapping a signal to 128+n. | +| `UNDELIVERED_INPUT_ERROR` | constant | Error returned when a send arrives while previous input is still going out. | +| `await_exit` | internal method | Waits, bounded, for a cooperative shutdown to finish before force-killing. | +| `SUBMIT_DELAY` | constant | How long after a send's text the terminating carriage return is written. | +| `begin_pending` | internal method | Records the send cursor, settle window, regex, deadline, and echo for an in-flight send. | +| `resolve_pending` | internal method | Re-evaluates an in-flight send against new output once per loop tick. | +| `settle_pending` | internal method | Replies to an in-flight send and clears the pending state. | +| `handle_stop` | internal method | Acknowledges a stop request and ends the event loop. | +| `status_payload` | internal method | Builds the reply for a `status` request. | +| `respond` | internal method | Writes one JSON reply line and closes the client. | +| `accept_client` | internal method | Accepts a waiting control connection without blocking. | +| `finish` | internal method | Records the session's exit code and logs the closing event. | +| `reap` | internal method | Reaps the child and normalizes exit or signal status. | +| `cleanup` | internal method | Tears down pending clients, the child, the socket, and the transcript handle. | +| `resolve_orphaned_pending` | internal method | Replies to a send that would otherwise never be answered because the supervisor is exiting. | +| `terminate_child` | internal method | Kills and reaps a still-running child. | +| `safe_close` | internal method | Closes an IO, tolerating one already closed. | +| `writable_log?` | internal predicate | Whether the transcript handle is open, checked before a record is generated. | +| `log_event` | internal method | Appends one timestamped NDJSON event to the transcript, recording a write that failed as a gap rather than losing it. | +| `append_log` | internal method | Writes one event, preceded by the `truncated` event accounting for any pending gap; nil when the event did not reach the file. | +| `write_record` | internal method | Writes one NDJSON record, preceded by the torn marker when the last write failed; nil when nothing can be trusted to have landed. | +| `gap_line` | internal method | The `truncated` event that accounts for output no write could record. | +| `note_log_gap` | internal method | Adds an unrecordable event's output bytes to the pending gap. | +| `writable_log` | internal method | The transcript handle, reopened if it has gone away, or nil while it cannot be opened. | +| `gap_field` | internal method | The `transcript_gap_bytes` field, present only while a hole is still owed. | +| `TORN_MARKER` | constant | Line written ahead of the first record after a failed write, so any fragment that write left cannot parse. | +| `REGEX_MATCH_TIMEOUT` | constant | How long one `--wait-for-regex` match may run before the pattern is abandoned. | +| `REGEX_TIMEOUT_ERROR` | constant | The regex-timeout error class, or an unraised stand-in on Ruby without one. | +| `positive_int` | internal method | Coerces a request value to a positive integer, falling back to a default. | +| `monotonic` | internal method | Returns the monotonic clock reading used for settle and deadline arithmetic. | +| `CHILD_ENV` | constant | Environment forced on the child, neutralizing interactive pagers. | +| `POLL_INTERVAL` | constant | Event-loop tick used to poll the pty and re-evaluate a pending send. | +| `READ_CHUNK` | constant | Maximum bytes read from the pty per iteration. | +| `DEFAULT_ROWS` | constant | Rows given to the child's pty, since a detached session has no terminal to copy. | +| `DEFAULT_COLUMNS` | constant | Columns given to the child's pty, since a detached session has no terminal to copy. | +| `request` | instance method | Sends one JSON request and returns the parsed reply. | +| `available?` | instance predicate | Reports whether the control socket currently accepts a connection. | +| `prompt_at_end?` | module function | True when the last non-blank line of text looks like an interactive prompt. | +| `call` | instance method | Routes a `rune session` subcommand, including the hidden supervisor entry point. | +| `human_render` | instance method | Renders sessions, transcript output, or a structured summary for a terminal. | +| `supervise` | internal method | Hidden entry point that runs the detached supervisor for one session. | +| `await_ready` | internal method | Waits for the supervisor to report ready, treating an already-exited child as ready. | +| `abandon` | internal method | Tears down a supervisor that was spawned but never became usable. | +| `executable_path` | internal method | Resolves rune's own executable, used to re-invoke it as the supervisor. | +| `send_input` | internal method | Validates send arguments and performs the control exchange. | +| `send_payload` | internal method | Builds the control-socket payload for a send. | +| `validate_regex` | internal method | Rejects an invalid `--wait-for-regex` before anything is sent. | +| `exchange` | internal method | Performs one control-socket request against a live session, mapping failures to results. | +| `alive_session` | internal method | Returns a failure result unless the named session's supervisor is alive. | +| `read_transcript` | internal method | Serves transcript output with cursor, tail, and byte bounds. | +| `slice_from` | internal method | Returns transcript bytes at or after a cursor. | +| `compile_grep` | internal method | Compiles a `--grep` pattern, returning `[pattern, nil]` or `[nil, Ruby's own reason]`. | +| `grep_failure` | internal method | Builds the `grep`/`grep_error` fields for a pattern that would not compile; no `grep_matches`, because nothing was searched. | +| `render_output` | internal method | Renders a `send`/`read` reply for a terminal: `grep_error` first, then the stripped text. | +| `bound_size` | internal method | Applies `--max-output` or `--tail` to already-filtered text. | +| `bound_output` | internal method | Applies `--tail`/`--max-output` and reports what was omitted. | +| `list` | internal method | Describes every known session. | +| `describe` | internal method | Builds one session's row, recomputing state from real process liveness. | +| `stop` | internal method | Stops a session gracefully, then force-kills any survivor, idempotently. | +| `graceful_stop` | internal method | Asks the supervisor to stop over its control socket, tolerating an unreachable one. | +| `kill_remaining` | internal method | Force-kills any surviving child and supervisor, tolerating already-dead pids. | +| `extract_options` | internal method | Extracts session flags before the first `--`, leaving the wrapped command untouched. | +| `flag_to_validate?` | internal predicate | Whether a flag-shaped token in this position is one a mistyped spelling should be refused for. | +| `scan_flags` | internal method | Walks the pre-separator tokens, consuming flags and rejecting a mistyped one that precedes the first operand. | +| `unknown_flag_error` | internal method | Rejects a flag-shaped token that matches no session flag, instead of letting it be typed at the child. | +| `suggestion` | internal method | Offers the dash-for-underscore correction when that exact spelling is a real flag, and nothing otherwise. | +| `KNOWN_FLAGS` | constant | Every long flag `session` answers to, both spellings `separate_form?` accepts, derived from the parser's own tables. | +| `consume_flag` | internal method | Consumes one boolean or value flag at an argv position. | +| `consume_value_flag` | internal method | Consumes a value flag in either `--flag=value` or `--flag value` form. | +| `assign` | internal method | Coerces and stores one flag value, reporting a message on failure. | +| `separate_form?` | internal predicate | Matches the space-separated spelling of a value flag. | +| `dashed` | internal method | Renders an option key as its user-facing flag name. | +| `flag_alias` | internal method | Maps internal option keys whose flag names differ to those names. | +| `coerce` | internal method | Coerces a raw flag value according to its declared kind. | +| `integer` | internal method | Parses an integer flag value, enforcing positivity where required. | +| `name_error` | internal method | Builds the message for a missing or invalid session name. | +| `no_such_session` | internal method | Builds the message for an unknown session name. | +| `render_list` | internal method | Renders the session list for a terminal. | +| `render_orphan` | internal method | Prints the warning line naming a session's orphaned child pid, if it has one. | +| `render_archive` | internal method | Renders an `archive` reply, printing the orphaned-child warning after the envelope. | +| `store` | internal method | Returns the memoized store for this invocation. | +| `SUBCOMMANDS` | constant | User-facing session subcommands, used for help and error messages. | +| `START_TIMEOUT` | constant | How long `start` waits for the supervisor to report ready. | +| `VALUE_FLAGS` | constant | Maps each option key to its argv pattern and value kind. | +| `BOOLEAN_FLAGS` | constant | Maps valueless flags to their option keys. | +| `reset_transcript` | instance method | Clears a session's transcript so a reused name starts a lifetime whose offsets match its new supervisor. | +| `broadcast` | internal method | Writes one output chunk to every attached terminal, dropping any that has gone away. | +| `handle_attach` | internal method | Acknowledges an attach, replays the current screen, and promotes the client to a raw duplex pipe. | +| `recent_transcript` | internal method | The trailing slice of transcript replayed to an attaching terminal. | +| `forward_from_attached` | internal method | Forwards bytes typed on an attached terminal into the child's pty. | +| `ECHO_GRACE_SECONDS` | constant | How long after a send a prefix-of-input is still assumed to be the pty echo. | +| `ATTACH_BACKLOG_BYTES` | constant | How much existing transcript an attaching terminal is replayed. | +| `Attachment` | class | Connects a human terminal to a live session until the detach key is pressed. | +| `close_quietly` | internal method | Closes the control socket and prints the closing note, tolerating an already-closed socket. | +| `forward_keystrokes` | internal method | Sends typed bytes to the session, stopping at the detach key but still delivering what preceded it. | +| `render_output` | internal method | Writes one chunk of session output to the local terminal. | +| `DETACH_KEY` | constant | Ctrl-], the key that detaches and leaves the session running. | +| `ENDED_WHILE_ATTACHED` | constant | Message used when an attachment ends without the human detaching. | +| `DETACH_HINT` | constant | The detach instruction shown when a terminal attaches. | +| `CHUNK` | constant | Maximum bytes moved per read while attached. | +| `with_clean_output` | internal method | Adds the ANSI-stripped `clean_output` beside a reply's raw output, matching `rune run`. | +| `withhold_dangling` | internal method | Splits off a trailing unterminated escape sequence so it is not delivered or counted. | +| `read_result` | internal method | Builds a read reply from a loaded transcript. | +| `bounded_output` | internal method | Applies `--max-output`/`--tail` to a control-socket reply, deriving `clean_output` from the bounded raw text. | +| `conflicting_bounds` | internal method | Rejects `--max-output` combined with `--tail`, the pair `rune run` already refuses. | +| `attach` | internal method | Validates the session and hands a real terminal to it. | +| `GRACEFUL_STOP_TIMEOUT` | constant | How long `stop` waits for a cooperative shutdown before force-killing. | +| `DISPATCH` | constant | Maps each session subcommand, including the hidden supervisor entry point, to its handler. | +| `project` | reader | Returns the project slug this store is scoped to. | +| `project_slug` | class method | Builds a readable, collision-safe identifier for a project directory. | +| `project_root` | class method | The enclosing git working tree, or the directory itself outside one. | +| `canonical` | class method | Resolves a path through symlinks so one directory cannot get two project identities. | +| `projects` | class method | Lists every project that has session state under a home. | +| `project_dir` | instance method | Returns this project's directory under the home. | +| `archive_dir` | instance method | Returns this project's archive directory. | +| `archive` | instance method | Moves a stopped session into the dated archive, freeing its name. | +| `archived_names` | instance method | Lists archived session directories for this project. | +| `generate_name` | instance method | Picks an unused `-` codename for a command. | +| `CODENAMES` | constant | Word list paired with a tool name to form generated session names. | +| `archive_session` | internal method | Archives a stopped session after validating it. | +| `archive_rejection` | internal method | Returns the failure that blocks an archive, or nil to proceed. | +| `with_orphans` | internal method | Adds `orphaned_child_pid` to each listed session whose child provably outlived its supervisor. | +| `orphaned_pids` | internal method | Maps session names to child pids that are provably still alive, in one batched `ps`. | +| `orphan_candidate` | internal method | One session's `[name, pid, recorded start time]`, or nil when the question cannot be asked soundly. | +| `still_running` | internal method | Message explaining that a session must be stopped before archiving. | +| `list_archived` | internal method | Lists this project's archived sessions. | +| `list_all_projects` | internal method | Lists live sessions across every project, labelled by project. | +| `activity` | internal method | Reports idle time and the last meaningful line from a session's transcript tail. | +| `tail_events` | internal method | Parses the trailing NDJSON events of a transcript without reading the whole file. | +| `summarize` | internal method | Reduces an output chunk to one readable, escape-free line. | +| `idle_suffix` | internal method | Renders idle time for the terminal session list. | +| `await_death` | internal method | Waits for signalled pids to disappear so `stop` is complete when it returns. | +| `ACTIVITY_TAIL_BYTES` | constant | How much of a transcript's tail `list` reads for activity reporting. | +| `ACTIVITY_LINE_LIMIT` | constant | Maximum length of the reported last line. | +| `DEATH_TIMEOUT` | constant | How long `stop` waits for signalled processes to actually exit. | +| `pending_client` | internal method | The in-flight send's socket, watched so a caller that goes away is noticed. | +| `discard_disconnected_pending` | internal method | Releases an in-flight send whose caller has closed its socket. | +| `client_gone?` | internal predicate | True when a readable client socket is at EOF rather than carrying data. | +| `read_request_line` | internal method | Reads one control request within a bound, so a partial line cannot freeze the loop. | +| `kill_group` | internal method | Signals the child's process group, falling back to the single pid. | +| `REQUEST_READ_TIMEOUT` | constant | How long one control request may take to deliver a complete line. | +| `MAX_REQUEST_BYTES` | constant | Largest control request accepted before the client is dropped. | +| `readiness` | internal method | Reports :ready, an error, or nil to keep waiting during start. | +| `serving?` | internal predicate | True when a session records running, has a socket, and its supervisor is alive. | +| `supervisor_died` | internal method | Message pointing at supervisor.log when the supervisor exited during start. | +| `client_ceiling` | internal method | Caller-side bound on a send, so a wedged supervisor cannot hang the caller. | +| `kill_process_group` | internal method | Force-kills a child and its workers by process group. | +| `kill_pid` | internal method | Force-kills a single pid, tolerating one already gone. | +| `DEFAULT_SEND_TIMEOUT_MS` | constant | Mirrors the supervisor's send timeout so the caller's ceiling is never tighter. | +| `CLIENT_TIMEOUT_MARGIN` | constant | Slack added to the caller's ceiling so it never pre-empts a legitimate wait. | +| `lock_path` | instance method | Returns the per-session start lock path. | +| `with_start_lock` | instance method | Serialises `start` for one session name under an exclusive lock. | +| `enqueue` | internal method | Queues bytes for an IO and attempts an immediate non-blocking flush. | +| `drain_outbox` | internal method | Flushes queued bytes to every IO the event loop reported writable. | +| `flush_outbox` | internal method | Writes as much of one IO's queue as it will take without blocking. | +| `drop_writer` | internal method | Handles an IO that failed to accept a write, distinguishing the pty from a terminal. | +| `detach` | internal method | Removes an attached terminal and restores the headless size when it was the last. | +| `reap_idle_clients` | internal method | Closes control connections that connected and never sent a request. | +| `send_rejection` | internal method | The reason a send cannot be accepted, or nil to proceed. | +| `handle_resize` | internal method | Applies a resize request sent over its own control connection. | +| `resize_child` | internal method | Sets the child's pty dimensions and signals SIGWINCH so it re-lays-out. | +| `record_window_size` | internal method | Records the child's current winsize in meta, so `--screen` can render at it. | +| `MAX_ROWS` | constant | Ceiling on a row count arriving over the control socket, applied to the pty and the record. | +| `MAX_COLUMNS` | constant | Ceiling on a column count arriving over the control socket, for the same reason. | +| `MAX_OUTBOX_BYTES` | constant | Ceiling on undrained output for one attached terminal before it is dropped. | +| `DEFAULT_SETTLE_MS` | constant | How long the child must be quiet before a send is considered answered. | +| `EXIT_SUPERVISOR_CRASHED` | constant | Exit code recorded when the supervisor itself died rather than the child. | +| `crashed` | internal method | Records why the supervisor died and finishes the session. | +| `child_still_talking?` | internal predicate | True when the child produced output within the settle window at send time. | +| `serialized_launch` | internal method | Runs the conflict check and launch for one name under the start lock. | +| `screen_after` | internal method | Renders the settled screen for `send --screen`, client-side. | +| `screen_fields` | internal method | The rendered screen and the size it was rendered at, for `--screen` on either command. | +| `window_size` | internal method | The child's last recorded winsize, resolved to a usable one. | +| `liveness` | internal method | The child's state and exit code, on every send and read rather than only on `list`. | +| `busy_fields` | internal method | Whether the child printed within the settle window, and how long since. | +| `read_payload` | internal method | Builds the result body for a transcript read. | +| `ALIASES` | constant | Internal option keys whose user-facing flag is not their name with dashes. | +| `flag_name` | internal method | What to call a flag when speaking to the person who typed it. | +| `GENERATED_NAME_ATTEMPTS` | constant | How many codenames a start without `--name` tries before giving up. | +| `REPLY_DRAIN_TIMEOUT` | constant | How long teardown keeps pushing out replies that are already queued. | +| `drain_replies` | internal method | Delivers queued replies before teardown closes their sockets. | +| `start_rejection` | internal method | Returns the failure that blocks a start, or nil to proceed. | +| `running_conflict` | internal method | Returns a failure when the name already has a live supervisor. | +| `launch` | internal method | Creates session state, spawns the supervisor, and waits for readiness. | +| `spawn_supervisor` | internal method | Re-invokes rune's executable as the detached supervisor for one session. | + +> Note: `conclude`, `handshake`, `with_raw_terminal`, `connect`, `name_base`, `socket_live?`, +> `terminal_size`, `forward_resize`, `forward_pending_resize`, `reset_log_state`, +> `write_atomic` and `with_resize_forwarding` are +> intentionally absent from the table above. They exist and are exercised by the suite, but +> SpecSync's Ruby extractor does not surface them from their position in the class body +> (rune#20 / spec-sync#479), and documenting an export it cannot see fails the contract check. +> The membership of this list is not stable: it moves whenever a neighbouring declaration is added +> or removed, which is the position-dependency the upstream issue describes — `serialized_launch` +> became visible purely because the method that followed it was deleted. +> This matches the existing convention in `pty_runner`'s spec for the same upstream bug. + + +### SPEC SECTION Invariants + +1. A started session's child survives both the launching `rune` process exiting and the launching + terminal closing. The supervisor calls `Process.setsid` (rescued where unsupported) and is + spawned with detached stdio, so it is not in the launcher's session or process group. +2. Exactly one supervisor process owns a given session's PTY master for that session's lifetime. + There is no central daemon. +3. The control channel is a Unix domain socket (`control.sock`), not a FIFO: one JSON request line + in, one JSON reply line out. A FIFO was rejected because a reader sees EOF whenever the last + writer closes, and because it cannot return a reply to the caller. +4. Settle detection runs in the **supervisor**, which owns the output stream and therefore knows + exactly when new bytes arrive — not in the client by tailing a file and guessing. The supervisor + is single-threaded: a `send` that blocked its handler would stop draining the pty, stall the + child on a full buffer, and guarantee the settle window never elapses. +5. `send` frames its response by taking the output cursor **at send time** and returning only bytes + after it, so output already on screen before the send is never misattributed to it. +6. `send` returns on whichever comes first, and **which conditions race depends on whether a + pattern was given**. Without `--wait-for-regex`: no new output for `--settle-ms`, the child + exiting, or `--timeout-ms` elapsing. With one, quiet is **not** among them — the send answers on + a match, the child exiting, or `--timeout-ms`, and `--settle-ms` has no effect on it. + + That is deliberate and was a fix, not an oversight. Quiet used to answer a regex send, so + `--wait-for-regex DONE --settle-ms 800` returned `settled: true, matched: nil` at 800ms against a + child that printed DONE five seconds later, 3/3 — and the documented workaround for the settle + defect did not work at the default settle window. An earlier version of this invariant listed + four racing conditions, and `--help` described the flag as an accelerator that returns "without + waiting out the settle window"; both read as though settle still applied, and callers lost whole + `--timeout-ms` windows to the difference. + + A `--timeout-ms` cap returns what was captured with `settled: false` and `timed_out: true` rather + than failing. A regex send additionally reports `matched: false` there, so the field is present + however the send ended and a caller reading it as a tri-state is not told `nil` for both "no + match" and "not a regex send". +7. The settle clock only starts once output arrives that is **not** the pty's echo of the input. A + pty in cooked mode echoes whatever is written straight back, so counting the echo as "the child + started answering" would settle a send on the caller's own words while the child was still + thinking. The echo is still included in the returned output — dropping data silently would be + worse than noise the caller can see. +8. Input is terminated with a carriage return, not a line feed, because that is what a real + terminal sends for Enter. Raw-mode TUIs — which is most agent CLIs — listen for `\r` and ignore + `\n`, so an `\n` terminator leaves the text sitting unsent in the child's composer. Cooked-mode + children are unaffected because the line discipline translates `\r` to `\n` on input. +8a. That terminator is written **separately from the text, after a short delay**, so the child + cannot receive both in one read. An agent TUI treats a large chunk arriving in a single read as a + paste, and a carriage return inside a paste is a newline in the composer rather than Enter. + Writing them together therefore typed the prompt and never sent it: measured against Claude Code, + 61 characters submitted and 82 did not, and every longer input sat unsubmitted while rune reported + a clean settle — with an agent prompt almost always longer than that. Splitting the write fixes it + for every length tried up to 262 characters, on claude, grok and agy alike. An outstanding + terminator is flushed immediately if another send arrives first, because ordering matters more + than the delay. The delay is measured from the last text byte actually going out, not from when + the send arrived: draining and delivery happen in the same tick, so a deadline already past would + fire microseconds after a backpressured write finished and land in the child's same read — the + exact coalescing the delay exists to prevent. +9. `prompt_detected` is advisory metadata and **never** gates whether a call returns. + `PromptDetector` matches shell-shaped prompts and is deliberately conservative, so it is usually + `false` for exactly the agent REPLs this module exists to drive. Waiting for a prompt would hang + on most real targets; settle-time is the primary signal and `--wait-for-regex` the deterministic + escape hatch. +10. The child's pty is given an explicit window size. A detached session has no controlling terminal + to copy dimensions from and an unset pty defaults to 0x0, which leaves a full-screen TUI agent + rendering into nothing. Every size the supervisor *changes* the child to is recorded in + `meta.json`, so a process that is not the supervisor can render the transcript at it. The + starting default is deliberately not recorded: an absent size renders at exactly those + dimensions anyway, and writing it would put a second meta write immediately after + `record_running`, against the parent's own update during launch. Only a size that actually + changed is written: a human dragging a window edge emits a SIGWINCH per frame, and each one + would otherwise rewrite the whole file on the thread that must keep pumping the pty. +11. Output is decoded incrementally as UTF-8 via `UTF8StreamDecoder`, same as `PTYRunner`/ + `PTYWatcher`: incomplete multi-byte suffixes are retained across reads. +12. The transcript is an append-only NDJSON log using the **same event vocabulary `PTYWatcher` + emits** (`start`, `output`, `exit`, each with a float `ts`), so one format serves both features + and `tail -f` works on a live session. +13. `read` is served by replaying that transcript from disk rather than over the control socket, so + it works identically for a live session and one whose supervisor has exited. Cursor offsets + agree with `send`'s because both count the same concatenated decoded output. +14. `list` determines liveness by checking the recorded pids directly, never by trusting + `meta.json`'s recorded state, so a supervisor that died without cleanup reports `dead`. A + session that exited on its own or was stopped deliberately reports `exited`/`stopped` instead, + so the stale case stays distinguishable from the ordinary ones. +15. `stop` kills and reaps both the child and the supervisor, leaves no orphan, and is idempotent. +16. A `start` that fails after spawning tears down the supervisor it spawned, so a failed start + leaves no process holding a pty for a session the caller was told does not exist. +17. A child that has already exited is a *ready* outcome for `start`, not a startup error: a + short-lived command legitimately finishes faster than `start` can observe it. This also keeps + the missing/non-executable case consistent with `rune run` and `rune watch`, where 127/126 is + the child's exit status on a successful `Result`. +18. Sessions are scoped to a project, so the same name in two checkouts is two different sessions + and neither is reachable from the wrong directory. The project is the enclosing git working + tree, or the directory itself outside one, resolved through symlinks so one tree cannot acquire + two identities. `list` shows the current project only; `--all-projects` opts out. +19. `--name` is optional for `start` and required by every other subcommand. When omitted an unused + `-` codename is generated, so a session always *has* a name without an agent having + to invent one — and so "the grok session" is unambiguous once there are two. +20. `archive` moves a stopped session out of the live namespace, freeing its name and keeping it out + of `list`. An archived session is never reachable as a live one, and archiving refuses while the + session is still running. +21. `stop` is observably complete when it returns: it waits for the signalled processes to actually + disappear, so an immediately following command cannot still see the session as running. +22. `list` reports `idle_ms` and a `last_line` summary per session, read from the tail of the + transcript rather than the whole file. This is what answers "is it stuck, and what was it last + doing" when several agents run at once. +23. Session state lives under `RUNE_HOME` (default `~/.rune`). The session directory is `0700` and + `meta.json`, `output.ndjson`, `supervisor.log`, and `control.sock` are `0600`, matching the + owner-only precedent already set for `rune watch`'s default event log. Sessions live under + `$RUNE_HOME/projects//sessions/`, archives under `.../archive/`. +24. Socket binding and connecting tolerate a long `RUNE_HOME`: `sockaddr_un` caps a path at 104 + bytes on macOS, which an ordinary deep home or any temp-dir-based test exceeds, so both ends + bind relative to the session directory when the absolute path is too long. +25. Rune's own flags are recognized only before the first `--`, same discipline as `rune run`, so a + wrapped command's identically named flags pass through untouched. `--name=NAME` and + `--name NAME` are both accepted. +26. `attach` connects a real terminal to a live session: the child's output streams to the screen, + local keystrokes are forwarded to it, and the current screen is replayed on connect so the + terminal does not sit blank until the child next repaints. Detaching (Ctrl-]) leaves the child, + the supervisor, and the session state untouched. `Ctrl-C` is deliberately *not* the detach key — + it must keep reaching the child so an attached human can interrupt a runaway agent. +27. Output is broadcast to every attached terminal, and a terminal that goes away is dropped rather + than allowed to break the event loop or kill the session. +28. A control client can never take the session down with it: a broken, reset, or half-written + request closes that client only. Before this was enforced, an `ECONNRESET` unwound the event + loop and the teardown path then SIGKILLed a perfectly healthy child. +29. A `send` that races the child's exit is answered with an error, not a dropped connection: the + write can fail after the last `pump` observed the child as alive, and that must still leave the + session's recorded state correct. +30. On an explicit stop the child is signalled before it is reaped. `Process.wait2` on an unsignalled + long-lived child never returns, which previously left the supervisor wedged short of recording + its own exit — visible only because the CLI force-kills afterwards. +31. `stop` bounds its cooperative shutdown and always proceeds to the force-kill. It is the + documented recovery path for a session that is *already* misbehaving, so it cannot depend on the + supervisor's normal reply guarantee. +32. Each supervisor lifetime owns exactly one transcript: starting a session under a previously used + name resets it. Otherwise `send` cursors (which restart at zero with the new supervisor) and + `read` offsets (which replayed the whole file) silently disagreed, and `read` returned a dead + session's output as if it were this one's. +33. `--wait-for-regex` is matched against the output *beyond* the pty's echo of the input, never + the raw slice. Matching the raw slice meant waiting for a marker you had just asked the agent to + print returned the caller's own echoed words immediately — and since that is the normal way the + flag is used, the documented deterministic escape hatch was the least reliable path available. +34. Echo suppression locates the echo within what has arrived rather than requiring it at the + cursor. The cursor is taken the instant input is written, so bytes the child was already + emitting (the tail of a previous prompt, a redraw) can arrive first. Until a copy of the input + is found, nothing is offered to the pattern for `ECHO_GRACE_SECONDS`; past that window what has + arrived is offered *provisionally*, because a child that never echoes at all would otherwise + hang every send to it. Provisional means the search continues: a child whose echo lands a + second late is not a child that did not echo, and when its copy turns up the offer is withdrawn + and the boundary set behind it. Output offered provisionally is therefore never latched as "the + child has spoken" — abandoning the search at the grace window instead was measured to settle + such a send on the echo alone, 0.8s after it arrived and a second before the child had said + anything of its own. +35. An in-flight send whose caller goes away is released as soon as its socket reports EOF, rather + than held until `--timeout-ms`. Otherwise one cancelled call locked the session for the whole + timeout — two minutes at the default — refusing every later send. +36. `send` bounds its own wait client-side at the requested `timeout-ms` plus a margin. The + supervisor normally guarantees a reply, but that guarantee does not hold when it is wedged, and + without a ceiling a stalled supervisor became a permanently hung caller. +37. `start` treats a session as ready only when the supervisor process is actually alive, not merely + when `meta.json` says `running` and the socket exists — a supervisor can record both and then + die. It also fails immediately once the supervisor is gone rather than waiting out the start + timeout for an answer that is already certain. +38. Teardown signals the child's process *group*. Agent CLIs routinely spawn workers, and signalling + only the recorded pid left those running after `stop`, holding ptys and ports where they could + collide with the next session for the same tool. +39. A control client can never take the session down: unexpected errors while handling a request + close that client only, a request line that never completes is abandoned after a short bound, + and a full disk while logging does not end the session. +40. Every directory rune creates under `RUNE_HOME` is owner-only, not just the leaf session + directory, so the set of tools being driven and their session names is not world-readable. +40a. `meta.json` is replaced, never truncated in place: the JSON is serialised first, written whole + to a private per-pid temp path, and renamed over the target, the same shape `rotate_output` + already uses. Every other rune process answers "does this session exist, and is it alive?" out of + this file with no lock to take, so an instant where it is short or empty is an instant where + `send` says "No such session", `list` reports `state: dead`, and `read --screen` loses the + recorded geometry. That was rare while meta was written a handful of times per session and stops + being rare once the winsize is recorded — a human dragging a window edge emits a SIGWINCH per + frame. Measured through a real attach dragged across 250 window shapes in 7.5 seconds while + another process did exactly what `alive_session` does: 90 of 294,728 reads came back unreadable + with the truncating write and 0 of 312,582 with the rename. The temp path carries the + writer's pid because two processes write this file — the CLI records `state`/`supervisor_pid` + while the supervisor records `state`/`child_pid` and the winsize — and a shared temp path would + let them interleave into one corrupt file that then got renamed into place. +41. Nothing on the event-loop thread blocks on a write — including control replies and the attach + acknowledgement, which are queued like everything else and whose client is closed only once the + reply has actually drained. Output to the child and to attached terminals is queued and drained + when the destination reports writable, so a child that stops reading stdin, or a peer that stops + reading, never costs the session its ability to pump the pty, evaluate a settle, or handle + `stop`. +41a. Queued output for an *attached terminal* is bounded. A terminal whose queue exceeds the ceiling + is dropped and its queue discarded, so one that accepts an attachment and then never reads + cannot grow the supervisor's memory without limit. The ceiling applies to nothing else: not to + the pty master, because a child that is slow to read is the session rather than a peer to be + disconnected, and not to control replies, because a reply is an answer the caller is blocked on + — discarding one reports a send as unreachable that in fact completed, and an agent then repeats + a turn the child already did. +41b. Closing an IO unregisters it from every structure the event loop selects on. A closed + descriptor reaching `IO.select` raises, which would unwind the loop and let teardown kill a + healthy child, so the bookkeeping lives in one place rather than at each call site. +41c. A reply that has been queued is delivered before teardown, within a short bound. One + `write_nonblock` takes at most a socket buffer's worth, so any reply larger than that is still + partly queued when the loop exits — and the loop exits as soon as the child is gone and nothing + is pending. Draining at teardown is what makes an answer survive the child that produced it; the + bound is what stops a caller that has stopped reading from holding the supervisor open. +41d. A send whose write to the pty failed is reported as an error, never as sent. A queued write + reports a dead master by marking the child finished rather than by raising, so a `--no-wait` + send — which has no later settle to catch it — must check for that before answering. +41e. A supervisor that dies for any reason records why and leaves the session marked finished. It + previously died silently: `meta.json` still read "running" with no exit code, no exit event was + logged, and supervisor.log was empty, so nothing anywhere named the cause. The cause is written + to the transcript as a `crash` event and to stderr, and the exit code becomes 70 (EX_SOFTWARE), + distinct from any status the child could return. +41f. Echo tracking counts characters, never bytes. `String#index`, `String#[]` and `start_with?` are + character-based, so mixing in a byte length both overshot the echo for non-ASCII input and asked + for more characters than existed — the latter yielding nil and raising, which killed the whole + supervisor and took the agent CLI with it. Multibyte output inside the echo grace window is the + norm for an agent TUI (spinners, box drawing), not an edge case. +41g. A send issued while the child was still producing output is reported as `busy_at_send: true`. + That is when the reply is most likely to be the previous turn's answer rather than this one's. +8b. A send is refused while a previous send's text is still going out. A `--no-wait` send sets no + in-flight guard, so a second send can arrive mid-drain on a backpressured pty; accepting it + would force the outstanding terminator out alongside undelivered text, putting both in one + write and one read — the coalescing 8a exists to prevent, reintroduced by the guard that + preserves ordering. +8c. Nothing that has already arrived can settle a send whose terminator has not gone out yet. While + the input is unsubmitted only the hard limits apply — the deadline and the child exiting — + because otherwise a small `--settle-ms`, or a regex matching a composer repaint, answers the + send in the same tick its carriage return is written, reporting the screen as it was before the + child was even given the line. +38a. `stop` lets the cooperative shutdown actually happen before force-killing, bounded. Acking the + stop only sets a flag; the supervisor tears down on its next tick, and killing it in between + meant the graceful path never once ran — an in-flight send's caller got "supervisor closed the + connection without replying" instead of its captured output, and the control socket was left on + disk. +38b. A session's recorded exit code reflects how the child actually died. `terminate_child` keeps + the status it waited for; discarding it left `reap` to hit `ECHILD` on an already-reaped child + and return a hardcoded 0, so a session killed on `stop` reported `exit_code: 0` as though it had + exited cleanly. +38c. Killing a child's process group is not conditional on the leader still being alive. A group + outlives its leader, so an agent CLI whose wrapper exits while its workers run left a live group + behind a dead pid, and checking the leader first skipped the kill and orphaned exactly those + workers. +41p. `read` reports `child_busy` and `idle_ms`, derived from the transcript's own timestamps so they + work for a stopped session too. Without them a caller had to grep the callee's rendered UI for a + busy marker, which is presentation rather than API. The flag says the child is *printing*, not + that it is *working*: a child that backgrounded a command and went quiet reports false. +41r. A mistyped flag is refused, not typed at the child. `send --name=x --settle_ms 500 'echo + HELLO'` matched no flag, so the flag, its value and the input were joined with spaces and + written to the child, which answered `status: ok`. Two limits keep the refusal from catching + anything that works: nothing after the first `--` is examined, so `send --name=x -- --settle_ms` + still types `--settle_ms`; and nothing after the first operand is examined, so + `start --name=x claude --resume` and `send --name=x git log --oneline` are untouched. `---` and + `--- section ---` are not flag-shaped and are sent as typed. + +41q. A `--grep` pattern that will not compile selects nothing, and the read returns nothing. It used + to return the entire transcript under `status: ok` — the exact opposite of the same read with a + valid pattern that matches nothing, so a caller that did not read `grep_error` saw every line as + though it had matched, at the maximum possible cost. `grep_matches` is absent rather than `0`, + because no search happened. The read still succeeds: `cursor`, `dropped_bytes`, + `prompt_detected`, `idle_ms`/`child_busy` and `screen` have no bearing on the pattern, and a + failure would take the cursor the caller needs down with them. `send` still rejects a bad + `--wait-for-regex` outright, because there the pattern decides when to return. + +41o. `read --grep` filters the *cleaned* text, not the repaint stream. A full-screen agent's frames + split words across escape sequences, so a pattern plainly visible on screen does not match the + bytes. The reply carries `grep_matches`; an unparseable pattern is reported as `grep_error` + rather than raised, because a bad regex from a caller is not a reason to fail a read. + + Three limits, all measured, that "filters the cleaned text" does not make obvious. Overwritten + history still matches and comes back as a clean standalone line, so a match can be text the + screen has not shown since. A cursor-painted frame contains no line breaks at all, so it is one + grep line: `--context` is inert and a single match returns the whole frame under a plausible + `grep_matches: 1`. And a pattern anchored to what is visible on screen can return + `grep_matches: 0`, because adjacency on the screen is not adjacency in the stream. The flag's + own help claimed it matched "the rendered text rather than the repaint stream", which is the + opposite of what it does; that is corrected. Use `--screen` when the question is what is + currently displayed. +41p. `--grep` searches the slice `--since` selected, not the whole transcript. It was handed the + slice and then discarded it, so `--since` had no effect on a grepped read: a read from a cursor + recorded after the first line still returned that line, and `grep_matches` counted it. A caller + paging with `--since=` got the entire history back on every page, under a count + that looked like it had filtered. Context windows are taken within the slice, so a match on its + first line has no preceding context to show — which is the same thing `--since` already means + for every other field in the reply. + +41n. Rotation costs no measurable memory. It seeks to the cut point rather than scanning what it is + dropping, reads the byte count each event already records rather than parsing the event, and + copies with `IO.copy_stream` so the bytes never enter Ruby. The first implementation read the + whole file and parsed every line twice, which put resident memory up 229MB the moment a + rotation ran; streaming the lines but still parsing them still cost 96MB. Bounding the disk is + not worth a memory spike larger than the problem it solves. +41m. A session's transcript file is bounded too, and rotation never makes a cursor lie. The + in-memory window stopped resident memory tracking output, but the file kept every byte for the + life of the session and `archive` moves it rather than pruning, so that cost outlived the + session that paid it — a 150-second run at 500KB/s left 80MB behind permanently. Rotation keeps + the recent tail and records what it dropped in a `truncated` event, so cursors stay absolute: a + cursor taken before a rotation still names the same position in the stream, and `read` reports + `dropped_bytes` rather than silently returning less than was asked for. +41w. A transcript write that fails is *recorded*, not merely survived. The in-memory cursor has + already advanced — those bytes really were produced — so a hole nothing accounts for makes every + cursor `send` hands out unresolvable by `read`, permanently and silently. Reproduced on a full + filesystem by the durability prototype this is taken from, and carried over rather than + re-derived here: `send` answered `cursor: 1849946` while `read` reported 187221 with + `dropped_bytes` nil, `read --since=1849946` returned "" for the rest of the session, and freeing + the disk made it worse because logging resumed over the hole without a word. Output that no + write could record is carried and emitted as a `truncated` event by the next write that + succeeds — the same vehicle rotation already uses — so `read` resolves a pre-hole cursor again + and reports `dropped_bytes`. While the hole is still owed there is nowhere on disk to record it, + so the supervisor reports `transcript_gap_bytes` on `status` and on the `send` reply that hands + out the unresolvable cursor; that is the only place the skew is known at all. +41x. "Recorded" means exactly "its own write returned". A write that fails part-way leaves a + fragment, and a fragment can be a *complete* JSON record that merely never got its newline — + which a later append would silently terminate, counting a gap twice. So each record is written + on its own, and the first record after a failure is preceded by `TORN_MARKER`, which makes any + dangling fragment unparseable. Swept here across every split point of the record carrying a gap: + 0 disagreements in 91 cases between the reconstructed cursor and the supervisor's own. +41y. A cursor is mapped through *each* dropped region, not past one running total. `since - dropped` + is correct only while the dropped region is a **prefix** of the stream, which rotation + guarantees and a failed write does not: a hole in the middle shifts output the cursor is not in + front of, and every cursor issued before the hole then resolves |hole| bytes early — already + delivered output, handed back as new, which re-fires prompt detection and every "did my command + finish" check built on it. Measured on 25 chunks x 4000B, a 48_000-byte mid-stream hole and 25 + more (cursor 248_000, dropped 48_000): `from(100_000)` returned 148_000 bytes beginning at the + start of the stream, 48_000 of them already delivered, against 100_000 beginning after the hole. + Each region is recorded as (retained offset, cumulative dropped) at load, a cursor landing + inside one clamps **forward** to its end — those bytes are gone either way, and later output is + honest where earlier output is not — and a single prefix collapses to exactly the old + arithmetic, byte for byte, at every probe of every rotation case. +41z. A rotation counts exactly the bytes the reader will reconstruct from the region it keeps, since + the head event it writes is `total_output - kept`. Two ways that was wrong, both permanent and + silent, both measured here on ~11MB transcripts rotated with the region in the kept tail: a + `truncated` event inside the tail was not counted, so a hole recorded mid-stream was counted + twice and every later cursor sat **+400_000** bytes past the end of the stream; and a fragment + left by a torn write *was* counted although `Transcript.load` cannot parse it, so every later + cursor sat low — **-4096/-16384/-40960** for 1/4/10 torn writes, scaling with the + outage because `TORN_MARKER` terminates each fragment into a countable line of its own. That + this is worse than 0.8.0's flat -4096, where a fragment and the record after it merged into one + unparseable line, is the prototype's measurement carried over and was not re-derived here. A + real outage moves both dials at once and they do not cancel: a torn write plus the gap it opened + measured **+16384**. All four cases, and a healthy transcript that must not move at all, measure + 0 once a line counts only if it is a whole record and a `truncated` in the tail counts as the + bytes it names. The test is the line's last byte rather than a parse, because parsing the kept + region cost 96MB per rotation (41n); `TORN_MARKER` is what makes that exact, since a fragment it + terminated ends in `n` and cannot parse either. The one shape a byte test cannot decide is a + fragment the file simply ends on, of which there is at most one, so a line with no trailing + newline is parsed outright: swept over every split point of every record shape, with braces, + quotes, escapes and the marker's own bytes inside the payload, 0 disagreements in 1760 cases, + against 10 with that branch removed. +41j. The supervisor holds a bounded *window* of output, not all of it. Cursors remain absolute byte + offsets into the whole stream, because `read` serves them client-side from the transcript file; + the process itself only needs the attach backlog and whatever the current send has produced, + and never trims past a live send's cursor however long that turn runs. A persistent session is + the entire feature, so this is not a detail: measured before the bound existed, resident memory + tracked output one-for-one — 27MB to 69MB in eighty seconds at 500KB/s — and never came down. + After it, resident memory plateaus: over one 150-second run the last 60 seconds added 30MB of + output and 0.16MB of memory. +41aa. Resolving an in-flight send costs the bytes that just arrived, never everything the turn has + produced. Both halves of that were quadratic and both starved the pty drain, because the same + thread does the copying and the pumping. The pattern was matched against the whole accumulated + slice on every 4 KB read — 66.69s inside the echo search and 17.65s inside the match, for a + 12 MB turn that then reported `settled: false, timed_out: true` at 90.51s while holding 11.46 MB + of a 12.00 MB answer whose completion marker the child had already printed. Underneath it, the + supervisor built that slice with `byteslice`, which marks a mutable String *shared*, so the very + next `<<` copied the whole transcript to make it independent again: one copy of the turn per + read, 85% of a sampled 24 MB profile, and the reason a plain `send` with no pattern at all was + superlinear too (48 MB in 118.87s). The send is now fed what `append` just received and holds + bounded state; the full slice is built once, on the tick that answers it. Measured after: 12 MB + settles `matched: true` in 0.98s with all 12.15 MB read, and 48 MB in 3.37s. +41ab. A `--wait-for-regex` pattern is matched against the most recent `MATCH_WINDOW_BYTES` of + post-echo output, with each scan resuming `MATCH_SPAN` characters behind where the last one + stopped. That resumption is the guarantee worth stating: any single match up to `MATCH_SPAN` + characters long is always found, because on the tick that completes it the scan still begins + behind where it started. A single match that must span more than that is never found — the + deliberate cost of the bound, documented in `docs/sessions.md`. The scan is resumed by position + rather than against a substring so that `\A` keeps meaning the start of the child's answer and + cannot be satisfied by wherever the window happens to begin. None of this bounds the reply: + `output` remains everything the child produced for the turn. +41i. A `--wait-for-regex` match is bounded, and a pattern that exceeds its budget is abandoned with + `regex_timed_out: true` rather than retried. Matching runs on the only thread, so a pattern that + backtracks catastrophically blocks the loop: it cannot pump the pty, answer `stop`, or even + check the send's own `--timeout-ms` — reproduced with `(a+)+\1$` against 60 `a`s, where the send + was still blocked long after its 8s deadline. Retrying next tick would spend the budget again on + a slice that only grows, so giving up on the pattern is the only outcome that ends. +41h. `--screen` on `send` and `read` returns the rendered terminal in addition to the byte stream, + and is omitted entirely when not asked so the default result shape is unchanged. It is rendered + in the calling process from the transcript file, never by the supervisor: re-rendering a long + session on the one thread that must keep pumping the pty would trade a reporting improvement for + a latency regression. `read --screen` renders the whole transcript rather than a `--since` + slice, because a screen is the product of every escape sequence before it and replaying from a + mid-stream cursor would show a screen the child never displayed. + + `--screen` is not bounded by the read filters — not `--since`, `--tail`, `--grep` or + `--max-output`. It is bounded by geometry instead, at most `screen_rows x (screen_cols + 1)`, + and both dimensions come back in the same reply: a 219,941-byte transcript rendered to 2,113 + bytes, and a dense 40x120 frame is 4,839 bytes of ASCII or 7,239 of CJK. So a caller passing + `--max-output` does get a bounded reply, just bounded by a different rule than the one they + named — the defect is surprise, not unboundedness, which is why two reporters declined to file + it. Applying the byte bound to the render was measured and rejected: rendering only the bounded + bytes paints a discarded frame plus rune's own elision marker into the child's screen and lost + 9 of 10 answers, and truncating the rendered string is a no-op where it matters while needing a + second `omitted_bytes` in one reply, which 50a forbids. +41s. `--screen` renders at the size the child's pty is actually set to, and reports it as + `screen_rows`/`screen_cols`. The size is not a constant — the child starts at + `DEFAULT_ROWS`x`DEFAULT_COLUMNS`, `attach` resizes it to the terminal that took it over, and + `detach` restores the default — so the supervisor records the current winsize in `meta.json` + whenever it changes it and the caller's process reads it back. Rendering at a fixed default + while a human was attached from any other shape produced a screen nobody ever saw: measured + against a child that lays out against its winsize, resized over the control socket to 30x100, + with pyte 0.8.2 and GNU screen 4.00.03 replaying the same transcript bytes as independent + oracles that agreed with each other exactly, **36 of 37 rows differed before and 0 of 31 after**. + Repeated at 24x80 (30/31 before, 0/25 after), 12x40 (18/19, 0/13) and 50x200 (50/51, 0/51); at + 40x120, where the two sizes coincide, 0 wrong both ways. Repeated again through a real + `rune session attach` in a real 30x100 pty, comparing against the bytes that terminal itself + received: 29 of 30 rows differed before, 0 of 30 after. + A size that was never recorded or that is not a usable terminal (hand-edited meta, a pty whose + size was never set) falls back to `DEFAULT_ROWS`x`DEFAULT_COLUMNS`, which is exactly the previous + behaviour and is also the size `apply_window_size` gives a child nobody has attached to. +41t. A caller can tell a recorded size from the fallback, and `screen_rows`/`screen_cols` are not how. + A session attached from a 40-row terminal records exactly the fallback numbers, so the pair + cannot carry the distinction; `screen_size_recorded` is the field that does. It is true only when + the resolved size is what `meta.json` actually held — a value that was clamped, discarded or + absent reports false, because what is being reported then is a default and not a fact about the + child. +41u. A winsize arriving over the control socket is clamped where it is recorded, not where it is + rendered. A pty's winsize fields are 16-bit, so `{"op":"resize","rows":65535,"cols":65535}` is + accepted by the kernel; recording it unbounded would make every later `--screen` drive a grid + that size for the rest of the session's life, reinstating one layer up the denial of service + behavioural point 12 of `parsers` clamps at the renderer. Measured on a 683KB `\e[999L` + transcript, one `read --screen`: **0.76s at 40x120, 17.72s at the 1000x2000 the renderer would + have clamped 65535 to, and 3.41s at the `MAX_ROWS`x`MAX_COLUMNS` ceiling** that is now the most + a client can ask for. The pty is clamped too, so the child, the record and the render agree — + recording a size the child never had is the bug this whole point exists to fix. The residual + cost at the ceiling is the renderer's per-row cost for line-insert and scroll operations, which + a genuinely 300-row terminal pays identically; it is bounded, not eliminated. +41v. The whole retained transcript is rendered at the *current* size, including output painted before + a resize. That is what an attaching terminal itself shows, because the supervisor replays the + backlog into it at its size — verified through a real attach at 0 of 30 rows wrong even for a + child that ignores SIGWINCH entirely. The unresolved case is a child that never repaints *and* + whose pty is resized under an already-attached terminal, where that terminal is reflowing glyphs + it has already drawn. There is no reference answer to match: fed the bytes that terminal + received and shrunk mid-stream from 40x120 to 24x80, GNU screen 4.00.03 kept only the cursor row + and pyte 0.8.2 kept nothing at all, and the two disagreed with each other on one row of the + little they retained. Rune keeps the content and re-flows it, which differs from both (24/24 + against pyte, 24/25 against GNU screen) where the old fixed 40x120 render differed in 15 — but + that score is an artifact of a mostly blank screen coincidentally matching mostly blank oracles, + not evidence that the fixed size was closer to what anyone saw. Documented rather than tuned to + whichever emulator was measured last. +41k. An attachment reports the way it ended, and never both ways at once. The note that the session + is still running is printed only when the human actually detached; when the attachment ended + because output stopped, the failure says so and points at `rune session list` rather than + asserting that the child or supervisor exited, which the attachment cannot know. Reported from + real use: a session that ended underneath produced "detached; the session is still running" + and "Session ended while attached" in the same exit, one of which is always wrong. +42. Attaching propagates the terminal's real dimensions to the child and forwards SIGWINCH for the + duration, over separate short-lived control connections — the attachment socket itself is a raw + byte pipe after the ack, so a control frame written there would be typed at the child instead. + When the last terminal detaches the child returns to the headless default, so a programmatic + `send` renders the same whether or not a human attached in between. +43. Control connections that connect and never send are reaped. A silent peer is never readable, so + it would otherwise sit in the client set for the life of the session, and enough of them would + exhaust the supervisor's file descriptors. +44. `start` is serialised per session name by an exclusive lock held across the conflict check and + the recording of a supervisor pid. Those are otherwise a check-then-act pair: two concurrent + starts could both see the name as free, and the loser would unlink the winner's socket and + orphan its child. +44a. A generated codename is chosen inside that lock, and contention retries another codename rather + than failing. Choosing it outside meant two concurrent `start -- ` calls could pick the + same codename and the loser would fail on a name it never asked for, with many others free — + which is precisely the parallel-agent case an optional `--name` exists to serve. An explicit + `--name` still fails on contention: that name was the request. +46. A rotation that cannot be written costs only the rotation. `rotate_output` closes the caller's + handle after the replacement is in place, never before, and removes any half-written temp file + on the way out. Closing first meant a failure anywhere later left the supervisor holding a + closed handle it had no idea was closed, and `log_event`'s own rescue then swallowed every + subsequent write — recording stopped silently and permanently. Measured on a real EACCES + directory: 200 further events left the transcript 564,000 bytes behind the cursor, and restoring + write permission widened the gap to 654,000 rather than resuming. A failed rotation is then + backed off for `ROTATE_RETRY_SECONDS` rather than retried on the next event, because + `@log_bytes` stays over the ceiling and every attempt seeks and scans the tail it means to keep + before it discovers it cannot write — 8,388,576 bytes in 4.8ms at the real bound, on the single + thread that also drains the pty. +46a. A transcript write that fails is recorded, not merely survived. The in-memory cursor has + already advanced, so a hole nothing accounts for makes every cursor `send` hands out + unresolvable by `read`, permanently — reproduced with RUNE_HOME on a full 20MB ramdisk, where + 852,000 bytes of output went unrecorded under `dropped: 0` and freeing the disk resumed logging + over the hole without a word. The lost byte count is carried and emitted as a `truncated` event + by the next write that succeeds, the same vehicle rotation uses, so a pre-hole cursor resolves + again and `read` reports `dropped_bytes` rather than silently returning less. Re-measured on the + same ramdisk: skew −873,000 during the outage and 0 after recovery. While the hole is still owed + there is nowhere on disk to record it, so `send` replies and `status` carry + `transcript_gap_bytes` — the only window in which the skew is knowable at all. +46b. A write that fails part-way leaves a fragment, and a fragment can be a complete JSON object + that merely never got its newline — which, once more text is appended, silently swallows the + next good record too. Measured on that ramdisk: 280 of 300 writes failed and one left a + 4,938-byte line parsing as neither record. `TORN_MARKER` is therefore written ahead of the first + record to follow a failure, so the fragment terminates into a line that cannot parse and only it + is lost. `Store#whole_record?` and `Transcript.load` must then agree exactly on which lines + count, because one feeds a rotation's head event and the other reconstructs the stream: the test + is a byte comparison (records are one line ending `}`, a marked fragment ends `n`) with the + file's unterminated last line parsed outright, since that is the one shape bytes cannot settle. + Swept over every split point of 36 record shapes with braces, quotes, escapes, raw newlines and + the marker's own bytes in the payload: 8,832 lines compared, 0 disagreements, and 10 cases the + byte test alone would have got wrong on that last line. +47. Teardown kills the child before it records the session as exited. `cleanup` used to write + `state: 'exited'` first, so a supervisor dying in that window left a concluded record beside a + live process holding a pty. `conclude` already had this order on the normal path; the abnormal + one now matches it. `terminate_child` is idempotent and each teardown step keeps its own rescue, + so a child that will not die still gets the record written after it. +48. A child that outlived its supervisor is *reported*, never made a reason to refuse. `list` and + the `archive` reply carry `orphaned_child_pid` when a session's supervisor is gone and its + recorded child is provably still running. Nothing is blocked and nothing is signalled; the + operator is told the number while it is still reachable, because archiving moves the session out + of the live namespace and that reply is the last place the pid appears. +48a. "Provably" means the pair (pid, start time), not the bare pid and not its process group. The + supervisor records the child's start time as `ps` reports it, under `LC_ALL=C` because `lstart` + is formatted through the locale (`Fri Aug 14 13:41:13 2026` under C, `ven. 14 août 13:41:13 + 2026` under fr_FR). A bare `alive?` answers yes for any process that recycled the number. Asking + the process group is not a fix and was measured to be actively wrong in both directions: 1,222 + of 1,390 live processes on the development machine (87.9%) lead their own group, and 130 of the + 200 most recently allocated pids (65.0%), so a group question answers "alive" for a stranger + about as often as a bare pid does — while a child that is *not* a group leader is missed + entirely. An earlier design refused the archive on that test and directed the caller to + `rune session stop`, which SIGKILLs the recorded pid's whole process group; two runs of it + killed unrelated live groups. +48b. The recorded `state` is deliberately not consulted. A check that skipped sessions recorded + `exited`/`stopped`/`failed` was blind to exactly the case invariant 47 describes. `state` is a + claim by a process that is now dead; the pid/start-time pair is evidence. +48c. Where the question cannot be asked soundly, the answer is silence rather than a guess. A + session with no recorded `child_started_at` — started before the field existed, or by a + supervisor that died in the window between recording the pid and recording the start time — + reports nothing, even if its child is in fact alive. +49. `rune run` and `rune watch` behavior and result shapes are unchanged; this module is purely + additive. +49a. The child ends up at the default geometry, but may observe `0x0` first. `PTY.spawn` returns the + master only once the child is already running, so `apply_window_size` cannot land before a child + that reads its winsize immediately; such a child is corrected by the SIGWINCH that follows. + Observed on a Ruby 3.1 CI runner as `SIZE:[0, 0]` then `RESIZED:[40, 120]`, where every other + version won the race. Closing it would mean opening the pty, setting its size, and spawning onto + the slave by hand instead of using `PTY.spawn` — a change to the spawn path that has not been + measured, so this is recorded as a limitation rather than fixed in a hurry. A child that reads + its size once at startup and never handles WINCH is the case that loses. +51. A read stops at the last **complete** escape sequence, not at the last byte, and its cursor + stops there too. `strip_ansi` only matches sequences that terminated, so a sequence split + across two pty reads was wrong at both ends: the fragment was delivered as visible text, and + the cursor advanced past it so the *next* read saw the remainder headless and stripped nothing. + Measured against a child that printed `READY`, then `\e[3`, slept, then `1mRED\e[0m`: + + read (no flags) clean_output "READY\n\e[3" cursor 10 + read --since=10 --screen clean_output "1mRED\n" screen "READY\nRED" + + The second reply contradicts itself: `clean_output` says the child printed `1mRED` and `screen` + says `RED`, from one invocation. The child printed `RED`. Withholding the fragment from both the + text and the cursor fixes both halves — the next read starts at the ESC and sees the sequence + whole. Nothing is lost: the bytes stay in the transcript and are returned once the sequence + completes, and a child that opens one and never closes it withholds those bytes indefinitely, + which is exactly what a terminal does with them. + +51a. `list`'s `last_line` is summarised from the reassembled tail, not from the last event alone. A + pty read boundary is neither a line boundary nor a sequence boundary, so an event-at-a-time + summary stripped nothing from either half of a split sequence and reported `1mRED` where the + child had displayed `RED`. + +50. `--max-output` and `--tail` bound `send` as well as `read`. Both flags were parsed for every + subcommand and applied only by `read`, so `send --max-output=120` returned everything under + `status: ok` — a caller that asked for a bound was told it succeeded and did not get one. + `send` is the worst place for that gap: it is the call an agent makes most, and one turn of a + full-screen TUI is megabytes. Bounding happens in the command rather than the supervisor + because the cap is one caller's presentation choice; the transcript, the cursor, and every + attached client still see the whole stream. +50a. `clean_output` is derived from the *bounded* raw text, not bounded separately. Bounding the + two independently lets them describe different windows of one reply and leaves + `omitted_bytes` true of only one of them. This is what `read` already does. +50b. `--max-output` and `--tail` are mutually exclusive on every session subcommand, with the same + message `rune run` has always used. Accepting both applied whichever `bound_size` tested + first, so the caller silently got the other one. + diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/watch.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/watch.md new file mode 100644 index 0000000..b01e8e7 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/deltas/watch.md @@ -0,0 +1,160 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `PTYWatcher` | class | Constructor: `(command, log: $stderr, input: $stdin, output: $stdout, timeout_seconds: nil, idle_timeout_seconds: nil)`. Method: `#watch` returns `Result`. | +| `WatchCommand` | class | Subcommand `rune watch [--log=PATH] [--timeout=SECONDS] [--idle-timeout=SECONDS] `. It selects live output from the renderer mode and declares usage and flags through the command DSL, so `rune watch --help` renders them without constructing a watcher. | +| `Rune` | module | Top-level rune namespace. | +| `watch` | instance method | Validates terminal support and runs one live watched session. | +| `build_result` | internal method | Logs session exit and constructs the duration/exit-code/timeout result. | +| `run_with_timeout` | internal method | Bounds total session wall-clock time with `--timeout`, killing and reaping the child on expiry. A no-op passthrough when `--timeout` was not given. | +| `with_raw_input` | internal method | Enters raw terminal mode with a narrow non-TTY fallback. | +| `pump_session` | internal method | Runs output pumping while an input-forwarding thread is active. | +| `forward_input` | internal method | Starts the disposable input-forwarding thread; also records input activity for `--idle-timeout`. | +| `pump_output` | internal method | Polls, decodes, displays, logs, and reaps child output; checks `--idle-timeout` once per poll. | +| `emit_output` | internal method | Writes and logs one non-empty decoded output chunk; also records output activity for `--idle-timeout`. | +| `idle_timed_out?` | internal predicate | True once `--idle-timeout` seconds have elapsed since the last output or input activity. | +| `idle_timeout_result` | internal method | Kills and reaps the child when `--idle-timeout` fires, returning exit code 124. | +| `synchronize_window_size` | internal method | Copies changed terminal dimensions onto the child PTY. | +| `valid_window_size?` | internal predicate | Accepts two positive integer terminal dimensions. | +| `terminate_child` | internal method | Kills and reaps a child after an output-sink failure or a timeout. | +| `interrupted_result` | internal method | Reaps the child and builds the session result for a run ended by a repeated INT/TERM, at the conventional `128 + signo` exit code. | +| `drain_available` | internal method | One bounded, best-effort pty read used while tearing an interrupted session down; keeps the child's last bytes on screen and in the log. | +| `wait_for_exit_code` | internal method | Reaps the child and normalizes exit or signal status. | +| `log_event` | internal method | Writes and flushes one timestamped NDJSON event. | +| `Commands` | module | Namespace containing concrete CLI command implementations. | +| `call` | instance method | Validates watch arguments, opens the log, selects display output, and runs `PTYWatcher`. | +| `human_render` | instance method | Prints watched-session exit, duration, and log location. | +| `attach_log_path` | internal method | Adds the concrete log path to a successful result. | +| `open_log` | internal method | Opens an explicit append log or creates a private temporary log. | +| `extract_options` | internal method | Extracts `--log=PATH`, `--timeout=SECONDS`, and `--idle-timeout=SECONDS` before the first `--` separator. | +| `scan_head` | internal method | Consumes watch's own flags out of the pre-separator argv, returning what is left. | +| `leftover_flag_error` | internal method | Rejects a flag-shaped token watch does not own before it becomes the command. | +| `VALUE_FLAGS` | constant | The watch flags that take a value, used by the leftover-flag guard. | +| `extract_log_value` | internal method | Validates one raw `--log=` value, rejecting an empty path. | +| `matching_timeout_flag` | internal method | Matches one argv token against `TIMEOUT_FLAGS`, returning the matched option key and `MatchData`, or `[nil, nil]`. | +| `parse_timeouts` | internal method | Parses every raw `--timeout`/`--idle-timeout` value, stopping at the first invalid one. | +| `TIMEOUT_FLAGS` | constant | Maps each `PTYWatcher` timeout keyword option to its argv pattern, flag name, and error-message value description. | + + +### SPEC SECTION Invariants + +1. Refuses to run (returns `Result.failure`) unless `input` is a real TTY — live passthrough + requires an actual terminal to put into raw mode; there is no meaningful non-interactive mode. +2. Refuses to run (returns `Result.failure`) if the `pty` stdlib is unavailable, same check and + message class as `PTYRunner.pty_available?`. +3. Output is decoded incrementally as UTF-8 before being written or logged, same as `PTYRunner`. + Incomplete multi-byte suffixes are retained across reads; genuinely invalid bytes are scrubbed. +4. The NDJSON event log emits, in order: one `start` event (`command`, `pid`), zero or more + `output` events (`bytes`, `text`) as chunks arrive, at most one `timeout` or `idle_timeout` event + if a bound fired, and exactly one `exit` event (`exit_code`) when the session ends. Every event + carries a `ts` (float Unix timestamp). +5. `Result#exit_code` (the process-level exit status) mirrors the wrapped command's real exit code + on a natural exit, same convention as `PTYRunner`/`RunCommand`; on a `--timeout`/`--idle-timeout` + expiry it is `124`, the same convention `PTYRunner --timeout` already uses. +6. INT/TERM are forwarded to the child using the same `SignalHandler` mechanism as `PTYRunner`, + including its escalation ladder: every signal is forwarded, and the second one within + `SignalHandler::BURST_WINDOW_SECONDS` is forwarded and *then* ends the session, reaping the + child and reporting the conventional `128 + signo` exit code. This matters more here than in + `PTYRunner` because `rune watch` has no default `--timeout`: before the ladder existed, a child + that trapped INT/TERM left the session with no bound at all — measured surviving 5x SIGINT + + 5x SIGTERM and needing SIGKILL, a CLI that could not be stopped by an init system. The reap + happens inside `pump_output`, while the pty reader is still open, because a SIGKILLed pty child + holding unread output wedges unreapably on macOS and only draining the master clears it (see + `pty_runner`'s invariant 26); draining also keeps the child's last bytes on screen and in the + NDJSON log. + A human's Ctrl-C at the terminal does *not* travel this path. Raw mode clears `ISIG`, so the + keystroke reaches the child as a `0x03` byte through the input-forwarding thread and the + child's own pty line discipline, and `rune`'s traps never fire. Verified against a real + controlling terminal: three Ctrl-Cs, three interrupts delivered to the child, session still + running. An agent CLI whose first Ctrl-C interrupts a turn is therefore unaffected by the + ladder, however many times it is pressed. +7. The input-forwarding thread never blocks process exit: it's explicitly killed once the child's + output stream ends, regardless of whether it's currently blocked reading more input. +8. `input`/`output`/`log` are constructor-injectable (defaulting to `$stdin`/`$stdout`/`$stderr`), + specifically so the live-passthrough mechanics are unit-testable without a real controlling + terminal — a fake terminal object needs only `#tty? => true`; entering raw mode is attempted via + `input.raw(&block)` and falls back to running the block directly on `Errno::ENOTTY` (not backed + by a real terminal, e.g. a test's `IO.pipe`) or `NoMethodError` (`#raw` doesn't exist at all). + `pty_watcher.rb` requires `io/console` itself at load time — a real bug found via live-terminal + dogfooding was that only a *child* command requiring `io/console` (e.g. `examples/demo_tui.rb`) + ever gained `#raw`/`#getch`; the parent CLI process's own `$stdin` never did, so raw mode was + silently never entered for actual `rune watch` usage, leaving the terminal in cooked mode + (local echo of literal escape sequences, kernel-level line buffering that swallowed + no-trailing-newline input like arrow keys entirely). +9. `WatchCommand` checks `$stdin.tty?` itself, before doing anything else — including before + computing/opening the default log file — so a failed invocation (piped/non-interactive) never + creates a stray temp file. This duplicates `PTYWatcher`'s own internal check by design, at the + CLI layer specifically to avoid that side effect. +10. `Result#data` on success carries `command`, `exit_code`, and `duration_ms` (milliseconds, + matching `PTYRunner`'s convention) from `PTYWatcher`. `WatchCommand#call` folds in `log_path` + (the actual path used, default or `--log=`) before returning, so `human_render` — which runs on + a separate `Command` instance from the one `#call` ran on, per `CLI#render_result` — can print a + closing summary and remind where the event log lives without relying on instance state. +11. `WatchCommand#human_render`'s duration is scaled to be readable at the length a watched session + actually runs (seconds to hours, not `PTYRunner`'s usual sub-second commands): a bare + `ms` under 1 second, a bare `s` (2 decimal places) under a minute — both already exact + enough on their own — or `Mm Ss` under an hour / `Hh Mm Ss` beyond that, each followed by + `, s` since those coarser forms lose sub-second precision the plain figure + doesn't need to restate. The exact-seconds suffix is a comma, not parentheses, and only + appears on the two coarser (minute/hour) forms — not on the sub-minute cases, where it would + just repeat the same number twice. +12. The controlling terminal's valid row/column size is copied to the child PTY initially and + whenever it changes during output polling. +13. The default event log is created atomically by `Tempfile` with owner-only `0600` permissions; + it does not use a predictable PID/timestamp path or follow a pre-created symlink. A newly + created explicit `--log=PATH` file is also opened as `0600`. +14. If the display output raises `EPIPE`, the watched child is killed and reaped before the error is + returned as a structured failure. +15. Array commands are passed to `PTY.spawn` as distinct argv entries. The spawned PID therefore + belongs to the wrapped target rather than an intermediary shell, so output-sink cleanup, + signal forwarding, and reaping act on the correct process. The structured `command` field + remains a shell-escaped display string; explicit string commands retain shell semantics. + A **single-element** array is the case that needed care: Ruby's `exec` family treats one + argument as a shell command line, so `rune watch -- 'my file'` reached `sh -c` and split on + the space, and a name containing `;` or backticks executed. `ExecArgv.for_spawn` passes the + `[command, argv0]` two-element form for that case, which forces the direct-exec path. + `PTYWatcher` distinguishes the two by recording whether its constructor received an Array, + not by counting elements — a watcher built from the `String` form still gets shell semantics + with one element, which is what its callers expect. +16. `WatchCommand` selects the live passthrough's destination from the output mode and passes it + explicitly as `PTYWatcher.new(..., output:)`: `$stdout` for a human on a TTY, `$stderr` in agent + mode (`--json`, `--ndjson`, or non-TTY stdout). Without this the command was the only one that + never passed `output:` at all, so `PTYWatcher`'s `$stdout` default wrote the child's live bytes + to the same stream the `Renderer` then wrote the envelope to, and `rune watch --json` produced + stdout that did not parse as JSON (`unexpected character: 'X' at line 1 column 1`). The live + view is routed to stderr rather than suppressed because a human is still driving the session + even when a wrapping process is capturing stdout — the same reasoning that already puts the + log-path announcement on stderr. +17. `--timeout=SECONDS` bounds total session wall-clock time by wrapping the raw-mode/pump work in + `Timeout.timeout`. `Timeout.timeout` only interrupts rune's own control flow, so on expiry the + spawned child is explicitly `SIGKILL`ed and reaped — same reasoning and mechanism as + `PTYRunner`'s existing `--timeout`. Neither option changes `data`'s shape when unset: no + `timed_out`/`timeout_kind` key appears when neither `--timeout` nor `--idle-timeout` was given, + preserving the existing JSON envelope for callers that don't opt in. +18. `--idle-timeout=SECONDS` bounds "no output from the child *and* no input from the human" for + `SECONDS`, independent of total elapsed time — checked cooperatively inside the existing output + poll loop (not expressible as a single `Timeout.timeout` deadline, since the window resets on + any activity). Any decoded output chunk or any chunk read from the human resets the idle clock. +19. `--timeout` and `--idle-timeout` may be combined in the same invocation; whichever bound is + reached first fires. Both report exit code 124 and `data[:timed_out]: true`, distinguished by + `data[:timeout_kind]`: `"timeout"` or `"idle_timeout"`. + +20. A flag-shaped token before the separator that `watch` does not own is refused, not run. + Until now anything unrecognised stayed in the argv and became the command: measured through a + real controlling terminal, `rune watch --timeout 5 -- echo hi` exited **127 with the child never + running**, because `--timeout` was exec'd as the program. `run` has guarded this since it grew + flags; `watch` never did, which made it the worse of the two — `run` at least says something. + + The guard is `Command.flag_error`, shared with `run` rather than copied, because the two had + already drifted once: `run` grew the inline-value branch and `watch` had no guard to grow it in. + A correctly spelled flag given a space-separated value gets the inline-value message naming + `rune watch`; anything else gets the unknown-flag message. + + It covers the **leading** position only, which is what `scan_head` leaves behind. A flag `watch` + owns that appears *after* the program name is still consumed rather than passed to the child — + `rune watch echo hi --log=/tmp/x` writes rune's own log to the child's path and prints only + `hi`. That is pre-existing and unchanged here, and it is the asymmetry `session` already closed + for itself: permuting for consumption and not for validation. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/docs.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/docs.md new file mode 100644 index 0000000..5e11cad --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/docs.md @@ -0,0 +1,29 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: docs +--- + +# Docs + +`watch.spec.md` invariant 20 carries the guard, the measurement, and the +leading-position limitation. `pty_runner.spec.md` invariant 29 carries the stream +bound. `session.spec.md` 41p carries `--grep` honouring `--since`, including that +context windows are taken within the slice. + +Export rows follow the code: `flag_error` and the two templates are now `cli` +exports, `scan_head`/`leftover_flag_error`/`VALUE_FLAGS` are `watch` exports, and +the two rows for what moved out of `RunCommand` are gone from `pty_runner`. + +Worth naming as an API change: `RunCommand::INLINE_VALUE_ERROR` still resolves +through inheritance but now carries a second placeholder, so +`format(..., name:)` alone raises `KeyError`. It was introduced days ago and has +never appeared in a tagged release, so the exposure is a caller who took it from +main. + +A specsync extractor quirk worth recording: `apply_output_limit` and +`execute_pty` are surfaced as exports and must be documented, while +`bound_stream` — a private instance method beside them, at the same level in the +same class — is not surfaced at all, and documenting it is a hard error +("Spec documents 'bound_stream' but no matching export found in source"). So the +spec documents its two neighbours and not it. That asymmetry is not something +this change can fix, and guessing at it would just move the error around. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/plan.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/plan.md new file mode 100644 index 0000000..9e60b5f --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/plan.md @@ -0,0 +1,18 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: plan +--- + +# Plan + +The guard moves to `Command.flag_error`, with both message templates, and both +commands pass their own value-flag list. `run` keeps `VALUE_FLAGS` derived from +`FLAG_PATTERNS`; watch derives the timeout flags and appends `--log`, whose +pattern is inline in `scan_head` — the comment says so rather than claiming a +derivation it does not have. + +`Transcript.grep_text` takes the text to search; `Transcript#grep` delegates with +`@text` so nothing else changes. + +`bound_stream` applies the same budget to each separate stream and returns the +text untouched when no bound was asked for. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/requirements.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/requirements.md new file mode 100644 index 0000000..1a60686 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/requirements.md @@ -0,0 +1,13 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: requirements +--- + +# Requirements + +1. A flag-shaped token watch does not own is refused, not executed. +2. run and watch share one guard, because they had already drifted once. +3. `--grep` searches the slice `--since` selected. +4. `--max-output`/`--tail` bound every field they return; with neither set, the + shape is byte-for-byte unchanged. +5. Each fix has a test that fails without it. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json new file mode 100644 index 0000000..6f82334 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json @@ -0,0 +1,53 @@ +{ + "schema_version": 1, + "id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "slug": "guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "title": "Guard the flags watch was executing, and bound the two fields max-output was not", + "description": "Guard the flags watch was executing, and bound the two fields max-output was not", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "created_at": 1787038047, + "updated_at": 1787038297, + "affected_specs": [ + "watch", + "pty_runner", + "session", + "cli" + ], + "affected_paths": [ + "lib/rune/command.rb", + "lib/rune/commands/run_command.rb", + "lib/rune/commands/watch_command.rb", + "lib/rune/commands/session_command.rb", + "lib/rune/pty_runner.rb", + "lib/rune/session/transcript.rb", + "spec/rune/commands/watch_command_spec.rb", + "spec/rune/pty_runner_spec.rb", + "spec/rune/session_spec.rb", + "specs/watch/watch.spec.md", + "specs/pty_runner/pty_runner.spec.md", + "specs/session/session.spec.md", + "specs/cli/cli.spec.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "rune watch refuses a flag-shaped token it does not own instead of executing it as the command, sharing run's guard rather than copying it. rune session read honours --since when --grep is given, searching the slice rather than the whole transcript. max-output and tail bound clean_stdout and clean_stderr as well as the merged fields. Each fix has tests that fail against deliberately reverted code, including a drift guard for watch's flag list. Export documentation follows the two constants and one method that moved from RunCommand to Command." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/tasks.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/tasks.md new file mode 100644 index 0000000..5de0831 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/tasks.md @@ -0,0 +1,14 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: tasks +--- + +# Tasks + +- [x] Verify all three, including re-testing watch through a real PTY +- [x] `Command.flag_error` shared by run and watch +- [x] `Transcript.grep_text`; `filter` searches the slice +- [x] `bound_stream` for clean_stdout/clean_stderr +- [x] Tests for all three, each falsified +- [x] Follow the moved exports through the specs +- [x] Invariants: watch 20, pty_runner 29, session 41p diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/testing.md b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/testing.md new file mode 100644 index 0000000..4bb8932 --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/testing.md @@ -0,0 +1,35 @@ +--- +change: CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not +artifact: testing +--- + +# Testing + +584 examples, 0 failures; rubocop clean; docs-check green; specsync 0 hard errors. + + fix control failures + watch guard leftover_flag_error removed 4 of 33 + grep honours --since grep the whole transcript again 1 of 2 + stream bounds bound_stream reverted 1 of 3 + +**An adversarial pass found two merge-blockers in my own work, and it was right +about both.** + +The first: I shipped the watch guard with *no test*. Reverting all three guard +files left the suite fully green — CI could not tell the fix from its absence, +including its headline case. Six tests now cover it, including watch`s own drift +guard and the case it must not lose (a child`s own flags surviving). + +The second: I moved `unknown_flag_error` and `INLINE_VALUE_ERROR` out of +`RunCommand` and left `specs/pty_runner/pty_runner.spec.md` documenting them, and +added six exports nothing documented. `specsync coverage` reported two hard +errors and six warnings; both errors are gone. + +It also caught a real defect in my own writing-up: watch`s `VALUE_FLAGS` comment +claimed the list "cannot drift from the parser" while `--log` is appended by +hand. Corrected to what is true. + +One reported loss was checked and left alone: the guard covers the leading +position only, so `rune watch echo hi --log=/tmp/x` still writes rune`s log to +the child`s path. Measured byte-identical before and after this change, so it is +pre-existing and out of scope — recorded in invariant 20 rather than fixed here. diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json new file mode 100644 index 0000000..ed67b5f --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1787038293, + "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json new file mode 100644 index 0000000..d2f485c --- /dev/null +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json @@ -0,0 +1,216 @@ +{ + "timestamp": 1787038293, + "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", + "acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "b22059658bcc96377420c687826bca763224b3ec26d9904f10192613f8b27212", + "entry_digest": "937c9cad43aa6eb8104df3d6e3d160db8c1befd414f3546b56fa660f7d1f8a1a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7c0883544e25b1c19eadfa330277339cc5984f86b6a608404ed5d5f919315322", + "entry_digest": "bac54e82ba25bf4202e51cef7d7f9e0e0606a30a37cd75baf367ac1598ec7f40", + "owners": [ + "cli" + ] + }, + { + "path": "lib/rune/commands/run_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f563628c917a909e26f524b68ab459f7e03efc9b01a9ccf32bb9f865347479b9", + "entry_digest": "2b9d735b4d5ebb1a7797bc7470b5c079d52a147dca4caf566c6a1d4f20ccf19f", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "c5e257ca12673bc0fd5267e69c0c5bf84369019dd1279b92a75abd2bea1dff45", + "entry_digest": "fda0508c6bfcdeff985e2362b5c1da88ad4e4fabab318a824d6df88c98089e76", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/commands/watch_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f78ec60ff4f17ed0739e8044b60cbee1e527627fcd57165e110f6f00fce40a7b", + "entry_digest": "9c15c7c063bbb33d736cf2ae011f327eab3fd76c9065baa6858ee9768d9fc6b1", + "owners": [ + "watch" + ] + }, + { + "path": "lib/rune/pty_runner.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "3721d699c8e934b706c6db570f2a0dfd3fd12ce003068328d63b1a4de6f75a26", + "entry_digest": "4efceba9d053a8cad51cb95986a36e94c4e9e74fe6e29ecff1b19dc3b2d40c3c", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/session/transcript.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "a8ff1157444c46abfc9746ed3d24068eca939606b13c647fb4b558bdda2147c5", + "entry_digest": "6909005a3771f84fe747776fc7f3a1cf74175a6554fcc36e4b7583356c1c5fbf", + "owners": [ + "session" + ] + }, + { + "path": "spec/rune/commands/watch_command_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "35243817b52618f480756094d672039a1c17d558c4cf5d4c8aac43079e78ba28", + "entry_digest": "5e7374088bf1b46ae354b76903b6f0c98b9cca0e2e3f4f77399dfe7d3118fb49", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/pty_runner_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "6dcf23ad4d7f1d7b4640c8a58e322183444cf1a6370356cad8882af8085a41be", + "entry_digest": "5cfe1ea1e9a52b3741290b512289e20e85af6dad39ac27485e372966ec1165c4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "447eced43e773ba84e9d879db75ee7fd563619dcb86135378d3afd0390d96eea", + "entry_digest": "a1c55527a1b1ba0206ca2f59c222241b7c4d3a7567941d2f31e3c014a7ae53fb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/cli/cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ce1bbc0237e58537ce3aa6a3c6f35e842d298bf4ac1887940ea2101d15d3d328", + "entry_digest": "333940b198a250142aa060972c0da362b30ae18a3c3bb08dd54251c588a23ee7", + "owners": [ + "cli" + ] + }, + { + "path": "specs/cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b3223697f7d83909e9718f17e068a205d0717ed45b65df647701dffee8157858", + "entry_digest": "4bdce8f159673c64d8ee23db590ddcf7480f1e276ee48d86e2f1260b9a730ca6", + "owners": [ + "cli" + ] + }, + { + "path": "specs/pty_runner/pty_runner.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "59206e9e7948f0f15fd1a4b51dc1cfabe5ec847a900f61b8452f7da43061e5ae", + "entry_digest": "a72cc0d9edbae9e6e3fc301cfac535406686c38987159e084fbca3981fb756a1", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/pty_runner/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3070f0d5d578baa2f058503359b33d17c02641731aec9e820e782f4e58813fcf", + "entry_digest": "6e141c1e84e140b7147f958068fa75dd6c0e22153ff3c50fe8782499d2f7dfb7", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f62b06e4a54c65cc23b9da0aaa6311a639d5e7cfbebe89e2053aaf83093248bc", + "entry_digest": "5f1ab883fc19211ef586f88218a956cbd75a7041e6943b3c3c0800b42a177e69", + "owners": [ + "session" + ] + }, + { + "path": "specs/watch/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4120dc4e6971fe20155d57e0110ef66f1dc8560a9471fffb954222cb6d7fd81a", + "entry_digest": "4396e75507693c7f324e619de816b9b20564813b26b608ac4bc577d26759bbf2", + "owners": [ + "watch" + ] + }, + { + "path": "specs/watch/watch.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f36197540bce91f8e639df54d3bb0af55a0137c1344e9fd9c45bfb55d6cf6da7", + "entry_digest": "f66be4ba003f4d6805ea541fa4c288036e1d16c4ac1a7ebdf991f5e9b55ad767", + "owners": [ + "watch" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/lib/rune/command.rb b/lib/rune/command.rb index 64998ba..1444785 100644 --- a/lib/rune/command.rb +++ b/lib/rune/command.rb @@ -63,8 +63,38 @@ def subcommand(name, description) end def command_subcommands = @command_subcommands ||= [] + + # Rejects a flag-shaped token that reached the wrapped command's argv. + # + # `run` and `watch` both own flags and both hand everything they do not recognise to the + # child, so an unrecognised flag is executed rather than reported. `run` guarded this and + # `watch` did not: measured through a real controlling terminal, `rune watch --timeout 5 -- + # echo hi` exited 127 with the child never running, because `--timeout` was exec'd as the + # program. That is the worse failure of the two — `run` at least says something. + # + # Shared here rather than copied because the two had already drifted once: `run` grew the + # inline-value branch and `watch` had no guard at all to grow it in. + def flag_error(leftovers, value_flags) + unknown = leftovers.take_while { |token| flag_shaped?(token) }.first + return nil unless unknown + + name = unknown.split('=', 2).first + template = value_flags.include?(name) ? INLINE_VALUE_ERROR : UNKNOWN_FLAG_ERROR + format(template, name: name, command: command_name) + end end + # A flag the command owns, spelled correctly, whose value was given with a space. The general + # message below got this wrong three ways: it called a flag rune owns "Unknown", it asserted a + # position error when the flag was already before the separator, and following its remedy hands + # the flag to the child instead of applying it. + INLINE_VALUE_ERROR = '%s takes its value inline: %s=VALUE. To pass it to the ' \ + 'command instead: rune %s -- %s VALUE' + + UNKNOWN_FLAG_ERROR = "Unknown option: %s. rune's own flags are recognized only before " \ + 'the wrapped command; to pass this one to that command instead, put the ' \ + 'command first or use a separator: rune %s -- %s' + # Override in subclasses def call(args, options) raise NotImplementedError, "#{self.class}#call must be implemented" diff --git a/lib/rune/commands/run_command.rb b/lib/rune/commands/run_command.rb index 3c5bdda..3d5ad98 100644 --- a/lib/rune/commands/run_command.rb +++ b/lib/rune/commands/run_command.rb @@ -76,14 +76,6 @@ def human_render(data, io) # the set meant "flags rune owns"; the drift test above caught it immediately. VALUE_FLAGS = FLAG_PATTERNS.values.map { |(_pattern, label, _unit)| label }.freeze - # A flag rune owns, spelled correctly, whose value was given with a space. The general - # unknown-option message got this wrong three ways at once: it called a flag rune owns - # "Unknown", it asserted a position error when the flag was already before the separator, and - # following its remedy literally hands `--timeout` to the child instead of applying a timeout. - # Two agents driving rune lost tool calls to it and one nearly filed it as a broken flag. - INLINE_VALUE_ERROR = '%s takes its value inline: %s=VALUE. To pass it to the ' \ - 'command instead: rune run -- %s VALUE' - private # Returns [PTYRunner_kwargs, remaining_args, error_message]. A malformed @@ -97,7 +89,7 @@ def extract_flags(args) leftovers, raw_values, separate_streams = scan_head(head) flags, error = parse_flags(raw_values) flags[:separate_streams] = true if separate_streams - [flags, leftovers + tail, error || unknown_flag_error(leftovers)] + [flags, leftovers + tail, error || self.class.flag_error(leftovers, VALUE_FLAGS)] end # Returns [tokens rune did not claim, raw flag values, whether --separate-streams was given]. @@ -126,17 +118,6 @@ def scan_head(head) # is what keeps `rune run cargo --version` and `rune run npm test --watch` working: once a # program name has been seen, every later `--flag` belongs to it. `rune run -- --version` # passes through untouched because the separator empties this list entirely. - def unknown_flag_error(leftovers) - unknown = leftovers.take_while { |token| self.class.flag_shaped?(token) }.first - return nil unless unknown - - name = unknown.split('=', 2).first - return format(INLINE_VALUE_ERROR, name: name) if VALUE_FLAGS.include?(name) - - "Unknown option: #{name}. rune's own flags are recognized only before the wrapped " \ - 'command; to pass this one to that command instead, put the command first or use a ' \ - "separator: rune run -- #{name}" - end def matching_flag(arg) FLAG_PATTERNS.each do |key, (pattern, _label, _unit)| diff --git a/lib/rune/commands/session_command.rb b/lib/rune/commands/session_command.rb index d842b2c..5bde243 100644 --- a/lib/rune/commands/session_command.rb +++ b/lib/rune/commands/session_command.rb @@ -616,7 +616,9 @@ def filter(text, options, transcript) pattern, reason = compile_grep(options[:grep]) return [+'', grep_failure(options[:grep], reason)] unless pattern - filtered, matches = transcript.grep(pattern, context: options[:context_lines].to_i) + # The sliced text, not the whole transcript: `--since` had no effect on a grepped read + # because this discarded the slice it was handed. + filtered, matches = Session::Transcript.grep_text(text, pattern, context: options[:context_lines].to_i) [filtered, { grep: options[:grep], grep_matches: matches }] end diff --git a/lib/rune/commands/watch_command.rb b/lib/rune/commands/watch_command.rb index b002574..96f7dd8 100644 --- a/lib/rune/commands/watch_command.rb +++ b/lib/rune/commands/watch_command.rb @@ -65,6 +65,15 @@ def human_render(data, io) idle_timeout_seconds: [/\A--idle-timeout=(.*)\z/, '--idle-timeout', 'number of seconds'] }.freeze + # The timeout flags are derived from `TIMEOUT_FLAGS`; `--log` is appended by hand because its + # pattern is written inline in `scan_head` rather than in a table. That is the shape this + # project has been bitten by before — a hand-maintained list is what let `--context` ship + # accepted-and-ignored — so the claim here is deliberately narrower than `run`'s: this list + # cannot drift for the *timeout* flags, and the spec's drift test covers whatever is in it. + # A new value flag added to `scan_head` and not to this list would be served the + # unknown-flag message, which is the wrong one. + VALUE_FLAGS = (TIMEOUT_FLAGS.values.map { |(_pattern, label, _unit)| label } + ['--log']).freeze + private # Where the wrapped child's live bytes go. This is the one command whose @@ -149,10 +158,19 @@ def extract_options(args) head = separator_index ? args[0...separator_index] : args tail = separator_index ? args[separator_index..] : [] + head, log_path, raw_timeouts, error = scan_head(head) + watcher_options, timeout_error = parse_timeouts(raw_timeouts) + [log_path, watcher_options, head + tail, error || timeout_error || leftover_flag_error(head)] + end + + # Consumes watch's own flags out of the pre-separator argv, returning what is left along with + # what was found. Separated from `extract_options` so the guard below is not competing with + # the scan for one method's complexity budget. + def scan_head(head) log_path = nil raw_timeouts = {} error = nil - head = head.select do |arg| + remaining = head.select do |arg| log_match = arg.match(/\A--log=(.*)\z/) if log_match log_path, error = extract_log_value(log_match[1], error) @@ -163,11 +181,14 @@ def extract_options(args) raw_timeouts[key] = match[1] if key key.nil? end - - watcher_options, timeout_error = parse_timeouts(raw_timeouts) - [log_path, watcher_options, head + tail, error || timeout_error] + [remaining, log_path, raw_timeouts, error] end + # Without this, anything watch does not recognise stays in `head` and becomes the command: + # `rune watch --timeout 5 -- echo hi` exec'd `--timeout` and exited 127 with the child never + # running. `run` has guarded this since it grew flags; watch never did. + def leftover_flag_error(head) = self.class.flag_error(head, VALUE_FLAGS) + def extract_log_value(value, error) return [nil, '--log requires a path, e.g. --log=/tmp/session.ndjson'] if value.empty? diff --git a/lib/rune/pty_runner.rb b/lib/rune/pty_runner.rb index f5d3d96..37df2d0 100644 --- a/lib/rune/pty_runner.rb +++ b/lib/rune/pty_runner.rb @@ -107,8 +107,30 @@ def build_result_data(raw_output, exit_code, prompt_detected, duration_ms, strea return data unless separate_streams stdout_buffer, stderr_buffer = stream_buffers - data.merge(clean_stdout: Parsers::TextSanitizer.strip_ansi(stdout_buffer), - clean_stderr: Parsers::TextSanitizer.strip_ansi(stderr_buffer)) + data.merge(clean_stdout: bound_stream(stdout_buffer), clean_stderr: bound_stream(stderr_buffer)) + end + + # `--separate-streams` adds two more fields, and they were not bounded at all: a 200-byte + # budget returned 10,506 bytes across the four fields, because only `clean_output` and + # `raw_output` went through `apply_output_limit`. A caller sets `--max-output` to cap what + # comes back, and adding a flag that surfaces the same output twice more should not silently + # uncap it. + # + # Each field is bounded to the same budget, which is the contract `--max-output` already + # states ("BYTES each"), and their omitted counts are not surfaced for the same reason + # `raw_output`'s is not — one reply carries one count, and it is `clean_output`'s. + def bound_stream(buffer) + text = Parsers::TextSanitizer.strip_ansi(buffer) + return text unless max_output_bytes || tail_lines + + bounded, = if max_output_bytes + OutputLimiter.truncate_middle(text, + max_output_bytes) + else + OutputLimiter.tail_lines(text, + tail_lines) + end + bounded end # Bounds clean_output/raw_output when --max-output or --tail was requested. Both fields are diff --git a/lib/rune/session/transcript.rb b/lib/rune/session/transcript.rb index 85be15b..05dae0a 100644 --- a/lib/rune/session/transcript.rb +++ b/lib/rune/session/transcript.rb @@ -130,8 +130,18 @@ def screen(rows: nil, columns: nil) # splits words across escape sequences, so a pattern plainly visible on # screen does not match the bytes — which would make search appear broken # in exactly the situation it exists for. - def grep(pattern, context: 0) - lines = Parsers::TextSanitizer.strip_ansi(@text).lines + def grep(pattern, context: 0) = self.class.grep_text(@text, pattern, context: context) + + # Greps a given stretch of transcript rather than always the whole of it. + # + # `read --since=N --grep=RE` sliced the transcript to the cursor and then handed the slice to + # a grep that ignored it and searched `@text`, so `--since` had no effect on a grepped read at + # all. Measured: a read from a cursor recorded *after* the first line still returned that + # line, and `grep_matches` counted it. A caller paging a long transcript with + # `--since=` got the whole history back on every page, under a match count that + # looked like it had filtered. + def self.grep_text(text, pattern, context: 0) + lines = Parsers::TextSanitizer.strip_ansi(text).lines matches = lines.each_index.select { |index| pattern.match?(lines[index]) } windows = matches.flat_map do |index| ([index - context, 0].max..[index + context, lines.size - 1].min).to_a diff --git a/spec/rune/commands/watch_command_spec.rb b/spec/rune/commands/watch_command_spec.rb index 7bb7b6c..819b6ce 100644 --- a/spec/rune/commands/watch_command_spec.rb +++ b/spec/rune/commands/watch_command_spec.rb @@ -5,6 +5,58 @@ RSpec.describe Rune::Commands::WatchCommand do describe '#call' do + # watch had no unknown-flag guard at all: anything flag-shaped it did not recognise stayed in + # the argv and became the command. Measured through a real controlling terminal before the fix, + # `rune watch --timeout 5 -- echo hi` exited 127 with the child never running, because + # `--timeout` was exec'd as the program. `run` has guarded this since it grew flags. + describe 'a flag watch owns, given a space-separated value' do + before do + allow($stdin).to receive(:tty?).and_return(true) + allow(Rune::PTYWatcher).to receive(:new) + end + + it 'is refused with the inline-value message rather than exec\'d as the command' do + result = described_class.new.call(%w[--timeout 5 -- echo hi], {}) + + expect(result.error).to include('--timeout takes its value inline: --timeout=VALUE') + expect(Rune::PTYWatcher).not_to have_received(:new) + end + + it 'names watch, not run, in the separator remedy' do + expect(described_class.new.call(%w[--timeout 5 -- echo hi], {}).error) + .to include('rune watch -- --timeout VALUE') + end + + # Mirrors run_command_spec's drift guard. It fails the day a value flag is added to watch and + # not to VALUE_FLAGS, which is when the caller starts getting the wrong message again. + it 'covers every value flag watch owns' do + described_class::VALUE_FLAGS.each do |flag| + result = described_class.new.call([flag, '5', '--', 'echo', 'hi'], {}) + + expect(result.error).to include("#{flag} takes its value inline"), "no inline-value message for #{flag}" + end + end + + it 'reports a genuinely unknown flag as unknown' do + expect(described_class.new.call(%w[--bogus 1 -- echo hi], {}).error).to include('Unknown option: --bogus') + end + + it 'leaves the inline form working' do + allow(Rune::PTYWatcher).to receive(:new).and_return(instance_double(Rune::PTYWatcher, watch: Rune::Result.success({}))) + + expect(described_class.new.call(%w[--timeout=5 -- echo hi], {})).to be_success + end + + # The child's own flags must survive. watch wraps arbitrary programs, so a flag-shaped token + # that belongs to the child is the case this guard must not eat. + it 'passes a child\'s own flags through untouched' do + allow(Rune::PTYWatcher).to receive(:new).and_return(instance_double(Rune::PTYWatcher, watch: Rune::Result.success({}))) + + expect(described_class.new.call(%w[-- mytool --timeout 5], {})).to be_success + expect(described_class.new.call(%w[-- --version], {})).to be_success + end + end + it 'fails clearly without touching PTYWatcher at all when stdin is not a real terminal' do allow(Rune::PTYWatcher).to receive(:new) diff --git a/spec/rune/pty_runner_spec.rb b/spec/rune/pty_runner_spec.rb index 7c3cf3c..1eb2345 100644 --- a/spec/rune/pty_runner_spec.rb +++ b/spec/rune/pty_runner_spec.rb @@ -352,6 +352,49 @@ expect(result.data[:omitted_bytes]).to eq(4800) end + # Removes the elision line and the newlines that join it, whatever byte count it carries — + # `raw_output` and `clean_output` carry different ones under the same budget. + def without_marker(text) + text.sub(/\n?\[rune\] ==== \d+ bytes? omitted by --max-output ====\n?/, '') + end + + # The guard above uses `print "a" * 5000` — no newline, no ANSI — so the two fields are + # byte-identical and it cannot see the one thing that actually differs between them. These + # pin the real shape: each field is bounded to the budget, and under `--max-output` they + # describe DIFFERENT windows, which is the documented consequence of "BYTES each". + it 'bounds both fields on realistic output, where the two windows are not the same' do + child = ['ruby', '-e', '60.times { |i| printf("\e[1;32mrow %02d padded out here\e[0m\n", i) }'] + result = described_class.new(child, max_output_bytes: 200).run + marker = Rune::OutputLimiter.elision_marker(result.data[:omitted_bytes]) + + expect(result.data[:clean_output].sub(marker, '').bytesize).to eq(200) + # raw carries its own marker with a different count, so strip by shape rather than value. + # The budget is a ceiling rather than an exact size on raw: `truncate_middle` trims a + # dangling escape at the cut, so a colour-emitting child lands under it — 197 here. + expect(without_marker(result.data[:raw_output]).bytesize).to be <= 200 + # The divergence the old fixture was blind to, asserted rather than assumed. + expect(Rune::Parsers::TextSanitizer.strip_ansi(result.data[:raw_output])) + .not_to eq(result.data[:clean_output]) + end + + # `--separate-streams` surfaces the same output twice more, and those fields were not bounded + # at all: a 200-byte budget returned 10,506 bytes across the four. + it 'bounds clean_stdout and clean_stderr too, not only the merged view' do + child = ['ruby', '-e', 'print "a" * 5000; $stderr.print "b" * 5000'] + result = described_class.new(child, max_output_bytes: 200, separate_streams: true).run + + expect(without_marker(result.data[:clean_stdout]).bytesize).to eq(200) + expect(without_marker(result.data[:clean_stderr]).bytesize).to eq(200) + end + + it 'leaves the separate streams unbounded when no bound was asked for' do + child = ['ruby', '-e', 'print "a" * 5000; $stderr.print "b" * 5000'] + result = described_class.new(child, separate_streams: true).run + + expect(result.data[:clean_stdout].bytesize).to eq(5000) + expect(result.data[:clean_stderr].bytesize).to eq(5000) + end + it 'leaves output untouched when max_output_bytes is larger than the actual output' do runner = described_class.new('echo hi', max_output_bytes: 1_000_000) result = runner.run diff --git a/spec/rune/session_spec.rb b/spec/rune/session_spec.rb index d7f9bb5..38fbce6 100644 --- a/spec/rune/session_spec.rb +++ b/spec/rune/session_spec.rb @@ -496,6 +496,37 @@ def ansi_child # with `truncated` and `omitted_lines` absent, which reads as "nothing was # dropped". Measured against a live claude session, 84,945 bytes came back # identically for --tail=3, 5, 8, 12 and 20. + # `--since` sliced the transcript and then handed the slice to a grep that ignored it and + # searched the whole transcript, so a caller paging with `--since=` got the entire + # history back on every page, under a `grep_matches` count that looked like it had filtered. + describe '--grep together with --since' do + def echoing_child + ['ruby', '-e', 'STDOUT.sync = true; while (line = STDIN.gets); puts("SAW:" + line); end'] + end + + it 'greps only the slice the cursor selected' do + start_session('gs1', echoing_child) + session('send', '--name=gs1', '--settle-ms=400', '--timeout-ms=15000', '--', 'NEEDLE_ONE') + cursor = session('read', '--name=gs1').data[:cursor] + session('send', '--name=gs1', '--settle-ms=400', '--timeout-ms=15000', '--', 'NEEDLE_TWO') + + result = session('read', '--name=gs1', "--since=#{cursor}", '--grep=NEEDLE') + + expect(result.data[:clean_output]).to include('NEEDLE_TWO') + expect(result.data[:clean_output]).not_to include('NEEDLE_ONE') + end + + it 'still searches the whole transcript when no cursor was given' do + start_session('gs2', echoing_child) + session('send', '--name=gs2', '--settle-ms=400', '--timeout-ms=15000', '--', 'NEEDLE_ONE') + session('send', '--name=gs2', '--settle-ms=400', '--timeout-ms=15000', '--', 'NEEDLE_TWO') + + result = session('read', '--name=gs2', '--grep=NEEDLE') + + expect(result.data[:clean_output]).to include('NEEDLE_ONE').and include('NEEDLE_TWO') + end + end + describe '--tail against carriage-return repaint output' do def repainting_child ['ruby', '-e', 'STDOUT.sync = true; while STDIN.gets; print "l1\rl2\rl3\rl4\rl5\r"; end'] diff --git a/specs/cli/cli.spec.md b/specs/cli/cli.spec.md index bb61838..8dac05b 100644 --- a/specs/cli/cli.spec.md +++ b/specs/cli/cli.spec.md @@ -1,6 +1,6 @@ --- module: cli -version: 26 +version: 27 status: active files: - lib/rune.rb @@ -62,6 +62,9 @@ Core CLI framework for rune. Provides command registration, argument parsing, du | `command_flags` | reader | Returns the subclass's declared flags, defaulting to an empty array. | | `subcommand` | class method | Declares one subcommand (name + summary) for command help. | | `command_subcommands` | reader | Returns the subclass's declared subcommands, defaulting to an empty array. | +| `flag_error` | class method | Rejects a flag-shaped token that reached the wrapped command's argv, shared by `run` and `watch`. | +| `INLINE_VALUE_ERROR` | constant | Message template for a flag the command owns whose value was space-separated. | +| `UNKNOWN_FLAG_ERROR` | constant | Message template for a flag-shaped token the command does not own. | | `Help` | class | Builds and renders `rune --help`, `rune --help`, and `rune help [cmd]`. Class method: `.extract_flag!(args)`. Instance: `#overview`, `#for_command(name)`, `#render(data, io)`. | | `FLAGS` | constant | Tokens (`--help`, `-h`) recognized as a help request before the first `--`. | | `GLOBAL_FLAGS` | constant | Flags that apply to every command, rendered under "Global flags" and returned in every help payload. | @@ -206,3 +209,4 @@ Core CLI framework for rune. Provides command registration, argument parsing, du | 2026-08-17 | CHG-0058-integrate-the-post-0-8-0-fixes-two-quadratics-exec-fidelity-geometry-cursors: Integrate the post-0.8.0 fixes: two quadratics, exec fidelity, geometry, cursors, and the guide gate | | 2026-08-17 | CHG-0059-expose-subcommands-as-structured-data-in-per-command-help: Expose subcommands as structured data in per-command help | | 2026-08-17 | CHG-0061-release-0-9-0-bump-the-version-and-record-the-round-s-measurements: Release 0.9.0: bump the version and record the round's measurements | +| 2026-08-18 | CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not: Guard the flags watch was executing, and bound the two fields max-output was not | diff --git a/specs/pty_runner/pty_runner.spec.md b/specs/pty_runner/pty_runner.spec.md index 36dc939..553a39f 100644 --- a/specs/pty_runner/pty_runner.spec.md +++ b/specs/pty_runner/pty_runner.spec.md @@ -1,6 +1,6 @@ --- module: pty_runner -version: 11 +version: 12 status: active files: - lib/rune/pty_runner.rb @@ -55,6 +55,8 @@ Pseudo-Terminal (PTY) runner and text sanitizer for `rune`. Spawns un-structured | `on_output` | reader | Optional decoded-output callback. | | `OutputLimiter` | class | Bounds captured text without corrupting UTF-8 or splicing a half-escape-sequence at the cut boundary. Stateless; all entry points are class methods. | | `truncate_middle` | class method | `(text, max_bytes)` returns `[bounded_text, omitted_bytes]`; keeps head and tail with a marker between. `omitted_bytes` is measured in offsets into the original text, which is not the same as "every byte absent from the result" once a cut splits a character. | +| `apply_output_limit` | internal method | Applies `--max-output` or `--tail` to the merged clean/raw pair. | +| `execute_pty` | internal method | Runs the command in a pty and collects its output. | | `dangling_suffix` | class method | The trailing bytes of an escape sequence still waiting for its terminator, or empty. | | `LINE_WITH_TERMINATOR` | constant | One line plus its terminator, where a line ends at CR, LF or CRLF. | | `elision_marker` | class method | `(omitted)` returns the newline-delimited `[rune] ==== N bytes omitted by --max-output ====` line spliced between head and tail. Not charged against `max_bytes`: it is rune's annotation of the cut, not the child's output. | @@ -69,9 +71,7 @@ Pseudo-Terminal (PTY) runner and text sanitizer for `rune`. Spawns un-structured | `human_render` | instance method | Prints a concise command summary and captured clean output. | | `FLAG_PATTERNS` | constant | Maps each `PTYRunner` value-taking keyword option (`--timeout`, `--max-output`, `--tail`) to its argv pattern, flag name, and error-message value description. `--separate-streams` takes no value, so it is matched separately rather than via this table. | | `matching_flag` | internal method | Matches one argv token against `FLAG_PATTERNS`, returning the matched option key and `MatchData`, or `[nil, nil]`. | -| `unknown_flag_error` | internal method | Rejects a flag-shaped token before the first operand. A flag `run` owns, spelled correctly but given a space-separated value, gets the inline-value message instead of "Unknown option". | -| `VALUE_FLAGS` | constant | The flags that take a value, derived from `FLAG_PATTERNS` so it cannot drift from the parser. | -| `INLINE_VALUE_ERROR` | constant | The message for a correctly spelled flag whose value was space-separated. | +| `VALUE_FLAGS` | constant | The `run` flags that take a value, derived from `FLAG_PATTERNS` so it cannot drift from the parser. The guard itself moved to `Command.flag_error`, shared with `watch`. | | `parse_flags` | internal method | Parses every raw `--timeout`/`--max-output`/`--tail` value, stopping at the first invalid one, then checks mutual exclusion. | | `both_output_limits?` | internal predicate | True when both `--max-output` and `--tail` were given. | | `parse_positive_int` | internal method | Accepts a positive integer value for `--timeout`/`--max-output`/`--tail` and rejects every other value. | @@ -181,6 +181,16 @@ Pseudo-Terminal (PTY) runner and text sanitizer for `rune`. Spawns un-structured which contradicts the scrub invariant, and redefining it would change the marker's rendered length and could flip `truncated` for callers who changed nothing. +29. `--max-output` and `--tail` bound `clean_stdout` and `clean_stderr` as well as the merged + fields. They were not bounded at all: a 200-byte budget returned 10,506 bytes across the four + fields, because only `clean_output` and `raw_output` went through `apply_output_limit`. A caller + sets the flag to cap what comes back, and adding `--separate-streams` — which surfaces the same + output twice more — should not silently uncap it. Measured after: 1,012 bytes for the same + budget. Each field is bounded to the same budget, which is the "BYTES each" contract already + stated, and their omitted counts are not surfaced for the same reason `raw_output`'s is not: + one reply carries one count and it is `clean_output`'s. With neither flag set the fields are + byte-for-byte unchanged. + ## Behavioral Examples - `ruby bin/rune run -- echo "Hello PTY"` outputs clean JSON in agent mode (`--json`) containing `exit_code: 0`, `clean_output: "Hello PTY\n"`, and `duration_ms`. @@ -297,3 +307,4 @@ Pseudo-Terminal (PTY) runner and text sanitizer for `rune`. Spawns un-structured | 2026-08-18 | CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the: Stop a read mid-escape: withhold an unterminated sequence from the text and the cursor | | 2026-08-18 | CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg: Make --tail count a carriage return as a line break, and report matched on a regex send's timeout | | 2026-08-18 | CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun: Correct the flag message run gets wrong, and the five contracts the dogfood found documented wrong | +| 2026-08-18 | CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not: Guard the flags watch was executing, and bound the two fields max-output was not | diff --git a/specs/session/session.spec.md b/specs/session/session.spec.md index 6f08d76..c459442 100644 --- a/specs/session/session.spec.md +++ b/specs/session/session.spec.md @@ -1,6 +1,6 @@ --- module: session -version: 32 +version: 33 status: active files: - lib/rune/session/store.rb @@ -60,6 +60,7 @@ deciding who talks to whom stays the calling agent's job. | `retained_offset` | instance method | Where an absolute cursor lands in the retained text, walking past each dropped region rather than subtracting one total. | | `screen` | instance method | What a terminal of a given size would be showing. | | `grep` | instance method | Lines matching a pattern with surrounding context, and how many matched. | +| `grep_text` | class method | Greps a given stretch of transcript, so a `--since` slice is searched rather than the whole of it. | | `filter` | internal method | Applies `--grep` to a read, or fails the filter closed and reports why. | | `Echo` | class | The pty's echo of one send, and where it ends in what has arrived back. | | `ESCAPE_SEQUENCE` | constant | Escape forms removed when condensing text for echo location. | @@ -619,6 +620,14 @@ deciding who talks to whom stays the calling agent's job. own help claimed it matched "the rendered text rather than the repaint stream", which is the opposite of what it does; that is corrected. Use `--screen` when the question is what is currently displayed. +41p. `--grep` searches the slice `--since` selected, not the whole transcript. It was handed the + slice and then discarded it, so `--since` had no effect on a grepped read: a read from a cursor + recorded after the first line still returned that line, and `grep_matches` counted it. A caller + paging with `--since=` got the entire history back on every page, under a count + that looked like it had filtered. Context windows are taken within the slice, so a match on its + first line has no preceding context to show — which is the same thing `--since` already means + for every other field in the reply. + 41n. Rotation costs no measurable memory. It seeks to the cut point rather than scanning what it is dropping, reads the byte count each event already records rather than parsing the event, and copies with `IO.copy_stream` so the bytes never enter Ruby. The first implementation read the @@ -1105,3 +1114,4 @@ deciding who talks to whom stays the calling agent's job. | 2026-08-18 | CHG-0066-stop-a-read-mid-escape-withhold-an-unterminated-sequence-from-the-text-and-the: Stop a read mid-escape: withhold an unterminated sequence from the text and the cursor | | 2026-08-18 | CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg: Make --tail count a carriage return as a line break, and report matched on a regex send's timeout | | 2026-08-18 | CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun: Correct the flag message run gets wrong, and the five contracts the dogfood found documented wrong | +| 2026-08-18 | CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not: Guard the flags watch was executing, and bound the two fields max-output was not | diff --git a/specs/watch/watch.spec.md b/specs/watch/watch.spec.md index bfccb92..b24fd1d 100644 --- a/specs/watch/watch.spec.md +++ b/specs/watch/watch.spec.md @@ -1,6 +1,6 @@ --- module: watch -version: 9 +version: 10 status: active files: - lib/rune/pty_watcher.rb @@ -48,6 +48,9 @@ there. | `attach_log_path` | internal method | Adds the concrete log path to a successful result. | | `open_log` | internal method | Opens an explicit append log or creates a private temporary log. | | `extract_options` | internal method | Extracts `--log=PATH`, `--timeout=SECONDS`, and `--idle-timeout=SECONDS` before the first `--` separator. | +| `scan_head` | internal method | Consumes watch's own flags out of the pre-separator argv, returning what is left. | +| `leftover_flag_error` | internal method | Rejects a flag-shaped token watch does not own before it becomes the command. | +| `VALUE_FLAGS` | constant | The watch flags that take a value, used by the leftover-flag guard. | | `extract_log_value` | internal method | Validates one raw `--log=` value, rejecting an empty path. | | `matching_timeout_flag` | internal method | Matches one argv token against `TIMEOUT_FLAGS`, returning the matched option key and `MatchData`, or `[nil, nil]`. | | `parse_timeouts` | internal method | Parses every raw `--timeout`/`--idle-timeout` value, stopping at the first invalid one. | @@ -156,6 +159,23 @@ there. reached first fires. Both report exit code 124 and `data[:timed_out]: true`, distinguished by `data[:timeout_kind]`: `"timeout"` or `"idle_timeout"`. +20. A flag-shaped token before the separator that `watch` does not own is refused, not run. + Until now anything unrecognised stayed in the argv and became the command: measured through a + real controlling terminal, `rune watch --timeout 5 -- echo hi` exited **127 with the child never + running**, because `--timeout` was exec'd as the program. `run` has guarded this since it grew + flags; `watch` never did, which made it the worse of the two — `run` at least says something. + + The guard is `Command.flag_error`, shared with `run` rather than copied, because the two had + already drifted once: `run` grew the inline-value branch and `watch` had no guard to grow it in. + A correctly spelled flag given a space-separated value gets the inline-value message naming + `rune watch`; anything else gets the unknown-flag message. + + It covers the **leading** position only, which is what `scan_head` leaves behind. A flag `watch` + owns that appears *after* the program name is still consumed rather than passed to the child — + `rune watch echo hi --log=/tmp/x` writes rune's own log to the child's path and prints only + `hi`. That is pre-existing and unchanged here, and it is the asymmetry `session` already closed + for itself: permuting for consumption and not for validation. + ## Behavioral Examples - `rune watch -- ruby examples/demo_tui.rb` puts your terminal in raw mode, runs the demo TUI @@ -238,3 +258,4 @@ there. | 2026-08-14 | CHG-0021-add-timeout-and-idle-timeout-to-rune-watch-so-an-agent-driven-session-can-t: Add --timeout and --idle-timeout to rune watch so an agent-driven session can't hang forever, closing #14 | | 2026-08-17 | CHG-0058-integrate-the-post-0-8-0-fixes-two-quadratics-exec-fidelity-geometry-cursors: Integrate the post-0.8.0 fixes: two quadratics, exec fidelity, geometry, cursors, and the guide gate | | 2026-08-17 | CHG-0062-bound-rune-run-timeout-when-the-child-is-still-printing-and-let-a-second-sign: Bound rune run --timeout when the child is still printing, and let a second signal stop rune | +| 2026-08-18 | CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not: Guard the flags watch was executing, and bound the two fields max-output was not | From 5f4d77b08f0d96682520e66e7eeb5411ed5a5da3 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 18 Aug 2026 07:12:56 -0600 Subject: [PATCH 2/4] Add: a cell model, so a wide glyph occupies the two columns it is drawn in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the five renderer gaps, and a correction of my own conclusion about it. A cell model was built once before and reverted, and the spec recorded that it had been "measured worse than the gap". That was wrong. The A/B compared two working trees and misattributed which output came from which side. Re-measured against three explicit revisions on the same 56,928-byte grok capture that had emitted a CJK table: cc8bb3c (one column) "東h京 Tokyo" "大 阪 Osaka" ad76e22 (one column) "東h京 Tokyo" "大 阪 Osaka" cell model "東京 Tokyo" "大阪 Osaka" The one-column model corrupts real agent output and always did: an agent positions its columns assuming two per CJK glyph, and a renderer counting one puts every later write in the wrong place. I had it backwards, reverted a correct fix, and wrote the mistake into the contract. I also ruled out the four renderer fixes shipped since — alt screen, DECAWM, IRM, charsets — by disabling each in turn on top of main. The corrupted rows are byte-identical in all four cases. It is the column arithmetic. Rows are now Arrays of cells: nil, a String of one graphic plus its combining marks, or CONTINUATION for the right half of a wide glyph. That makes two things true the String could not. A cell holds any number of characters without moving the cells after it, which is what makes combining marks work at all — appending a mark to a String row put every later index off by one and the next graphic overwrote it. And the pair invariant can be restored after the fact, in one heal pass, instead of being taught to twelve operations that each slice the row. The first attempt tried the latter and lost. pad/padded_line are gone: assigning past the end of an array fills with nil and a nil cell renders blank. That padding was the mechanism by which a column index became an index into text. 591 examples, 0 failures. Controls: forcing the width table to one column fails 4 of 136 parser examples; making heal a no-op fails 2. Stacked on leif/watch-flag-guard so the change records do not collide. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rf59AtQmJcodUJ6vXDZNY --- .specsync/change-sequence.json | 4 +- .../approvals.json | 19 ++ .../change.md | 24 ++ .../context.md | 28 +++ .../deltas/parsers.md | 226 ++++++++++++++++++ .../design.md | 26 ++ .../docs.md | 16 ++ .../plan.md | 20 ++ .../requirements.md | 14 ++ .../research.md | 29 +++ .../state.json | 43 ++++ .../tasks.md | 14 ++ .../testing.md | 26 ++ .../verification-attempts.json | 35 +++ .../verification.json | 96 ++++++++ lib/rune/parsers/character_width.rb | 71 ++++++ lib/rune/parsers/screen.rb | 167 +++++++++---- spec/rune/parsers/screen_renderer_spec.rb | 44 ++++ specs/parsers/parsers.spec.md | 86 ++++--- 19 files changed, 895 insertions(+), 93 deletions(-) create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/change.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/context.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/deltas/parsers.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/design.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/docs.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/plan.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/requirements.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/research.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/tasks.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/testing.md create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json create mode 100644 .specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json create mode 100644 lib/rune/parsers/character_width.rb diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 5baec85..8feca28 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 69, - "id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "sequence": 70, + "id": "CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json new file mode 100644 index 0000000..2ec9608 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json @@ -0,0 +1,19 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1787058320, + "digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787058499, + "digest": "e2756764d41e9cf863a9d73ec54a28da404392e89f2bc2711327b4f167f3d9ef", + "note": null + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/change.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/change.md new file mode 100644 index 0000000..905434d --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +state: accepted +type: feature +base_commit: ac38dba529ff6cb4838f825b5c3c9594af36b7d1 +--- + +# Give the screen a cell model so a wide glyph occupies the two columns it is drawn in + +## Intent + +Give the screen a cell model so a wide glyph occupies the two columns it is drawn in + +## Affected Canonical Specs + +- `parsers` + +## Acceptance Criteria + +- A screen row is an array of cells, so a column index is an array index. A wide glyph occupies two columns and wraps rather than splitting at the margin; destroying either half blanks both, as a terminal does. A zero-width character attaches to the cell before it and occupies no column. The wide-glyph invariant is restored by one heal pass after each mutating operation rather than by teaching twelve operations about pairs. All five renderer gaps are closed, and the spec invariant that wrongly recorded this fix as measured-worse is corrected with the three-revision measurement that reverses it. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/context.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/context.md new file mode 100644 index 0000000..73c0a86 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/context.md @@ -0,0 +1,28 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: context +--- + +# Context + +The last of the five renderer gaps, and a correction of my own earlier conclusion +about it. + +A cell model was built once before and reverted, and this spec recorded that it +had been "measured worse than the gap". **That was wrong.** The A/B compared two +working trees and misattributed which output came from which side. Re-measured +against three explicit revisions on the same 56,928-byte grok capture that had +emitted a CJK table: + + cc8bb3c (one column) "東h京 Tokyo" "大 阪 Osaka" + ad76e22 (one column) "東h京 Tokyo" "大 阪 Osaka" + cell model "東京 Tokyo" "大阪 Osaka" + +The one-column model corrupts real agent output and always did. An agent +positions its columns assuming two per CJK glyph; a renderer counting one puts +every later write in the wrong place. I had it backwards, reverted a correct fix, +and then wrote the mistake into the contract. + +I also checked whether the four renderer fixes shipped since could be the cause, +by disabling each on top of `main` in turn — alt screen, DECAWM, IRM, charsets. +None of them changes the corrupted rows. It is the column arithmetic. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/deltas/parsers.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/deltas/parsers.md new file mode 100644 index 0000000..f1939c9 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/deltas/parsers.md @@ -0,0 +1,226 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `TableParser` | class | Class method `.parse(text, format: :auto)` converts space or pipe-delimited table text into array of hashes. `format:` accepts `:auto` (default heuristic), `:pipe`, or `:space` to force a parsing mode; raises `ArgumentError` for any other value. | +| `KeyValueParser` | class | Class method `.parse(text)` converts key-value text lines (`key: val`) into typed hashes. | +| `TextSanitizer` | class | Class method `.strip_ansi(text)` strips ANSI escape codes and normalizes line endings. | +| `ScreenRenderer` | class | Class method `.render(text, rows:, columns:, tail_bytes:)` replays a terminal byte stream onto a virtual screen and returns what a terminal would be showing. | +| `PromptDetector` | class | Class method `.detect?(line)` reports whether a single line of (possibly ANSI-colored) output looks like an interactive prompt awaiting input — used by `PTYRunner`/`PTYWatcher` to set `prompt_detected` in their results. | +| `Rune` | module | Top-level rune namespace. | +| `Parsers` | module | Namespace for terminal-output parsing helpers. | +| `parse` | class method | Parses the supplied text using the parser's documented format contract. | +| `parse_pipe_table` | internal method | Builds rows from pipe-delimited headers and cells. | +| `parse_space_table` | internal method | Builds rows from aligned whitespace-delimited columns. | +| `find_headers_and_spans` | internal method | Derives normalized headers and source-column spans. | +| `multi_space_spans` | internal method | Computes spans for headers separated by two or more spaces. | +| `single_space_spans` | internal method | Computes fallback spans from individual non-space tokens. | +| `set_span_ends` | internal method | Completes each detected column span using the following start offset. | +| `extract_values` | internal method | Selects split-based or span-based value extraction for a row. | +| `extract_by_spans` | internal method | Slices row values according to detected header positions. | +| `normalize_header` | internal method | Converts a header to a lowercase underscored symbol. | +| `build_row` | internal method | Zips normalized headers with values, filling missing cells with empty strings. | +| `strip_ansi` | class method | Removes supported ANSI sequences and normalizes CRLF/CR line endings. | +| `ANSI_REGEX` | constant | Escape-sequence pattern removed by `TextSanitizer`. | +| `render` | class method | Replays a byte stream onto a virtual screen and returns the visible text. | +| `Screen` | class | The grid and cursor a terminal maintains, separated from escape-sequence parsing. | +| `write` | instance method | Writes characters at the cursor, wrapping at the right margin. | +| `to_s` | instance method | Returns the visible screen, right-trimmed with trailing blank lines removed. | +| `insert_blanks` | instance method | ICH: shifts the rest of the line right, losing what falls off the edge. | +| `delete_characters` | instance method | DCH: shifts the rest of the line left over the deleted characters. | +| `erase_characters` | instance method | ECH: blanks characters in place without shifting. | +| `insert_lines` | instance method | IL: inserts blank lines at the cursor row, pushing the rest down. | +| `delete_lines` | instance method | DL: removes lines at the cursor row, pulling the rest up. | +| `scroll_up` | instance method | SU: scrolls the screen up, blanking the lines it exposes. | +| `scroll_down` | instance method | SD: scrolls the screen down, blanking the lines it exposes. | +| `DEFAULT_ROWS` | constant | Rows assumed when no size is given. | +| `DEFAULT_COLUMNS` | constant | Columns assumed when no size is given. | +| `MAX_ROWS` | constant | Ceiling on a caller-supplied row count, since the grid is allocated eagerly. | +| `MAX_COLUMNS` | constant | Ceiling on a caller-supplied column count, for the same reason. | +| `dimensions` | class method | The size a render will actually use, given what the caller asked for. | +| `CSI` | constant | The ECMA-48 CSI grammar: parameters, then intermediates, then a final byte. | +| `IGNORED` | constant | Escape forms consumed and dropped, because anything not consumed is printed. | +| `INCOMPLETE` | constant | A sequence the stream ended in the middle of, its terminator not yet arrived. | +| `full_reset` | instance method | RIS: clears the grid, homes the cursor and resets the scroll region. | +| `soft_reset` | instance method | DECSTR: resets region, saved cursor and origin without clearing the display. | +| `scroll_region` | instance method | DECSTBM: confines scrolling to a band of rows, and homes the cursor. | +| `private_modes` | instance method | Applies every mode in a `CSI ? Pm h/l` parameter list. | +| `private_mode` | instance method | One DEC private mode: the alternate buffer, DECAWM, or a cursor save. | +| `ansi_modes` | instance method | Applies every mode in a `CSI Pm h/l` parameter list; only IRM changes the grid. | +| `alternate_buffer` | instance method | Enters or leaves the alternate buffer for modes 1049, 1047 and 47. | +| `enter_alternate` | instance method | Switches to a cleared alternate buffer, optionally saving the cursor. | +| `leave_alternate` | instance method | Restores the primary buffer, optionally restoring the cursor. | +| `designate_charset` | instance method | Designates ASCII or DEC Special Graphics into a G0/G1 slot. | +| `shift_out` | instance method | SO: selects G1 for subsequent graphics. | +| `shift_in` | instance method | SI: selects G0 for subsequent graphics. | +| `ALTERNATE_MODES` | constant | The alternate-buffer modes, mapped to whether each one saves the cursor. | +| `GRAPHICS` | constant | DEC Special Graphics, the `acsc` set ncurses draws boxes with. | +| `CONTINUATION` | constant | Marks the right half of a wide glyph: a column for the cursor, nothing for the render. | +| `render_row` | internal method | Renders one row of cells to the text a terminal would show. | +| `render_cell` | internal method | One cell as displayed, including an orphan continuation holding its column. | +| `wide?` | internal predicate | Whether a cell holds a glyph two columns wide. | +| `heal` | internal method | Restores the wide-glyph invariant after an operation moved cells. | +| `write_char` | internal method | Places one graphic according to the columns it occupies. | +| `settle_wrap` | internal method | Takes a pending wrap, early if a wide glyph would split at the margin. | +| `place` | internal method | Writes a glyph and its continuation, then heals the row. | +| `combine` | internal method | Attaches a zero-width character to the cell before the cursor. | +| `CharacterWidth` | module | Columns a character occupies, from a curated UAX #11 subset. | +| `of` | module function | 0 for a combining mark, 2 for a wide glyph, 1 otherwise. | +| `ASCII_CEILING` | constant | Below this every character is a single-column graphic; the hot path. | +| `ZERO` | constant | Codepoint ranges that occupy no column. | +| `WIDE` | constant | Codepoint ranges that occupy two columns. | +| `translate` | internal method | Maps a graphic through the active charset, passing ASCII through unchanged. | +| `BYTE_CONTROLS` | constant | Control byte to the operation it performs, including SO/SI. | +| `MODE_FORM` | constant | `CSI Pm h/l`, with an optional private prefix, and nothing else. | +| `scroll_region_up` | instance method | Scrolls the region up, losing its top row. | +| `scroll_region_down` | instance method | Scrolls the region down, losing its bottom row. | +| `RESYNC_SCAN_BYTES` | constant | How far past the tail cut to look for an escape to resync on. | +| `DEFAULT_TAIL_BYTES` | constant | How much of a transcript tail is replayed, bounding work for a long session. | +| `TAB_WIDTH` | constant | Columns between tab stops. | +| `PRINTABLE` | constant | Pattern for bytes the renderer writes rather than interprets. | +| `CONTROLS` | constant | CSI final byte to the screen operation it performs. | +| `ESCAPES` | constant | Single-byte escapes that move the cursor, so cannot be discarded. | +| `detect?` | class method | Reports whether a cleaned line resembles a supported interactive prompt. | +| `PROMPT_PATTERNS` | constant | Positive prompt-detection patterns. | +| `FALSE_POSITIVES` | constant | Exclusions applied before positive prompt matching. | + +| `carriage_return` | instance method | Returns the cursor to column zero without changing the row. | +| `backspace` | instance method | Moves the cursor one column left without deleting what follows. | +| `tab` | instance method | Advances to the next tab stop. | +| `newline` | instance method | Moves down a row, scrolling the region when already at its bottom. | +| `index` | instance method | ESC D: down one row, scrolling at the region bottom. | +| `next_line` | instance method | ESC E: down one row and back to column zero. | +| `reverse_index` | instance method | ESC M: up one row, scrolling the region down at its top. | +| `save_cursor` | instance method | Records the cursor position and pending-wrap state. | +| `restore_cursor` | instance method | Returns the cursor to the saved position and state. | +| `cursor_up` | instance method | CUU: up N rows, clamped to the screen. | +| `cursor_down` | instance method | CUD: down N rows, clamped to the screen. | +| `cursor_right` | instance method | CUF: right N columns, clamped to the line. | +| `cursor_left` | instance method | CUB: left N columns, clamped to column zero. | +| `cursor_next_line` | instance method | CNL: down N rows and to column zero. | +| `cursor_previous_line` | instance method | CPL: up N rows and to column zero. | +| `cursor_column` | instance method | CHA: to an absolute column on the current row. | +| `cursor_row` | instance method | VPA: to an absolute row, keeping the column. | +| `cursor_position` | instance method | CUP: to an absolute row and column. | +| `erase_display` | instance method | ED: erases the screen, inclusive of the cell under the cursor. | +| `erase_line` | instance method | EL: erases the line, inclusive of the cell under the cursor. | + + +### SPEC SECTION Invariants + +1. `TableParser.parse` converts header titles to lowercase underscored symbols. +2. `KeyValueParser.parse` coerces integer, float, and boolean values automatically. +3. `TextSanitizer.strip_ansi` returns an empty string for nil input. +4. `TableParser.parse` with `format: :auto` (default) detects pipe vs. space tables by checking whether the header line contains `|`; `format: :pipe`/`:space` bypass detection entirely. +5. `TableParser.parse` raises `ArgumentError` for an unrecognized `format:` value regardless of + input size — validated unconditionally, not only once the input has 2+ non-empty lines. +6. `PromptDetector.detect?` strips ANSI codes before matching, and returns `false` (never raises) + for `nil`, empty, or whitespace-only input. +7. `PromptDetector.detect?` recognizes explicit confirmations, labeled prompts, anchored + interactive-wizard markers, arrow prompts, and recognizable shell prompts + (`user@host:path$`, macOS-style `user@host cwd %`, optional `(venv)` prefixes, and named + shells such as `bash-5.2#` / `zsh-5.9%`). Arbitrary prose questions and ordinary output + ending in a bare `#`, `>`, `$`, or `%` are not sufficient evidence of a prompt. This + intentionally favors rare false negatives over false positives that cause an agent to take an + incorrect interactive branch. + +8a. `ScreenRenderer` obeys every sequence that moves the cursor, including the single-byte escapes + `ESC D`, `ESC E` and `ESC M`, cursor save and restore in both DECSC/DECRC and CSI forms, `VPA`, + the insert/delete/erase-character family, and line insert, delete and scroll. An unrecognised + escape is not merely ignored: its introducer is consumed and the byte after it lands as text, so + `hello\eDworld` rendered as `helloDworld`. Private-parameter CSI forms are modes and are never + treated as their public namesakes. +8b. The cursor on the last column follows xterm rather than wrapping immediately: it stays on that + cell with a pending wrap, and the wrap happens when the next graphic character arrives. Any + explicit move clears the pending wrap. Leaving the column one past the end put it in a state no + terminal uses, which every relative move — backspace, `CUB`, line feed, `EL` — then read wrong. +8. `ScreenRenderer.render` obeys the escape sequences that decide where text lands — cursor + motion, erasing, and line discipline — rather than deleting them as `TextSanitizer` does. This + is the difference between every frame of a repaint and only the frame on screen: measured + against grok, a 361KB transcript stripped to 36.9KB of escape-free text but rendered to 1.1KB, + and an answer absent from the stripped text was present in the rendered screen. +9. `ScreenRenderer` erases inclusive of the cell under the cursor, in both directions, per ECMA-48. + Excluding it left one character of a repainted line surviving that a real terminal would have + cleared. +10. `ScreenRenderer.render` never fails to consume input. Its scanner advances on every iteration, + including for bytes it does not act on, because a scan loop that can match without consuming is + a hang rather than a wrong answer. +11. `ScreenRenderer.render` returns an empty string for nil or empty input, tolerates invalid + UTF-8, and bounds work by rendering only the tail of a long transcript. +12. The rendering size is a caller's to supply and is resolved by `ScreenRenderer.dimensions`, which + is also what a caller reports when it has to say which geometry a screen was rendered at. Absent + or nonsensical dimensions become the defaults, and dimensions past `MAX_ROWS`/`MAX_COLUMNS` are + clamped rather than allocated — the size now arrives from outside the process (a session records + its child's winsize in a JSON file and reads it back), and the grid is allocated eagerly, so an + unbounded value would be an allocation an untrusted file could ask for. This ceiling is a + library backstop for a size that reached the renderer without passing through whatever recorded + it; a caller that owns the size clamps it where it records it, and `session` clamps tighter. + +13. The renderer honours the modes that decide what the grid *contains*, and only those. Every + `?`-prefixed form used to be dropped whole, which is right for the ones that change a real + terminal's hardware — cursor visibility, bracketed paste, mouse reporting — and wrong for two: + + - **The alternate screen buffer** (1049, and the older 1047/47). An agent CLI enters it at + startup, so without it every byte printed before the switch stayed on the grid. Measured on a + real shell entering and leaving a TUI: the old renderer showed `BEFORE_TUI / INSIDE_TUI / + AFTER_TUI` and the new one shows `BEFORE_TUI / AFTER_TUI`, because content written in the + alternate buffer is discarded with it. 1049 saves and restores the cursor and 1047/47 do not, + which is the reason 1049 exists. Entering twice is idempotent rather than a second save — the + alternative overwrites the primary buffer with the alternate one. A full reset returns to the + primary buffer, so pre-reset output cannot reappear at the next exit. + - **DECAWM** (7). With autowrap off the pending wrap is never taken: the cursor stays on the + last cell and further graphics overwrite it, which is how a TUI paints a bottom-right corner + without scrolling the screen out from under itself. + + The scroll region is deliberately not part of the buffer snapshot. DECSTBM margins belong to the + terminal rather than to a buffer, so a region set inside the alternate buffer survives the + switch back. + +14. IRM (`CSI 4 h`) shifts the rest of the line right rather than overwriting, reusing ICH so + the clamp at the right margin cannot diverge between the two. DECSTR returns it to reset. + +15. `ESC ( 0` designates DEC Special Graphics, the `acsc` set every ncurses program draws boxes + from, and SO/SI select G1/G0. Dropping the designation printed `qqq` where a border belonged. + Only 0x5F-0x7E are remapped, so text between `ESC ( 0` and `ESC ( B` is not mangled, and any + designation other than `0` returns the slot to ASCII rather than guessing at a national set. + +16. `TextSanitizer` strips the two-byte escapes as well as the structured ones. Found by + driving Claude Code through `rune session`: every read's `clean_output` opened with a literal + `\e7\e8`, present uncapped as well as capped, so the stripper rather than the truncation. + `ScreenRenderer` already acted on `[DEM78c]`, so the two parsers in this module disagreed about + what an escape is and the sanitizer was the one that was wrong. Verified against a live Claude + Code session: 2 escapes in `clean_output` before, 0 after. + +17. **A cell holds one glyph and its combining marks, and a wide glyph occupies two columns.** A + row is an `Array` of cells rather than a `String`, so a column index is an array index and a + cell can hold any number of characters without moving the ones after it. `CONTINUATION` marks + the right half of a wide glyph: it occupies a column for cursor arithmetic and contributes + nothing to the rendered line. + + Insert, delete, erase and scroll all slice the row and any of them can separate a glyph from + its continuation, so the invariant is restored by one `heal` pass after each mutation rather + than taught to twelve operations independently. Healing blanks *both* halves when either is + destroyed, which is what a terminal does — half a character is not something it can show. + + Zero-width characters attach to the cell before them, so a decomposed `é` is one column and a + ZWJ emoji sequence keeps its joiner. Under the previous `String` rows this was impossible: + appending a mark put every later index in that row off by one and the next graphic overwrote + it. + + **This corrects an earlier version of this invariant, which claimed a cell model had been + measured *worse* than the one-column gap and reverted.** That conclusion was wrong, and the + error was mine rather than the measurement's: the A/B compared two working trees and + misattributed which output came from which side. Re-measured against three explicit revisions + on the same 56,928-byte grok capture that had emitted a CJK table: + + cc8bb3c (one column) "東h京 Tokyo" "大 阪 Osaka" + ad76e22 (one column) "東h京 Tokyo" "大 阪 Osaka" + cell model "東京 Tokyo" "大阪 Osaka" + + The one-column model corrupts real agent output and always did — an agent positions its columns + assuming two per CJK glyph, and a renderer that counts one puts every later write in the wrong + place. The synthetic case is `\e[H東京\e[1;5HX`, which a terminal renders `東京X` and one column + per glyph renders `東京 X`. + diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/design.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/design.md new file mode 100644 index 0000000..52bccc3 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/design.md @@ -0,0 +1,26 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: design +--- + +# Design + +A row is an `Array`; a cell is `nil`, a `String` of one graphic plus its marks, or +`CONTINUATION`. + +The alternative considered and rejected was keeping `String` rows and encoding a +wide glyph as base-plus-sentinel. That is what the first attempt did, and it +cannot hold: every operation slices the row directly, so any of them can strip a +sentinel or orphan one, and the orphan renders as a space. Twelve operations each +needing to know about pairs is twelve chances to forget. + +The array makes two things true that the string could not. A cell holds any +number of characters without moving the cells after it, which is what makes +combining marks work at all. And the pair invariant can be restored *after* the +fact, in one `heal` pass, because a row is a sequence of cells rather than a +sequence of bytes — so `heal` is the only place that knows the rule, and a new +operation inherits it by construction rather than by remembering. + +`pad`/`padded_line` disappear: assigning past the end of an array fills with nil +and a nil cell renders blank. That padding was the mechanism by which a column +index became an index into text. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/docs.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/docs.md new file mode 100644 index 0000000..a661d9d --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/docs.md @@ -0,0 +1,16 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: docs +--- + +# Docs + +`parsers.spec.md` invariant 17 is rewritten. It previously recorded that a cell +model had been measured worse and reverted; it now records the measurement that +reverses that, and says plainly that the earlier error was a mislabelled +comparison rather than a bad measurement. Leaving the old text in place would +have told the next reader not to attempt the fix that works. + +`character_width.rb` joins the parsers module. Its table is a curated UAX #11 +subset, deliberately not generated: rune carries no runtime dependencies, and +what it misses renders one column wide, which is what everything did before. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/plan.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/plan.md new file mode 100644 index 0000000..893bb91 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/plan.md @@ -0,0 +1,20 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: plan +--- + +# Plan + +Rows become `Array`s of cells. A cell is `nil` (never written), a `String` holding +one graphic plus any combining marks, or `CONTINUATION`. + +The operations convert almost one-for-one from string slicing to array slicing, +and `pad`/`padded_line` disappear: assigning past the end of an array fills with +nil and a nil cell renders blank, where a string row had to be padded by hand — +and that padding is what made every column index an index into text. + +The one design decision worth naming: insert, delete, erase and scroll can each +separate a glyph from its continuation, and the first attempt tried to make each +of them pair-aware. It lost. This restores the invariant with a single `heal` +pass after each mutation — the same rule expressed once instead of twelve times, +and the thing a new operation cannot forget to do. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/requirements.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/requirements.md new file mode 100644 index 0000000..9873ab9 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/requirements.md @@ -0,0 +1,14 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: requirements +--- + +# Requirements + +1. A column index is an array index, so a cell can hold a glyph plus its marks + without moving the cells after it. +2. A wide glyph occupies two columns, wraps rather than splitting at the margin, + and blanks both halves when either is destroyed. +3. A zero-width character occupies none and stays attached. +4. Every operation that slices a row leaves the pair invariant intact. +5. Plain ASCII rendering is unchanged. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/research.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/research.md new file mode 100644 index 0000000..1b200d9 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/research.md @@ -0,0 +1,29 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: research +--- + +# Research + +Three questions had to be settled before touching the grid. + +**Was the earlier "measured worse" conclusion right?** No. Rendering the same +56,928-byte grok capture through three explicit revisions shows the one-column +model corrupting real output at `cc8bb3c` and `ad76e22` alike, and only the cell +model producing `東京`. The earlier A/B compared two working trees and got the +sides the wrong way round. + +**Could one of the four renderer fixes since have caused it instead?** No. Each +was disabled in turn on top of `main` — the alternate screen buffer, DECAWM, IRM, +and the charset designation — and the corrupted rows are byte-identical in all +four cases. It is the column arithmetic, not the modes. + +**What does a terminal actually do when half a wide glyph is destroyed?** It +blanks both halves; half a character is not something it can show. That is the +rule `heal` implements, and it is why the probes now return ` 京AB` rather than +an orphan. + +Width data comes from UAX #11 East_Asian_Width (W and F) plus Emoji_Presentation, +and the combining-mark ranges for zero width. Not generated from the full +database: rune carries no runtime dependencies, and an uncovered codepoint +renders one column wide, which is what every codepoint did before. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json new file mode 100644 index 0000000..f50b08a --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "id": "CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", + "slug": "give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", + "title": "Give the screen a cell model so a wide glyph occupies the two columns it is drawn in", + "description": "Give the screen a cell model so a wide glyph occupies the two columns it is drawn in", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", + "created_at": 1787058231, + "updated_at": 1787058499, + "affected_specs": [ + "parsers" + ], + "affected_paths": [ + "lib/rune/parsers/screen.rb", + "lib/rune/parsers/character_width.rb", + "spec/rune/parsers/screen_renderer_spec.rb", + "specs/parsers/parsers.spec.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "A screen row is an array of cells, so a column index is an array index. A wide glyph occupies two columns and wraps rather than splitting at the margin; destroying either half blanks both, as a terminal does. A zero-width character attaches to the cell before it and occupies no column. The wide-glyph invariant is restored by one heal pass after each mutating operation rather than by teaching twelve operations about pairs. All five renderer gaps are closed, and the spec invariant that wrongly recorded this fix as measured-worse is corrected with the three-revision measurement that reverses it." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "research", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/tasks.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/tasks.md new file mode 100644 index 0000000..bdf62ae --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/tasks.md @@ -0,0 +1,14 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: tasks +--- + +# Tasks + +- [x] Re-measure the earlier conclusion against explicit revisions +- [x] Rule out the four shipped renderer fixes as the cause +- [x] Rows to arrays; operations to array slices +- [x] `heal` after each mutation; wide-glyph placement and margin wrap +- [x] Zero-width characters attach to the previous cell +- [x] Eight tests, falsified against both a one-column width table and a removed heal +- [x] Correct invariant 17, which recorded the opposite conclusion diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/testing.md b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/testing.md new file mode 100644 index 0000000..65217b2 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/testing.md @@ -0,0 +1,26 @@ +--- +change: CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw +artifact: testing +--- + +# Testing + +591 examples, 0 failures; rubocop clean; all five gaps ok in +`harnesses/renderer_gaps.rb`. + +Controls: + + mutation failures + width table forced to one column 4 of 136 parser examples + `heal` made a no-op 2 of 136 + +Synthetic, against what a terminal gives: + + \e[H東京\e[1;5HX -> "東京X" (one column gave "東京 X") + \e[H日本語\e[1;7HX -> "日本語X" + \e[HABC\e[1;3HX -> "ABX" (unchanged) + +The pair-breaking probes that killed the first attempt now heal rather than leave +orphans: erasing inside a pair gives ` 京AB`, deleting before one gives ` 京AB`, +and repainting over the left half gives `h 京AB` — both halves blanked in each +case, which is what a terminal does with half a character. diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json new file mode 100644 index 0000000..6489748 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1787058487, + "commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "9f5a3eff2dbf8d58020be6f6a8d9700530ace6b33d7f11172bcbfadce58daee4", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json new file mode 100644 index 0000000..85ac2d2 --- /dev/null +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json @@ -0,0 +1,96 @@ +{ + "timestamp": 1787058487, + "commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "9f5a3eff2dbf8d58020be6f6a8d9700530ace6b33d7f11172bcbfadce58daee4", + "acceptance_input_digest": "4fafd52ed068ecd269e10c9fedbebb9959b05ad3c9e746c938fb30cec73f9be7", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "57aa7ad9684c8744c3f099bc9b4a7b490fee1519f25f51d891864355c579a49c", + "entry_digest": "49f0761f8dd5bf8a8f09cf2a645da5e09b3abee3809f33212b53093863a1142e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "75e09f7da7574fc2143a461edaecc84c1142a84136e4758cfa7c333b0f81a568", + "entry_digest": "c9b190418c153fbef63629d5acaf9897f988023d86085445c488e977942676e3", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "00676c5b2db085f6e90964c325d7408b39b9c125bfc806e3182ba84ac76c901c", + "entry_digest": "d0347393d2f75138c2f23d2226f48a45cc704415839a63437a5a8cb6abf91dc4", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "28e4c7a3032c1288f42f5d404f174bd890a0a66ba82dd62b00105541778eedb3", + "entry_digest": "ac16014128b08ae98d7e8cb2aeae5aeabea32942e6c765e2444e7c0247d738ed", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f506c33f810ab723222fa0219dc1488b602febaa35b2ba7ca91097d7ab8d520", + "entry_digest": "7192a40f44587eb45c7bb39bde898a0be757794fccf57c2009f07d9f9cb45505", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/lib/rune/parsers/character_width.rb b/lib/rune/parsers/character_width.rb new file mode 100644 index 0000000..56c9d97 --- /dev/null +++ b/lib/rune/parsers/character_width.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Rune + module Parsers + # How many terminal columns one character occupies. + # + # A terminal advances the cursor by two for an East Asian Wide or Fullwidth glyph and by zero + # for a combining mark. Counting every character as one column put an absolute column after a + # wide run three cells early — `\e[H日本語\e[1;7HX` rendered `日本語 X` where a terminal + # renders `日本語X` — and charged a combining mark a column of its own, so a decomposed `é` + # took two columns and a ZWJ family emoji took three. + # + # The table is a curated subset of UAX #11 East_Asian_Width (W and F) plus the + # Emoji_Presentation characters outside those blocks, and of the combining-mark ranges for zero + # width. Deliberately not generated from the full Unicode database: rune has no runtime + # dependencies, and a complete table is a lot of data to carry for a renderer whose job is agent + # CLI output. What it covers is CJK, Hangul, Kana, fullwidth forms and the emoji blocks in + # actual use; what it misses renders one column wide, which is the answer this gave for + # everything before it existed. + # + # Ambiguous-width characters (UAX #11 A) are treated as narrow, which is what xterm does without + # `-cjk_width` and the right default for a terminal not in an East Asian locale. + module CharacterWidth + # Below this every character is a plain single-column graphic. Checked first because agent + # output is overwhelmingly ASCII and this runs once per character written to the grid. + ASCII_CEILING = 0x0300 + + ZERO = [ + 0x0300..0x036F, 0x0483..0x0489, 0x0591..0x05BD, 0x0610..0x061A, + 0x064B..0x065F, 0x0670..0x0670, 0x06D6..0x06DC, 0x0E31..0x0E31, + 0x0E34..0x0E3A, 0x0E47..0x0E4E, 0x1AB0..0x1AFF, 0x1DC0..0x1DFF, + 0x200B..0x200F, 0x2060..0x2064, 0x20D0..0x20F0, 0xFE00..0xFE0F, + 0xFE20..0xFE2F, 0xE0100..0xE01EF + ].freeze + + WIDE = [ + 0x1100..0x115F, 0x231A..0x231B, 0x23E9..0x23EC, 0x23F0..0x23F0, + 0x23F3..0x23F3, 0x25FD..0x25FE, 0x2614..0x2615, 0x2648..0x2653, + 0x267F..0x267F, 0x2693..0x2693, 0x26A1..0x26A1, 0x26AA..0x26AB, + 0x26BD..0x26BE, 0x26C4..0x26C5, 0x26CE..0x26CE, 0x26D4..0x26D4, + 0x26EA..0x26EA, 0x26F2..0x26F3, 0x26F5..0x26F5, 0x26FA..0x26FA, + 0x26FD..0x26FD, 0x2705..0x2705, 0x270A..0x270B, 0x2728..0x2728, + 0x274C..0x274C, 0x274E..0x274E, 0x2753..0x2755, 0x2757..0x2757, + 0x2795..0x2797, 0x27B0..0x27B0, 0x27BF..0x27BF, 0x2B1B..0x2B1C, + 0x2B50..0x2B50, 0x2B55..0x2B55, 0x2E80..0x303E, 0x3041..0x33FF, + 0x3400..0x4DBF, 0x4E00..0x9FFF, 0xA000..0xA4CF, 0xA960..0xA97F, + 0xAC00..0xD7A3, 0xF900..0xFAFF, 0xFE10..0xFE19, 0xFE30..0xFE6F, + 0xFF00..0xFF60, 0xFFE0..0xFFE6, 0x1F004..0x1F004, 0x1F0CF..0x1F0CF, + 0x1F18E..0x1F18E, 0x1F191..0x1F19A, 0x1F200..0x1F2FF, + 0x1F300..0x1F64F, 0x1F680..0x1F6FF, 0x1F900..0x1F9FF, + 0x1FA70..0x1FAFF, 0x20000..0x2FFFD, 0x30000..0x3FFFD + ].freeze + + module_function + + # Columns occupied: 0 for a combining mark, 2 for a wide glyph, 1 otherwise. + def of(char) + codepoint = char.ord + return 1 if codepoint < ASCII_CEILING + return 0 if ZERO.any? { |range| range.cover?(codepoint) } + return 2 if WIDE.any? { |range| range.cover?(codepoint) } + + 1 + rescue ArgumentError + # An invalid byte has no codepoint. Raising here would take the supervisor down with it — + # the transcript is scrubbed, but a caller may render raw bytes. + 1 + end + end + end +end diff --git a/lib/rune/parsers/screen.rb b/lib/rune/parsers/screen.rb index 9fd3f83..7009aab 100644 --- a/lib/rune/parsers/screen.rb +++ b/lib/rune/parsers/screen.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'character_width' + module Rune module Parsers # The grid and cursor a terminal would maintain, extracted from @@ -16,7 +18,7 @@ class Screen def initialize(rows:, columns:) @rows = rows.positive? ? rows : DEFAULT_ROWS @columns = columns.positive? ? columns : DEFAULT_COLUMNS - @grid = Array.new(@rows) { +'' } + @grid = Array.new(@rows) { [] } @row = 0 @column = 0 # xterm's "deferred wrap": after writing the last cell the cursor @@ -71,7 +73,7 @@ def full_reset(_numbers = []) @autowrap = true @charsets = { 'G0' => :ascii, 'G1' => :ascii } @gl = 'G0' - @grid = Array.new(@rows) { +'' } + @grid = Array.new(@rows) { [] } @saved = nil soft_reset end @@ -180,7 +182,7 @@ def enter_alternate(save:) save_cursor if save @alternate = @grid - @grid = Array.new(@rows) { +'' } + @grid = Array.new(@rows) { [] } end def leave_alternate(restore:) @@ -191,24 +193,101 @@ def leave_alternate(restore:) restore_cursor if restore end - def to_s = @grid.map(&:rstrip).join("\n").sub(/\n+\z/, '') + # A cell is nil (never written), a String holding one graphic plus any combining marks, or + # CONTINUATION for the right half of a wide glyph. The continuation occupies a column for + # every purpose except display, where it contributes nothing — so cursor arithmetic counts in + # columns while the rendered line counts in characters, and a wide glyph is two of the former + # and one of the latter. + CONTINUATION = :wide_tail - def write(chunk) - chunk.each_char do |char| - # With DECAWM off the pending wrap is never taken: the cursor stays on - # the last cell and each further graphic overwrites it. That is what - # lets a TUI paint the bottom-right corner of a border without - # scrolling the screen out from under itself. - wrap if @wrap_pending && @autowrap - @wrap_pending = false unless @autowrap - pad - # IRM: make room first, so the tail of the line shifts right rather - # than being overwritten. ICH is the same operation, which is why this - # reuses it rather than reimplementing the clamp at the right margin. - insert_blanks([1]) if @insert - @grid[@row][@column] = translate(char) - advance + def to_s = @grid.map { |row| render_row(row).rstrip }.join("\n").sub(/\n+\z/, '') + + def render_row(row) + row.each_with_index.map { |cell, index| render_cell(cell, row, index) }.join + end + + def render_cell(cell, row, index) + return ' ' if cell.nil? + # An orphan continuation renders as a blank, not as nothing: `heal` normally removes them, + # and one that survives must still hold its column open rather than silently shortening the + # line. + return wide?(row[index - 1]) && index.positive? ? '' : ' ' if cell == CONTINUATION + + cell + end + + def wide?(cell) = cell.is_a?(String) && CharacterWidth.of(cell[0]) == 2 + + # Restores the wide-glyph invariant after any operation that moved cells. + # + # Insert, delete, erase and scroll all slice the row, and any of them can separate a wide + # glyph from its continuation. Teaching each one about pairs is what the first attempt at + # this did, and it lost: measured on live grok output, `東 京` and `東h京` appeared where the + # one-column renderer produced `東京`. Repairing once, after the fact, is the same invariant + # expressed in one place instead of twelve. + def heal(row) + row.each_index do |index| + if row[index] == CONTINUATION + row[index] = nil unless index.positive? && wide?(row[index - 1]) + elsif wide?(row[index]) && row[index + 1] != CONTINUATION + # Its other half is gone, so neither half can be shown: a terminal blanks both. + row[index] = ' ' + end end + row + end + + def write(chunk) + chunk.each_char { |char| write_char(translate(char)) } + end + + # One graphic, placed according to how many columns it occupies. + # + # A combining mark takes none: it belongs to the glyph already written, so it is appended to + # that cell rather than given one of its own. That is safe here and was not before — a cell + # is one array slot however many characters it holds, where a String row put every later + # index off by one and the next graphic overwrote the mark. + def write_char(char) + width = CharacterWidth.of(char) + return combine(char) if width.zero? + + settle_wrap(width) + # IRM: make room first, so the tail of the line shifts right rather than being overwritten. + # ICH is the same operation, which is why this reuses it. + insert_blanks([width]) if @insert + place(char, width) + end + + # Takes any pending wrap, and takes one early rather than split a wide glyph at the margin. + # + # With DECAWM off the pending wrap is never taken at all: the cursor stays on the last cell + # and each further graphic overwrites it, which is what lets a TUI paint the bottom-right + # corner of a border without scrolling the screen out from under itself. A terminal also + # never splits a wide glyph across the margin — it wraps first and leaves the last cell blank + # rather than painting half a character there. + def settle_wrap(width) + return @wrap_pending = false unless @autowrap + + wrap if @wrap_pending || (width == 2 && @column + 1 >= @columns) + end + + def place(char, width) + line = @grid[@row] + line[@column] = char + line[@column + 1] = CONTINUATION if width == 2 + heal(line) + width.times { advance } + end + + # A combining mark modifies the graphic before the cursor, and attaches to the wide glyph + # itself rather than to its continuation. + def combine(char) + line = @grid[@row] + target = @column.positive? ? @column - 1 : 0 + target -= 1 if line[target] == CONTINUATION && target.positive? + return if line[target].nil? || line[target] == CONTINUATION + + line[target] = line[target] + char end def translate(char) @@ -299,12 +378,12 @@ def erase_display(numbers) case numbers.first.to_i when 0 erase_line([0]) - ((@row + 1)...@rows).each { |row| @grid[row] = +'' } + ((@row + 1)...@rows).each { |row| @grid[row] = [] } when 1 erase_line([1]) - (0...@row).each { |row| @grid[row] = +'' } + (0...@row).each { |row| @grid[row] = [] } when 2 - @grid = Array.new(@rows) { +'' } + @grid = Array.new(@rows) { [] } end # 3 is "erase saved lines" — the scrollback, which this renderer does # not keep — and anything else is undefined. Both were reaching an @@ -320,34 +399,34 @@ def erase_line(numbers) line = @grid[@row] # Only 0, 1 and 2 are defined; an unknown parameter is a no-op rather # than the full-line erase an `else` used to give it. - @grid[@row] = case numbers.first.to_i - when 0 then line[0, @column].to_s - when 1 then (' ' * (@column + 1)) + line[(@column + 1)..].to_s - when 2 then +'' - else line - end + @grid[@row] = heal(case numbers.first.to_i + when 0 then line[0, @column].to_a + when 1 then Array.new(@column + 1) + line[(@column + 1)..].to_a + when 2 then [] + else line + end) end # ICH: shift the rest of the line right, losing what falls off the edge. def insert_blanks(numbers) - line = padded_line + line = @grid[@row] @grid[@row] = - (line[0, @column].to_s + (' ' * span(numbers, @columns)) + line[@column..].to_s)[0, @columns].to_s + heal((line[0, @column].to_a + Array.new(span(numbers, @columns)) + line[@column..].to_a)[0, @columns].to_a) @wrap_pending = false end # DCH: shift the rest of the line left over the deleted characters. def delete_characters(numbers) - line = padded_line - @grid[@row] = line[0, @column].to_s + line[(@column + span(numbers, @columns))..].to_s + line = @grid[@row] + @grid[@row] = heal(line[0, @column].to_a + line[(@column + span(numbers, @columns))..].to_a) @wrap_pending = false end # ECH: blank characters in place, without shifting anything. def erase_characters(numbers) - line = padded_line + line = @grid[@row] blanks = span(numbers, @columns) - @grid[@row] = line[0, @column].to_s + (' ' * blanks) + line[(@column + blanks)..].to_s + @grid[@row] = heal(line[0, @column].to_a + Array.new(blanks) + line[(@column + blanks)..].to_a) @wrap_pending = false end @@ -358,7 +437,7 @@ def insert_lines(numbers) return unless @row.between?(@top, @bottom) span(numbers, @rows).times do - @grid.insert(@row, +'') + @grid.insert(@row, []) @grid.delete_at(@bottom + 1) end end @@ -369,7 +448,7 @@ def delete_lines(numbers) span(numbers, @rows).times do @grid.delete_at(@row) - @grid.insert(@bottom, +'') + @grid.insert(@bottom, []) end end @@ -388,14 +467,14 @@ def scroll_down(numbers) def scroll_region_up(lines) lines.times do @grid.delete_at(@top) - @grid.insert(@bottom, +'') + @grid.insert(@bottom, []) end end def scroll_region_down(lines) lines.times do @grid.delete_at(@bottom) - @grid.insert(@top, +'') + @grid.insert(@top, []) end end @@ -443,15 +522,9 @@ def wrap newline end - def padded_line - pad - @grid[@row] - end - - def pad - line = @grid[@row] - line << (' ' * (@column - line.length)) if line.length < @column - end + # No padding helper any more: assigning past the end of an Array fills the gap with nil, + # and a nil cell renders as a blank. A String row had to be padded by hand first, and that + # padding is what made every column index a byte index into text. end # rubocop:enable Metrics/ClassLength end diff --git a/spec/rune/parsers/screen_renderer_spec.rb b/spec/rune/parsers/screen_renderer_spec.rb index d479ec4..2e5baec 100644 --- a/spec/rune/parsers/screen_renderer_spec.rb +++ b/spec/rune/parsers/screen_renderer_spec.rb @@ -240,6 +240,50 @@ end end + # A row is an Array of cells, so a column index is an array index: a wide glyph takes two of + # them and a combining mark takes none. Under the previous String rows a cell could not hold + # more than one character without moving every column after it. + describe 'wide and zero-width characters' do + it 'advances two columns for a wide glyph, so an absolute column lands where a terminal puts it' do + expect(described_class.render("\e[H東京\e[1;5HX", rows: 3, columns: 20)).to eq('東京X') + end + + it 'wraps rather than splitting a wide glyph at the margin' do + expect(described_class.render('ABCDEFGHI日', rows: 3, columns: 10)).to eq("ABCDEFGHI\n日") + end + + it 'blanks both halves when either is overwritten, as a terminal does' do + expect(described_class.render("\e[H東京\e[1;1HX", rows: 3, columns: 12)).to eq('X 京') + expect(described_class.render("\e[H東京\e[1;2HX", rows: 3, columns: 12)).to eq(' X京') + end + + # The operations that slice the row are the ones that separate a pair, and they are exactly + # what a repainting TUI uses most. These are the cases that killed the first attempt. + it 'heals a pair broken by delete, erase or insert rather than leaving an orphan' do + expect(described_class.render("\e[H東京AB\e[1;2H\e[X", rows: 3, columns: 12)).to eq(' 京AB') + expect(described_class.render("\e[H東京AB\e[1;1H\e[P", rows: 3, columns: 12)).to eq(' 京AB') + expect(described_class.render("\e[H東京AB\e[1;1H\e[@", rows: 3, columns: 12)).to eq(' 東京AB') + end + + it 'gives a combining mark no column of its own and keeps it attached' do + frame = described_class.render("\e[He\u0301X", rows: 3, columns: 20) + + expect(frame).to eq("e\u0301X") + expect(frame.index('X')).to eq(2) + end + + it 'keeps a ZWJ sequence joined instead of charging the joiner a column' do + frame = described_class.render("\e[H\u{1F468}\u200D\u{1F469}|", rows: 3, columns: 20) + + expect(frame).to include("\u200D") + expect(frame.index('|')).to eq(3) + end + + it 'leaves plain ASCII untouched' do + expect(described_class.render("\e[HABC\e[1;3HX", rows: 3, columns: 20)).to eq('ABX') + end + end + # A TUI turns autowrap off to paint the last cell of a row without scrolling # the screen out from under itself. describe 'autowrap (DECAWM)' do diff --git a/specs/parsers/parsers.spec.md b/specs/parsers/parsers.spec.md index 1d5346a..a1a75a4 100644 --- a/specs/parsers/parsers.spec.md +++ b/specs/parsers/parsers.spec.md @@ -1,6 +1,6 @@ --- module: parsers -version: 13 +version: 14 status: active files: - lib/rune/parsers/table_parser.rb @@ -9,6 +9,7 @@ files: - lib/rune/parsers/prompt_detector.rb - lib/rune/parsers/screen_renderer.rb - lib/rune/parsers/screen.rb + - lib/rune/parsers/character_width.rb --- # Parsers @@ -72,6 +73,20 @@ Text parsing utilities for `rune`. Converts unstructured terminal text, tables, | `shift_in` | instance method | SI: selects G0 for subsequent graphics. | | `ALTERNATE_MODES` | constant | The alternate-buffer modes, mapped to whether each one saves the cursor. | | `GRAPHICS` | constant | DEC Special Graphics, the `acsc` set ncurses draws boxes with. | +| `CONTINUATION` | constant | Marks the right half of a wide glyph: a column for the cursor, nothing for the render. | +| `render_row` | internal method | Renders one row of cells to the text a terminal would show. | +| `render_cell` | internal method | One cell as displayed, including an orphan continuation holding its column. | +| `wide?` | internal predicate | Whether a cell holds a glyph two columns wide. | +| `heal` | internal method | Restores the wide-glyph invariant after an operation moved cells. | +| `write_char` | internal method | Places one graphic according to the columns it occupies. | +| `settle_wrap` | internal method | Takes a pending wrap, early if a wide glyph would split at the margin. | +| `place` | internal method | Writes a glyph and its continuation, then heals the row. | +| `combine` | internal method | Attaches a zero-width character to the cell before the cursor. | +| `CharacterWidth` | module | Columns a character occupies, from a curated UAX #11 subset. | +| `of` | module function | 0 for a combining mark, 2 for a wide glyph, 1 otherwise. | +| `ASCII_CEILING` | constant | Below this every character is a single-column graphic; the hot path. | +| `ZERO` | constant | Codepoint ranges that occupy no column. | +| `WIDE` | constant | Codepoint ranges that occupy two columns. | | `translate` | internal method | Maps a graphic through the active charset, passing ASCII through unchanged. | | `BYTE_CONTROLS` | constant | Control byte to the operation it performs, including SO/SI. | | `MODE_FORM` | constant | `CSI Pm h/l`, with an optional private prefix, and nothing else. | @@ -193,54 +208,36 @@ Text parsing utilities for `rune`. Converts unstructured terminal text, tables, what an escape is and the sanitizer was the one that was wrong. Verified against a live Claude Code session: 2 escapes in `clean_output` before, 0 after. -17. **Double-width characters are still counted as one column, and the obvious fix was measured - and rejected.** A CJK or emoji glyph occupies two cells in a terminal; here it occupies one, so - an absolute column after a wide run lands early — `\e[H日本語\e[1;7HX` renders `日本語 X` - where a terminal renders `日本語X`. +17. **A cell holds one glyph and its combining marks, and a wide glyph occupies two columns.** A + row is an `Array` of cells rather than a `String`, so a column index is an array index and a + cell can hold any number of characters without moving the ones after it. `CONTINUATION` marks + the right half of a wide glyph: it occupies a column for cursor arithmetic and contributes + nothing to the rendered line. - A cell model was built and reverted the same session, because it made real output *worse*. A - row is a String whose index is its column, so a wide glyph was stored as its base character - plus a continuation cell removed at render time. That works in isolation — wide glyphs advanced - two columns, wrapped rather than splitting at the margin, and blanked their partner when - overwritten, 8/8 on a probe. It fails as soon as anything else touches the row, because every - other grid operation manipulates the String directly and knows nothing about continuation - cells. Against a live grok session that had emitted `東京 / 大阪 / 京都`: + Insert, delete, erase and scroll all slice the row and any of them can separate a glyph from + its continuation, so the invariant is restored by one `heal` pass after each mutation rather + than taught to twelve operations independently. Healing blanks *both* halves when either is + destroyed, which is what a terminal does — half a character is not something it can show. - one-column (shipped) "東京 Tokyo" "大阪 Osaka" - cell model (reverted) "東 京 Tokyo" "東h京 Tokyo" + Zero-width characters attach to the cell before them, so a decomposed `é` is one column and a + ZWJ emoji sequence keeps its joiner. Under the previous `String` rows this was impossible: + appending a mark put every later index in that row off by one and the next graphic overwrote + it. - A space appears between the halves and a stray character lands inside them, because an - operation that splits a pair leaves the continuation cell behind and it renders as a space. The - same shapes reproduce synthetically — `\e[H東京AB\e[1;2H\e[X` gives `東 京AB` under the cell - model against `東 AB` without it, and `\e[H東京AB\e[1;1Hh` gives `h 京AB` against `h京AB`. A - TUI repaints constantly, so the operations that break pairs are the ones it uses most. + **This corrects an earlier version of this invariant, which claimed a cell model had been + measured *worse* than the one-column gap and reverted.** That conclusion was wrong, and the + error was mine rather than the measurement's: the A/B compared two working trees and + misattributed which output came from which side. Re-measured against three explicit revisions + on the same 56,928-byte grok capture that had emitted a CJK table: - Not every probe distinguishes them: `\e[H東京AB\e[1;1H\e[P` gives `京AB` either way, so it is - a baseline rather than evidence. `harnesses/renderer_gaps.rb` prints the current behaviour for - all five and says which the cell model changed. + cc8bb3c (one column) "東h京 Tokyo" "大 阪 Osaka" + ad76e22 (one column) "東h京 Tokyo" "大 阪 Osaka" + cell model "東京 Tokyo" "大阪 Osaka" - Zero-width characters share the root cause and fail in the opposite direction. The shipped rule - is simply **one column per codepoint**, so the text survives codepoint-exact while the column - arithmetic is wrong in the other direction from wide glyphs. Measured: - - precomposed é U+00E9 1 column terminal 1 correct - decomposed é U+0065 U+0301 2 columns terminal 1 one too many - ZWJ family 👨‍👩 U+1F468 U+200D U+1F469 3 columns terminal 2 one too many - - Every codepoint is retained in all three; only the cursor arithmetic is off. - - An earlier draft of this invariant said they were *dropped*, which was wrong and is worth - recording as an error rather than quietly fixing. It described the reverted cell model, where - `combine` discarded them because appending a mark to its base cell puts every later index in - that row off by one — measured there, a decomposed `é` rendered `e`. That behaviour never - shipped. It was caught by an agent translating this README into Hindi through rune, which is - exactly the case Devanagari would have exposed. - - So the fix is not a width table — that part was correct and is not what failed. It is a grid of - cells rather than a String per row, where a cell holds a base character plus its marks and the - column index is the array index, with every operation rewritten against it. That is the same - change a retained per-session `Screen` needs, and it should be done once, deliberately, with - the reproduction above as its acceptance test. + The one-column model corrupts real agent output and always did — an agent positions its columns + assuming two per CJK glyph, and a renderer that counts one puts every later write in the wrong + place. The synthetic case is `\e[H東京\e[1;5HX`, which a terminal renders `東京X` and one column + per glyph renders `東京 X`. ## Behavioral Examples @@ -299,3 +296,4 @@ Text parsing utilities for `rune`. Converts unstructured terminal text, tables, | 2026-08-17 | CHG-0058-integrate-the-post-0-8-0-fixes-two-quadratics-exec-fidelity-geometry-cursors: Integrate the post-0.8.0 fixes: two quadratics, exec fidelity, geometry, cursors, and the guide gate | | 2026-08-17 | CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th: Honour the modes and charsets that decide what the screen contains, and strip the escapes the sanitizer missed | | 2026-08-17 | CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the: Record that the wide-character cell model was built and measured worse than the gap | +| 2026-08-18 | CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw: Give the screen a cell model so a wide glyph occupies the two columns it is drawn in | From 19cb4718d413efab1ee8139f5557cc092e080580 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 18 Aug 2026 10:14:45 -0600 Subject: [PATCH 3/4] Fix: a failed launch was reported as success, and three defects non-ASCII found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects from two sources, each verified before being planned. A launch that never happened reported success. `start` with a command not on PATH returned status "ok", state "exited", exit_code 127 — so a caller checking the field whose entire job is to say whether the call worked saw success. It was documented as "check state instead", which is the wrong shape of answer: an envelope should not need a footnote to be read correctly. Reported from a 22-minute real drive where it cost an hour. Only 127 fails, deliberately. `start -- true` exits 0 immediately and is a successful launch of a program with nothing to do; treating any prompt exit as failure would break every short-lived child. The session record is kept rather than deleted — start failing loudly is the fix, and removing the transcript that shows why would trade one quiet failure for another. An error that was confidently wrong. A session started in one directory and read from another got "No such session", and that error told the reader to run `rune session list`, which is scoped to their own project and returns nothing, actively confirming the wrong conclusion. rune knew where it was the whole time. The message now names the project. This has caught three readers, two of whom had read the guide's warning first, which is when a documented gotcha stops being a documentation problem. Two defects the native-language translation round found, by conducting each rune session entirely in the language being translated rather than sending English: CharacterWidth::ZERO covered Latin, Greek, Cyrillic, Hebrew, Arabic and Thai and omitted every Indic script, so हिन्दी was charged six columns for six codepoints. My own table, written hours earlier. Fixed as the Mn/Me subset, not every Indic mark: U+093F is a spacing mark and takes a column in wcwidth and xterm, so zeroing all of them would be wrong in the other direction. हिन्दी is now five columns, matching xterm rather than the three a shaping engine draws. ScreenRenderer.resync searched with String#index (characters) and sliced with byteslice (bytes). On "日本語テキスト\e[1mAFTER" the ESC is at character 7 and byte 21; it cut at 7, returning "\xAA\x9Eテキスト\e[1mAFTER" — both splitting a character and failing to drop the remainder it exists to drop. One claim from the same report did NOT reproduce and is not fixed: that a send over ~1024 bytes jams the session while rune answers settled:true. Measured at 600/1100/4096/20000 bytes — no jam below 20k, and at 20k the follow-up is refused with status:error and recovers by itself in ~10s, which is what ROADMAP already records. The reported shape looks like a probe reading clean_output without checking status. Also lands the six in-language guide translations and their report. 604 examples, 0 failures. Controls: launch_failure reverted fails 1 of 3, resync byte index 2, Indic ranges 2, project lookup 2. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rf59AtQmJcodUJ6vXDZNY --- .specsync/change-sequence.json | 4 +- .../approvals.json | 244 ++++- .../state.json | 2 +- .../verification-attempts.json | 55 ++ .../verification.json | 20 +- .../approvals.json | 124 ++- .../state.json | 2 +- .../verification-attempts.json | 80 ++ .../verification.json | 20 +- .../approvals.json | 19 + .../change.md | 25 + .../context.md | 34 + .../deltas/parsers.md | 109 +++ .../deltas/session.md | 911 ++++++++++++++++++ .../docs.md | 15 + .../plan.md | 21 + .../requirements.md | 12 + .../state.json | 44 + .../tasks.md | 14 + .../testing.md | 28 + .../verification-attempts.json | 35 + .../verification.json | 136 +++ RUNE_NATIVE_I18N.md | 637 ++++++++++++ docs/i18n/getting_started.ar.md | 325 +++++++ docs/i18n/getting_started.hi.md | 240 +++++ docs/i18n/getting_started.ja.md | 352 +++++++ docs/i18n/getting_started.ko.md | 231 +++++ docs/i18n/getting_started.ru.md | 330 +++++++ docs/i18n/getting_started.zh-CN.md | 322 +++++++ lib/rune/commands/session_command.rb | 62 +- lib/rune/parsers/character_width.rb | 35 +- lib/rune/parsers/screen_renderer.rb | 8 +- spec/rune/parsers/screen_renderer_spec.rb | 47 + spec/rune/session_spec.rb | 70 ++ specs/parsers/parsers.spec.md | 3 +- specs/session/session.spec.md | 30 +- 36 files changed, 4610 insertions(+), 36 deletions(-) create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/change.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/context.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/parsers.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/session.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/docs.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/plan.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/requirements.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/tasks.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/testing.md create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json create mode 100644 .specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json create mode 100644 RUNE_NATIVE_I18N.md create mode 100644 docs/i18n/getting_started.ar.md create mode 100644 docs/i18n/getting_started.hi.md create mode 100644 docs/i18n/getting_started.ja.md create mode 100644 docs/i18n/getting_started.ko.md create mode 100644 docs/i18n/getting_started.ru.md create mode 100644 docs/i18n/getting_started.zh-CN.md diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 8feca28..79c6c63 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 70, - "id": "CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", + "sequence": 71, + "id": "CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json index 655c3de..3c4c2aa 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json @@ -27,7 +27,249 @@ "timestamp": 1787038297, "digest": "351fbb5c5a60597a54463c7905c1ac128cd4751a189c81949945d300897a4f65", "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787067164, + "digest": "cea69894d7e9437732b733a6275e989349ff86aed8084eb666f52c947361dbcf", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "actor": "claude", + "reason": "the envelope and multibyte fixes (CHG-0071) moved shared specs and lib files these changes also deliver; re-verifying against the tree as it now stands", + "timestamp": 1787066134, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787038297, + "digest": "351fbb5c5a60597a54463c7905c1ac128cd4751a189c81949945d300897a4f65", + "note": null + }, + "prior_verification": { + "timestamp": 1787038293, + "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", + "acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "b22059658bcc96377420c687826bca763224b3ec26d9904f10192613f8b27212", + "entry_digest": "937c9cad43aa6eb8104df3d6e3d160db8c1befd414f3546b56fa660f7d1f8a1a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7c0883544e25b1c19eadfa330277339cc5984f86b6a608404ed5d5f919315322", + "entry_digest": "bac54e82ba25bf4202e51cef7d7f9e0e0606a30a37cd75baf367ac1598ec7f40", + "owners": [ + "cli" + ] + }, + { + "path": "lib/rune/commands/run_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f563628c917a909e26f524b68ab459f7e03efc9b01a9ccf32bb9f865347479b9", + "entry_digest": "2b9d735b4d5ebb1a7797bc7470b5c079d52a147dca4caf566c6a1d4f20ccf19f", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "c5e257ca12673bc0fd5267e69c0c5bf84369019dd1279b92a75abd2bea1dff45", + "entry_digest": "fda0508c6bfcdeff985e2362b5c1da88ad4e4fabab318a824d6df88c98089e76", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/commands/watch_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f78ec60ff4f17ed0739e8044b60cbee1e527627fcd57165e110f6f00fce40a7b", + "entry_digest": "9c15c7c063bbb33d736cf2ae011f327eab3fd76c9065baa6858ee9768d9fc6b1", + "owners": [ + "watch" + ] + }, + { + "path": "lib/rune/pty_runner.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "3721d699c8e934b706c6db570f2a0dfd3fd12ce003068328d63b1a4de6f75a26", + "entry_digest": "4efceba9d053a8cad51cb95986a36e94c4e9e74fe6e29ecff1b19dc3b2d40c3c", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/session/transcript.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "a8ff1157444c46abfc9746ed3d24068eca939606b13c647fb4b558bdda2147c5", + "entry_digest": "6909005a3771f84fe747776fc7f3a1cf74175a6554fcc36e4b7583356c1c5fbf", + "owners": [ + "session" + ] + }, + { + "path": "spec/rune/commands/watch_command_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "35243817b52618f480756094d672039a1c17d558c4cf5d4c8aac43079e78ba28", + "entry_digest": "5e7374088bf1b46ae354b76903b6f0c98b9cca0e2e3f4f77399dfe7d3118fb49", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/pty_runner_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "6dcf23ad4d7f1d7b4640c8a58e322183444cf1a6370356cad8882af8085a41be", + "entry_digest": "5cfe1ea1e9a52b3741290b512289e20e85af6dad39ac27485e372966ec1165c4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "447eced43e773ba84e9d879db75ee7fd563619dcb86135378d3afd0390d96eea", + "entry_digest": "a1c55527a1b1ba0206ca2f59c222241b7c4d3a7567941d2f31e3c014a7ae53fb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/cli/cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ce1bbc0237e58537ce3aa6a3c6f35e842d298bf4ac1887940ea2101d15d3d328", + "entry_digest": "333940b198a250142aa060972c0da362b30ae18a3c3bb08dd54251c588a23ee7", + "owners": [ + "cli" + ] + }, + { + "path": "specs/cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b3223697f7d83909e9718f17e068a205d0717ed45b65df647701dffee8157858", + "entry_digest": "4bdce8f159673c64d8ee23db590ddcf7480f1e276ee48d86e2f1260b9a730ca6", + "owners": [ + "cli" + ] + }, + { + "path": "specs/pty_runner/pty_runner.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "59206e9e7948f0f15fd1a4b51dc1cfabe5ec847a900f61b8452f7da43061e5ae", + "entry_digest": "a72cc0d9edbae9e6e3fc301cfac535406686c38987159e084fbca3981fb756a1", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/pty_runner/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3070f0d5d578baa2f058503359b33d17c02641731aec9e820e782f4e58813fcf", + "entry_digest": "6e141c1e84e140b7147f958068fa75dd6c0e22153ff3c50fe8782499d2f7dfb7", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f62b06e4a54c65cc23b9da0aaa6311a639d5e7cfbebe89e2053aaf83093248bc", + "entry_digest": "5f1ab883fc19211ef586f88218a956cbd75a7041e6943b3c3c0800b42a177e69", + "owners": [ + "session" + ] + }, + { + "path": "specs/watch/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4120dc4e6971fe20155d57e0110ef66f1dc8560a9471fffb954222cb6d7fd81a", + "entry_digest": "4396e75507693c7f324e619de816b9b20564813b26b608ac4bc577d26759bbf2", + "owners": [ + "watch" + ] + }, + { + "path": "specs/watch/watch.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f36197540bce91f8e639df54d3bb0af55a0137c1344e9fd9c45bfb55d6cf6da7", + "entry_digest": "f66be4ba003f4d6805ea541fa4c288036e1d16c4ac1a7ebdf991f5e9b55ad767", + "owners": [ + "watch" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "current_acceptance_input_digest": "eacab9d75eb7d0c86a943aeecfb300eef15e2c437bcafaa3fc8227ab5540524c" + } + ] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json index 6f82334..c630ac5 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", "created_at": 1787038047, - "updated_at": 1787038297, + "updated_at": 1787067164, "affected_specs": [ "watch", "pty_runner", diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json index ed67b5f..3aa8704 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json @@ -30,6 +30,61 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1787066343, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "passed": false, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": false, + "exit_code": 1 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1787067157, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json index d2f485c..4fc0c97 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1787038293, - "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "timestamp": 1787067157, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", - "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", - "acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "acceptance_input_digest": "eacab9d75eb7d0c86a943aeecfb300eef15e2c437bcafaa3fc8227ab5540524c", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -41,8 +41,8 @@ "path": "lib/rune/commands/session_command.rb", "kind": "file", "mode": 33188, - "payload_digest": "c5e257ca12673bc0fd5267e69c0c5bf84369019dd1279b92a75abd2bea1dff45", - "entry_digest": "fda0508c6bfcdeff985e2362b5c1da88ad4e4fabab318a824d6df88c98089e76", + "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", + "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", "owners": [ "session" ] @@ -101,8 +101,8 @@ "path": "spec/rune/session_spec.rb", "kind": "file", "mode": 33188, - "payload_digest": "447eced43e773ba84e9d879db75ee7fd563619dcb86135378d3afd0390d96eea", - "entry_digest": "a1c55527a1b1ba0206ca2f59c222241b7c4d3a7567941d2f31e3c014a7ae53fb", + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", "owners": [ "@exact:delivery" ] @@ -161,8 +161,8 @@ "path": "specs/session/session.spec.md", "kind": "file", "mode": 33188, - "payload_digest": "f62b06e4a54c65cc23b9da0aaa6311a639d5e7cfbebe89e2053aaf83093248bc", - "entry_digest": "5f1ab883fc19211ef586f88218a956cbd75a7041e6943b3c3c0800b42a177e69", + "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", + "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", "owners": [ "session" ] diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json index 2ec9608..3fbf1a1 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json @@ -13,7 +13,129 @@ "timestamp": 1787058499, "digest": "e2756764d41e9cf863a9d73ec54a28da404392e89f2bc2711327b4f167f3d9ef", "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787068416, + "digest": "dc26719468e43a557dc40e24d3b62ed0177a3fa47a8cfed7f6d1813687ed9b09", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", + "actor": "claude", + "reason": "the envelope and multibyte fixes (CHG-0071) moved shared specs and lib files these changes also deliver; re-verifying against the tree as it now stands", + "timestamp": 1787066388, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787058499, + "digest": "e2756764d41e9cf863a9d73ec54a28da404392e89f2bc2711327b4f167f3d9ef", + "note": null + }, + "prior_verification": { + "timestamp": 1787058487, + "commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "9f5a3eff2dbf8d58020be6f6a8d9700530ace6b33d7f11172bcbfadce58daee4", + "acceptance_input_digest": "4fafd52ed068ecd269e10c9fedbebb9959b05ad3c9e746c938fb30cec73f9be7", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "57aa7ad9684c8744c3f099bc9b4a7b490fee1519f25f51d891864355c579a49c", + "entry_digest": "49f0761f8dd5bf8a8f09cf2a645da5e09b3abee3809f33212b53093863a1142e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "75e09f7da7574fc2143a461edaecc84c1142a84136e4758cfa7c333b0f81a568", + "entry_digest": "c9b190418c153fbef63629d5acaf9897f988023d86085445c488e977942676e3", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "00676c5b2db085f6e90964c325d7408b39b9c125bfc806e3182ba84ac76c901c", + "entry_digest": "d0347393d2f75138c2f23d2226f48a45cc704415839a63437a5a8cb6abf91dc4", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "28e4c7a3032c1288f42f5d404f174bd890a0a66ba82dd62b00105541778eedb3", + "entry_digest": "ac16014128b08ae98d7e8cb2aeae5aeabea32942e6c765e2444e7c0247d738ed", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f506c33f810ab723222fa0219dc1488b602febaa35b2ba7ca91097d7ab8d520", + "entry_digest": "7192a40f44587eb45c7bb39bde898a0be757794fccf57c2009f07d9f9cb45505", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "4fafd52ed068ecd269e10c9fedbebb9959b05ad3c9e746c938fb30cec73f9be7", + "current_acceptance_input_digest": "0eb3b681f69886f8c08e6f14b893e2e06647e16499614537d617cedcc5e19460" + } + ] } diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json index f50b08a..7cfc219 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", "created_at": 1787058231, - "updated_at": 1787058499, + "updated_at": 1787068416, "affected_specs": [ "parsers" ], diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json index 6489748..44b7364 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json @@ -30,6 +30,86 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1787066624, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "passed": false, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": false, + "exit_code": 1 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1787067352, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "passed": false, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": false, + "exit_code": 1 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1787068410, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json index 85ac2d2..e8fb736 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1787058487, - "commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", + "timestamp": 1787068410, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", - "workspace_digest": "9f5a3eff2dbf8d58020be6f6a8d9700530ace6b33d7f11172bcbfadce58daee4", - "acceptance_input_digest": "4fafd52ed068ecd269e10c9fedbebb9959b05ad3c9e746c938fb30cec73f9be7", + "workspace_digest": "169f8f78f4126a5b7ba6549c154b49de080c8977528a69acb6b37b983ee39896", + "acceptance_input_digest": "0eb3b681f69886f8c08e6f14b893e2e06647e16499614537d617cedcc5e19460", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -21,8 +21,8 @@ "path": "lib/rune/parsers/character_width.rb", "kind": "file", "mode": 33188, - "payload_digest": "75e09f7da7574fc2143a461edaecc84c1142a84136e4758cfa7c333b0f81a568", - "entry_digest": "c9b190418c153fbef63629d5acaf9897f988023d86085445c488e977942676e3", + "payload_digest": "fefe3f4ca109ca45c1bfd2349fe185dfe3555082989a55cbdfc9a1149087bfbc", + "entry_digest": "eed613a7dbf151310c19cba34b281e791589c7054961f7eae1856efecf67bdb0", "owners": [ "parsers" ] @@ -41,8 +41,8 @@ "path": "spec/rune/parsers/screen_renderer_spec.rb", "kind": "file", "mode": 33188, - "payload_digest": "28e4c7a3032c1288f42f5d404f174bd890a0a66ba82dd62b00105541778eedb3", - "entry_digest": "ac16014128b08ae98d7e8cb2aeae5aeabea32942e6c765e2444e7c0247d738ed", + "payload_digest": "d064a3e93b3cd62baa6bad2d2b1c0b67afedc86fb863f44c659b2774972ec6bb", + "entry_digest": "e29155b72d0b7cd7df039ecee3cb62b023e2c1cac31a47c5415f7a1ad39b057c", "owners": [ "@exact:delivery" ] @@ -51,8 +51,8 @@ "path": "specs/parsers/parsers.spec.md", "kind": "file", "mode": 33188, - "payload_digest": "0f506c33f810ab723222fa0219dc1488b602febaa35b2ba7ca91097d7ab8d520", - "entry_digest": "7192a40f44587eb45c7bb39bde898a0be757794fccf57c2009f07d9f9cb45505", + "payload_digest": "d3242f740bc8ce65716a34942848f12b911b30e74b242681e7a4905d82fcae3a", + "entry_digest": "72df09dafb06bde593202cd51eee288dc201433fc5fca15201a4e9603f7c630b", "owners": [ "parsers" ] diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json new file mode 100644 index 0000000..6b67076 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json @@ -0,0 +1,19 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1787065684, + "digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787065870, + "digest": "4daa53f7d60fefe2aa9836155eb1fae184ac0dcd173f0fcc6d9ac9e5e9b756cc", + "note": null + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/change.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/change.md new file mode 100644 index 0000000..714f8b5 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/change.md @@ -0,0 +1,25 @@ +--- +id: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +state: accepted +type: feature +base_commit: 5f4d77b08f0d96682520e66e7eeb5411ed5a5da3 +--- + +# Make a failed launch loud, name the project a session is in, and fix two multibyte defects + +## Intent + +Make a failed launch loud, name the project a session is in, and fix two multibyte defects + +## Affected Canonical Specs + +- `session` +- `parsers` + +## Acceptance Criteria + +- rune session start returns status error when the command is not on PATH, while a child that exits zero immediately still succeeds. The no-such-session error names the project the session is actually in and points at a remedy that shows it. CharacterWidth gives Indic nonspacing marks no column while leaving spacing marks at one, matching wcwidth. ScreenRenderer resync searches by byte offset rather than character index, so a multi-byte head is dropped whole instead of cut mid-character. Each has tests that fail against deliberately reverted code. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/context.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/context.md new file mode 100644 index 0000000..b63214d --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/context.md @@ -0,0 +1,34 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: context +--- + +# Context + +Four defects, from two sources, all verified here before being planned. + +**A launch that never happened reported success.** `start` with a command not on +PATH returned `status: "ok"`, `state: "exited"`, `exit_code: 127`. A caller +checking `status` — the field whose entire job is to say whether the call worked +— saw success. It was documented as "check `state` instead", which is the wrong +shape of answer. Reported from a 22-minute real drive where it cost an hour, as +one of three instances of the same root cause: rune does not make failure loud. + +**An error that was confidently wrong.** A session started in one directory and +read from another got `No such session`, and that error told the reader to run +`rune session list`, which is scoped to their project and returns nothing — +actively confirming the wrong conclusion. `--all-projects` finds it. This has now +caught three readers, two of whom had read the guide first, which is the point at +which a documented gotcha stops being a documentation problem. + +**Two defects the native-language translation round found**, by conducting each +rune session entirely in the language being translated rather than sending +English prompts: + +- `CharacterWidth::ZERO` covered Latin, Greek, Cyrillic, Hebrew, Arabic and Thai + and omitted every Indic script, so `हिन्दी` was charged six columns for six + codepoints. This is my own table, written hours earlier. +- `ScreenRenderer.resync` searched with `String#index` (characters) and sliced + with `byteslice` (bytes). On `日本語テキスト\e[1mAFTER` the ESC is at character + 7 and byte 21; it cut at 7, returning `"\xAA\x9Eテキスト\e[1mAFTER"` — both + splitting a character and failing to drop the remainder it exists to drop. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/parsers.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/parsers.md new file mode 100644 index 0000000..09199bd --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/parsers.md @@ -0,0 +1,109 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `TableParser` | class | Class method `.parse(text, format: :auto)` converts space or pipe-delimited table text into array of hashes. `format:` accepts `:auto` (default heuristic), `:pipe`, or `:space` to force a parsing mode; raises `ArgumentError` for any other value. | +| `KeyValueParser` | class | Class method `.parse(text)` converts key-value text lines (`key: val`) into typed hashes. | +| `TextSanitizer` | class | Class method `.strip_ansi(text)` strips ANSI escape codes and normalizes line endings. | +| `ScreenRenderer` | class | Class method `.render(text, rows:, columns:, tail_bytes:)` replays a terminal byte stream onto a virtual screen and returns what a terminal would be showing. | +| `PromptDetector` | class | Class method `.detect?(line)` reports whether a single line of (possibly ANSI-colored) output looks like an interactive prompt awaiting input — used by `PTYRunner`/`PTYWatcher` to set `prompt_detected` in their results. | +| `Rune` | module | Top-level rune namespace. | +| `Parsers` | module | Namespace for terminal-output parsing helpers. | +| `parse` | class method | Parses the supplied text using the parser's documented format contract. | +| `parse_pipe_table` | internal method | Builds rows from pipe-delimited headers and cells. | +| `parse_space_table` | internal method | Builds rows from aligned whitespace-delimited columns. | +| `find_headers_and_spans` | internal method | Derives normalized headers and source-column spans. | +| `multi_space_spans` | internal method | Computes spans for headers separated by two or more spaces. | +| `single_space_spans` | internal method | Computes fallback spans from individual non-space tokens. | +| `set_span_ends` | internal method | Completes each detected column span using the following start offset. | +| `extract_values` | internal method | Selects split-based or span-based value extraction for a row. | +| `extract_by_spans` | internal method | Slices row values according to detected header positions. | +| `normalize_header` | internal method | Converts a header to a lowercase underscored symbol. | +| `build_row` | internal method | Zips normalized headers with values, filling missing cells with empty strings. | +| `strip_ansi` | class method | Removes supported ANSI sequences and normalizes CRLF/CR line endings. | +| `ANSI_REGEX` | constant | Escape-sequence pattern removed by `TextSanitizer`. | +| `render` | class method | Replays a byte stream onto a virtual screen and returns the visible text. | +| `Screen` | class | The grid and cursor a terminal maintains, separated from escape-sequence parsing. | +| `write` | instance method | Writes characters at the cursor, wrapping at the right margin. | +| `to_s` | instance method | Returns the visible screen, right-trimmed with trailing blank lines removed. | +| `insert_blanks` | instance method | ICH: shifts the rest of the line right, losing what falls off the edge. | +| `delete_characters` | instance method | DCH: shifts the rest of the line left over the deleted characters. | +| `erase_characters` | instance method | ECH: blanks characters in place without shifting. | +| `insert_lines` | instance method | IL: inserts blank lines at the cursor row, pushing the rest down. | +| `delete_lines` | instance method | DL: removes lines at the cursor row, pulling the rest up. | +| `scroll_up` | instance method | SU: scrolls the screen up, blanking the lines it exposes. | +| `scroll_down` | instance method | SD: scrolls the screen down, blanking the lines it exposes. | +| `DEFAULT_ROWS` | constant | Rows assumed when no size is given. | +| `DEFAULT_COLUMNS` | constant | Columns assumed when no size is given. | +| `MAX_ROWS` | constant | Ceiling on a caller-supplied row count, since the grid is allocated eagerly. | +| `MAX_COLUMNS` | constant | Ceiling on a caller-supplied column count, for the same reason. | +| `dimensions` | class method | The size a render will actually use, given what the caller asked for. | +| `CSI` | constant | The ECMA-48 CSI grammar: parameters, then intermediates, then a final byte. | +| `IGNORED` | constant | Escape forms consumed and dropped, because anything not consumed is printed. | +| `INCOMPLETE` | constant | A sequence the stream ended in the middle of, its terminator not yet arrived. | +| `full_reset` | instance method | RIS: clears the grid, homes the cursor and resets the scroll region. | +| `soft_reset` | instance method | DECSTR: resets region, saved cursor and origin without clearing the display. | +| `scroll_region` | instance method | DECSTBM: confines scrolling to a band of rows, and homes the cursor. | +| `private_modes` | instance method | Applies every mode in a `CSI ? Pm h/l` parameter list. | +| `private_mode` | instance method | One DEC private mode: the alternate buffer, DECAWM, or a cursor save. | +| `ansi_modes` | instance method | Applies every mode in a `CSI Pm h/l` parameter list; only IRM changes the grid. | +| `alternate_buffer` | instance method | Enters or leaves the alternate buffer for modes 1049, 1047 and 47. | +| `enter_alternate` | instance method | Switches to a cleared alternate buffer, optionally saving the cursor. | +| `leave_alternate` | instance method | Restores the primary buffer, optionally restoring the cursor. | +| `designate_charset` | instance method | Designates ASCII or DEC Special Graphics into a G0/G1 slot. | +| `shift_out` | instance method | SO: selects G1 for subsequent graphics. | +| `shift_in` | instance method | SI: selects G0 for subsequent graphics. | +| `ALTERNATE_MODES` | constant | The alternate-buffer modes, mapped to whether each one saves the cursor. | +| `GRAPHICS` | constant | DEC Special Graphics, the `acsc` set ncurses draws boxes with. | +| `CONTINUATION` | constant | Marks the right half of a wide glyph: a column for the cursor, nothing for the render. | +| `render_row` | internal method | Renders one row of cells to the text a terminal would show. | +| `render_cell` | internal method | One cell as displayed, including an orphan continuation holding its column. | +| `wide?` | internal predicate | Whether a cell holds a glyph two columns wide. | +| `heal` | internal method | Restores the wide-glyph invariant after an operation moved cells. | +| `write_char` | internal method | Places one graphic according to the columns it occupies. | +| `settle_wrap` | internal method | Takes a pending wrap, early if a wide glyph would split at the margin. | +| `place` | internal method | Writes a glyph and its continuation, then heals the row. | +| `combine` | internal method | Attaches a zero-width character to the cell before the cursor. | +| `CharacterWidth` | module | Columns a character occupies, from a curated UAX #11 subset. | +| `of` | module function | 0 for a combining mark, 2 for a wide glyph, 1 otherwise. | +| `ASCII_CEILING` | constant | Below this every character is a single-column graphic; the hot path. | +| `ZERO` | constant | Codepoint ranges that occupy no column. | +| `WIDE` | constant | Codepoint ranges that occupy two columns. | +| `translate` | internal method | Maps a graphic through the active charset, passing ASCII through unchanged. | +| `BYTE_CONTROLS` | constant | Control byte to the operation it performs, including SO/SI. | +| `MODE_FORM` | constant | `CSI Pm h/l`, with an optional private prefix, and nothing else. | +| `scroll_region_up` | instance method | Scrolls the region up, losing its top row. | +| `scroll_region_down` | instance method | Scrolls the region down, losing its bottom row. | +| `RESYNC_SCAN_BYTES` | constant | How far past the tail cut to look for an escape to resync on. | +| `DEFAULT_TAIL_BYTES` | constant | How much of a transcript tail is replayed, bounding work for a long session. | +| `TAB_WIDTH` | constant | Columns between tab stops. | +| `PRINTABLE` | constant | Pattern for bytes the renderer writes rather than interprets. | +| `CONTROLS` | constant | CSI final byte to the screen operation it performs. | +| `ESCAPES` | constant | Single-byte escapes that move the cursor, so cannot be discarded. | +| `detect?` | class method | Reports whether a cleaned line resembles a supported interactive prompt. | +| `PROMPT_PATTERNS` | constant | Positive prompt-detection patterns. | +| `FALSE_POSITIVES` | constant | Exclusions applied before positive prompt matching. | + +| `carriage_return` | instance method | Returns the cursor to column zero without changing the row. | +| `backspace` | instance method | Moves the cursor one column left without deleting what follows. | +| `tab` | instance method | Advances to the next tab stop. | +| `newline` | instance method | Moves down a row, scrolling the region when already at its bottom. | +| `index` | instance method | ESC D: down one row, scrolling at the region bottom. | +| `next_line` | instance method | ESC E: down one row and back to column zero. | +| `reverse_index` | instance method | ESC M: up one row, scrolling the region down at its top. | +| `save_cursor` | instance method | Records the cursor position and pending-wrap state. | +| `restore_cursor` | instance method | Returns the cursor to the saved position and state. | +| `cursor_up` | instance method | CUU: up N rows, clamped to the screen. | +| `cursor_down` | instance method | CUD: down N rows, clamped to the screen. | +| `cursor_right` | instance method | CUF: right N columns, clamped to the line. | +| `cursor_left` | instance method | CUB: left N columns, clamped to column zero. | +| `cursor_next_line` | instance method | CNL: down N rows and to column zero. | +| `cursor_previous_line` | instance method | CPL: up N rows and to column zero. | +| `cursor_column` | instance method | CHA: to an absolute column on the current row. | +| `cursor_row` | instance method | VPA: to an absolute row, keeping the column. | +| `cursor_position` | instance method | CUP: to an absolute row and column. | +| `erase_display` | instance method | ED: erases the screen, inclusive of the cell under the cursor. | +| `erase_line` | instance method | EL: erases the line, inclusive of the cell under the cursor. | + diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/session.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/session.md new file mode 100644 index 0000000..5642a62 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/deltas/session.md @@ -0,0 +1,911 @@ +## MODIFIED + +### SPEC SECTION Public API + +| Name | Type | Description | +|------|------|-------------| +| `Session` | module | Namespace for persistent session support. | +| `Rune` | module | Top-level rune namespace. | +| `Commands` | module | Namespace containing concrete CLI command implementations. | +| `Store` | class | Per-session state on disk: `RUNE_HOME` resolution, owner-only dirs/files, `meta.json` read/write, liveness. | +| `Supervisor` | class | The detached process owning one session's PTY master and serving its control socket. | +| `Client` | class | One request/reply exchange against a session's control socket. | +| `Unavailable` | class | Raised when a control socket is missing or refuses a connection — how a dead supervisor presents. | +| `PromptScanner` | module | Reports whether the last non-blank line of text looks like an interactive prompt. | +| `Transcript` | class | One session's durable transcript: reconstruction, cursors across rotation, search and rendering. | +| `load` | class method | Reads a transcript log, returning the retained text and where in it the stream is not contiguous. | +| `record_gap` | class method | Records one dropped region at a retained offset, merging it with one already recorded there. | +| `text` | reader | The output the log still holds. | +| `dropped` | reader | Bytes of earlier output that was discarded, by rotation or by a write that failed. | +| `gaps` | reader | Each dropped region as the retained offset it sits at and the total dropped up to and including it. | +| `cursor` | instance method | Total bytes the child has produced, including everything discarded. | +| `from` | instance method | Everything from an absolute cursor onwards, as far as the retained text reaches. | +| `retained_offset` | instance method | Where an absolute cursor lands in the retained text, walking past each dropped region rather than subtracting one total. | +| `screen` | instance method | What a terminal of a given size would be showing. | +| `grep` | instance method | Lines matching a pattern with surrounding context, and how many matched. | +| `grep_text` | class method | Greps a given stretch of transcript, so a `--since` slice is searched rather than the whole of it. | +| `filter` | internal method | Applies `--grep` to a read, or fails the filter closed and reports why. | +| `Echo` | class | The pty's echo of one send, and where it ends in what has arrived back. | +| `ESCAPE_SEQUENCE` | constant | Escape forms removed when condensing text for echo location. | +| `PRINTED` | constant | A run of characters that survives condensing. | +| `ECHO_SEARCH_LIMIT` | constant | Ceiling on the slice searched for the echo, bounding per-tick cost. | +| `ECHO_COPY_MARGIN` | constant | How far around a match to look for a repainted copy of the input. | +| `REPAINT_MARGIN_FLOOR` | constant | Smallest window the repaint veto will consider, for a short echo. | +| `condense` | class method | Drops escapes and whitespace, so a transformed echo still matches what was sent. | +| `empty?` | instance predicate | Whether anything was sent to echo back. | +| `ends_at` | instance method | The character offset just past the echo, or nil while it is unlocated. | +| `repaint?` | instance method | Whether a repainted copy of the input covers a candidate match. | +| `PendingSend` | class | One in-flight `send` and the decision of when it has been answered. | +| `absorb` | instance method | Folds the bytes that arrived since the last tick into everything the send decides on. | +| `outcome` | instance method | The outcome for this tick, or nil to keep waiting. | +| `matchable` | instance method | The bounded text a `--wait-for-regex` pattern is matched against this tick. | +| `busy_at_send` | reader | Whether the child was still producing output when this send landed. | +| `client` | reader | The control connection waiting on this send. | +| `cursor` | reader | Transcript offset taken when the send was written, so the reply holds only its own output. | +| `compile_regex` | class method | Compiles `--wait-for-regex` with a bounded match budget, returning nil when absent or invalid. | +| `supports_regex_timeout?` | class predicate | Whether this Ruby can bound a single regex match. | +| `ECHO_GRACE_SECONDS` | constant | How long a prefix of the input may still be assumed to be the pty's echo. | +| `MATCH_WINDOW_BYTES` | constant | How much recent post-echo output a `--wait-for-regex` pattern is matched against. | +| `MATCH_WINDOW_SLACK` | constant | How far past that window output may accumulate before it is trimmed back. | +| `MATCH_SPAN` | constant | How far each scan resumes behind the last, and so the longest match always found. | +| `BLANK_CHARACTER` | constant | The first character that counts as the child having produced output of its own. | +| `UTF8_CONTINUATION` | constant | Bytes a window trim must not cut on, so the window stays valid UTF-8. | +| `REGEX_MATCH_TIMEOUT` | constant | How long one `--wait-for-regex` match may run before the pattern is abandoned. | +| `REGEX_TIMEOUT_ERROR` | constant | The regex-timeout error class, or an unraised stand-in on Ruby without one. | +| `DEFAULT_TIMEOUT_MS` | constant | Hard cap on a whole send when the caller does not set one. | +| `SessionCommand` | class | Subcommand `rune session `. | +| `home` | reader | Returns the resolved session-state root for this store. | +| `default_home` | class method | Resolves `RUNE_HOME`, treating an empty value as unset, else `~/.rune`. | +| `valid_name?` | class predicate | Accepts only session names safe to use as a directory component. | +| `alive?` | class predicate | Asks the OS whether a pid exists; `EPERM` counts as alive, a bad value as dead. | +| `process_start_times` | class method | Start times for the pids that still exist, keyed by pid, read from `ps` under `LC_ALL=C`. | +| `process_start_time` | class method | Start time for one pid, or nil when it is gone. | +| `parse_start_times` | class method | Parses `ps -o pid=,lstart=` output into a pid-to-start-time map. | +| `positive_pid` | class method | Coerces a value to a positive pid, or nil when it is not one. | +| `with_bindable_path` | class method | Runs a bind/connect against a path short enough for `sockaddr_un`, chdir-ing into the session directory when the absolute path is too long. | +| `sessions_dir` | instance method | Returns the directory holding every session. | +| `session_dir` | instance method | Returns one session's directory. | +| `meta_path` | instance method | Returns one session's `meta.json` path. | +| `MAX_LOG_BYTES` | constant | Ceiling on a session's transcript file before it is rotated. | +| `LOG_KEEP_BYTES` | constant | How much recent output a rotation keeps. | +| `rotate_output` | instance method | Rewrites the transcript keeping its recent tail, recording what was dropped. | +| `prepare_rotation` | instance method | Writes the replacement transcript to a temp path, without touching the caller's open handle. | +| `output_bytes` | instance method | Output bytes carried by one transcript line. | +| `tail_offset` | instance method | Byte offset of the first whole line within the keep bound of the end. | +| `output_bytes_from` | instance method | Stream bytes the region being kept accounts for — its output and any gap it already records — counting only whole records, without parsing them. | +| `whole_record?` | instance predicate | Whether a transcript line is a record the reader will parse, decided on its last byte; a line with no trailing newline is parsed instead. | +| `parseable?` | instance predicate | Whether one line parses as JSON. | +| `NEWLINE_BYTE` | constant | The byte `whole_record?` treats as a line terminator. | +| `CLOSE_BRACE_BYTE` | constant | The byte a whole NDJSON record ends on. | +| `output_size` | instance method | Current size of a session's transcript file. | +| `rotate_log` | internal method | Rotates the transcript once it reaches the ceiling, backing off rather than retrying an attempt that failed. | +| `HARD_LOG_CEILING` | constant | The size past which recording stops rather than growing, when rotation cannot succeed. | +| `ROTATE_RETRY_SECONDS` | constant | How long a failed rotation waits before it is attempted again. | +| `output_path` | instance method | Returns one session's NDJSON transcript path. | +| `socket_path` | instance method | Returns one session's control-socket path. | +| `exist?` | instance predicate | Reports whether a session directory exists. | +| `names` | instance method | Lists known session names in sorted order. | +| `create` | instance method | Creates a session directory and forces owner-only permissions. | +| `remove` | instance method | Deletes a session directory and its contents. | +| `write_meta` | instance method | Replaces `meta.json` atomically, with owner-only permissions. | +| `read_meta` | instance method | Reads `meta.json`, returning nil when absent or unparseable. | +| `update_meta` | instance method | Merges fields into existing metadata, or nil when the session is unknown. | +| `open_output` | instance method | Opens the append-only transcript for the supervisor's lifetime, owner-only and unbuffered. | +| `DEFAULT_DIR_NAME` | constant | Directory name used under the home directory when `RUNE_HOME` is unset. | +| `DIR_MODE` | constant | Owner-only directory mode for session state. | +| `FILE_MODE` | constant | Owner-only file mode for session state. | +| `NAME_PATTERN` | constant | Pattern a session name must match to be usable as a directory component. | +| `SOCKET_PATH_LIMIT` | constant | Path length beyond which socket bind/connect switches to a session-relative path. | +| `run` | instance method | Runs one supervised session: detach, bind the socket, spawn the child, serve until it ends. | +| `pump` | internal method | Reads and decodes one chunk from the pty, marking the child finished at EOF. | +| `append` | internal method | Appends decoded output to the transcript, records activity, and logs an event. | +| `handle_request` | internal method | Reads one JSON request line from a client and dispatches it. | +| `dispatch` | internal method | Routes one control request to its handler. | +| `handle_send` | internal method | Writes input to the child and either replies immediately or begins a pending settle. | +| `write_to_child` | internal method | Writes the request text to the pty and schedules the terminating carriage return as a separate write. | +| `schedule_submit` | internal method | Records when the terminating carriage return becomes due. | +| `deliver_submit` | internal method | Writes the terminator once its delay has passed and the text has drained. | +| `flush_submit` | internal method | Writes any outstanding terminator immediately, preserving order against a new send. | +| `transcript_bytes` | internal method | Total bytes the child has ever produced, which is what cursors count. | +| `slice_from` | internal method | Everything from an absolute cursor onwards, as far as the held window reaches. | +| `trim_transcript` | internal method | Drops output older than the attach backlog and older than any in-flight send. | +| `pending_text?` | internal predicate | True while a send's text is still queued for the pty master. | +| `undelivered_input?` | internal predicate | True while a previous send's text is queued and its terminator still owed. | +| `exit_status` | internal method | Normalizes a Process::Status into an exit code, mapping a signal to 128+n. | +| `UNDELIVERED_INPUT_ERROR` | constant | Error returned when a send arrives while previous input is still going out. | +| `await_exit` | internal method | Waits, bounded, for a cooperative shutdown to finish before force-killing. | +| `SUBMIT_DELAY` | constant | How long after a send's text the terminating carriage return is written. | +| `begin_pending` | internal method | Records the send cursor, settle window, regex, deadline, and echo for an in-flight send. | +| `resolve_pending` | internal method | Re-evaluates an in-flight send against new output once per loop tick. | +| `settle_pending` | internal method | Replies to an in-flight send and clears the pending state. | +| `handle_stop` | internal method | Acknowledges a stop request and ends the event loop. | +| `status_payload` | internal method | Builds the reply for a `status` request. | +| `respond` | internal method | Writes one JSON reply line and closes the client. | +| `accept_client` | internal method | Accepts a waiting control connection without blocking. | +| `finish` | internal method | Records the session's exit code and logs the closing event. | +| `reap` | internal method | Reaps the child and normalizes exit or signal status. | +| `cleanup` | internal method | Tears down pending clients, the child, the socket, and the transcript handle. | +| `resolve_orphaned_pending` | internal method | Replies to a send that would otherwise never be answered because the supervisor is exiting. | +| `terminate_child` | internal method | Kills and reaps a still-running child. | +| `safe_close` | internal method | Closes an IO, tolerating one already closed. | +| `writable_log?` | internal predicate | Whether the transcript handle is open, checked before a record is generated. | +| `log_event` | internal method | Appends one timestamped NDJSON event to the transcript, recording a write that failed as a gap rather than losing it. | +| `append_log` | internal method | Writes one event, preceded by the `truncated` event accounting for any pending gap; nil when the event did not reach the file. | +| `write_record` | internal method | Writes one NDJSON record, preceded by the torn marker when the last write failed; nil when nothing can be trusted to have landed. | +| `gap_line` | internal method | The `truncated` event that accounts for output no write could record. | +| `note_log_gap` | internal method | Adds an unrecordable event's output bytes to the pending gap. | +| `writable_log` | internal method | The transcript handle, reopened if it has gone away, or nil while it cannot be opened. | +| `gap_field` | internal method | The `transcript_gap_bytes` field, present only while a hole is still owed. | +| `TORN_MARKER` | constant | Line written ahead of the first record after a failed write, so any fragment that write left cannot parse. | +| `REGEX_MATCH_TIMEOUT` | constant | How long one `--wait-for-regex` match may run before the pattern is abandoned. | +| `REGEX_TIMEOUT_ERROR` | constant | The regex-timeout error class, or an unraised stand-in on Ruby without one. | +| `positive_int` | internal method | Coerces a request value to a positive integer, falling back to a default. | +| `monotonic` | internal method | Returns the monotonic clock reading used for settle and deadline arithmetic. | +| `CHILD_ENV` | constant | Environment forced on the child, neutralizing interactive pagers. | +| `POLL_INTERVAL` | constant | Event-loop tick used to poll the pty and re-evaluate a pending send. | +| `READ_CHUNK` | constant | Maximum bytes read from the pty per iteration. | +| `DEFAULT_ROWS` | constant | Rows given to the child's pty, since a detached session has no terminal to copy. | +| `DEFAULT_COLUMNS` | constant | Columns given to the child's pty, since a detached session has no terminal to copy. | +| `request` | instance method | Sends one JSON request and returns the parsed reply. | +| `available?` | instance predicate | Reports whether the control socket currently accepts a connection. | +| `prompt_at_end?` | module function | True when the last non-blank line of text looks like an interactive prompt. | +| `call` | instance method | Routes a `rune session` subcommand, including the hidden supervisor entry point. | +| `human_render` | instance method | Renders sessions, transcript output, or a structured summary for a terminal. | +| `supervise` | internal method | Hidden entry point that runs the detached supervisor for one session. | +| `await_ready` | internal method | Waits for the supervisor to report ready, treating an already-exited child as ready. | +| `abandon` | internal method | Tears down a supervisor that was spawned but never became usable. | +| `executable_path` | internal method | Resolves rune's own executable, used to re-invoke it as the supervisor. | +| `send_input` | internal method | Validates send arguments and performs the control exchange. | +| `send_payload` | internal method | Builds the control-socket payload for a send. | +| `validate_regex` | internal method | Rejects an invalid `--wait-for-regex` before anything is sent. | +| `exchange` | internal method | Performs one control-socket request against a live session, mapping failures to results. | +| `alive_session` | internal method | Returns a failure result unless the named session's supervisor is alive. | +| `read_transcript` | internal method | Serves transcript output with cursor, tail, and byte bounds. | +| `slice_from` | internal method | Returns transcript bytes at or after a cursor. | +| `compile_grep` | internal method | Compiles a `--grep` pattern, returning `[pattern, nil]` or `[nil, Ruby's own reason]`. | +| `grep_failure` | internal method | Builds the `grep`/`grep_error` fields for a pattern that would not compile; no `grep_matches`, because nothing was searched. | +| `render_output` | internal method | Renders a `send`/`read` reply for a terminal: `grep_error` first, then the stripped text. | +| `bound_size` | internal method | Applies `--max-output` or `--tail` to already-filtered text. | +| `bound_output` | internal method | Applies `--tail`/`--max-output` and reports what was omitted. | +| `list` | internal method | Describes every known session. | +| `describe` | internal method | Builds one session's row, recomputing state from real process liveness. | +| `stop` | internal method | Stops a session gracefully, then force-kills any survivor, idempotently. | +| `graceful_stop` | internal method | Asks the supervisor to stop over its control socket, tolerating an unreachable one. | +| `kill_remaining` | internal method | Force-kills any surviving child and supervisor, tolerating already-dead pids. | +| `extract_options` | internal method | Extracts session flags before the first `--`, leaving the wrapped command untouched. | +| `flag_to_validate?` | internal predicate | Whether a flag-shaped token in this position is one a mistyped spelling should be refused for. | +| `scan_flags` | internal method | Walks the pre-separator tokens, consuming flags and rejecting a mistyped one that precedes the first operand. | +| `unknown_flag_error` | internal method | Rejects a flag-shaped token that matches no session flag, instead of letting it be typed at the child. | +| `suggestion` | internal method | Offers the dash-for-underscore correction when that exact spelling is a real flag, and nothing otherwise. | +| `KNOWN_FLAGS` | constant | Every long flag `session` answers to, both spellings `separate_form?` accepts, derived from the parser's own tables. | +| `consume_flag` | internal method | Consumes one boolean or value flag at an argv position. | +| `consume_value_flag` | internal method | Consumes a value flag in either `--flag=value` or `--flag value` form. | +| `assign` | internal method | Coerces and stores one flag value, reporting a message on failure. | +| `separate_form?` | internal predicate | Matches the space-separated spelling of a value flag. | +| `dashed` | internal method | Renders an option key as its user-facing flag name. | +| `flag_alias` | internal method | Maps internal option keys whose flag names differ to those names. | +| `coerce` | internal method | Coerces a raw flag value according to its declared kind. | +| `integer` | internal method | Parses an integer flag value, enforcing positivity where required. | +| `name_error` | internal method | Builds the message for a missing or invalid session name. | +| `no_such_session` | internal method | Builds the message for an unknown session name. | +| `projects_holding` | internal method | The other projects that hold a session of this name, for the scoping hint. | +| `render_list` | internal method | Renders the session list for a terminal. | +| `render_orphan` | internal method | Prints the warning line naming a session's orphaned child pid, if it has one. | +| `render_archive` | internal method | Renders an `archive` reply, printing the orphaned-child warning after the envelope. | +| `store` | internal method | Returns the memoized store for this invocation. | +| `SUBCOMMANDS` | constant | User-facing session subcommands, used for help and error messages. | +| `START_TIMEOUT` | constant | How long `start` waits for the supervisor to report ready. | +| `VALUE_FLAGS` | constant | Maps each option key to its argv pattern and value kind. | +| `BOOLEAN_FLAGS` | constant | Maps valueless flags to their option keys. | +| `reset_transcript` | instance method | Clears a session's transcript so a reused name starts a lifetime whose offsets match its new supervisor. | +| `broadcast` | internal method | Writes one output chunk to every attached terminal, dropping any that has gone away. | +| `handle_attach` | internal method | Acknowledges an attach, replays the current screen, and promotes the client to a raw duplex pipe. | +| `recent_transcript` | internal method | The trailing slice of transcript replayed to an attaching terminal. | +| `forward_from_attached` | internal method | Forwards bytes typed on an attached terminal into the child's pty. | +| `ECHO_GRACE_SECONDS` | constant | How long after a send a prefix-of-input is still assumed to be the pty echo. | +| `ATTACH_BACKLOG_BYTES` | constant | How much existing transcript an attaching terminal is replayed. | +| `Attachment` | class | Connects a human terminal to a live session until the detach key is pressed. | +| `close_quietly` | internal method | Closes the control socket and prints the closing note, tolerating an already-closed socket. | +| `forward_keystrokes` | internal method | Sends typed bytes to the session, stopping at the detach key but still delivering what preceded it. | +| `render_output` | internal method | Writes one chunk of session output to the local terminal. | +| `DETACH_KEY` | constant | Ctrl-], the key that detaches and leaves the session running. | +| `ENDED_WHILE_ATTACHED` | constant | Message used when an attachment ends without the human detaching. | +| `DETACH_HINT` | constant | The detach instruction shown when a terminal attaches. | +| `CHUNK` | constant | Maximum bytes moved per read while attached. | +| `with_clean_output` | internal method | Adds the ANSI-stripped `clean_output` beside a reply's raw output, matching `rune run`. | +| `withhold_dangling` | internal method | Splits off a trailing unterminated escape sequence so it is not delivered or counted. | +| `read_result` | internal method | Builds a read reply from a loaded transcript. | +| `bounded_output` | internal method | Applies `--max-output`/`--tail` to a control-socket reply, deriving `clean_output` from the bounded raw text. | +| `conflicting_bounds` | internal method | Rejects `--max-output` combined with `--tail`, the pair `rune run` already refuses. | +| `attach` | internal method | Validates the session and hands a real terminal to it. | +| `GRACEFUL_STOP_TIMEOUT` | constant | How long `stop` waits for a cooperative shutdown before force-killing. | +| `DISPATCH` | constant | Maps each session subcommand, including the hidden supervisor entry point, to its handler. | +| `project` | reader | Returns the project slug this store is scoped to. | +| `project_slug` | class method | Builds a readable, collision-safe identifier for a project directory. | +| `project_root` | class method | The enclosing git working tree, or the directory itself outside one. | +| `canonical` | class method | Resolves a path through symlinks so one directory cannot get two project identities. | +| `projects` | class method | Lists every project that has session state under a home. | +| `project_dir` | instance method | Returns this project's directory under the home. | +| `archive_dir` | instance method | Returns this project's archive directory. | +| `archive` | instance method | Moves a stopped session into the dated archive, freeing its name. | +| `archived_names` | instance method | Lists archived session directories for this project. | +| `generate_name` | instance method | Picks an unused `-` codename for a command. | +| `CODENAMES` | constant | Word list paired with a tool name to form generated session names. | +| `archive_session` | internal method | Archives a stopped session after validating it. | +| `archive_rejection` | internal method | Returns the failure that blocks an archive, or nil to proceed. | +| `with_orphans` | internal method | Adds `orphaned_child_pid` to each listed session whose child provably outlived its supervisor. | +| `orphaned_pids` | internal method | Maps session names to child pids that are provably still alive, in one batched `ps`. | +| `orphan_candidate` | internal method | One session's `[name, pid, recorded start time]`, or nil when the question cannot be asked soundly. | +| `still_running` | internal method | Message explaining that a session must be stopped before archiving. | +| `list_archived` | internal method | Lists this project's archived sessions. | +| `list_all_projects` | internal method | Lists live sessions across every project, labelled by project. | +| `activity` | internal method | Reports idle time and the last meaningful line from a session's transcript tail. | +| `tail_events` | internal method | Parses the trailing NDJSON events of a transcript without reading the whole file. | +| `summarize` | internal method | Reduces an output chunk to one readable, escape-free line. | +| `idle_suffix` | internal method | Renders idle time for the terminal session list. | +| `await_death` | internal method | Waits for signalled pids to disappear so `stop` is complete when it returns. | +| `ACTIVITY_TAIL_BYTES` | constant | How much of a transcript's tail `list` reads for activity reporting. | +| `ACTIVITY_LINE_LIMIT` | constant | Maximum length of the reported last line. | +| `DEATH_TIMEOUT` | constant | How long `stop` waits for signalled processes to actually exit. | +| `pending_client` | internal method | The in-flight send's socket, watched so a caller that goes away is noticed. | +| `discard_disconnected_pending` | internal method | Releases an in-flight send whose caller has closed its socket. | +| `client_gone?` | internal predicate | True when a readable client socket is at EOF rather than carrying data. | +| `read_request_line` | internal method | Reads one control request within a bound, so a partial line cannot freeze the loop. | +| `kill_group` | internal method | Signals the child's process group, falling back to the single pid. | +| `REQUEST_READ_TIMEOUT` | constant | How long one control request may take to deliver a complete line. | +| `MAX_REQUEST_BYTES` | constant | Largest control request accepted before the client is dropped. | +| `readiness` | internal method | Reports :ready, an error, or nil to keep waiting during start. | +| `serving?` | internal predicate | True when a session records running, has a socket, and its supervisor is alive. | +| `supervisor_died` | internal method | Message pointing at supervisor.log when the supervisor exited during start. | +| `client_ceiling` | internal method | Caller-side bound on a send, so a wedged supervisor cannot hang the caller. | +| `kill_process_group` | internal method | Force-kills a child and its workers by process group. | +| `kill_pid` | internal method | Force-kills a single pid, tolerating one already gone. | +| `DEFAULT_SEND_TIMEOUT_MS` | constant | Mirrors the supervisor's send timeout so the caller's ceiling is never tighter. | +| `CLIENT_TIMEOUT_MARGIN` | constant | Slack added to the caller's ceiling so it never pre-empts a legitimate wait. | +| `lock_path` | instance method | Returns the per-session start lock path. | +| `with_start_lock` | instance method | Serialises `start` for one session name under an exclusive lock. | +| `enqueue` | internal method | Queues bytes for an IO and attempts an immediate non-blocking flush. | +| `drain_outbox` | internal method | Flushes queued bytes to every IO the event loop reported writable. | +| `flush_outbox` | internal method | Writes as much of one IO's queue as it will take without blocking. | +| `drop_writer` | internal method | Handles an IO that failed to accept a write, distinguishing the pty from a terminal. | +| `detach` | internal method | Removes an attached terminal and restores the headless size when it was the last. | +| `reap_idle_clients` | internal method | Closes control connections that connected and never sent a request. | +| `send_rejection` | internal method | The reason a send cannot be accepted, or nil to proceed. | +| `handle_resize` | internal method | Applies a resize request sent over its own control connection. | +| `resize_child` | internal method | Sets the child's pty dimensions and signals SIGWINCH so it re-lays-out. | +| `record_window_size` | internal method | Records the child's current winsize in meta, so `--screen` can render at it. | +| `MAX_ROWS` | constant | Ceiling on a row count arriving over the control socket, applied to the pty and the record. | +| `MAX_COLUMNS` | constant | Ceiling on a column count arriving over the control socket, for the same reason. | +| `MAX_OUTBOX_BYTES` | constant | Ceiling on undrained output for one attached terminal before it is dropped. | +| `DEFAULT_SETTLE_MS` | constant | How long the child must be quiet before a send is considered answered. | +| `EXIT_SUPERVISOR_CRASHED` | constant | Exit code recorded when the supervisor itself died rather than the child. | +| `crashed` | internal method | Records why the supervisor died and finishes the session. | +| `child_still_talking?` | internal predicate | True when the child produced output within the settle window at send time. | +| `serialized_launch` | internal method | Runs the conflict check and launch for one name under the start lock. | +| `screen_after` | internal method | Renders the settled screen for `send --screen`, client-side. | +| `screen_fields` | internal method | The rendered screen and the size it was rendered at, for `--screen` on either command. | +| `window_size` | internal method | The child's last recorded winsize, resolved to a usable one. | +| `liveness` | internal method | The child's state and exit code, on every send and read rather than only on `list`. | +| `busy_fields` | internal method | Whether the child printed within the settle window, and how long since. | +| `read_payload` | internal method | Builds the result body for a transcript read. | +| `ALIASES` | constant | Internal option keys whose user-facing flag is not their name with dashes. | +| `flag_name` | internal method | What to call a flag when speaking to the person who typed it. | +| `GENERATED_NAME_ATTEMPTS` | constant | How many codenames a start without `--name` tries before giving up. | +| `REPLY_DRAIN_TIMEOUT` | constant | How long teardown keeps pushing out replies that are already queued. | +| `drain_replies` | internal method | Delivers queued replies before teardown closes their sockets. | +| `start_rejection` | internal method | Returns the failure that blocks a start, or nil to proceed. | +| `running_conflict` | internal method | Returns a failure when the name already has a live supervisor. | +| `launch` | internal method | Creates session state, spawns the supervisor, and waits for readiness. | +| `launch_failure` | internal method | Turns a child that exited 127 at launch into a failed result. | +| `EXEC_FAILURE_STATUS` | constant | 127, the shell's report for a command that is not on PATH. | +| `spawn_supervisor` | internal method | Re-invokes rune's executable as the detached supervisor for one session. | + +> Note: `conclude`, `handshake`, `with_raw_terminal`, `connect`, `name_base`, `socket_live?`, +> `terminal_size`, `forward_resize`, `forward_pending_resize`, `reset_log_state`, +> `write_atomic` and `with_resize_forwarding` are +> intentionally absent from the table above. They exist and are exercised by the suite, but +> SpecSync's Ruby extractor does not surface them from their position in the class body +> (rune#20 / spec-sync#479), and documenting an export it cannot see fails the contract check. +> The membership of this list is not stable: it moves whenever a neighbouring declaration is added +> or removed, which is the position-dependency the upstream issue describes — `serialized_launch` +> became visible purely because the method that followed it was deleted. +> This matches the existing convention in `pty_runner`'s spec for the same upstream bug. + + +### SPEC SECTION Invariants + +1. A started session's child survives both the launching `rune` process exiting and the launching + terminal closing. The supervisor calls `Process.setsid` (rescued where unsupported) and is + spawned with detached stdio, so it is not in the launcher's session or process group. +2. Exactly one supervisor process owns a given session's PTY master for that session's lifetime. + There is no central daemon. +3. The control channel is a Unix domain socket (`control.sock`), not a FIFO: one JSON request line + in, one JSON reply line out. A FIFO was rejected because a reader sees EOF whenever the last + writer closes, and because it cannot return a reply to the caller. +4. Settle detection runs in the **supervisor**, which owns the output stream and therefore knows + exactly when new bytes arrive — not in the client by tailing a file and guessing. The supervisor + is single-threaded: a `send` that blocked its handler would stop draining the pty, stall the + child on a full buffer, and guarantee the settle window never elapses. +5. `send` frames its response by taking the output cursor **at send time** and returning only bytes + after it, so output already on screen before the send is never misattributed to it. +6. `send` returns on whichever comes first, and **which conditions race depends on whether a + pattern was given**. Without `--wait-for-regex`: no new output for `--settle-ms`, the child + exiting, or `--timeout-ms` elapsing. With one, quiet is **not** among them — the send answers on + a match, the child exiting, or `--timeout-ms`, and `--settle-ms` has no effect on it. + + That is deliberate and was a fix, not an oversight. Quiet used to answer a regex send, so + `--wait-for-regex DONE --settle-ms 800` returned `settled: true, matched: nil` at 800ms against a + child that printed DONE five seconds later, 3/3 — and the documented workaround for the settle + defect did not work at the default settle window. An earlier version of this invariant listed + four racing conditions, and `--help` described the flag as an accelerator that returns "without + waiting out the settle window"; both read as though settle still applied, and callers lost whole + `--timeout-ms` windows to the difference. + + A `--timeout-ms` cap returns what was captured with `settled: false` and `timed_out: true` rather + than failing. A regex send additionally reports `matched: false` there, so the field is present + however the send ended and a caller reading it as a tri-state is not told `nil` for both "no + match" and "not a regex send". +7. The settle clock only starts once output arrives that is **not** the pty's echo of the input. A + pty in cooked mode echoes whatever is written straight back, so counting the echo as "the child + started answering" would settle a send on the caller's own words while the child was still + thinking. The echo is still included in the returned output — dropping data silently would be + worse than noise the caller can see. +8. Input is terminated with a carriage return, not a line feed, because that is what a real + terminal sends for Enter. Raw-mode TUIs — which is most agent CLIs — listen for `\r` and ignore + `\n`, so an `\n` terminator leaves the text sitting unsent in the child's composer. Cooked-mode + children are unaffected because the line discipline translates `\r` to `\n` on input. +8a. That terminator is written **separately from the text, after a short delay**, so the child + cannot receive both in one read. An agent TUI treats a large chunk arriving in a single read as a + paste, and a carriage return inside a paste is a newline in the composer rather than Enter. + Writing them together therefore typed the prompt and never sent it: measured against Claude Code, + 61 characters submitted and 82 did not, and every longer input sat unsubmitted while rune reported + a clean settle — with an agent prompt almost always longer than that. Splitting the write fixes it + for every length tried up to 262 characters, on claude, grok and agy alike. An outstanding + terminator is flushed immediately if another send arrives first, because ordering matters more + than the delay. The delay is measured from the last text byte actually going out, not from when + the send arrived: draining and delivery happen in the same tick, so a deadline already past would + fire microseconds after a backpressured write finished and land in the child's same read — the + exact coalescing the delay exists to prevent. +9. `prompt_detected` is advisory metadata and **never** gates whether a call returns. + `PromptDetector` matches shell-shaped prompts and is deliberately conservative, so it is usually + `false` for exactly the agent REPLs this module exists to drive. Waiting for a prompt would hang + on most real targets; settle-time is the primary signal and `--wait-for-regex` the deterministic + escape hatch. +10. The child's pty is given an explicit window size. A detached session has no controlling terminal + to copy dimensions from and an unset pty defaults to 0x0, which leaves a full-screen TUI agent + rendering into nothing. Every size the supervisor *changes* the child to is recorded in + `meta.json`, so a process that is not the supervisor can render the transcript at it. The + starting default is deliberately not recorded: an absent size renders at exactly those + dimensions anyway, and writing it would put a second meta write immediately after + `record_running`, against the parent's own update during launch. Only a size that actually + changed is written: a human dragging a window edge emits a SIGWINCH per frame, and each one + would otherwise rewrite the whole file on the thread that must keep pumping the pty. +11. Output is decoded incrementally as UTF-8 via `UTF8StreamDecoder`, same as `PTYRunner`/ + `PTYWatcher`: incomplete multi-byte suffixes are retained across reads. +12. The transcript is an append-only NDJSON log using the **same event vocabulary `PTYWatcher` + emits** (`start`, `output`, `exit`, each with a float `ts`), so one format serves both features + and `tail -f` works on a live session. +13. `read` is served by replaying that transcript from disk rather than over the control socket, so + it works identically for a live session and one whose supervisor has exited. Cursor offsets + agree with `send`'s because both count the same concatenated decoded output. +14. `list` determines liveness by checking the recorded pids directly, never by trusting + `meta.json`'s recorded state, so a supervisor that died without cleanup reports `dead`. A + session that exited on its own or was stopped deliberately reports `exited`/`stopped` instead, + so the stale case stays distinguishable from the ordinary ones. +15. `stop` kills and reaps both the child and the supervisor, leaves no orphan, and is idempotent. +16. A `start` that fails after spawning tears down the supervisor it spawned, so a failed start + leaves no process holding a pty for a session the caller was told does not exist. +17. A child that has already exited is a *ready* outcome for `start`, not a startup error: a + short-lived command legitimately finishes faster than `start` can observe it. This also keeps + the missing/non-executable case consistent with `rune run` and `rune watch`, where 127/126 is + the child's exit status on a successful `Result`. +18. Sessions are scoped to a project, so the same name in two checkouts is two different sessions + and neither is reachable from the wrong directory. The project is the enclosing git working + tree, or the directory itself outside one, resolved through symlinks so one tree cannot acquire + two identities. `list` shows the current project only; `--all-projects` opts out. +19. `--name` is optional for `start` and required by every other subcommand. When omitted an unused + `-` codename is generated, so a session always *has* a name without an agent having + to invent one — and so "the grok session" is unambiguous once there are two. +20. `archive` moves a stopped session out of the live namespace, freeing its name and keeping it out + of `list`. An archived session is never reachable as a live one, and archiving refuses while the + session is still running. +21. `stop` is observably complete when it returns: it waits for the signalled processes to actually + disappear, so an immediately following command cannot still see the session as running. +22. `list` reports `idle_ms` and a `last_line` summary per session, read from the tail of the + transcript rather than the whole file. This is what answers "is it stuck, and what was it last + doing" when several agents run at once. +23. Session state lives under `RUNE_HOME` (default `~/.rune`). The session directory is `0700` and + `meta.json`, `output.ndjson`, `supervisor.log`, and `control.sock` are `0600`, matching the + owner-only precedent already set for `rune watch`'s default event log. Sessions live under + `$RUNE_HOME/projects//sessions/`, archives under `.../archive/`. +24. Socket binding and connecting tolerate a long `RUNE_HOME`: `sockaddr_un` caps a path at 104 + bytes on macOS, which an ordinary deep home or any temp-dir-based test exceeds, so both ends + bind relative to the session directory when the absolute path is too long. +25. Rune's own flags are recognized only before the first `--`, same discipline as `rune run`, so a + wrapped command's identically named flags pass through untouched. `--name=NAME` and + `--name NAME` are both accepted. +26. `attach` connects a real terminal to a live session: the child's output streams to the screen, + local keystrokes are forwarded to it, and the current screen is replayed on connect so the + terminal does not sit blank until the child next repaints. Detaching (Ctrl-]) leaves the child, + the supervisor, and the session state untouched. `Ctrl-C` is deliberately *not* the detach key — + it must keep reaching the child so an attached human can interrupt a runaway agent. +27. Output is broadcast to every attached terminal, and a terminal that goes away is dropped rather + than allowed to break the event loop or kill the session. +28. A control client can never take the session down with it: a broken, reset, or half-written + request closes that client only. Before this was enforced, an `ECONNRESET` unwound the event + loop and the teardown path then SIGKILLed a perfectly healthy child. +29. A `send` that races the child's exit is answered with an error, not a dropped connection: the + write can fail after the last `pump` observed the child as alive, and that must still leave the + session's recorded state correct. +30. On an explicit stop the child is signalled before it is reaped. `Process.wait2` on an unsignalled + long-lived child never returns, which previously left the supervisor wedged short of recording + its own exit — visible only because the CLI force-kills afterwards. +31. `stop` bounds its cooperative shutdown and always proceeds to the force-kill. It is the + documented recovery path for a session that is *already* misbehaving, so it cannot depend on the + supervisor's normal reply guarantee. +32. Each supervisor lifetime owns exactly one transcript: starting a session under a previously used + name resets it. Otherwise `send` cursors (which restart at zero with the new supervisor) and + `read` offsets (which replayed the whole file) silently disagreed, and `read` returned a dead + session's output as if it were this one's. +33. `--wait-for-regex` is matched against the output *beyond* the pty's echo of the input, never + the raw slice. Matching the raw slice meant waiting for a marker you had just asked the agent to + print returned the caller's own echoed words immediately — and since that is the normal way the + flag is used, the documented deterministic escape hatch was the least reliable path available. +34. Echo suppression locates the echo within what has arrived rather than requiring it at the + cursor. The cursor is taken the instant input is written, so bytes the child was already + emitting (the tail of a previous prompt, a redraw) can arrive first. Until a copy of the input + is found, nothing is offered to the pattern for `ECHO_GRACE_SECONDS`; past that window what has + arrived is offered *provisionally*, because a child that never echoes at all would otherwise + hang every send to it. Provisional means the search continues: a child whose echo lands a + second late is not a child that did not echo, and when its copy turns up the offer is withdrawn + and the boundary set behind it. Output offered provisionally is therefore never latched as "the + child has spoken" — abandoning the search at the grace window instead was measured to settle + such a send on the echo alone, 0.8s after it arrived and a second before the child had said + anything of its own. +35. An in-flight send whose caller goes away is released as soon as its socket reports EOF, rather + than held until `--timeout-ms`. Otherwise one cancelled call locked the session for the whole + timeout — two minutes at the default — refusing every later send. +36. `send` bounds its own wait client-side at the requested `timeout-ms` plus a margin. The + supervisor normally guarantees a reply, but that guarantee does not hold when it is wedged, and + without a ceiling a stalled supervisor became a permanently hung caller. +37. `start` treats a session as ready only when the supervisor process is actually alive, not merely + when `meta.json` says `running` and the socket exists — a supervisor can record both and then + die. It also fails immediately once the supervisor is gone rather than waiting out the start + timeout for an answer that is already certain. +38. Teardown signals the child's process *group*. Agent CLIs routinely spawn workers, and signalling + only the recorded pid left those running after `stop`, holding ptys and ports where they could + collide with the next session for the same tool. +39. A control client can never take the session down: unexpected errors while handling a request + close that client only, a request line that never completes is abandoned after a short bound, + and a full disk while logging does not end the session. +40. Every directory rune creates under `RUNE_HOME` is owner-only, not just the leaf session + directory, so the set of tools being driven and their session names is not world-readable. +40a. `meta.json` is replaced, never truncated in place: the JSON is serialised first, written whole + to a private per-pid temp path, and renamed over the target, the same shape `rotate_output` + already uses. Every other rune process answers "does this session exist, and is it alive?" out of + this file with no lock to take, so an instant where it is short or empty is an instant where + `send` says "No such session", `list` reports `state: dead`, and `read --screen` loses the + recorded geometry. That was rare while meta was written a handful of times per session and stops + being rare once the winsize is recorded — a human dragging a window edge emits a SIGWINCH per + frame. Measured through a real attach dragged across 250 window shapes in 7.5 seconds while + another process did exactly what `alive_session` does: 90 of 294,728 reads came back unreadable + with the truncating write and 0 of 312,582 with the rename. The temp path carries the + writer's pid because two processes write this file — the CLI records `state`/`supervisor_pid` + while the supervisor records `state`/`child_pid` and the winsize — and a shared temp path would + let them interleave into one corrupt file that then got renamed into place. +41. Nothing on the event-loop thread blocks on a write — including control replies and the attach + acknowledgement, which are queued like everything else and whose client is closed only once the + reply has actually drained. Output to the child and to attached terminals is queued and drained + when the destination reports writable, so a child that stops reading stdin, or a peer that stops + reading, never costs the session its ability to pump the pty, evaluate a settle, or handle + `stop`. +41a. Queued output for an *attached terminal* is bounded. A terminal whose queue exceeds the ceiling + is dropped and its queue discarded, so one that accepts an attachment and then never reads + cannot grow the supervisor's memory without limit. The ceiling applies to nothing else: not to + the pty master, because a child that is slow to read is the session rather than a peer to be + disconnected, and not to control replies, because a reply is an answer the caller is blocked on + — discarding one reports a send as unreachable that in fact completed, and an agent then repeats + a turn the child already did. +41b. Closing an IO unregisters it from every structure the event loop selects on. A closed + descriptor reaching `IO.select` raises, which would unwind the loop and let teardown kill a + healthy child, so the bookkeeping lives in one place rather than at each call site. +41c. A reply that has been queued is delivered before teardown, within a short bound. One + `write_nonblock` takes at most a socket buffer's worth, so any reply larger than that is still + partly queued when the loop exits — and the loop exits as soon as the child is gone and nothing + is pending. Draining at teardown is what makes an answer survive the child that produced it; the + bound is what stops a caller that has stopped reading from holding the supervisor open. +41d. A send whose write to the pty failed is reported as an error, never as sent. A queued write + reports a dead master by marking the child finished rather than by raising, so a `--no-wait` + send — which has no later settle to catch it — must check for that before answering. +41e. A supervisor that dies for any reason records why and leaves the session marked finished. It + previously died silently: `meta.json` still read "running" with no exit code, no exit event was + logged, and supervisor.log was empty, so nothing anywhere named the cause. The cause is written + to the transcript as a `crash` event and to stderr, and the exit code becomes 70 (EX_SOFTWARE), + distinct from any status the child could return. +41f. Echo tracking counts characters, never bytes. `String#index`, `String#[]` and `start_with?` are + character-based, so mixing in a byte length both overshot the echo for non-ASCII input and asked + for more characters than existed — the latter yielding nil and raising, which killed the whole + supervisor and took the agent CLI with it. Multibyte output inside the echo grace window is the + norm for an agent TUI (spinners, box drawing), not an edge case. +41g. A send issued while the child was still producing output is reported as `busy_at_send: true`. + That is when the reply is most likely to be the previous turn's answer rather than this one's. +8b. A send is refused while a previous send's text is still going out. A `--no-wait` send sets no + in-flight guard, so a second send can arrive mid-drain on a backpressured pty; accepting it + would force the outstanding terminator out alongside undelivered text, putting both in one + write and one read — the coalescing 8a exists to prevent, reintroduced by the guard that + preserves ordering. +8c. Nothing that has already arrived can settle a send whose terminator has not gone out yet. While + the input is unsubmitted only the hard limits apply — the deadline and the child exiting — + because otherwise a small `--settle-ms`, or a regex matching a composer repaint, answers the + send in the same tick its carriage return is written, reporting the screen as it was before the + child was even given the line. +38a. `stop` lets the cooperative shutdown actually happen before force-killing, bounded. Acking the + stop only sets a flag; the supervisor tears down on its next tick, and killing it in between + meant the graceful path never once ran — an in-flight send's caller got "supervisor closed the + connection without replying" instead of its captured output, and the control socket was left on + disk. +38b. A session's recorded exit code reflects how the child actually died. `terminate_child` keeps + the status it waited for; discarding it left `reap` to hit `ECHILD` on an already-reaped child + and return a hardcoded 0, so a session killed on `stop` reported `exit_code: 0` as though it had + exited cleanly. +38c. Killing a child's process group is not conditional on the leader still being alive. A group + outlives its leader, so an agent CLI whose wrapper exits while its workers run left a live group + behind a dead pid, and checking the leader first skipped the kill and orphaned exactly those + workers. +41p. `read` reports `child_busy` and `idle_ms`, derived from the transcript's own timestamps so they + work for a stopped session too. Without them a caller had to grep the callee's rendered UI for a + busy marker, which is presentation rather than API. The flag says the child is *printing*, not + that it is *working*: a child that backgrounded a command and went quiet reports false. +41r. A mistyped flag is refused, not typed at the child. `send --name=x --settle_ms 500 'echo + HELLO'` matched no flag, so the flag, its value and the input were joined with spaces and + written to the child, which answered `status: ok`. Two limits keep the refusal from catching + anything that works: nothing after the first `--` is examined, so `send --name=x -- --settle_ms` + still types `--settle_ms`; and nothing after the first operand is examined, so + `start --name=x claude --resume` and `send --name=x git log --oneline` are untouched. `---` and + `--- section ---` are not flag-shaped and are sent as typed. + +41q. A `--grep` pattern that will not compile selects nothing, and the read returns nothing. It used + to return the entire transcript under `status: ok` — the exact opposite of the same read with a + valid pattern that matches nothing, so a caller that did not read `grep_error` saw every line as + though it had matched, at the maximum possible cost. `grep_matches` is absent rather than `0`, + because no search happened. The read still succeeds: `cursor`, `dropped_bytes`, + `prompt_detected`, `idle_ms`/`child_busy` and `screen` have no bearing on the pattern, and a + failure would take the cursor the caller needs down with them. `send` still rejects a bad + `--wait-for-regex` outright, because there the pattern decides when to return. + +41o. `read --grep` filters the *cleaned* text, not the repaint stream. A full-screen agent's frames + split words across escape sequences, so a pattern plainly visible on screen does not match the + bytes. The reply carries `grep_matches`; an unparseable pattern is reported as `grep_error` + rather than raised, because a bad regex from a caller is not a reason to fail a read. + + Three limits, all measured, that "filters the cleaned text" does not make obvious. Overwritten + history still matches and comes back as a clean standalone line, so a match can be text the + screen has not shown since. A cursor-painted frame contains no line breaks at all, so it is one + grep line: `--context` is inert and a single match returns the whole frame under a plausible + `grep_matches: 1`. And a pattern anchored to what is visible on screen can return + `grep_matches: 0`, because adjacency on the screen is not adjacency in the stream. The flag's + own help claimed it matched "the rendered text rather than the repaint stream", which is the + opposite of what it does; that is corrected. Use `--screen` when the question is what is + currently displayed. +41p. `--grep` searches the slice `--since` selected, not the whole transcript. It was handed the + slice and then discarded it, so `--since` had no effect on a grepped read: a read from a cursor + recorded after the first line still returned that line, and `grep_matches` counted it. A caller + paging with `--since=` got the entire history back on every page, under a count + that looked like it had filtered. Context windows are taken within the slice, so a match on its + first line has no preceding context to show — which is the same thing `--since` already means + for every other field in the reply. + +41n. Rotation costs no measurable memory. It seeks to the cut point rather than scanning what it is + dropping, reads the byte count each event already records rather than parsing the event, and + copies with `IO.copy_stream` so the bytes never enter Ruby. The first implementation read the + whole file and parsed every line twice, which put resident memory up 229MB the moment a + rotation ran; streaming the lines but still parsing them still cost 96MB. Bounding the disk is + not worth a memory spike larger than the problem it solves. +41m. A session's transcript file is bounded too, and rotation never makes a cursor lie. The + in-memory window stopped resident memory tracking output, but the file kept every byte for the + life of the session and `archive` moves it rather than pruning, so that cost outlived the + session that paid it — a 150-second run at 500KB/s left 80MB behind permanently. Rotation keeps + the recent tail and records what it dropped in a `truncated` event, so cursors stay absolute: a + cursor taken before a rotation still names the same position in the stream, and `read` reports + `dropped_bytes` rather than silently returning less than was asked for. +41w. A transcript write that fails is *recorded*, not merely survived. The in-memory cursor has + already advanced — those bytes really were produced — so a hole nothing accounts for makes every + cursor `send` hands out unresolvable by `read`, permanently and silently. Reproduced on a full + filesystem by the durability prototype this is taken from, and carried over rather than + re-derived here: `send` answered `cursor: 1849946` while `read` reported 187221 with + `dropped_bytes` nil, `read --since=1849946` returned "" for the rest of the session, and freeing + the disk made it worse because logging resumed over the hole without a word. Output that no + write could record is carried and emitted as a `truncated` event by the next write that + succeeds — the same vehicle rotation already uses — so `read` resolves a pre-hole cursor again + and reports `dropped_bytes`. While the hole is still owed there is nowhere on disk to record it, + so the supervisor reports `transcript_gap_bytes` on `status` and on the `send` reply that hands + out the unresolvable cursor; that is the only place the skew is known at all. +41x. "Recorded" means exactly "its own write returned". A write that fails part-way leaves a + fragment, and a fragment can be a *complete* JSON record that merely never got its newline — + which a later append would silently terminate, counting a gap twice. So each record is written + on its own, and the first record after a failure is preceded by `TORN_MARKER`, which makes any + dangling fragment unparseable. Swept here across every split point of the record carrying a gap: + 0 disagreements in 91 cases between the reconstructed cursor and the supervisor's own. +41y. A cursor is mapped through *each* dropped region, not past one running total. `since - dropped` + is correct only while the dropped region is a **prefix** of the stream, which rotation + guarantees and a failed write does not: a hole in the middle shifts output the cursor is not in + front of, and every cursor issued before the hole then resolves |hole| bytes early — already + delivered output, handed back as new, which re-fires prompt detection and every "did my command + finish" check built on it. Measured on 25 chunks x 4000B, a 48_000-byte mid-stream hole and 25 + more (cursor 248_000, dropped 48_000): `from(100_000)` returned 148_000 bytes beginning at the + start of the stream, 48_000 of them already delivered, against 100_000 beginning after the hole. + Each region is recorded as (retained offset, cumulative dropped) at load, a cursor landing + inside one clamps **forward** to its end — those bytes are gone either way, and later output is + honest where earlier output is not — and a single prefix collapses to exactly the old + arithmetic, byte for byte, at every probe of every rotation case. +41z. A rotation counts exactly the bytes the reader will reconstruct from the region it keeps, since + the head event it writes is `total_output - kept`. Two ways that was wrong, both permanent and + silent, both measured here on ~11MB transcripts rotated with the region in the kept tail: a + `truncated` event inside the tail was not counted, so a hole recorded mid-stream was counted + twice and every later cursor sat **+400_000** bytes past the end of the stream; and a fragment + left by a torn write *was* counted although `Transcript.load` cannot parse it, so every later + cursor sat low — **-4096/-16384/-40960** for 1/4/10 torn writes, scaling with the + outage because `TORN_MARKER` terminates each fragment into a countable line of its own. That + this is worse than 0.8.0's flat -4096, where a fragment and the record after it merged into one + unparseable line, is the prototype's measurement carried over and was not re-derived here. A + real outage moves both dials at once and they do not cancel: a torn write plus the gap it opened + measured **+16384**. All four cases, and a healthy transcript that must not move at all, measure + 0 once a line counts only if it is a whole record and a `truncated` in the tail counts as the + bytes it names. The test is the line's last byte rather than a parse, because parsing the kept + region cost 96MB per rotation (41n); `TORN_MARKER` is what makes that exact, since a fragment it + terminated ends in `n` and cannot parse either. The one shape a byte test cannot decide is a + fragment the file simply ends on, of which there is at most one, so a line with no trailing + newline is parsed outright: swept over every split point of every record shape, with braces, + quotes, escapes and the marker's own bytes inside the payload, 0 disagreements in 1760 cases, + against 10 with that branch removed. +41j. The supervisor holds a bounded *window* of output, not all of it. Cursors remain absolute byte + offsets into the whole stream, because `read` serves them client-side from the transcript file; + the process itself only needs the attach backlog and whatever the current send has produced, + and never trims past a live send's cursor however long that turn runs. A persistent session is + the entire feature, so this is not a detail: measured before the bound existed, resident memory + tracked output one-for-one — 27MB to 69MB in eighty seconds at 500KB/s — and never came down. + After it, resident memory plateaus: over one 150-second run the last 60 seconds added 30MB of + output and 0.16MB of memory. +41aa. Resolving an in-flight send costs the bytes that just arrived, never everything the turn has + produced. Both halves of that were quadratic and both starved the pty drain, because the same + thread does the copying and the pumping. The pattern was matched against the whole accumulated + slice on every 4 KB read — 66.69s inside the echo search and 17.65s inside the match, for a + 12 MB turn that then reported `settled: false, timed_out: true` at 90.51s while holding 11.46 MB + of a 12.00 MB answer whose completion marker the child had already printed. Underneath it, the + supervisor built that slice with `byteslice`, which marks a mutable String *shared*, so the very + next `<<` copied the whole transcript to make it independent again: one copy of the turn per + read, 85% of a sampled 24 MB profile, and the reason a plain `send` with no pattern at all was + superlinear too (48 MB in 118.87s). The send is now fed what `append` just received and holds + bounded state; the full slice is built once, on the tick that answers it. Measured after: 12 MB + settles `matched: true` in 0.98s with all 12.15 MB read, and 48 MB in 3.37s. +41ab. A `--wait-for-regex` pattern is matched against the most recent `MATCH_WINDOW_BYTES` of + post-echo output, with each scan resuming `MATCH_SPAN` characters behind where the last one + stopped. That resumption is the guarantee worth stating: any single match up to `MATCH_SPAN` + characters long is always found, because on the tick that completes it the scan still begins + behind where it started. A single match that must span more than that is never found — the + deliberate cost of the bound, documented in `docs/sessions.md`. The scan is resumed by position + rather than against a substring so that `\A` keeps meaning the start of the child's answer and + cannot be satisfied by wherever the window happens to begin. None of this bounds the reply: + `output` remains everything the child produced for the turn. +41i. A `--wait-for-regex` match is bounded, and a pattern that exceeds its budget is abandoned with + `regex_timed_out: true` rather than retried. Matching runs on the only thread, so a pattern that + backtracks catastrophically blocks the loop: it cannot pump the pty, answer `stop`, or even + check the send's own `--timeout-ms` — reproduced with `(a+)+\1$` against 60 `a`s, where the send + was still blocked long after its 8s deadline. Retrying next tick would spend the budget again on + a slice that only grows, so giving up on the pattern is the only outcome that ends. +41h. `--screen` on `send` and `read` returns the rendered terminal in addition to the byte stream, + and is omitted entirely when not asked so the default result shape is unchanged. It is rendered + in the calling process from the transcript file, never by the supervisor: re-rendering a long + session on the one thread that must keep pumping the pty would trade a reporting improvement for + a latency regression. `read --screen` renders the whole transcript rather than a `--since` + slice, because a screen is the product of every escape sequence before it and replaying from a + mid-stream cursor would show a screen the child never displayed. + + `--screen` is not bounded by the read filters — not `--since`, `--tail`, `--grep` or + `--max-output`. It is bounded by geometry instead, at most `screen_rows x (screen_cols + 1)`, + and both dimensions come back in the same reply: a 219,941-byte transcript rendered to 2,113 + bytes, and a dense 40x120 frame is 4,839 bytes of ASCII or 7,239 of CJK. So a caller passing + `--max-output` does get a bounded reply, just bounded by a different rule than the one they + named — the defect is surprise, not unboundedness, which is why two reporters declined to file + it. Applying the byte bound to the render was measured and rejected: rendering only the bounded + bytes paints a discarded frame plus rune's own elision marker into the child's screen and lost + 9 of 10 answers, and truncating the rendered string is a no-op where it matters while needing a + second `omitted_bytes` in one reply, which 50a forbids. +41s. `--screen` renders at the size the child's pty is actually set to, and reports it as + `screen_rows`/`screen_cols`. The size is not a constant — the child starts at + `DEFAULT_ROWS`x`DEFAULT_COLUMNS`, `attach` resizes it to the terminal that took it over, and + `detach` restores the default — so the supervisor records the current winsize in `meta.json` + whenever it changes it and the caller's process reads it back. Rendering at a fixed default + while a human was attached from any other shape produced a screen nobody ever saw: measured + against a child that lays out against its winsize, resized over the control socket to 30x100, + with pyte 0.8.2 and GNU screen 4.00.03 replaying the same transcript bytes as independent + oracles that agreed with each other exactly, **36 of 37 rows differed before and 0 of 31 after**. + Repeated at 24x80 (30/31 before, 0/25 after), 12x40 (18/19, 0/13) and 50x200 (50/51, 0/51); at + 40x120, where the two sizes coincide, 0 wrong both ways. Repeated again through a real + `rune session attach` in a real 30x100 pty, comparing against the bytes that terminal itself + received: 29 of 30 rows differed before, 0 of 30 after. + A size that was never recorded or that is not a usable terminal (hand-edited meta, a pty whose + size was never set) falls back to `DEFAULT_ROWS`x`DEFAULT_COLUMNS`, which is exactly the previous + behaviour and is also the size `apply_window_size` gives a child nobody has attached to. +41t. A caller can tell a recorded size from the fallback, and `screen_rows`/`screen_cols` are not how. + A session attached from a 40-row terminal records exactly the fallback numbers, so the pair + cannot carry the distinction; `screen_size_recorded` is the field that does. It is true only when + the resolved size is what `meta.json` actually held — a value that was clamped, discarded or + absent reports false, because what is being reported then is a default and not a fact about the + child. +41u. A winsize arriving over the control socket is clamped where it is recorded, not where it is + rendered. A pty's winsize fields are 16-bit, so `{"op":"resize","rows":65535,"cols":65535}` is + accepted by the kernel; recording it unbounded would make every later `--screen` drive a grid + that size for the rest of the session's life, reinstating one layer up the denial of service + behavioural point 12 of `parsers` clamps at the renderer. Measured on a 683KB `\e[999L` + transcript, one `read --screen`: **0.76s at 40x120, 17.72s at the 1000x2000 the renderer would + have clamped 65535 to, and 3.41s at the `MAX_ROWS`x`MAX_COLUMNS` ceiling** that is now the most + a client can ask for. The pty is clamped too, so the child, the record and the render agree — + recording a size the child never had is the bug this whole point exists to fix. The residual + cost at the ceiling is the renderer's per-row cost for line-insert and scroll operations, which + a genuinely 300-row terminal pays identically; it is bounded, not eliminated. +41v. The whole retained transcript is rendered at the *current* size, including output painted before + a resize. That is what an attaching terminal itself shows, because the supervisor replays the + backlog into it at its size — verified through a real attach at 0 of 30 rows wrong even for a + child that ignores SIGWINCH entirely. The unresolved case is a child that never repaints *and* + whose pty is resized under an already-attached terminal, where that terminal is reflowing glyphs + it has already drawn. There is no reference answer to match: fed the bytes that terminal + received and shrunk mid-stream from 40x120 to 24x80, GNU screen 4.00.03 kept only the cursor row + and pyte 0.8.2 kept nothing at all, and the two disagreed with each other on one row of the + little they retained. Rune keeps the content and re-flows it, which differs from both (24/24 + against pyte, 24/25 against GNU screen) where the old fixed 40x120 render differed in 15 — but + that score is an artifact of a mostly blank screen coincidentally matching mostly blank oracles, + not evidence that the fixed size was closer to what anyone saw. Documented rather than tuned to + whichever emulator was measured last. +41k. An attachment reports the way it ended, and never both ways at once. The note that the session + is still running is printed only when the human actually detached; when the attachment ended + because output stopped, the failure says so and points at `rune session list` rather than + asserting that the child or supervisor exited, which the attachment cannot know. Reported from + real use: a session that ended underneath produced "detached; the session is still running" + and "Session ended while attached" in the same exit, one of which is always wrong. +42. Attaching propagates the terminal's real dimensions to the child and forwards SIGWINCH for the + duration, over separate short-lived control connections — the attachment socket itself is a raw + byte pipe after the ack, so a control frame written there would be typed at the child instead. + When the last terminal detaches the child returns to the headless default, so a programmatic + `send` renders the same whether or not a human attached in between. +43. Control connections that connect and never send are reaped. A silent peer is never readable, so + it would otherwise sit in the client set for the life of the session, and enough of them would + exhaust the supervisor's file descriptors. +44. `start` is serialised per session name by an exclusive lock held across the conflict check and + the recording of a supervisor pid. Those are otherwise a check-then-act pair: two concurrent + starts could both see the name as free, and the loser would unlink the winner's socket and + orphan its child. +44a. A generated codename is chosen inside that lock, and contention retries another codename rather + than failing. Choosing it outside meant two concurrent `start -- ` calls could pick the + same codename and the loser would fail on a name it never asked for, with many others free — + which is precisely the parallel-agent case an optional `--name` exists to serve. An explicit + `--name` still fails on contention: that name was the request. +46. A rotation that cannot be written costs only the rotation. `rotate_output` closes the caller's + handle after the replacement is in place, never before, and removes any half-written temp file + on the way out. Closing first meant a failure anywhere later left the supervisor holding a + closed handle it had no idea was closed, and `log_event`'s own rescue then swallowed every + subsequent write — recording stopped silently and permanently. Measured on a real EACCES + directory: 200 further events left the transcript 564,000 bytes behind the cursor, and restoring + write permission widened the gap to 654,000 rather than resuming. A failed rotation is then + backed off for `ROTATE_RETRY_SECONDS` rather than retried on the next event, because + `@log_bytes` stays over the ceiling and every attempt seeks and scans the tail it means to keep + before it discovers it cannot write — 8,388,576 bytes in 4.8ms at the real bound, on the single + thread that also drains the pty. +46a. A transcript write that fails is recorded, not merely survived. The in-memory cursor has + already advanced, so a hole nothing accounts for makes every cursor `send` hands out + unresolvable by `read`, permanently — reproduced with RUNE_HOME on a full 20MB ramdisk, where + 852,000 bytes of output went unrecorded under `dropped: 0` and freeing the disk resumed logging + over the hole without a word. The lost byte count is carried and emitted as a `truncated` event + by the next write that succeeds, the same vehicle rotation uses, so a pre-hole cursor resolves + again and `read` reports `dropped_bytes` rather than silently returning less. Re-measured on the + same ramdisk: skew −873,000 during the outage and 0 after recovery. While the hole is still owed + there is nowhere on disk to record it, so `send` replies and `status` carry + `transcript_gap_bytes` — the only window in which the skew is knowable at all. +46b. A write that fails part-way leaves a fragment, and a fragment can be a complete JSON object + that merely never got its newline — which, once more text is appended, silently swallows the + next good record too. Measured on that ramdisk: 280 of 300 writes failed and one left a + 4,938-byte line parsing as neither record. `TORN_MARKER` is therefore written ahead of the first + record to follow a failure, so the fragment terminates into a line that cannot parse and only it + is lost. `Store#whole_record?` and `Transcript.load` must then agree exactly on which lines + count, because one feeds a rotation's head event and the other reconstructs the stream: the test + is a byte comparison (records are one line ending `}`, a marked fragment ends `n`) with the + file's unterminated last line parsed outright, since that is the one shape bytes cannot settle. + Swept over every split point of 36 record shapes with braces, quotes, escapes, raw newlines and + the marker's own bytes in the payload: 8,832 lines compared, 0 disagreements, and 10 cases the + byte test alone would have got wrong on that last line. +47. Teardown kills the child before it records the session as exited. `cleanup` used to write + `state: 'exited'` first, so a supervisor dying in that window left a concluded record beside a + live process holding a pty. `conclude` already had this order on the normal path; the abnormal + one now matches it. `terminate_child` is idempotent and each teardown step keeps its own rescue, + so a child that will not die still gets the record written after it. +48. A child that outlived its supervisor is *reported*, never made a reason to refuse. `list` and + the `archive` reply carry `orphaned_child_pid` when a session's supervisor is gone and its + recorded child is provably still running. Nothing is blocked and nothing is signalled; the + operator is told the number while it is still reachable, because archiving moves the session out + of the live namespace and that reply is the last place the pid appears. +48a. "Provably" means the pair (pid, start time), not the bare pid and not its process group. The + supervisor records the child's start time as `ps` reports it, under `LC_ALL=C` because `lstart` + is formatted through the locale (`Fri Aug 14 13:41:13 2026` under C, `ven. 14 août 13:41:13 + 2026` under fr_FR). A bare `alive?` answers yes for any process that recycled the number. Asking + the process group is not a fix and was measured to be actively wrong in both directions: 1,222 + of 1,390 live processes on the development machine (87.9%) lead their own group, and 130 of the + 200 most recently allocated pids (65.0%), so a group question answers "alive" for a stranger + about as often as a bare pid does — while a child that is *not* a group leader is missed + entirely. An earlier design refused the archive on that test and directed the caller to + `rune session stop`, which SIGKILLs the recorded pid's whole process group; two runs of it + killed unrelated live groups. +48b. The recorded `state` is deliberately not consulted. A check that skipped sessions recorded + `exited`/`stopped`/`failed` was blind to exactly the case invariant 47 describes. `state` is a + claim by a process that is now dead; the pid/start-time pair is evidence. +48c. Where the question cannot be asked soundly, the answer is silence rather than a guess. A + session with no recorded `child_started_at` — started before the field existed, or by a + supervisor that died in the window between recording the pid and recording the start time — + reports nothing, even if its child is in fact alive. +49. `rune run` and `rune watch` behavior and result shapes are unchanged; this module is purely + additive. +49a. The child ends up at the default geometry, but may observe `0x0` first. `PTY.spawn` returns the + master only once the child is already running, so `apply_window_size` cannot land before a child + that reads its winsize immediately; such a child is corrected by the SIGWINCH that follows. + Observed on a Ruby 3.1 CI runner as `SIZE:[0, 0]` then `RESIZED:[40, 120]`, where every other + version won the race. Closing it would mean opening the pty, setting its size, and spawning onto + the slave by hand instead of using `PTY.spawn` — a change to the spawn path that has not been + measured, so this is recorded as a limitation rather than fixed in a hurry. A child that reads + its size once at startup and never handles WINCH is the case that loses. +51. A read stops at the last **complete** escape sequence, not at the last byte, and its cursor + stops there too. `strip_ansi` only matches sequences that terminated, so a sequence split + across two pty reads was wrong at both ends: the fragment was delivered as visible text, and + the cursor advanced past it so the *next* read saw the remainder headless and stripped nothing. + Measured against a child that printed `READY`, then `\e[3`, slept, then `1mRED\e[0m`: + + read (no flags) clean_output "READY\n\e[3" cursor 10 + read --since=10 --screen clean_output "1mRED\n" screen "READY\nRED" + + The second reply contradicts itself: `clean_output` says the child printed `1mRED` and `screen` + says `RED`, from one invocation. The child printed `RED`. Withholding the fragment from both the + text and the cursor fixes both halves — the next read starts at the ESC and sees the sequence + whole. Nothing is lost: the bytes stay in the transcript and are returned once the sequence + completes, and a child that opens one and never closes it withholds those bytes indefinitely, + which is exactly what a terminal does with them. + +51a. `list`'s `last_line` is summarised from the reassembled tail, not from the last event alone. A + pty read boundary is neither a line boundary nor a sequence boundary, so an event-at-a-time + summary stripped nothing from either half of a split sequence and reported `1mRED` where the + child had displayed `RED`. + +50. `--max-output` and `--tail` bound `send` as well as `read`. Both flags were parsed for every + subcommand and applied only by `read`, so `send --max-output=120` returned everything under + `status: ok` — a caller that asked for a bound was told it succeeded and did not get one. + `send` is the worst place for that gap: it is the call an agent makes most, and one turn of a + full-screen TUI is megabytes. Bounding happens in the command rather than the supervisor + because the cap is one caller's presentation choice; the transcript, the cursor, and every + attached client still see the whole stream. +50a. `clean_output` is derived from the *bounded* raw text, not bounded separately. Bounding the + two independently lets them describe different windows of one reply and leaves + `omitted_bytes` true of only one of them. This is what `read` already does. +50b. `--max-output` and `--tail` are mutually exclusive on every session subcommand, with the same + message `rune run` has always used. Accepting both applied whichever `bound_size` tested + first, so the caller silently got the other one. + +52. A launch that never happened returns `status: error`. `start` with a command that is not on + PATH used to return `status: "ok"` with `state: "exited"` and `exit_code: 127`, so a caller + checking the field whose entire job is to say whether the call worked saw success. It was + documented as "check `state` instead", which is the wrong shape of answer — an envelope should + not need a footnote to be read correctly. Reported from a 22-minute real drive, where it cost + an hour. + + Only 127 fails, and that is deliberate: `start -- true` exits 0 immediately and is a + *successful* launch of a program that had nothing to do, so treating any prompt exit as failure + would break every short-lived child. 127 is the shell's "command not found" — the one case + where the child never ran. + + The session record is kept rather than deleted. `start` failing loudly is the fix; removing the + transcript that shows why would trade one quiet failure for another, and `list` reporting the + session as dead with `exit_code: 127` is the diagnosis a caller needs. + +53. An error naming a session says where the session actually is, when it is somewhere. A + session started in one directory and read from another got `No such session`, and the remedy + that error printed — `rune session list` — is scoped to the caller's own project and returns + nothing, which reads as proof the session died. rune knew the answer the whole time: + `--all-projects` finds it. It has now caught three separate readers, two of whom had read the + guide's warning about directory scoping first, which is when a documented gotcha stops being a + documentation problem. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/docs.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/docs.md new file mode 100644 index 0000000..07d648d --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/docs.md @@ -0,0 +1,15 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: docs +--- + +# Docs + +session.spec.md gains invariants 52 (a failed launch is an error, and why only +127) and 53 (an error names the project). Export rows for `launch_failure`, +`EXEC_FAILURE_STATUS` and `projects_holding`. + +The wider point from the same report — that rune has three different envelope +shapes and a caller must independently know to check each — is deliberately not +addressed here. That is an API-shape decision for 1.0 and wants designing, not a +third patch; this change fixes the one instance where `status` itself was wrong. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/plan.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/plan.md new file mode 100644 index 0000000..dbba17a --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/plan.md @@ -0,0 +1,21 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: plan +--- + +# Plan + +Only exit 127 fails the launch. Any prompt exit would break every short-lived +child, and 127 is specifically the shell reporting a command it could not find. + +The scoping hint costs one directory listing per project and is rescued, because +a best-effort hint must never turn a clear error into a crash. + +The width fix is the Mn/Me subset, not every Indic mark. The report framed it as +"one column per codepoint", which overstates it: U+093F is a *spacing* mark and +legitimately takes a column, so zeroing everything Indic would be as wrong in the +other direction. `हिन्दी` is five columns here and in xterm, not the three a +shaping engine draws — this follows wcwidth and does not try to settle shaping. + +`resync` searches a binary copy, because `byteindex` arrived in Ruby 3.2 and this +gem supports 3.0. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/requirements.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/requirements.md new file mode 100644 index 0000000..705ca54 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/requirements.md @@ -0,0 +1,12 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: requirements +--- + +# Requirements + +1. A command not on PATH fails; a child that exits zero at once does not. +2. A refused launch keeps its record, because that record is the diagnosis. +3. An error about a missing session names the project holding it, when one does. +4. Indic nonspacing marks take no column; spacing marks still take one. +5. `resync` drops the pre-escape remainder whole, whatever encoding it is in. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json new file mode 100644 index 0000000..87fb127 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "id": "CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby", + "slug": "make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby", + "title": "Make a failed launch loud, name the project a session is in, and fix two multibyte defects", + "description": "Make a failed launch loud, name the project a session is in, and fix two multibyte defects", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "created_at": 1787065644, + "updated_at": 1787065870, + "affected_specs": [ + "session", + "parsers" + ], + "affected_paths": [ + "lib/rune/commands/session_command.rb", + "lib/rune/parsers/character_width.rb", + "lib/rune/parsers/screen_renderer.rb", + "spec/rune/session_spec.rb", + "spec/rune/parsers/screen_renderer_spec.rb", + "specs/session/session.spec.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "rune session start returns status error when the command is not on PATH, while a child that exits zero immediately still succeeds. The no-such-session error names the project the session is actually in and points at a remedy that shows it. CharacterWidth gives Indic nonspacing marks no column while leaving spacing marks at one, matching wcwidth. ScreenRenderer resync searches by byte offset rather than character index, so a multi-byte head is dropped whole instead of cut mid-character. Each has tests that fail against deliberately reverted code." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/tasks.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/tasks.md new file mode 100644 index 0000000..c92d3b2 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/tasks.md @@ -0,0 +1,14 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: tasks +--- + +# Tasks + +- [x] Verify all four before planning +- [x] `launch_failure` on 127, keeping the record +- [x] `projects_holding` and the reworded error +- [x] Indic Mn/Me ranges; spacing marks left at one column +- [x] `resync` by byte offset +- [x] Tests for each, falsified +- [x] session.spec.md invariants 52 and 53, export rows diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/testing.md b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/testing.md new file mode 100644 index 0000000..19e014f --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/testing.md @@ -0,0 +1,28 @@ +--- +change: CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby +artifact: testing +--- + +# Testing + +604 examples, 0 failures; rubocop clean; specsync 0 hard errors. + + check before after + start -- status ok status error + start -- true status ok status ok + read from another project "No such session" names the project + हिन्दी 6 columns 5 (wcwidth) + resync("日本語テキスト\\e[1mAFTER") "\\xAA\\x9Eテキスト…" "\\e[1mAFTER" + +Controls: reverting `launch_failure` fails 1 of 3; reverting the resync byte +index fails 2; removing the Indic ranges fails 2; reverting the project lookup +fails 2. + +One claim from the same report did **not** reproduce and is not fixed: that a +send over ~1024 bytes jams every later send while rune answers `settled: true, +state: running`. Measured at 600, 1100, 4096 and 20,000 bytes — the first three +show no jam at all, and 20,000 refuses the follow-up with `status: error` and +"previous input is still being delivered to the child", recovering by itself in +about ten seconds. That is what ROADMAP already records. The reported shape looks +like a probe reading `clean_output` without checking `status`, which is the exact +mistake this session made once already. diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json new file mode 100644 index 0000000..5f1922d --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1787065852, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "fbf9a273d33f9f9f956bc4059249d33725870c320eb8a95ee09eeade00ea9b3c", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json new file mode 100644 index 0000000..21e4703 --- /dev/null +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json @@ -0,0 +1,136 @@ +{ + "timestamp": 1787065852, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "fbf9a273d33f9f9f956bc4059249d33725870c320eb8a95ee09eeade00ea9b3c", + "acceptance_input_digest": "6d19bdabf2c16f1771dc7c016351e4fff940d8f744f73740c386733086460c01", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "cffe0258b98ea4df6a0150d1c1dc3d3c327990f99f4520183ada93eeceaae46e", + "entry_digest": "cce3ce814ebe5d102e72350883ee3524ae9f22183a5c2c248c727294eec22782", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", + "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "fefe3f4ca109ca45c1bfd2349fe185dfe3555082989a55cbdfc9a1149087bfbc", + "entry_digest": "eed613a7dbf151310c19cba34b281e791589c7054961f7eae1856efecf67bdb0", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen_renderer.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "0f50fa04a465f21f3e592fdd1c6f34ced703103f8c095f95311046888b1e7393", + "entry_digest": "f8e99a18c98b1913b06b53b343d0ee2f36f4237191d0ce0314e454ca6b733a16", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "d064a3e93b3cd62baa6bad2d2b1c0b67afedc86fb863f44c659b2774972ec6bb", + "entry_digest": "e29155b72d0b7cd7df039ecee3cb62b023e2c1cac31a47c5415f7a1ad39b057c", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d3242f740bc8ce65716a34942848f12b911b30e74b242681e7a4905d82fcae3a", + "entry_digest": "72df09dafb06bde593202cd51eee288dc201433fc5fca15201a4e9603f7c630b", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", + "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", + "owners": [ + "session" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/RUNE_NATIVE_I18N.md b/RUNE_NATIVE_I18N.md new file mode 100644 index 0000000..663a6a0 --- /dev/null +++ b/RUNE_NATIVE_I18N.md @@ -0,0 +1,637 @@ +# rune native-language i18n dogfooding report + +Six agents translated `docs/getting_started.md` into Japanese, Simplified Chinese, Korean, Russian, +Arabic and Hindi — and, unlike the previous round, **conducted the entire rune session in the +language they were translating into**. The translations are the side effect. The point is that +every byte written *into* the pty was non-ASCII, which is ground the previous round never touched. + +This document is a **verification pass**, not a transcription. Every claim below was re-run against +the local build (`ruby -Ilib bin/rune`, 0.9.0, `RUNE_HOME=/tmp/native-verify`) on fresh probe +sessions. Verdicts are mine. Where a reporter's framing did not survive re-measurement I say so, and +several did not — including one finding this round overturns from the *previous* report. + +Nothing outside this file was written. `.specsync/`, `specs/`, `lib/` and `spec/` were read only. + +> **Tree note.** `lib/rune/commands/session_command.rb` and `spec/rune/session_spec.rb` were already +> modified when this pass began (a concurrent agent's improvement to the `no_such_session` message, +> naming the owning project). I did not author or revert them. The change touches only an error path +> none of the findings below exercise, so no verdict depends on it. + +--- + +## 1. Why this round found different things + +The previous round sent **English prompts** and could therefore only observe what rune did with +non-ASCII **output**. Everything it found lived on the read path: `--tail`, `--grep`, `clean_output`, +`--max-output`, screen rendering. + +Conducting the session in-language moves non-ASCII onto the **write** path — the argv, the pty +write, the line discipline, the echo boundary, the session name. Four of the confirmed findings +below are structurally unreachable from an English-prompt round: + +| Finding | Why only an in-language round finds it | +|---|---| +| Cooked-mode overflow **jams the channel** | Needs a >1024-byte single line. Japanese hits it at 341 characters, Korean at ~341 — an ordinary paragraph. English needs 1024. | +| Mid-character cut fabricates U+FFFD | Requires the 1024-byte cut to land inside a character. Impossible in ASCII. | +| `command` display field is illegible | Requires a non-ASCII argument. | +| Non-ASCII session name rejected | Requires naming a session in your own script. | + +That is the honest answer to "what did conducting in-language expose": **the input path is largely +sound, and the four places it is not are places English can never reach.** + +--- + +## 2. Language discipline — who actually stayed in-language + +`conducted_in_language` is self-assessed, and the six agents applied visibly different standards. The +flag as reported is not comparable across runs, so here is what each actually did. + +| Language | Reported | Agent dialogue | Ancillary probe traffic | Assessment | +|---|---|---|---|---| +| Russian | `true` | 13 sends, 100% Cyrillic | none — instructed grok to read line ranges from disk | **Cleanest.** No English through the pty at all. | +| Simplified Chinese | **`false`** | 7 sends, 100% 简体中文 | ASCII payloads to `cat` / `sh` control children | **Stricter than its own flag.** Nothing English reached the agent. | +| Japanese | `true` | 5 sends, 100% Japanese; approvals driven with raw `\033[B`/`\r` | ASCII control arms in probes | Equivalent to zh-CN, flagged the other way. | +| Arabic | `true` | 8 sends, 100% Arabic | ASCII control arms, disclosed | Equivalent to zh-CN, flagged the other way. | +| Hindi | `true` | 5 sends, Hindi prose with English identifiers embedded | — | Fair: identifiers must stay English by the brief. | +| Korean | `true` | 8 sends Korean **plus English source sections quoted verbatim into the agent** | — | **Weakest `true`.** English bytes did reach the child, unlike zh-CN's. | + +**No round quietly reverted to English.** All six conducted the actual translation dialogue in the +target language, and the input-path findings rest on genuinely non-ASCII sends. + +The inconsistency worth recording: **zh-CN marked itself `false` for exactly the practice Japanese +and Arabic marked `true` for** (ASCII control arms against non-agent children), while Korean marked +`true` despite piping English source text to the agent. Read literally, the flag would rank the most +scrupulous reporter lowest. The paired ASCII control is *methodologically required* — two of the +strongest findings below are stated as a delta against one, and a control is what killed the largest +candidate finding of the round. A future brief should ask for "language used with the driven agent" +and treat control payloads as a separate, expected disclosure. + +--- + +## 3. Verdicts at a glance + +| # | Claim | Reporters | Side | Verdict | +|---|---|---|---|---| +| 1 | Cooked-mode overflow jams **every later send**; rune reports `settled:true, state:running` | ja | input | **CONFIRMED — new** | +| 2 | Overflow cut mid-character fabricates U+FFFD in `clean_output` | ja | input | **CONFIRMED — new** | +| 3 | `command` display field backslash-escapes every non-ASCII character | ar | input | **CONFIRMED — new** | +| 4 | Non-ASCII session name rejected; message says "letters" | ar | input | **CONFIRMED — new** | +| 5 | `ScreenRenderer.resync` uses a char index as a byte offset | ko, ru | output | **CONFIRMED — new** | +| 6 | `--wait-for-regex` satisfied by a repaint of an **earlier reply's** sentinel | hi | output | **CONFIRMED — new** | +| 7 | `--since` mid-character silently returns U+FFFD | zh, ko, ar, hi, ru | read | **CONFIRMED — low severity** | +| 8 | `--screen` over-charges Devanagari width | hi | output | **CONFIRMED, mechanism corrected** | +| 9 | MAX_CANON truncation bites CJK/Hangul at ⅓ the characters | ja, ko, ar | input | **CONFIRMED — already documented** | +| 10 | `--wait-for-regex` matches the raw byte stream, not the screen | ko, ru | output | **CONFIRMED — already in ROADMAP:82** | +| 11 | Escape-interleaving is "far worse for Korean than English" | ko | output | **REFUTED** | +| 12 | `--screen` charges one column per codepoint | hi | output | **REFUTED as framed** | +| 13 | *Previous round, Rank 5:* combining marks "charged a column each" | prior report | output | **REFUTED** | + +--- + +## 4. Input-side findings — the new ground + +### 4.1 A single over-long send jams the channel permanently, and rune reports success — **CONFIRMED** + +The documented limitation (`docs/sessions.md:499`, `specs/session/session.spec.md:983`) covers the +loss of the over-long line: *"1023 bytes arrive, 1024 do not… Chunk it, or drive a raw-mode target."* +It does not say that **every subsequent send to that session is also swallowed.** + +Paired arms against `cat`, differing only in the size of the **first** send: + +``` +CONTROL(1023B) FIRST sent=1023 settled=true timed_out=nil state=running back=2048 +CONTROL(1023B) probe0 echoed=true ... out="PROBE\nPROBE\n" +CONTROL(1023B) probe1 echoed=true +CONTROL(1023B) probe2 echoed=true + +OVERFLOW(1200B) FIRST sent=1200 settled=true timed_out=nil state=running back=1025 +OVERFLOW(1200B) probe0 echoed=false settled=true timed_out=nil state=running out="\a\a\a\a\a\a" +OVERFLOW(1200B) probe1 echoed=false settled=true timed_out=nil state=running +OVERFLOW(1200B) probe2 echoed=false settled=true timed_out=nil state=running +``` + +The probes are ordinary 5-byte sends. Every one returns `settled: true`, `timed_out: nil`, +`state: running` — the envelope of a successful send. The only trace is a run of BEL bytes in +`clean_output`, the tty ringing for each rejected byte. + +Recovery exists and is undiscoverable. A bare **U+0015 (line kill)** clears it instantly: + +``` +after CTRL-U echoed=true settled=true state=running out="PROBE\nPROBE\n" +``` + +Grepping `docs/`, `specs/` and `ROADMAP.md` for `line.kill|0x15|subsequent send|jam` returns +nothing. The mechanism is the kernel line discipline, not a rune write bug — which is why this is +filed as a **reporting and recovery gap**: rune has the BELs in hand and answers "fine". + +Why it is a native-language finding despite being byte-based: the bound is **1024 bytes**, so +Japanese trips it at 341 characters and Korean at ~341, where English survives to 1024. The JA +reporter's real prompts were 400–500 characters and only worked because the child ran raw mode. + +### 4.2 The overflow cut lands mid-character and fabricates a U+FFFD — **CONFIRMED** + +Same 1200-byte send, one arm per script, fresh `cat` session each: + +``` +ja sent_bytes=1200 back_bytes=1027 back_chars=343 FFFD=1 valid=true tail="あああああ" +asc sent_bytes=1200 back_bytes=1025 back_chars=1025 FFFD=0 valid=true +``` + +341 × あ = 1023 bytes fit; the 1024th byte accepted is the **first byte of character 342**, and its +two continuation bytes are discarded. The tty echoes the orphan, `TextSanitizer` scrubs it, and a +Japanese caller reading `clean_output` sees 341 characters they typed plus one character that exists +nowhere in their input. The spec says the line is *"silently discarded… the child never sees it"* — +true of the child, not of the transcript the caller is handed. Severity genuinely low; it is a +contract mismatch, not an encoding bug. + +### 4.3 The `command` display field is illegible for any non-ASCII argument — **CONFIRMED** + +``` +$ rune run --json -- echo 'مرحبا بالعالم' + command = "echo \\م\\ر\\ح\\ب\\ا\\ \\ب\\ا\\ل\\ع\\ا\\ل\\م" + clean_output = "مرحبا بالعالم\n" + +$ rune run --json -- echo 'hello world' + command = "echo hello\\ world" <- one escape, for the space + +$ rune run --json -- echo '日本語 テスト' + command = "echo \\日\\本\\語\\ \\テ\\ス\\ト" +``` + +`Shellwords.join` escapes every character outside `[A-Za-z0-9_\-.,:+/@\n]`, which is every Arabic, +CJK, Cyrillic, Devanagari and Hangul codepoint. **Not Arabic-specific** — the AR reporter framed it +as such; it is universal to non-Latin scripts, and I confirmed CJK and Cyrillic independently. + +Scope is confined to the joined display string; `meta.json`, the `session list` row and the +`session start` reply all carry the clean argv array. It is not shell-incorrect and it round-trips +through `Shellwords.split`. But `docs/getting_started.md:180` and `specs/watch/watch.spec.md:131` +describe this field as a *"shell-escaped display reconstruction for humans"*, and for a non-Latin +argument no human can read it. The field's only purpose is the one it fails at. + +### 4.4 A session named in your own script is rejected, by a message that says it shouldn't be — **CONFIRMED** + +``` +$ rune session start --name جلسة --json -- cat +{"status":"error","error":"Invalid session name \"جلسة\". Use letters, digits, '.', '_' or '-' (max 64 chars), starting with a letter or digit."} + +$ rune session start --name сессия --json -- cat +{"status":"error","error":"Invalid session name \"сессия\". ..."} +``` + +`lib/rune/session/store.rb:28` — `NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/`. ASCII +letters only, while the message at `session_command.rb:1178` says "letters, digits". جلسة is four +letters; сессия is six. The reader is told their name qualifies and has nowhere to go. + +Restricting names to ASCII is **defensible** — they become directory and socket path components — so +this is filed against the message, not the rule. The name is echoed back correctly, so rune received +it intact and rejected it on the pattern. + +### 4.5 MAX_CANON truncation — **CONFIRMED, already documented** + +Reproduced (§4.1, §4.2). The limit, the exact 1023/1024 boundary and the silence are all recorded at +`docs/sessions.md:499-505` and `specs/session/session.spec.md:983-989`, including that raw-mode +children are unaffected. The reporters' contribution is the **character-count framing** — the same +byte ceiling costs a CJK or Hangul caller two thirds of their line — which is a fair addition to the +docs but not a new defect. Not filed. The *jam* (§4.1) is the new part. + +--- + +## 5. Output-side findings + +### 5.1 `ScreenRenderer.resync` reads a character index as a byte offset — **CONFIRMED** + +`lib/rune/parsers/screen_renderer.rb:204-207`: + +```ruby +escape = window.byteslice(0, RESYNC_SCAN_BYTES).to_s.index("\e") # CHARACTER index +return window if escape.nil? || escape.zero? + +window.byteslice(escape..).to_s # BYTE offset +``` + +They coincide only on ASCII. Reproduced offline from a synthetic >512KB fixture with **no session +and no agent involved** — source valid UTF-8, zero U+FFFD in all arms, window cut verified to land +exactly at the head of each run, suffix filled with `\e[0m` no-ops so row 0 survives: + +``` +case escC escB winOK srcFFFD scrFFFD row0 +ASCII before ESC (control) 2 2 true 0 0 "ZZ" +1 x 3-byte braille 1 3 true 0 2 "ZZ" +2 x 3-byte braille 2 6 true 0 1 "⢿ZZ" +3 x 2-byte Cyrillic 3 6 true 0 1 "нZZ" +2 x 3-byte CJK 2 6 true 0 1 "本ZZ" +3 x 3-byte Devanagari 3 9 true 0 0 "िनZZ" +1 x 2-byte Arabic 1 2 true 0 1 "ZZ" +1 x 4-byte emoji 1 4 true 0 3 "ZZ" +``` + +Rendering the identical text with `tail_bytes: nil` gives **zero U+FFFD in every arm**, which is what +isolates `resync` from the window cut's own `.scrub`. + +**Two distinct symptoms**, and only the first was reported: + +1. **Fabricated U+FFFD** — the screen shows replacement characters that exist nowhere in the + transcript. Both reporters measured this live (ru at cursor 722,429; ko at 2.9 MB, transcript + U+FFFD count zero). +2. **`resync` fails at its own job** — the Devanagari arm slices at byte 3, which happens to be a + valid boundary, so there is no U+FFFD; but `िन` survives on screen when the whole point of + resync is to drop everything before the first ESC. The comment above the method says *"Resyncing + to the first `ESC` drops that remainder."* Cutting short of the ESC leaves it. + +Reachability is real, not theoretical: it needs a transcript over `DEFAULT_TAIL_BYTES` (512 KB) with +non-ASCII in the 256 bytes after the cut — routine for a long agent session in any non-Latin +language. Invisible on ASCII, where the two indices coincide. + +### 5.2 `--wait-for-regex` is satisfied by a repaint of an *earlier reply's* sentinel — **CONFIRMED** + +This is the most consequential output-side finding of the round, and the HI reporter partly +disowned it as their own sentinel-reuse error. The underlying defect is real and independent of that. + +I reproduced it deterministically with an **ASCII sentinel the caller never types at all**. The child +reprints its scrollback on every input line, then works for 6 seconds, then prints a new reply: + +``` +send0 elapsed=6.4s matched=true settled=true | distinct replies child has EVER made=1 (expected 1) +send1 elapsed=0.4s matched=true settled=true | distinct replies child has EVER made=1 (expected 2) +send2 elapsed=5.87s matched=true settled=true | distinct replies child has EVER made=2 (expected 3) +``` + +`send1` returns in **0.4 s** with `matched: true, settled: true` while the child is still sleeping and +has printed nothing new. The match was the repaint of reply #1's sentinel. + +`Echo#repaint?` (`pending_send.rb:470`) suppresses a match covered by a copy of the **current +input**. Nothing covers a repaint of **earlier output**, so a long-lived TUI that repaints its +scrollback re-arms every sentinel it has ever printed. `ROADMAP.md:82`'s table covers *"caller's +prompt in a composer box"* — the caller's input. This is a different false positive and is not +recorded anywhere. + +This is script-neutral. It surfaced in the Hindi run because agent CLIs repaint scrollback, not +because of Devanagari. + +### 5.3 `--since` at a mid-character offset silently returns U+FFFD — **CONFIRMED, low severity** + +Reported independently by five of the six agents. Transcript `हिन्दी 日本語 مرحبا Привет`: + +``` +--since=0 status=ok bytes=108 FFFD=0 valid=true head="हिन्दी 日本語" +--since=1 status=ok bytes=111 FFFD=2 valid=true head="िन्दी 日本" +--since=2 status=ok bytes=108 FFFD=1 valid=true head="िन्दी 日本語" +--since=3 status=ok bytes=105 FFFD=0 valid=true head="िन्दी 日本語 " +--since=4 status=ok bytes=108 FFFD=2 valid=true head="न्दी 日本語" +``` + +One U+FFFD per orphaned continuation byte, `status: ok`, `valid_encoding?` true — a consumer cannot +distinguish rune's slicing damage from a U+FFFD the child emitted. Mechanism: +`lib/rune/session/transcript.rb:89`, `(@text.byteslice(offset..) || +'').scrub`. + +Note the arithmetic consequence: `--since=1` returns **more** bytes (111) than `--since=0` (108), +because two orphan bytes become two 3-byte replacement characters. Cursor arithmetic does not +reconcile on a misaligned cursor. + +**Severity is bounded by a negative result I verified independently** (§7.1): rune never *hands out* +a bad cursor. This only bites a caller doing arithmetic on one — e.g. `--since=$((cursor-200))` to +back up, which is a natural thing to do with a documented byte offset, and which is safe for every +value on ASCII and unsafe for roughly half of them on a 3-byte script. + +### 5.4 `--wait-for-regex` matches the raw byte stream — **CONFIRMED, already recorded** + +Deterministic repro, child emitting per line +`\e[32m가\e[0m\e[32m나\e[0m \e[32mA\e[0m\e[32mB\e[0m\r\n` then `다라 CD\r\n`: + +``` +contiguous Hangul (다라) matched=true timed_out=nil +contiguous ASCII (CD) matched=true timed_out=nil +escape-interleaved Hangul (가나) matched=false timed_out=true +escape-interleaved ASCII (AB) matched=false timed_out=true +``` + +`--screen` renders both rows correctly (`가나 AB`, `다라 CD`), so the pattern is plainly visible while +the wait fails. `ROADMAP.md:82` already records the byte stream as *"the wrong surface"* with a +mechanism table. The `--help` text does not carry the caveat, and `docs/sessions.md:156,464,493` still +recommends the flag as *"deterministic"* — which the previous report already filed as Rank 8. + +--- + +## 6. Refutations — the useful part + +### 6.1 "Escape-interleaving is far worse for Korean than English" — **REFUTED** + +The KO summary states the defect is *"far worse for Korean than for English"* because grok emits a +cursor escape between every Hangul character but writes ASCII runs contiguously. + +The **rune-side mechanism is script-neutral**. My paired control above shows `AB` failing exactly as +`가나` fails, in the same reply, from the same session. The asymmetry is entirely a property of the +child's painting — non-Latin text tokenizes at roughly one token per character, so grok paints it per +cell — and would vanish against a child that writes contiguously. + +KO's own `controlled` section concedes this ("the fragmentation is grok's token-by-token painting, +not rune writing bytes wrongly"), and the RU reporter states it correctly throughout. But the summary +line, which is what a maintainer reads, attributes a child's behaviour to rune. The real and +reportable fact is narrower: **for a non-Latin sentinel against a token-painting TUI, ROADMAP:82's +"split by streaming token paint" row is not an occasional mechanism, it is the default one.** + +### 6.2 "`--screen` charges one column per codepoint" — **REFUTED as framed; a narrower defect CONFIRMED** + +HI's evidence reproduces byte-exactly. `D:हिन्दी\e[5GXY` really does render `D:हिXYदी`, and हिन्दी +really is charged 6 columns. But the stated mechanism is wrong, and the wrong mechanism points at +the wrong fix. + +``` +abcdef (6 ASCII) codepoints=6 rune_cols=6 +हिन्दी (6 cp, 3 clusters) codepoints=6 rune_cols=6 <- over by 1 (the virama) +بَبَبَ (6 cp, 3 clusters) codepoints=6 rune_cols=3 <- correct +日本語 (3 cp wide) codepoints=3 rune_cols=6 <- correct +é decomposed (2 cp) codepoints=2 rune_cols=1 <- correct +``` + +rune does **not** charge per codepoint. `lib/rune/parsers/character_width.rb` has a `ZERO` table, and +it works — for the scripts it covers. The actual defect is that **the table omits every Indic +combining mark**: + +``` +Arabic fatha U+064E width=0 (0x064B..0x065F covered) +Hebrew point U+05B0 width=0 (0x0591..0x05BD covered) +Thai U+0E34 width=0 (0x0E31, 0x0E34..0x0E3A covered) +Combining acute U+0301 width=0 (0x0300..0x036F covered) +Devanagari virama U+094D width=1 <- should be 0 (Mn) +Deva anusvara U+0902 width=1 <- should be 0 (Mn) +Bengali virama U+09CD width=1 <- should be 0 (Mn) +Tamil virama U+0BCD width=1 <- should be 0 (Mn) +``` + +`U+0900..U+0DFF` is absent from `ZERO` entirely. rune over-charges a Devanagari string by exactly its +count of non-spacing marks, so it wraps early and lands an absolute-column escape in the wrong cell. +The file's own comment says the table is *"a curated subset… what it misses renders one column +wide"* — so this is a known-shape gap, not an oversight in logic. The fix is data, not code. + +Two further corrections to HI's framing: + +- **The "shearing" is not solely rune's.** `CSI n G` into the middle of a grapheme cluster is + destructive in a real terminal too. rune's version differs in *where* it lands, because of the + width error — not in *that* it lands destructively. The Arabic arm shears identically + (`D:بَبَبَ\e[5GXY` → `D:بَبَXY`) while being width-correct. +- **The interleaved-ASCII example** (`"ि कितनी tarttbynreadingethedsourceुfile.ै"`) is the documented + cursor-addressed-repaint limitation, which HI themselves correctly dropped elsewhere. + +### 6.3 Previous round, Rank 5: "combining marks are charged a column each" — **REFUTED** + +The AR reporter flagged a disagreement with `RUNE_I18N_DOGFOOD.md` Rank 5 and declined to assert a +regression without seeing the earlier fixture. They were right to flag it, and they are correct. + +Re-running the prior report's **exact fixture** on this build: + +``` +w_ascii (x*100 + |END) src_cps=104 src_cols=104 END_on_row=0 +w_bare (beh*100 + |END) src_cps=104 src_cols=104 END_on_row=0 +w_marks ((beh+fatha)*100 + |END) src_cps=204 src_cols=104 END_on_row=0 <- prior report: row 1 +``` + +The prior report recorded `END` on **row 1** with row 0 holding 120 codepoints, and concluded *"a +line occupying 104 real columns is treated as 204 and split at codepoint 120."* On 0.9.0 the marked +fixture puts END on **row 0**, with row 0 carrying 204 codepoints in 104 columns. Arabic combining +marks are zero-width and the wrap point is correct. + +The half of Rank 5 that **stands**: marks are *preserved* codepoint-exact +(`0x5b 0x65 0x301 0x5d 0x5b 0x628 0x64e 0x5d …`), which does contradict +`specs/parsers/parsers.spec.md:222-225`'s claim that a decomposed `é` renders as `e`. The spec is +still wrong about the direction of failure. The half that **falls**: the column charge. + +The generalisation *"vocalised Arabic and Devanagari wrap at roughly half width"* was true of +Devanagari and false of Arabic, and the previous round did not separate them. This round did, +because an Arabic-speaking and a Hindi-speaking agent measured the same claim independently and +disagreed — which is exactly what the parallel-language design is for. + +--- + +## 7. Measured clean — negative results worth not re-running + +These cost most of the probe budget across six runs and all reproduce. Recording them so nobody +spends the budget again. + +### 7.1 Cursors rune issues never land mid-character + +Child emitting mixed 3- and 4-byte text (あ 日 ह ب н 😀) **one byte at a time** at 4 ms intervals, so +every character is guaranteed to straddle pty reads; polled every 50 ms, each returned cursor fed +straight back as `--since`: + +``` +samples=84 distinct_cursors=84 final_cursor=3410 +chunks with U+FFFD: 0 / 84 +joined U+FFFD=0 valid=true +cursors landing ON a continuation byte: 0 +reassembled == full transcript prefix: true +``` + +With 3- and 4-byte sequences and arbitrary poll boundaries, most of 84 samples would have split a +character absent a decoder. Observing zero is not luck: `lib/rune/utf8_stream_decoder.rb` holds an +incomplete trailing sequence in `@pending` until it completes. + +### 7.2 The write path is byte-exact for every script tested + +Raw-mode child reporting byte count and SHA-256 of exactly what it received: + +``` +ASCII x2000 sent=2000 child_got=2000 sha_match=true EXACT +Japanese x700 sent=2100 child_got=2100 sha_match=true EXACT +Devanagari x500 sent=9000 child_got=9000 sha_match=true EXACT +Arabic x600 sent=6000 child_got=6000 sha_match=true EXACT +Mixed + emoji sent=4500 child_got=4500 sha_match=true EXACT +``` + +Nothing rune writes is re-encoded, re-chunked or truncated — including 9 KB of Devanagari and 4-byte +emoji. The MAX_CANON ceiling is a cooked-mode tty limit, not a rune write bound. + +### 7.3 The echo detector is script-neutral + +Against a child that consumes input and prints nothing, so any match could only be the caller's own +echo: + +``` +ASCII matched=false timed_out=true elapsed=7.19s +Japanese matched=false timed_out=true elapsed=7.15s +Devanagari matched=false timed_out=true elapsed=7.20s +Arabic matched=false timed_out=true elapsed=7.20s +``` + +No arm was ever handed its own words back. `Echo.condense` operates on characters, not bytes, and +`PRINTED = /[^\s\e]+/` is script-agnostic by construction. Corroborated independently by the ja, zh, +ko and ar runs. + +Also confirmed clean across the six runs and spot-checked here: `--screen` column arithmetic for CJK +(including the wide-glyph straddle at the right margin) and for Cyrillic; `last_line` truncating by +characters rather than bytes; `--grep` with non-Latin patterns; round-tripping non-ASCII through a +non-repainting child. + +--- + +## 8. The translations + +### 8.1 Structural fidelity — verified mechanically, all six pass + +| lang | lines | fences | blocks | blocks vs EN | heading levels | U+FFFD | valid UTF-8 | +|---|---|---|---|---|---|---|---| +| EN | 328 | 40 | 20 | — | — | 0 | true | +| ja | 352 | 40 | 20 | **identical** | identical | 0 | true | +| zh-CN | 322 | 40 | 20 | **identical** | identical | 0 | true | +| ko | 231 | 40 | 20 | **identical** | identical | 0 | true | +| ru | 330 | 40 | 20 | **identical** | identical | 0 | true | +| ar | 325 | 40 | 20 | **identical** | identical | 0 | true | +| hi | 240 | 40 | 20 | **identical** | identical | 0 | true | + +All twenty code blocks are **byte-identical to the English in every one of the six files** — a +stricter result than the previous round, where two of nine had translated comment prose inside +fences. Every command, flag, path, JSON key and identifier is copy-pasteable unchanged. + +### 8.2 One objective quality defect the self-reviews missed + +The guide has exactly one intra-document anchor. Three files retargeted it to the translated +heading; **three left the English anchor pointing at a heading that no longer exists**: + +| lang | anchor | resolves | +|---|---|---| +| zh-CN | `#用-rune-watch-实时观看会话` | yes | +| ko | `#rune-watch로-세션을-실시간으로-보기` | yes | +| ar | `#متابعة-الجلسة-حياً-عبر-rune-watch` | yes | +| **ja** | `#watching-a-session-live-with-rune-watch` | **no** | +| **ru** | `#watching-a-session-live-with-rune-watch` | **no** | +| **hi** | `#watching-a-session-live-with-rune-watch` | **no** | + +Worth dwelling on: zh-CN, ko and ar each **reported catching and fixing this themselves**. ja, ru and +hi all delivered detailed native-speaker review passes — ru listed ten separate prose nits by line +number — and none of the three noticed a dead link. A structural check catches it in a second; a +prose review does not, because the anchor is invisible in rendered output until clicked. **If this +exercise is repeated, make anchor resolution a mechanical gate.** + +### 8.3 Naturalness — what the native-speaker judgements actually say + +These are self-assessments by the same agent that drove the translation, and I cannot independently +verify prose quality in six languages. What I can report is that they are **specific, falsifiable and +unanimous in direction**: all six judged the output genuinely idiomatic rather than translationese, +and every one of the six backed it with concrete evidence and then listed defects it found anyway. + +The evidence offered is the kind that is hard to fake: + +- **ru** — "solid and publishable… but there is recognisable translationese in about ten places", + naming two genuine reader-ambiguities: «долгая сборка — не простой» (*downtime* colliding with + *simple*) and «выводы» (*conclusions*) in a document that uses «вывод» for *output* throughout. +- **zh-CN** — the tell is that it "restructures rather than maps clause-for-clause": "PTY inception" + → 「PTY 套娃」, the actual Chinese idiom for nesting, not a literal 「盗梦空间」. +- **ja** — 「黙って優先順位が適用されるのではなくエラーになります」 for "an error rather than a silent + precedence", a genuinely hard construction. +- **ar** — verbal-sentence word order and idiomatic connectors (فـ / أما...فـ / إذ) "instead of the + calqued و chains machine translation produces". +- **hi** — diagnosed its own draft's two failure modes: over-Sanskritised register no working + developer speaks (वैश्विक → ग्लोबल, धारा → स्ट्रीम) and calque (लिपटी हुई कमांड for "wrapped + command", which means cloth wrapped around something). +- **ko** — the English's long subordinate chains consistently broken into separate Korean sentences + rather than transliterated in word order. + +**The finding that matters for using an agent CLI as a translator:** every single run found at least +one substantive error requiring a native speaker, and three found the *same class* of error — a +mistranslation that structural checks cannot catch and that changes the meaning: + +| lang | the phrase | what the agent produced | why it is wrong | +|---|---|---|---| +| ja | "do not diagnose quoting from it" | 「引用符の問題をこれから診断しないでください」 | "do not diagnose the quoting problem **from now on**" — loses "from *this field*" entirely | +| hi | "do not diagnose quoting from it" | कोटिंग | reads as "**coating**" | +| zh-CN | "library" | 「代码库」 | means *codebase/repository*, not a linkable library | +| hi | "not an exception" (the construct) | कोई अपवाद नहीं | reads as "no exception **to the rule**" — opposite emphasis | +| ko | `stdout가` / `stdin가` | wrong particle | 이/가 after consonant-final Latin words; 5 occurrences | + +Three of six agents independently mangled the **same sentence** — the `command`-field warning from +`CLAUDE.md` — in three different languages. That sentence is doing subtle work ("it" = this field, +not quoting in general) and it is exactly where an LLM translator drops the referent. + +So the honest summary: **the prose is better than machine-translation baseline and reads as native +technical writing, and it is not shippable without a native-speaker pass.** Both halves are the +finding. The structural gates (fences, code blocks, headings) passed 6/6 and caught none of the five +errors above; the anchor gate would have caught a sixth that no prose review found. + +--- + +## 9. Harness errors + +Collected because the failure modes repeat, and because in this round they twice came within one +control of producing a confident false finding. + +**Mine, in this verification pass:** + +1. **Read `output` where I should have read `screen`.** My first `--wait-for-regex` probe printed + `--screen` rows still containing raw SGR escapes, which briefly looked like a rendering defect. The + `read --screen` reply carries **both** `output` (raw transcript) and `screen` (rendered grid); I + printed the wrong one. `ScreenRenderer` was correct all along. This is the repo's own documented + error shape — reading a display-only field and diagnosing from it. +2. **Synthetic fixture padding drifted.** My first resync reproduction filled the 512 KB suffix by + integer division and two of six arms landed the window cut in the wrong place (visible as + `winOK=false`). The defect was real in the aligned arms, but I re-ran with exact byte arithmetic + and an explicit `winOK` assertion rather than trusting the construction. Two prior runs (ru) made + the same class of error three separate times on the same fixture. +3. **A literal U+0015 in a shell heredoc** was rejected by tool input validation. Moved the control + byte into a Ruby source escape. + +**Theirs, reported and worth propagating:** + +4. **Shared-scratchpad contamination — twice, both nearly fatal.** The `ko` run globbed + `/*.json` to audit "every cursor rune issued", picked up 172 files belonging to other + concurrent agents, and reported *"8 cursors land mid-character"* — a false finding it caught only + by noticing filenames it had never written. Separately, `ko` read another agent's Kimi session (in + Chinese) as its own grok session's `clean_output` after a generic `r1.json` filename was + overwritten between write and read; it would have filed "a spectacular cross-session-leak bug + against rune that does not exist." **Use a private, uniquely-named subdirectory.** +5. **One session reused across every case of a probe.** The `ja` run's first input-length probe + shared one `cat` session; the Japanese overflow jammed it (§4.1), so all four later ASCII cases + came back empty and the table read as *"Japanese input is dropped where ASCII survives."* A fresh + session per case showed an identical byte boundary in both scripts. The `zh` run made the same + error with a cumulative `--screen` buffer across four payloads and briefly concluded Arabic + wrapped at 120 codepoints; re-run per-session, the opposite was true. +6. **Concluding "permanent" without trying the obvious control.** `ja` reported the jammed session + "permanently mute" after only ever sending it more *text*. One control byte recovered it + instantly. Corrected before filing. +7. **Sentinel matched the agent's own reasoning trace.** Both `ja` and `zh` had `--wait-for-regex` + satisfied by the child *restating the instruction* ("User wants… then output `###ZHDONE###`"), + not by the answer. Describing a sentinel rather than typing it defeats your own echo but not the + agent's paraphrase. `ja` caught it only by reading the matched text instead of trusting + `matched: true`. +8. **Sentinel reused across turns** — `hi`, which is what surfaced §5.2. The brief said pick a + sentinel that cannot appear in your own *prompt*; it appeared in an earlier *reply*. +9. **Gutter width assumed constant.** `zh` stripped a fixed 3-column gutter when reconstructing a + wrapped prompt from `--screen`; kimi uses 2 on the first row and 4 on continuations, producing a + 419-vs-417 mismatch read as *"rune dropped 2 characters of my input."* +10. **Nearly filing a documented limitation as new.** `hi`, `ru` and `ar` each almost reported + `clean_output` collapsing inter-word spacing as a script-specific defect; the paired ASCII arm + showed identical behaviour in every case. This is the cursor-addressed-repaint limitation. +11. **Blaming rune for the harness.** A 120 s Bash tool timeout SIGTERMing the client read as "rune + hanging" (`ko`); a `ruby -e` fragment clobbering `set -- $pair` positionals produced five empty + sends and five "rune failures" (`ja`); tool-sandbox refusals read as environment problems with + rune (`ar`); `head -c 3000` on a `session read` truncating multi-byte output (`ja`), the exact + mistake the repo's harness-error list already names twice. + +The pattern across 4, 5, 9 and 11: **every one is a control the reporter had not run, not a subtlety +of rune.** The two that would have shipped as false findings were both caught by checking ground +truth on disk rather than trusting a returned field. + +--- + +## 10. Housekeeping + +All probe sessions started by this pass were stopped (14 created, 0 running at exit); everything +lived under `RUNE_HOME=/tmp/native-verify`. Probe scripts are in the session scratchpad at +`.../scratchpad/nverify/` (`resync2.rb`, `jam.rb`, `regex.rb`, `repaint.rb`, `cursor.rb`, +`input.rb`, `struct.rb`). + +The six reporting agents stopped their own sessions and confined their writes to `docs/i18n/`. +`git status` shows the six translations plus the previous round's `RUNE_I18N_DOGFOOD.md` as +untracked, and the two pre-existing `lib`/`spec` modifications noted at the top, which are not mine. + +**Recommended follow-ups, in order of how badly they bite:** + +1. `ScreenRenderer.resync` — one-line fix (`byteindex` for `index`, or slice by characters), fabricates + corruption on any >512 KB non-Latin transcript. §5.1 +2. `--wait-for-regex` re-arming on repainted scrollback — silently returns "done" mid-turn. §5.2 +3. The cooked-mode jam — document it and the U+0015 recovery at minimum; ideally surface the BELs + rune already receives. §4.1 +4. Add the Indic combining-mark ranges to `CharacterWidth::ZERO`. Data-only. §6.2 +5. Correct `specs/parsers/parsers.spec.md:222-225`, still wrong in the direction the previous round + identified, and now also wrong per §6.3. +6. Fix the session-name error message, or accept Unicode letters. §4.4 +7. Make anchor resolution a mechanical gate on translated docs. §8.2 diff --git a/docs/i18n/getting_started.ar.md b/docs/i18n/getting_started.ar.md new file mode 100644 index 0000000..c2e3a50 --- /dev/null +++ b/docs/i18n/getting_started.ar.md @@ -0,0 +1,325 @@ +> هذه ترجمة عربية لملف `docs/getting_started.md`، والنسخة الإنجليزية الأصلية هي المرجع المعتمد. + +# البدء مع rune + +`rune` أداة سطر أوامر ومكتبة مكتوبتان بلغة Ruby، صُمِّمتا ليستعملهما الإنسان من الطرفية ووكيل الذكاء +الاصطناعي الذي يقودهما برمجياً بالقدر نفسه من اليسر. كل أمر يُعيد كائن `Result` المنظَّم نفسه — وما +يتغيَّر هو *طريقة العرض* وحدها تبعاً لطريقة استدعائك له. + +## التثبيت + +اسم الجيم المجرَّد `rune` محجوز أصلاً في سجل RubyGems.org العام لحزمة لا صلة لها بالمشروع، ولذلك فإن +`gem install rune` هناك يثبِّت شيئاً آخر. أما مسار التثبيت المعتمد للمستخدم النهائي فهو الصيغة +المثبَّتة على مجموع تحقُّق في مستودع tap الخاص بـ CorvidLabs في Homebrew: + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +يضيف Homebrew هذا المستودع تلقائياً عند أول تثبيت. ولترقية Rune استعمل: + +```sh +brew upgrade corvidlabs/tap/rune +``` + +ولا تستنسخ الشيفرة المصدرية إلا إذا كنت تطوّر Rune نفسه: + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +أو بوصفه إضافة لـ [fledge](https://github.com/CorvidLabs/fledge): + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## اكتشاف ما هو متاح + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +تسرد مساعدة الأمر رايات ذلك الأمر نفسه — `--timeout=SECONDS` مع `rune run`، و`--log=PATH` مع +`rune watch` — إلى جانب الرايات العامة. وهي منظَّمة في وضع الوكيل أيضاً، فلا يحتاج الاكتشاف إلى +تحليل العرض الموجَّه للبشر: + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +وتخضع رايات المساعدة لقاعدة الفاصل نفسها التي يخضع لها كل شيء آخر (انظر أدناه): فالأمر +`rune run -- mytool --help` يمرِّر `--help` إلى `mytool`. + +## أوضاع الإخراج الثلاثة + +يختار `rune` وضع العرض تلقائياً بحسب طريقة استدعائه، أو يمكنك فرض وضع بعينه براية صريحة. والأوضاع +الثلاثة تنفِّذ منطق الأمر ذاته تماماً — الفارق في صيغة الإخراج وحدها. + +### 1. وضع الطرفية البشري (الافتراضي، طرفية تفاعلية) + +حين يكون stdout طرفيةً حقيقية ولم تُمرَّر راية `--json` أو `--ndjson`، يطبع `rune` مخرجات ملوَّنة +مهيَّأة للقراءة البشرية: + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. وضع JSON للوكلاء (`--json`، أو الكشف التلقائي عن الأنبوب) + +مرِّر `--json` صراحةً، أو اكتفِ بتوجيه مخرجات `rune` عبر أنبوب أو إعادة توجيه — فوجود stdout غير +طرفي ينقل العرض إلى JSON تلقائياً دون حاجة إلى أي راية: + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code` هو حالة خروج العملية المغلَّفة، لا حكمٌ على صحة العمل.** إنه يجيب عن سؤال "هل انتهت +> العملية، وكيف انتهت"، وهو في واجهة أوامر موجَّهة للوكلاء يساوي `0` في أغلب الأحوال — بما في ذلك +> عمليات جاءت مخرجاتها خاطئة. فقد حصل أحد المستدعين على `0` في ثماني عمليات `rune run` متتالية، +> أنتج عدد منها استنتاجات اضطر إلى تصحيحها لاحقاً. وإن أردت أن تعرف هل نجح *العمل* نفسه، فذلك يأتي +> من المخرجات لا من هذا الحقل. و`124` هو الاستثناء الجدير بالمعرفة: فهو يعني أن rune أنهى العملية +> عند انقضاء `--timeout`. + +ولكل استجابة JSON الغلاف نفسه: `{"status": "ok"|"error", "data": {...}}` (أو +`{"status": "error", "error": "..."}` عند الإخفاق). + +يكتب Rune الغلاف النهائي إلى stdout في حالتَي النجاح والإخفاق معاً. وهذا يمنح الوكلاء قناة نتائج +واحدة قابلة للتحليل، لكنه يعني أيضاً أن من يعيد توجيه stdout من البشر يعيد معه توجيه رسائل الأخطاء +الصادرة عن Rune نفسه. أما stderr فمحجوز للإعلانات التشغيلية ولتمرير `rune watch` الحي الذي يجب ألا +يفسد مخرجات stdout المنظَّمة. + +ولا يُعترف برايات الإخراج العامة إلا قبل أول فاصل `--`. أما الرموز التي تليه فهي ملك للأمر المغلَّف +وتُحفظ كما هي، فالأمر `rune run -- tool --json` يمرِّر `--json` إلى `tool`. + +وأي راية `--flag` لا يعرفها rune، إذا وردت في موضع رايات rune نفسها، تُعدّ خطأً لا شيئاً يُمرَّر +بصمت: فالأمر `rune run --tiemout=5 -- echo hi` كان فيما مضى يحاول *تنفيذ* الراية المكتوبة خطأً +ويجيب بـ `status: ok` مع `exit_code: 127`. ولا تُفحص إلا الرموز السابقة للأمر المغلَّف، فيبقى +`rune run cargo clippy --tests` و`rune run -- mytool --tiemout=5` بلا مساس — فما إن يظهر اسم الأمر +حتى تصير كل راية `--flag` بعده ملكاً له. + +### 3. وضع غلاف NDJSON للوكلاء (`--ndjson`) + +تغلِّف `--ndjson` النتيجة نفسها في غلاف `{"event": "result"|"error", ...}` بدلاً من الشكل المجرَّد +`{"status": ...}` الذي تستعمله `--json` — وهي صيغة تتوقعها بعض بيئات الوكلاء على نحو موحَّد لكل أمر، +بما في ذلك `rune run`: + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +ومع `rune run` يبقى هذا سطراً واحداً بالضبط، يُبعث بعد انتهاء الأمر — إذ يخزّن `PTYRunner` التشغيلة +كاملة في ذاكرة مؤقتة ثم يعيد `Result` واحداً، فـ `--ndjson` هنا اختيار غلاف لا بثّاً تدريجياً. وإذا +أردت تدفّق أحداث حياً فعلاً مع تقدّم أمر طويل الأمد أو تفاعلي، فانظر +[`rune watch`](#متابعة-الجلسة-حياً-عبر-rune-watch) أدناه، فهو يبعث سطر NDJSON واحداً لكل دفعة +مخرجات لحظة حدوثها. + +## تشغيل الأوامر عبر `rune run` + +يشغّل `rune run` أي أمر سطر أوامر أو واجهة نصية تفاعلية داخل PTY حقيقي، ويجرّد تسلسلات الهروب ANSI، +ويعطّل صفحات العرض، ويقيس زمن التنفيذ: + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### تجاوز المهلة + +لكل استدعاء لـ `rune run` مهلة افتراضية مقدارها ثلاثون ثانية. وتستطيع تجاوزها بـ `--timeout=SECONDS` +موضوعة *قبل* الفاصل `--` كي لا تُحسب راية تخصّ الأمر المغلَّف: + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +والأمر الذي تنقضي مهلته يعيد رمز الخروج `124` مع رسالة +`[rune] Execution timed out after N seconds` مُلحَقة بالمخرجات الملتقطة — وهو مع ذلك `Result` عادي +لا استثناء. + +**تُعاد دائماً المخرجات الملتقطة قبل الإنهاء**، فالعملية الابنة التي طبعت شيئاً ثم تعلّقت تُظهر ما +طبعته. وإذا كانت المخرجات *فارغة* فذلك يعني أن الابنة لم تطبع شيئاً حقاً، ويقول rune ذلك صراحةً مع +ذكر السبب الأكثر شيوعاً. + +**لا يمرِّر `rune run` مدخلاته القياسية إلى العملية الابنة.** فالطرفية ملك للإنسان — والاستيلاء +عليها مهمة `rune watch` — كما أن تمرير أنبوب سيعيد صدى مدخلات المستدعي عبر الـ pty إلى +`clean_output`. لذلك تنقضي مهلة `echo hi | rune run -- cat`: فـ `cat` ينتظر مدخلات لا تصل أبداً. +ضع إعادة التوجيه داخل الأمر نفسه، حيث تنفّذها الصدفة داخل الـ pty: + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +هذا يعمل، وكذلك يعمل تمرير مُوجَّه متعدد الفقرات وسيطاً واحداً — فأسطر الفصل تعبر argv سليمة. أما +حقل `command` في الرد فهو إعادة بناء *للعرض* على البشر بعد تهريب رموز الصدفة، لا ما تلقّته العملية +الابنة؛ فلا تشخّص مشكلات الاقتباس انطلاقاً منه. + +### تحديد حجم المخرجات وفصل التدفقات + +ثلاث رايات إضافية، جميعها قبل الفاصل `--`، وجميعها تغيّر *شكل* النتيجة: + +- **`--max-output=BYTES`** تحدّ `clean_output` و`raw_output` بـ BYTES لكل منهما، مبقيةً على الصدر + والذيل، وتضيف `truncated: true` مع `omitted_bytes`. ويترتب على كلمة "لكل منهما" أمران: أن الحقلين + محدودان *كلٌّ على حدة*، فهما تحت هذه الراية يصفان نافذتين مختلفتين من التشغيلة، ولا يكون + `clean_output` هو `strip_ansi(raw_output)` — فـ `omitted_bytes` هو عدّاد `clean_output`، بينما + يحمل `raw_output` علامته الخاصة بعدد مختلف. ثم إن `omitted_bytes` يُقاس بالإزاحات داخل النص + الأصلي، فيتطابق تماماً في نصوص ASCII ويحيد ببضعة بايتات في النصوص متعددة البايتات، حيث قد يشطر + القطعُ محرفاً. ويُوصل النصفان بسطر + `[rune] ==== N bytes omitted by --max-output ====` بدلاً من لصقهما، كي لا يُقرأ النص المُعاد بوصفه + شيئاً طبعه الأمر: فمن دونه أسقط سجلٌّ طوله 201 بايت عند `--max-output=200` البايت الذي حوّل + `chsh -s /bin/zsh` إلى `chsh -s bin/zsh` بالضبط. وهذه العلامة تعليقٌ من rune لا مخرجاتٌ للأمر، + فلا تُحتسب ضمن BYTES وقد يتجاوز الرد الميزانية قليلاً. +- **`--tail=N`** تبقي على آخر N سطراً فقط، وتضيف `truncated: true` مع `omitted_lines`. وهي متنافية + مع `--max-output`؛ وتمريرهما معاً خطأ لا أولوية صامتة لأحدهما. +- **`--separate-streams`** تضيف `clean_stdout` و`clean_stderr` إلى جانب `clean_output` المدمج، لا + بدلاً منه. + +ولـ `--separate-streams` كلفة حقيقية، ولهذا كانت اختيارية لا افتراضية: فللـ pty تدفق واحد، وفصل +التدفقين يعني منح stderr أنبوبه الخاص. عندئذ لا ترى العملية الابنة طرفية تحكّم واحدة لكليهما، +والبرنامج الذي يفحص `isatty(2)` سيتصرف كأن أخطاءه يُعاد توجيهها — وهو ما يعني في كثير من أدوات سطر +الأوامر إسقاط الألوان أو التحول إلى وضع غير تفاعلي بالكامل. فاستعملها حين تكون حاجتك إلى الفصل أكبر +من حاجتك إلى أن تصدّق العملية الابنة أنها على طرفية. + +## متابعة الجلسة حياً عبر `rune watch` + +يخزّن `rune run` مخرجات الأمر كاملة ولا يعيدها إلا بعد انتهائه — وهذا ممتاز للبرمجة النصية والالتقاط، +لكنه لا يصلح إن أردت فعلاً أن تجلس إلى لوحة المفاتيح وتقود برنامجاً تفاعلياً بينما يراقب الجلسةَ طرفٌ +آخر. وقد صُنع `rune watch` لهذا الغرض: يضع طرفيتك في الوضع الخام، ويمرِّر كل ضغطة مفتاح تكتبها إلى +العملية الابنة حياً — بما في ذلك تسلسلات الهروب الخام كمفاتيح الأسهم، لا الأسطر الكاملة وحدها — +ويبثّ مخرجات الابنة إلى شاشتك لحظة حدوثها (لا في النهاية)، ويسجّل في الوقت نفسه كل دفعة بوصفها حدث +NDJSON — فيستطيع وكيل ذكاء اصطناعي متابعة الجلسة آنياً بينما يقودها إنسان. + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +ويقع سجل الأحداث افتراضياً في ملف مؤقت آمن من التصادم ومقصور على مالكه (`0600`)، لا في stderr — فقد +كان خلط أحداث NDJSON بالطرفية نفسها التي يجري فيها التمرير الحي هو التصميم الأصلي، وسرعان ما أظهر +الاستعمال الواقعي أنه الافتراض الخاطئ (إذ جعل تداخلُ JSON الجلسةَ غير قابلة للقراءة). ويُعلَن المسار +مرة واحدة في البداية: + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +استعمل `tail -f` على ذلك المسار من لوح آخر (أو دع وكيلاً يتابعه) لترى الجلسة حية مع بقاء طرفيتك +نظيفة. ووجّهه إلى موضع محدد بدلاً من ذلك عبر `--log=PATH`: + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +وكل سطر في السجل كائن JSON: `{"event":"start","command":"...","pid":...}`، ثم +`{"event":"output","bytes":N,"text":"..."}` واحد لكل دفعة أثناء بثّها، ثم +`{"event":"exit","exit_code":N}` عند خروج العملية الابنة. + +### `rune watch` في وضع الوكيل + +يتبع `rune watch` قواعد أوضاع الإخراج نفسها التي يتبعها كل أمر آخر. فمع `--json` أو `--ndjson`، أو +في أي حال لا يكون فيها stdout طرفيةً، ينتقل التمرير الحي إلى **stderr** ويحمل stdout غلاف النتيجة +وحده — فيستطيع البرنامج المُغلِّف تحليل stdout مباشرة بينما يظل الإنسان الجالس إلى لوحة المفاتيح +يرى جلسته: + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +واحذف `2>/dev/null` إن أردت الاستمرار في متابعة الجلسة بنفسك بينما يُلتقط JSON في مكان آخر. + +ويتطلب `rune watch` طرفيةً حقيقية (فهو يرفض العمل إن لم يكن stdin طرفية TTY — إذ لا معنى لوضع غير +تفاعلي هنا)، ولا يعمل داخل الـ PTY المتداخل الذي ينشئه `rune run`، ولذلك يتعذر عرضه في مثال ممرَّر +عبر أنبوب كما هي حال بقية هذا الدليل. والقائمة العليا في `examples/humans/demo_tui.rb` منتقٍ حقيقي +بمفاتيح الأسهم (↑/↓ مع Enter، أو `q` للخروج) لا قائمةَ اكتب-رقماً-واضغط-Enter، والغرض من ذلك تحديداً +تمرين تمرير البايتات المفردة الخام وتسلسلات الهروب — وهو ما لا تمسّه أبداً قائمةٌ مخزَّنة سطراً +بسطر. ويحوي التعليق الافتتاحي في `examples/humans/demo_tui.rb` أوامر جاهزة للنسخ واللصق، ويبيّن +`spec/rune/pty_watcher_spec.rb` كيف تُختبر وحدوياً آليات التمرير والتسجيل الكامنة، بما في ذلك اختبار +يقود قائمة مفاتيح الأسهم نفسها من طرف إلى طرف (كائن طرفية زائف مع أنابيب `IO.pipe` يقود عملية ابنة +تفاعلية حقيقية دون الحاجة إلى طرفية تحكّم فعلية). + + +### تحديد حدود المتابعة + +حدّان مستقلان، كلاهما قبل الفاصل `--`، وكلاهما معطَّل افتراضياً: + +- **`--timeout=SECONDS`** ينهي الجلسة بعد N ثانية من الزمن الفعلي مهما بلغ انشغالها. +- **`--idle-timeout=SECONDS`** ينهيها بعد N ثانية **بلا مخرجات ولا مدخلات** — وهو ما تريده للحالة + التي "توقف فيها هذا الوكيل عن فعل أي شيء"، إذ إن البناء الطويل ليس خمولاً. + +وأيّهما وقع أعطى رمز الخروج `124` مع `timed_out: true` و`timeout_kind` بقيمة `"timeout"` أو +`"idle_timeout"` تبيّن أيّهما انطلق. +## تحليل النص المنظَّم + +يحوّل كلٌّ من `Rune::Parsers::TableParser` و`Rune::Parsers::KeyValueParser` مخرجات الطرفية غير +المنظَّمة إلى جداول تجزئة في Ruby: + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +ويقبل `TableParser.parse` كلمة مفتاحية `format:` (قيمتها `:auto` افتراضياً، أو `:pipe`/`:space` +لفرض وضع تحليل بعينه) — وانظر [`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md) +للاطلاع على القيود المعروفة في الاستدلال قبل الاعتماد على `:auto` مع مخرجات غير مألوفة. + +## الخطوات التالية + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) — عبر `ruby examples/smoke_test.rb` أو + `fledge run smoke-test`. جولة مستقلة قائمة على التوكيدات في السلوك الحقيقي (لا تحتاج إلى bundler + أو rspec): أوضاع الإخراج، والتحقق من `--timeout`، والمحلّلات، و`Script`، وتمرير الإشارات، وكشف + المُحَثّات. +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) — العرض التفاعلي المستعمل في كل + قسم `rune watch` أعلاه. أما [`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb) + و[`table_parser_example.rb`](../examples/agents/table_parser_example.rb) + و[`script_automation_example.rb`](../examples/agents/script_automation_example.rb) فبرامج أصغر + يعالج كلٌّ منها فكرة واحدة — وكلٌّ منها قابل للتشغيل مباشرة (`ruby examples/agents/.rb`) بلا + إعداد سوى `require_relative '../lib/rune'`. +- [دليل معمارية PTY](pty_architecture.md) — كيف يعمل داخلياً مشغّل PTY، وقراءة التدفقات، وكشف + المُحَثّات، والتمرير الحي في `rune watch`. +- [`specs/`](../specs/) — عقود الوحدات المفحوصة آلياً (`spec-sync`) لـ `cli` و`parsers` + و`pty_runner` و`session` و`watch`. +- [`AGENTS.md`](../AGENTS.md) — الأعراف المتّبعة في إضافة أوامر جديدة والعمل مع سلسلة أدوات الثقة. diff --git a/docs/i18n/getting_started.hi.md b/docs/i18n/getting_started.hi.md new file mode 100644 index 0000000..5491be9 --- /dev/null +++ b/docs/i18n/getting_started.hi.md @@ -0,0 +1,240 @@ +> यह पृष्ठ `docs/getting_started.md` का हिन्दी अनुवाद है। प्रामाणिक संस्करण अंग्रेज़ी वाला ही है; किसी भी अंतर की स्थिति में उसी को मान्य माना जाए। + +# rune के साथ शुरुआत + +`rune` एक Ruby CLI और लाइब्रेरी है, जिसे इस तरह बनाया गया है कि टर्मिनल पर बैठा इंसान और उसे प्रोग्राम से चलाने वाला AI एजेंट, दोनों इसे बराबर सहजता से इस्तेमाल कर सकें। हर कमांड वही संरचित `Result` लौटाता है — बदलता सिर्फ़ *प्रस्तुतीकरण* है, इस आधार पर कि आप उसे कैसे बुला रहे हैं। + +## इंस्टॉल करना + +सार्वजनिक RubyGems.org रजिस्ट्री पर सादा `rune` नाम पहले से ही किसी असंबंधित पैकेज के पास है, इसलिए वहाँ से `gem install rune` चलाने पर ग़लत चीज़ इंस्टॉल हो जाती है। आम उपयोगकर्ता के लिए समर्थित रास्ता CorvidLabs के Homebrew tap में रखा, चेकसम से पिन किया हुआ फ़ॉर्मूला है: + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +पहली बार इंस्टॉल करते समय Homebrew tap अपने आप जोड़ लेता है। Rune को अपग्रेड करने के लिए: + +```sh +brew upgrade corvidlabs/tap/rune +``` + +सोर्स क्लोन तभी करें जब आप ख़ुद Rune पर काम कर रहे हों: + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +या फिर [fledge](https://github.com/CorvidLabs/fledge) प्लगइन के रूप में: + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## उपलब्ध सुविधाएँ कैसे खोजें + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +किसी कमांड की help उस कमांड के अपने फ़्लैग गिनाती है — जैसे `rune run` के लिए `--timeout=SECONDS` और `rune watch` के लिए `--log=PATH` — साथ में ग्लोबल फ़्लैग भी। एजेंट मोड में भी यह संरचित रूप में आती है, इसलिए जानकारी पाने के लिए मनुष्यों वाले आउटपुट को पार्स करने की ज़रूरत नहीं पड़ती: + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +help वाले फ़्लैग पर भी वही सेपरेटर नियम लागू होता है जो बाक़ी सब पर (नीचे देखें): `rune run -- mytool --help` चलाने पर `--help` सीधे `mytool` को चला जाता है। + +## तीन आउटपुट मोड + +`rune` यह अपने आप तय कर लेता है कि किस रूप में आउटपुट देना है — यह इस बात पर निर्भर करता है कि उसे कैसे बुलाया गया — या आप फ़्लैग देकर किसी एक मोड को ज़बरदस्ती चुन सकते हैं। तीनों मोड में कमांड का लॉजिक बिल्कुल एक जैसा चलता है; फ़र्क़ सिर्फ़ आउटपुट के प्रारूप का है। + +### 1. Human TTY मोड (डिफ़ॉल्ट, इंटरैक्टिव टर्मिनल) + +जब stdout एक असली टर्मिनल हो और `--json`/`--ndjson` में से कोई फ़्लैग न दिया गया हो, तब `rune` रंगीन, मनुष्यों के पढ़ने लायक़ आउटपुट छापता है: + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. Agent JSON मोड (`--json`, या पाइप का स्वतः पता लगना) + +या तो `--json` साफ़-साफ़ दीजिए, या फिर `rune` का आउटपुट पाइप/रीडायरेक्ट कर दीजिए — stdout के TTY न होने पर रेंडरिंग अपने आप JSON में बदल जाती है, किसी फ़्लैग की ज़रूरत नहीं: + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code` रैप की गई प्रक्रिया का एग्ज़िट स्टेटस है, काम की गुणवत्ता पर फ़ैसला नहीं।** यह सिर्फ़ इतना बताता है कि "प्रक्रिया ख़त्म हुई या नहीं, और किस तरह" — और एजेंट CLI के मामले में यह लगभग हमेशा `0` ही रहता है, उन रनों में भी जिनका आउटपुट ग़लत था। एक उपयोगकर्ता के यहाँ लगातार आठ `rune run` डिस्पैच `0` लौटाए, जिनमें से कई ऐसे नतीजों पर पहुँचे जिन्हें बाद में सुधारना पड़ा। अगर आपको यह जानना है कि *काम* सफल हुआ या नहीं, तो वह आउटपुट से पता चलेगा, इस फ़ील्ड से नहीं। `124` इसका उल्लेखनीय अपवाद है: इसका मतलब है कि `--timeout` लगने पर rune ने प्रक्रिया को मार दिया। + +हर JSON जवाब का लिफ़ाफ़ा एक जैसा होता है: `{"status": "ok"|"error", "data": {...}}` (या विफलता पर `{"status": "error", "error": "..."}`)। + +सफलता हो या विफलता, Rune आख़िरी लिफ़ाफ़ा stdout पर ही लिखता है। इससे एजेंटों को नतीजे के लिए एक ही पार्स करने योग्य चैनल मिल जाता है, पर इसका मतलब यह भी है कि stdout को रीडायरेक्ट करने वाले इंसान के साथ Rune के अपने त्रुटि-संदेश भी रीडायरेक्ट हो जाएँगे। stderr को संचालन संबंधी सूचनाओं और `rune watch` के लाइव पासथ्रू के लिए बचाकर रखा गया है, ताकि संरचित stdout ख़राब न हो। + +ग्लोबल आउटपुट फ़्लैग सिर्फ़ पहले `--` सेपरेटर से पहले ही पहचाने जाते हैं। उसके बाद के टोकन रैप की गई कमांड के होते हैं और ज्यों के त्यों रहते हैं, इसलिए `rune run -- tool --json` में `--json` सीधे `tool` को मिलता है। + +जिस जगह rune के अपने फ़्लैग आते हैं, वहाँ कोई ऐसा `--flag` मिले जिसे rune नहीं जानता, तो उसे चुपचाप आगे बढ़ाने के बजाय त्रुटि माना जाता है: पहले `rune run --tiemout=5 -- echo hi` उस ग़लत लिखे फ़्लैग को ही *चलाने* की कोशिश करता था और `exit_code: 127` के साथ `status: ok` लौटा देता था। जाँच केवल रैप की गई कमांड से पहले वाले टोकनों की होती है, इसलिए `rune run cargo clippy --tests` और `rune run -- mytool --tiemout=5` अछूते रहते हैं — कमांड का नाम दिख जाने के बाद हर आगे वाला `--flag` उसी का माना जाता है। + +### 3. Agent NDJSON लिफ़ाफ़ा मोड (`--ndjson`) + +`--ndjson` वही नतीजा `{"event": "result"|"error", ...}` लिफ़ाफ़े में लपेटकर देता है, न कि सादे `{"status": ...}` ढाँचे में जो `--json` इस्तेमाल करता है — कुछ एजेंट हार्नेस हर कमांड के लिए, `rune run` समेत, यही प्रारूप एक-समान चाहते हैं: + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +`rune run` के लिए यह अब भी ठीक एक ही पंक्ति होती है, जो कमांड पूरा होने पर निकलती है — `PTYRunner` पूरे रन को बफ़र करके एक ही `Result` लौटाता है, इसलिए यहाँ `--ndjson` लिफ़ाफ़े का चुनाव भर है, टुकड़ों में स्ट्रीमिंग नहीं। किसी लंबे चलने वाले या इंटरैक्टिव कमांड के दौरान असल में लाइव इवेंट स्ट्रीम चाहिए तो नीचे [`rune watch`](#watching-a-session-live-with-rune-watch) देखिए, जो हर आउटपुट खंड के लिए, जैसे-जैसे वह आता है, एक NDJSON पंक्ति भेजता है। + +## `rune run` से कमांड चलाना + +`rune run` किसी भी CLI कमांड या इंटरैक्टिव TUI को असली PTY के भीतर चलाता है, ANSI एस्केप अनुक्रम हटा देता है, पेजर बंद कर देता है, और चलने में लगा समय नापता है: + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### टाइमआउट बदलना + +हर `rune run` पर डिफ़ॉल्ट रूप से 30 सेकंड का टाइमआउट लगा होता है। इसे `--timeout=SECONDS` से बदला जा सकता है, जिसे `--` सेपरेटर से *पहले* रखना ज़रूरी है, वरना उसे रैप की गई कमांड का फ़्लैग समझ लिया जाएगा: + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +टाइमआउट होने पर कमांड `124` एग्ज़िट कोड लौटाती है और पकड़े गए आउटपुट के अंत में `[rune] Execution timed out after N seconds` संदेश जुड़ जाता है — यह तब भी सामान्य `Result` ही है, कोई एक्सेप्शन नहीं। + +**प्रक्रिया मारे जाने से पहले जो आउटपुट पकड़ा जा चुका था, वह हमेशा लौटाया जाता है**, इसलिए अगर कोई चाइल्ड कुछ छापकर अटक गया हो तो जो छापा था वह दिख जाता है। अगर आउटपुट *ख़ाली* है, तो इसका मतलब है कि चाइल्ड ने सचमुच कुछ नहीं छापा, और rune यह बात इसके सबसे आम कारण के साथ बता देता है। + +**`rune run` अपना stdin चाइल्ड को आगे नहीं भेजता।** tty इंसान की चीज़ है — उसे लेना `rune watch` का काम है — और पाइप को आगे भेजने पर बुलाने वाले का अपना इनपुट pty के रास्ते वापस गूँजकर `clean_output` में आ जाएगा। इसीलिए `echo hi | rune run -- cat` टाइमआउट हो जाता है: `cat` ऐसे इनपुट का इंतज़ार करता रहता है जो कभी आता ही नहीं। इसके बजाय रीडायरेक्ट को कमांड के भीतर रखिए, जहाँ शेल उसे pty के अंदर ही निभा देता है: + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +यह चल जाता है, और कई पैराग्राफ़ वाला प्रॉम्प्ट एक ही आर्गुमेंट के रूप में देना भी चलता है — नई पंक्तियाँ argv में ज्यों की त्यों बची रहती हैं। जवाब में आने वाला `command` फ़ील्ड इंसानों के लिए शेल-एस्केप किया हुआ *प्रदर्शन* भर है, वह नहीं जो चाइल्ड को असल में मिला; उससे क्वोटिंग की जाँच-पड़ताल मत कीजिए। + +### आउटपुट को सीमित करना, और स्ट्रीम अलग करना + +तीन और फ़्लैग, तीनों `--` सेपरेटर से पहले, और तीनों नतीजे की *बनावट* बदलते हैं: + +- **`--max-output=BYTES`** `clean_output` और `raw_output` दोनों को अलग-अलग BYTES तक सीमित करता है, शुरुआत और अंत का हिस्सा बचाए रखता है, और `omitted_bytes` के साथ `truncated: true` जोड़ देता है। "दोनों को अलग-अलग" से दो बातें निकलती हैं: फ़ील्ड *स्वतंत्र रूप से* सीमित होते हैं, इसलिए इस फ़्लैग के साथ वे रन के अलग-अलग हिस्से दिखाते हैं और `clean_output` अब `strip_ansi(raw_output)` नहीं रहता — `omitted_bytes` `clean_output` की गिनती है, जबकि `raw_output` अपना अलग मार्कर अपनी अलग गिनती के साथ लेकर चलता है। दूसरी बात, `omitted_bytes` मूल पाठ में ऑफ़सेट के हिसाब से नापा जाता है, इसलिए ASCII पर हिसाब ठीक बैठता है पर बहु-बाइट पाठ में कुछ बाइट का अंतर आ सकता है, जहाँ कटाव किसी अक्षर को बीच से काट सकता है। दोनों हिस्सों को सीधे जोड़ने के बजाय बीच में `[rune] ==== N bytes omitted by --max-output ====` पंक्ति डाली जाती है, ताकि लौटाया गया पाठ कभी ऐसा न लगे मानो कमांड ने ही वह छापा हो: इसके बिना, `--max-output=200` पर 201 बाइट के एक ट्रांसक्रिप्ट से ठीक वही बाइट उड़ गई थी जो `chsh -s /bin/zsh` को `chsh -s bin/zsh` बना देती है। यह मार्कर rune की अपनी टिप्पणी है, कमांड का आउटपुट नहीं, इसलिए इसे BYTES में नहीं गिना जाता और जवाब तय सीमा से थोड़ा ऊपर जा सकता है। +- **`--tail=N`** सिर्फ़ आख़िरी N पंक्तियाँ रखता है, और `omitted_lines` के साथ `truncated: true` जोड़ता है। यह `--max-output` के साथ परस्पर अनन्य है; दोनों एक साथ देना चुपचाप किसी एक को वरीयता देने के बजाय त्रुटि मानी जाती है। +- **`--separate-streams`** मिले-जुले `clean_output` को हटाता नहीं, बल्कि उसके साथ `clean_stdout` और `clean_stderr` भी जोड़ देता है। + +`--separate-streams` की एक असली क़ीमत है, और इसीलिए यह डिफ़ॉल्ट न होकर वैकल्पिक है: pty में स्ट्रीम एक ही होती है, इसलिए उन्हें अलग करने का मतलब है stderr को अपना अलग पाइप देना। तब चाइल्ड को दोनों के लिए एक ही नियंत्रक टर्मिनल नहीं दिखता, और `isatty(2)` जाँचने वाला प्रोग्राम ऐसे बर्ताव करेगा मानो उसकी त्रुटियाँ कहीं रीडायरेक्ट हो रही हों — कई CLI के लिए इसका मतलब है रंग छोड़ देना, या पूरी तरह ग़ैर-इंटरैक्टिव मोड में चले जाना। इसे तभी इस्तेमाल कीजिए जब आपको यह बँटवारा चाहिए ही, बनिस्बत इसके कि चाइल्ड यह समझता रहे कि वह टर्मिनल पर है। + +## `rune watch` से सत्र को लाइव देखना + +`rune run` कमांड का पूरा आउटपुट बफ़र करता है और तभी लौटाता है जब कमांड ख़त्म हो जाए — स्क्रिप्टिंग और रिकॉर्ड रखने के लिए बढ़िया, पर तब बेकार जब आप ख़ुद कीबोर्ड पर बैठकर किसी इंटरैक्टिव प्रोग्राम को चलाना चाहते हों और साथ-साथ कोई और उस सत्र को देखता रहे। `rune watch` इसी के लिए बना है: यह आपके टर्मिनल को raw mode में डालता है, आपका हर कीस्ट्रोक तुरंत चाइल्ड तक पहुँचाता है — पूरी पंक्तियाँ ही नहीं, बल्कि ऐरो कीज़ जैसे कच्चे escape sequence भी — चाइल्ड का आउटपुट जैसे-जैसे आता है वैसे-वैसे आपकी स्क्रीन पर दिखाता है (अंत में नहीं), और साथ ही हर खंड को NDJSON इवेंट के रूप में लॉग करता जाता है — यानी इंसान सत्र चला रहा हो, उसी वक़्त कोई AI एजेंट उसे रीयल टाइम में पढ़ सकता है। + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +इवेंट लॉग डिफ़ॉल्ट रूप से एक ऐसी अस्थायी फ़ाइल में जाता है जिसका नाम टकराता नहीं और जिसे सिर्फ़ मालिक पढ़ सकता है (`0600`) — stderr पर नहीं। शुरुआती डिज़ाइन में NDJSON इवेंट उसी टर्मिनल में लाइव पासथ्रू के साथ मिला दिए जाते थे, और असल इस्तेमाल ने तुरंत दिखा दिया कि यह ग़लत डिफ़ॉल्ट था (बीच-बीच में घुसा JSON सत्र को पढ़ने लायक़ ही नहीं छोड़ता था)। पथ शुरू में एक बार बता दिया जाता है: + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +दूसरे पेन से उस पथ पर `tail -f` चलाइए (या किसी एजेंट से चलवाइए) और सत्र लाइव देखते रहिए, जबकि आपका अपना टर्मिनल साफ़ बना रहेगा। किसी तय जगह लॉग चाहिए तो `--log=PATH` दीजिए: + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +लॉग की हर पंक्ति एक JSON ऑब्जेक्ट होती है: पहले `{"event":"start","command":"...","pid":...}`, फिर स्ट्रीम होते हर खंड के लिए एक `{"event":"output","bytes":N,"text":"..."}`, और चाइल्ड के बाहर निकलते ही `{"event":"exit","exit_code":N}`। + +### एजेंट मोड में `rune watch` + +`rune watch` पर आउटपुट-मोड के वही नियम लागू होते हैं जो बाक़ी हर कमांड पर। `--json`, `--ndjson` के साथ, या जब भी stdout टर्मिनल न हो, लाइव पासथ्रू **stderr** पर चला जाता है और stdout पर सिर्फ़ नतीजे का लिफ़ाफ़ा रहता है — इससे ऊपर बैठा प्रोग्राम stdout को सीधे पार्स कर सकता है और कीबोर्ड पर बैठे इंसान को अपना सत्र दिखता रहता है: + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +`2>/dev/null` हटा दीजिए, तो JSON कहीं और पकड़ा जाता रहेगा और आप ख़ुद सत्र देखते रह सकेंगे। + +`rune watch` को असली टर्मिनल चाहिए (stdin अगर TTY न हो तो यह चलने से मना कर देता है — इसका कोई सार्थक ग़ैर-इंटरैक्टिव रूप है ही नहीं) और यह `rune run` के अपने PTY के भीतर PTY वाले ढाँचे में काम नहीं करेगा, इसलिए इसे इस गाइड के बाक़ी हिस्सों की तरह पाइप वाले उदाहरण से दिखाया नहीं जा सकता। `examples/humans/demo_tui.rb` का सबसे ऊपरी मेन्यू जान-बूझकर नंबर टाइप करके Enter दबाने वाला नहीं, बल्कि असली ऐरो-की वाला चयनकर्ता है (↑/↓ + Enter, या बाहर निकलने के लिए `q`), ताकि कच्चे सिंगल-बाइट और escape sequence फ़ॉरवर्डिंग की परख हो सके — वह चीज़ जिसे केवल पंक्ति-दर-पंक्ति चलने वाला मेन्यू कभी छूता ही नहीं। `examples/humans/demo_tui.rb` की अपनी हेडर टिप्पणी में कॉपी-पेस्ट करने लायक़ कमांड दिए हैं, और `spec/rune/pty_watcher_spec.rb` दिखाता है कि नीचे चल रही फ़ॉरवर्डिंग/लॉगिंग की कार्यविधि की यूनिट टेस्टिंग कैसे होती है — इसमें वह टेस्ट भी शामिल है जो ऐरो-की मेन्यू को शुरू से आख़िर तक ख़ुद चलाकर देखता है (एक नक़ली टर्मिनल ऑब्जेक्ट और `IO.pipe` मिलकर असली इंटरैक्टिव चाइल्ड प्रोसेस चला देते हैं, बिना किसी सचमुच के नियंत्रक टर्मिनल के)। + + +### watch पर सीमाएँ लगाना + +दो स्वतंत्र सीमाएँ, दोनों `--` सेपरेटर से पहले, और दोनों डिफ़ॉल्ट रूप से बंद: + +- **`--timeout=SECONDS`** सत्र कितना भी व्यस्त क्यों न हो, N सेकंड बीतते ही उसे ख़त्म कर देता है। +- **`--idle-timeout=SECONDS`** उसे तब ख़त्म करता है जब N सेकंड तक **न कोई आउटपुट आए और न कोई इनपुट जाए** — "इस एजेंट ने कुछ भी करना बंद कर दिया है" वाली स्थिति के लिए यही सही विकल्प है, क्योंकि लंबा चलता बिल्ड निष्क्रिय नहीं कहलाता। + +दोनों में से कोई भी लगे तो एग्ज़िट कोड `124` मिलता है, साथ में `timed_out: true` और `timeout_kind` में `"timeout"` या `"idle_timeout"`, जो बताता है कि कौन-सी सीमा लगी। + +## संरचित पाठ को पार्स करना + +`Rune::Parsers::TableParser` और `Rune::Parsers::KeyValueParser` टर्मिनल के बेतरतीब आउटपुट को Ruby हैश में बदल देते हैं: + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +`TableParser.parse` एक `format:` कीवर्ड लेता है (डिफ़ॉल्ट `:auto`, या पार्सिंग मोड तय करने के लिए `:pipe`/`:space`) — अनजाने आउटपुट पर `:auto` भरोसे चलने से पहले [`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md) में इस ह्यूरिस्टिक की ज्ञात सीमाएँ ज़रूर देख लीजिए। + +## आगे क्या पढ़ें + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) — `ruby examples/smoke_test.rb` या `fledge + run smoke-test` से चलाइए। यह असली बर्ताव की एक स्वतंत्र, assertion-आधारित सैर है (bundler/rspec की ज़रूरत नहीं): आउटपुट मोड, `--timeout` की जाँच, पार्सर, `Script`, सिग्नल फ़ॉरवर्डिंग और प्रॉम्प्ट पहचान। +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) — वही इंटरैक्टिव डेमो जो ऊपर `rune watch` + वाले भाग में बार-बार इस्तेमाल हुआ है। [`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb), + [`table_parser_example.rb`](../examples/agents/table_parser_example.rb), और + [`script_automation_example.rb`](../examples/agents/script_automation_example.rb) छोटी, एक-एक विषय पर केंद्रित + स्क्रिप्ट हैं — हर एक सीधे चलाई जा सकती है (`ruby examples/agents/.rb`), और `require_relative '../lib/rune'` + के अलावा कोई तैयारी नहीं चाहिए। +- [PTY Architecture Guide](pty_architecture.md) — भीतर से यह कैसे काम करता है: PTY रनर, स्ट्रीम पढ़ना, प्रॉम्प्ट + पहचान, और `rune watch` का लाइव पासथ्रू। +- [`specs/`](../specs/) — `cli`, `parsers`, `pty_runner`, `session` और `watch` के लिए मशीन से जाँचे जाने वाले + मॉड्यूल कॉन्ट्रैक्ट (`spec-sync`)। +- [`AGENTS.md`](../AGENTS.md) — नए कमांड जोड़ने और ट्रस्ट टूलचेन के साथ काम करने की तौर-तरीक़े। diff --git a/docs/i18n/getting_started.ja.md b/docs/i18n/getting_started.ja.md new file mode 100644 index 0000000..8a8b80f --- /dev/null +++ b/docs/i18n/getting_started.ja.md @@ -0,0 +1,352 @@ +> この文書は `docs/getting_started.md` の日本語訳です。内容に相違がある場合は、英語版を正文とします。 + +# rune をはじめる + +`rune` は Ruby 製の CLI およびライブラリで、ターミナル上の人間からも、プログラム的に操作する AI +エージェントからも等しく使いやすいよう設計されています。すべてのコマンドは同じ構造化された `Result` +を返します。呼び出し方によって変わるのはその*レンダリング*だけです。 + +## インストール + +`rune` という修飾なしの gem 名は、公開レジストリ RubyGems.org 上ですでに無関係のパッケージに +取得されているため、そこで `gem install rune` を実行すると別物がインストールされてしまいます。 +サポートされているエンドユーザー向けのインストール方法は、CorvidLabs の Homebrew tap にある +チェックサム固定の formula です。 + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +初回インストール時に Homebrew が自動的に tap を追加します。Rune のアップグレードは次のように行います。 + +```sh +brew upgrade corvidlabs/tap/rune +``` + +ソースのクローンは Rune 自体を開発する場合のみ行ってください。 + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +あるいは [fledge](https://github.com/CorvidLabs/fledge) のプラグインとしても利用できます。 + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## 利用可能な機能の調べ方 + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +コマンドのヘルプには、グローバルなフラグと並んで、そのコマンド固有のフラグ(`rune run` なら +`--timeout=SECONDS`、`rune watch` なら `--log=PATH`)が一覧表示されます。エージェントモードでも +構造化されているため、人間向けのレンダリングを解析しなくても機能を調べられます。 + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +ヘルプ用のフラグも他のすべてと同じセパレータの規則(後述)に従います。 +`rune run -- mytool --help` とすると `--help` は `mytool` に渡されます。 + +## 3 つの出力モード + +`rune` は呼び出され方に応じてレンダリングモードを自動的に選択します。フラグを指定して明示的に +固定することもできます。どのモードでも実行されるコマンドのロジックはまったく同じで、異なるのは +出力形式だけです。 + +### 1. 人間向け TTY モード(デフォルト、対話型ターミナル) + +stdout が実際のターミナルで、`--json`/`--ndjson` フラグが指定されていない場合、`rune` は +色付きの人間向けフォーマットで出力します。 + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. エージェント向け JSON モード(`--json`、またはパイプの自動検出) + +`--json` を明示的に渡すか、単に `rune` の出力をパイプまたはリダイレクトしてください。stdout が +TTY でない場合、フラグなしでレンダリングが自動的に JSON に切り替わります。 + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code` はラップされたプロセスの終了ステータスであり、作業の成否を示すものではありません。** +> これが答えるのは「プロセスが終了したか、どのように終了したか」で、エージェント CLI ではほぼ +> 常に `0` になります。出力内容が誤っていた実行でも同様です。ある呼び出し側では `rune run` の +> ディスパッチが 8 回連続で `0` を返しましたが、そのうち複数は後で訂正が必要な結論を出していました。 +> *作業*が成功したかどうかを知りたい場合は、このフィールドではなく出力から判断する必要があります。 +> 知っておくべき例外は `124` です。これは rune が `--timeout` によってプロセスを強制終了したことを +> 意味します。 + +すべての JSON レスポンスは同じエンベロープ構造を持ちます。`{"status": "ok"|"error", "data": {...}}` +(失敗時は `{"status": "error", "error": "..."}`)です。 + +Rune は成功時・失敗時のどちらでも最終的なエンベロープを stdout に書き出します。これにより +エージェントは解析可能な結果チャネルを 1 つだけ見ればよくなりますが、同時に、人間が stdout を +リダイレクトすると Rune レベルのエラーメッセージも一緒にリダイレクトされることになります。 +stderr は運用上の告知や、構造化された stdout を壊してはならない `rune watch` のライブ +パススルーのために予約されています。 + +グローバルな出力フラグは最初の `--` セパレータより前でのみ認識されます。それ以降のトークンは +ラップされたコマンドのものとしてそのまま保持されるため、`rune run -- tool --json` は `--json` を +`tool` に渡します。 + +rune 自身のフラグが置かれるべき位置に、rune の知らない `--flag` があると、黙って後続へ渡すのでは +なくエラーになります。以前のバージョンでは、`rune run --tiemout=5 -- echo hi` はスペルミスした +フラグを*実行*しようとして、`exit_code: 127` で `status: ok` を返していました。チェック対象は +ラップされるコマンドより前のトークンだけなので、`rune run cargo clippy --tests` や +`rune run -- mytool --tiemout=5` は影響を受けません。コマンド名が現れた以降の `--flag` はすべて +そのコマンドのものとみなされます。 + +### 3. エージェント向け NDJSON エンベロープモード(`--ndjson`) + +`--ndjson` は、`--json` が使う素の `{"status": ...}` 形式の代わりに、同じ結果を +`{"event": "result"|"error", ...}` というエンベロープで包みます。これは、一部のエージェント +ハーネスが `rune run` を含むすべてのコマンドについて一律に期待する形式です。 + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +`rune run` では、これは依然としてコマンド終了時に一度だけ出力される 1 行です。`PTYRunner` は +実行全体をバッファして単一の `Result` を返すので、ここでの `--ndjson` はエンベロープの選択で +あって、逐次ストリーミングではありません。長時間実行されるコマンドや対話型コマンドの進行に +応じた実際のライブイベントストリームについては、後述の +[`rune watch`](#watching-a-session-live-with-rune-watch) を参照してください。こちらは出力 +チャンクが発生するたびに 1 行の NDJSON を出力します。 + +## `rune run` でコマンドを実行する + +`rune run` は任意の CLI コマンドや対話型 TUI を実際の PTY 内で起動し、ANSI エスケープ +シーケンスを除去し、ページャを無効化し、実行時間を計測します。 + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### タイムアウトの上書き + +すべての `rune run` 呼び出しにはデフォルトで 30 秒のタイムアウトがあります。上書きするには +`--timeout=SECONDS` を使います。ラップされるコマンドのフラグと取り違えられないよう、 +`--` セパレータの*前*に置いてください。 + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +タイムアウトしたコマンドは終了コード `124` を返し、捕捉された出力の末尾に +`[rune] Execution timed out after N seconds` というメッセージが付きます。これは例外ではなく、 +通常の `Result` です。 + +**強制終了の前に捕捉された出力は必ず返されます。** そのため、出力してからハングした子プロセス +でも、出力した内容を確認できます。出力が*空*の場合は、子プロセスが本当に何も出力していない +ということであり、rune はその旨を最もよくある原因とあわせて伝えます。 + +**`rune run` は自身の stdin を子プロセスに転送しません。** tty は人間のものであり、それを +引き受けるのは `rune watch` の役目です。また、パイプを転送すると呼び出し側自身の入力が pty +経由でエコーされ、`clean_output` に混ざってしまいます。そのため `echo hi | rune run -- cat` は +タイムアウトします。`cat` が決して届かない入力を待ち続けるからです。代わりに、シェルが pty 内で +リダイレクトを実行するように、コマンドの内側に書いてください。 + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +これはうまく動きます。複数段落のプロンプトを 1 つの引数として渡すのも同様で、改行は argv 経由で +そのまま残ります。なお、応答の `command` フィールドは人間向けにシェルエスケープされた*表示用*の +再構成であり、子プロセスが実際に受け取ったものではありません。このフィールドを根拠にクォートの +扱いを判断しないでください。 + +### 出力の制限とストリームの分離 + +以下の 3 つのフラグは、いずれも `--` セパレータの前に置き、いずれも結果の*形*を変えます。 + +- **`--max-output=BYTES`** は `clean_output` と `raw_output` をそれぞれ BYTES バイト以内に + 制限し、先頭部分と末尾部分を残して、`omitted_bytes` 付きの `truncated: true` を追加します。 + 「それぞれ」からは 2 つのことが帰結します。1 つは、各フィールドが*個別に*制限されるため、 + このフラグの下では両者が実行の異なる区間を表し、`clean_output` は `strip_ansi(raw_output)` + ではなくなること。`omitted_bytes` は `clean_output` 側のカウントであり、`raw_output` は別の + 値を持つ独自のマーカーを含みます。もう 1 つは、`omitted_bytes` は元データへのオフセットで + 測られるため、ASCII では正確に一致しますが、マルチバイト文字列では切断位置が文字の途中に + なることがあり、数バイトずれることです。先頭と末尾の 2 つの部分は単純に接合されるのではなく + `[rune] ==== N bytes omitted by --max-output ====` という行でつながれるため、返されるテキストが + コマンドの出力であるかのように読めることはありません。この行がなければ、201 バイトの + トランスクリプトを `--max-output=200` で処理した場合に、`chsh -s /bin/zsh` を + `chsh -s bin/zsh` に変えてしまう 1 バイトがちょうど欠落していました。このマーカーはコマンドの + 出力ではなく rune による注釈なので、BYTES の計上対象にはならず、応答は予算をわずかに超える + ことがあります。 +- **`--tail=N`** は末尾 N 行のみを残し、`omitted_lines` 付きの `truncated: true` を追加します。 + `--max-output` とは相互排他で、両方を渡すと黙って優先順位が適用されるのではなくエラーに + なります。 +- **`--separate-streams`** はマージ済みの `clean_output` を置き換えるのではなく、それに加えて + `clean_stdout` と `clean_stderr` を追加します。 + +`--separate-streams` には実際のコストがあるため、デフォルトではなくオプトインになっています。 +pty のストリームは 1 本なので、分離するには stderr に独自のパイプを与える必要があります。 +すると子プロセスからは、もはや両方を受け持つ単一の制御端末が見えなくなり、`isatty(2)` を +チェックするプログラムはエラー出力がリダイレクトされているかのように振る舞います。多くの +CLI では、それはカラー表示をやめるか、完全に非対話モードに切り替わることを意味します。 +子プロセスに端末上で動いていると思わせることよりも、ストリームの分離が必要な場合に使って +ください。 + +## `rune watch` でセッションをライブ観察する + +`rune run` はコマンドの出力全体をバッファし、コマンドが終了してから初めてそれを返します。 +スクリプト化やキャプチャには最適ですが、実際にキーボードの前に座って対話型プログラムを操作し、 +その間セッションを別の何かに観察させたい場合には向きません。そのために作られたのが +`rune watch` です。ターミナルを raw モードにし、打ち込んだすべてのキーストロークを(行単位 +ではなく、矢印キーのような生のエスケープシーケンスを含めて)リアルタイムで子プロセスに転送し、 +子プロセスの出力を発生したその場で(最後ではなく)画面に流し、同時にすべてのチャンクを NDJSON +イベントとしてログに記録します。これにより、人間がセッションを操作している間、AI エージェントが +それをリアルタイムで tail できます。 + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +イベントログのデフォルトは衝突に安全で所有者のみがアクセスできる(`0600`)一時ファイルで、 +stderr ではありません。ライブパススルーと同じターミナルに NDJSON イベントを混ぜるのが当初の +設計でしたが、実際に使ってみるとそれが誤ったデフォルトだとすぐに分かりました(JSON が混ざり込む +せいでセッションが読めなくなったのです)。パスは開始時に一度だけ告知されます。 + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +別のペインからそのパスを `tail -f` する(あるいはエージェントに tail させる)と、自分の +ターミナルをクリーンに保ったままセッションをライブ観察できます。特定の場所に出力したい場合は +`--log=PATH` を使います。 + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +ログの各行は JSON オブジェクトです。まず `{"event":"start","command":"...","pid":...}`、 +次にストリームされるチャンクごとに `{"event":"output","bytes":N,"text":"..."}`、 +最後に子プロセスの終了時に `{"event":"exit","exit_code":N}` となります。 + +### エージェントモードでの `rune watch` + +`rune watch` は他のすべてのコマンドと同じ出力モードの規則に従います。`--json` や `--ndjson` +の指定時、あるいは stdout がターミナルでない場合は常に、ライブパススルーは **stderr** に移り、 +stdout には結果のエンベロープだけが載ります。そのため、ラップするプログラムは stdout を直接 +解析でき、キーボードの前の人間は引き続き自分のセッションを見ることができます。 + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +JSON を別の場所でキャプチャしながら自分でもセッションを見続けたい場合は、`2>/dev/null` を +外してください。 + +`rune watch` には実際のターミナルが必要です(stdin が TTY でない場合は実行を拒否します。 +意味のある非対話モードが存在しないためです)。また、`rune run` 自身の PTY 入れ子環境では +動作しないため、このガイドの他の部分のようにパイプ経由の例で実演することはできません。 +`examples/humans/demo_tui.rb` のトップレベルメニューは、数字を打って Enter を押す方式ではなく、 +本物の矢印キーセレクタ(↑/↓ + Enter、終了は `q`)になっています。これは、純粋に行バッファの +メニューでは決して触れることのない、生の 1 バイト入力とエスケープシーケンスの転送を実際に +試すためのものです。`examples/humans/demo_tui.rb` 自身のヘッダコメントにコピー&ペースト可能な +コマンドがあり、`spec/rune/pty_watcher_spec.rb` には基盤となる転送・ログ機構のユニットテストが +示されています。矢印キーメニュー自体をエンドツーエンドで操作するテストも含まれます(偽の +ターミナルオブジェクトと `IO.pipe` の組み合わせで、実際の制御端末なしに本物の対話型子プロセス +を動かしています)。 + + +### watch の制限 + +独立した 2 つの制限があり、どちらも `--` セパレータの前に置き、どちらもデフォルトでは無効です。 + +- **`--timeout=SECONDS`** は、どれだけ活発に出力があっても、実時間で N 秒後にセッションを + 強制終了します。 +- **`--idle-timeout=SECONDS`** は、**出力も入力もない**状態が N 秒続くと強制終了します。 + 長時間のビルドはアイドルではないので、「このエージェントは何もしなくなった」という場合に + 使うべきものはこちらです。 + +どちらの場合も終了コードは `124` で、`timed_out: true` と、発動した方を示す `"timeout"` または +`"idle_timeout"` の `timeout_kind` が付きます。 + +## 構造化テキストの解析 + +`Rune::Parsers::TableParser` と `Rune::Parsers::KeyValueParser` は、非構造化のターミナル出力を +Ruby のハッシュに変換します。 + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +`TableParser.parse` は `format:` キーワードを受け取ります(デフォルトは `:auto`。`:pipe` や +`:space` を指定すれば解析モードを強制できます)。見慣れない出力に対して `:auto` に頼る前に、 +ヒューリスティクスの既知の制限について +[`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md) を参照してください。 + +## 次のステップ + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) — `ruby examples/smoke_test.rb` または + `fledge run smoke-test` で実行します。実際の挙動をアサーションベースでたどる単体のツアーで + (bundler も rspec も不要)、出力モード、`--timeout` の検証、パーサ、`Script`、シグナル転送、 + プロンプト検出をカバーします。 +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) — 上の `rune watch` の節全体で + 使われている対話型デモです。[`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb)、 + [`table_parser_example.rb`](../examples/agents/table_parser_example.rb)、 + [`script_automation_example.rb`](../examples/agents/script_automation_example.rb) は、より小さな + 単一概念のスクリプトで、`require_relative '../lib/rune'` 以外のセットアップなしにそれぞれ直接 + 実行できます(`ruby examples/agents/.rb`)。 +- [PTY アーキテクチャガイド](pty_architecture.md) — PTY ランナー、ストリームの読み取り、 + プロンプト検出、`rune watch` のライブパススルーが内部でどう動作するかを解説しています。 +- [`specs/`](../specs/) — `cli`、`parsers`、`pty_runner`、`session`、`watch` の機械検証される + モジュール契約(`spec-sync`)です。 +- [`AGENTS.md`](../AGENTS.md) — 新しいコマンドの追加や trust ツールチェーンとの付き合い方に + 関する規約です。 diff --git a/docs/i18n/getting_started.ko.md b/docs/i18n/getting_started.ko.md new file mode 100644 index 0000000..f775fde --- /dev/null +++ b/docs/i18n/getting_started.ko.md @@ -0,0 +1,231 @@ +이 문서는 docs/getting_started.md의 한국어 번역본이며, 원문인 영어판이 정본입니다. + +# rune 시작하기 + +`rune`은 터미널에서 사람이 쓰든, 에이전트가 프로그램으로 제어하든 똑같이 쓸 수 있게 만든 Ruby CLI이자 라이브러리입니다. 모든 명령은 동일한 구조의 `Result`를 반환하며, 호출 방식에 따라 *렌더링*만 달라집니다. + +## 설치 + +공개 RubyGems.org 레지스트리에는 무관한 패키지가 이미 한정어 없는 `rune` 젬 이름을 선점하고 있어서, 그곳에서 `gem install rune`을 실행하면 잘못된 패키지가 설치됩니다. 지원하는 일반 사용자 설치 경로는 CorvidLabs Homebrew tap의 체크섬이 고정된 포뮬러입니다. + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +Homebrew는 처음 설치할 때 tap을 자동으로 추가합니다. Rune을 업그레이드하려면 다음을 실행하세요. + +```sh +brew upgrade corvidlabs/tap/rune +``` + +Rune 자체를 개발할 때만 소스를 클론하세요. + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +또는 [fledge](https://github.com/CorvidLabs/fledge) 플러그인으로 설치할 수도 있습니다. + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## 무엇을 쓸 수 있는지 살펴보기 + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +명령 도움말은 `--timeout=SECONDS`(`rune run`), `--log=PATH`(`rune watch`)처럼 해당 명령 고유 플래그를 전역 플래그와 함께 보여 줍니다. 에이전트 모드에서도 구조화된 결과가 나오므로, 사람이 읽는 렌더링을 파싱하지 않아도 기능을 파악할 수 있습니다. + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +도움말 플래그도 아래와 같은 구분자 규칙을 따릅니다. `rune run -- mytool --help`는 `--help`를 `mytool`에 넘깁니다. + +## 세 가지 출력 모드 + +`rune`은 호출 방식에 따라 렌더링 모드를 자동으로 고르며, 플래그로 강제로 지정할 수도 있습니다. 세 모드 모두 똑같은 명령 로직을 실행하고, 달라지는 것은 출력 형식뿐입니다. + +### 1. 사람이 읽는 TTY 모드 (기본값, 대화형 터미널) + +stdout이 실제 터미널이고 `--json`/`--ndjson` 플래그가 없으면, `rune`은 색이 입혀진 사람용 형식으로 출력합니다. + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. 에이전트 JSON 모드 (`--json`, 또는 파이프 자동 감지) + +`--json`을 명시하거나, `rune`의 출력을 파이프나 리다이렉트하면 됩니다. stdout이 TTY가 아니면 플래그 없이도 렌더링이 자동으로 JSON으로 바뀝니다. + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code`는 감싼 프로세스의 종료 상태이지, 작업이 맞았다는 판정이 아닙니다.** 이 값이 답하는 질문은 "프로세스가 끝났는가, 어떻게 끝났는가"입니다. 에이전트 CLI라면 출력이 틀렸던 실행까지 포함해 거의 항상 `0`입니다. 어떤 호출자는 `rune run`을 여덟 번 연속으로 보냈고 모두 `0`을 받았는데, 그중 여럿은 나중에 고쳐야 할 결론을 냈습니다. *작업*이 성공했는지를 알려면 이 필드가 아니라 출력에서 판단해야 합니다. 알아 둘 예외는 `124`입니다. rune이 `--timeout`으로 프로세스를 죽인 경우입니다. + +모든 JSON 응답은 같은 봉투를 씁니다. `{"status": "ok"|"error", "data": {...}}`이고, 실패 시에는 `{"status": "error", "error": "..."}`입니다. + +성공이든 실패든 Rune은 최종 봉투를 stdout에 씁니다. 에이전트는 파싱 가능한 결과 채널을 하나만 보면 되지만, 사람이 stdout을 리다이렉트하면 Rune 수준의 오류 메시지도 함께 넘어갑니다. stderr는 운영 안내와, 구조화된 stdout을 망치면 안 되는 실시간 `rune watch` 패스스루를 위해 남겨 둡니다. + +전역 출력 플래그는 첫 `--` 구분자 앞에서만 인식합니다. 그 뒤 토큰은 감싼 명령에 속하며 그대로 보존되므로, `rune run -- tool --json`은 `--json`을 `tool`에 넘깁니다. + +rune 자신의 플래그가 올 자리에 rune이 모르는 `--flag`가 있으면, 조용히 넘기지 않고 오류로 처리합니다. 예전에는 `rune run --tiemout=5 -- echo hi`가 오타 난 플래그를 *실행*하려 들며 `status: ok`와 `exit_code: 127`을 돌려주었습니다. 검사하는 것은 감싼 명령 앞의 토큰뿐이므로, `rune run cargo clippy --tests`와 `rune run -- mytool --tiemout=5`는 손대지 않습니다. 명령 이름을 본 뒤에는 이어지는 `--flag`는 모두 그 명령의 것입니다. + +### 3. 에이전트 NDJSON 봉투 모드 (`--ndjson`) + +`--ndjson`은 같은 결과를 `--json`이 쓰는 평범한 `{"status": ...}` 형태 대신 `{"event": "result"|"error", ...}` 봉투로 감쌉니다. 일부 에이전트 하네스는 `rune run`을 포함해 모든 명령에 이 형식을 일관되게 기대합니다. + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +`rune run`에서는 명령이 끝난 뒤에 한 줄만 나갑니다. `PTYRunner`가 실행 전체를 버퍼링한 뒤 `Result` 하나를 반환하므로, 여기서 `--ndjson`은 증분 스트리밍이 아니라 봉투 선택입니다. 오래 걸리거나 대화형인 명령이 진행되는 동안 실제 실시간 이벤트 스트림이 필요하면, 아래 [`rune watch`](#rune-watch로-세션을-실시간으로-보기)를 보세요. 출력 청크가 나올 때마다 NDJSON 한 줄을 보냅니다. + +## `rune run`으로 명령 실행하기 + +`rune run`은 CLI 명령이나 대화형 TUI를 실제 PTY 안에서 띄우고, ANSI 이스케이프 시퀀스를 걷어 내고, 페이저를 끄고, 실행 시간을 잽니다. + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### 타임아웃 바꾸기 + +`rune run`은 호출마다 기본 타임아웃이 30초입니다. `--timeout=SECONDS`로 바꿀 수 있는데, 감싼 명령의 플래그로 오해되지 않도록 `--` 구분자 *앞*에 두세요. + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +타임아웃이 난 명령은 종료 코드 `124`를 반환하고, 캡처한 출력 끝에 `[rune] Execution timed out after N seconds` 메시지를 붙입니다. 예외가 아니라 평범한 `Result`입니다. + +**죽이기 전에 캡처한 출력은 항상 돌려줍니다.** 그래서 출력한 뒤 멈춘 자식은 그때까지 찍은 내용이 보입니다. 출력이 *비어 있으면* 자식이 정말로 아무것도 찍지 않은 것이고, rune은 그 사실과 가장 흔한 이유를 함께 알려 줍니다. + +**`rune run`은 자신의 stdin을 자식에게 넘기지 않습니다.** tty는 사람의 것이고, 그것을 가져가는 일은 `rune watch`의 몫입니다. 파이프를 그대로 넘기면 호출자의 입력이 pty를 타고 `clean_output`으로 다시 들어옵니다. 그래서 `echo hi | rune run -- cat`은 타임아웃이 납니다. `cat`이 오지 않는 입력을 기다리기 때문입니다. 리다이렉트는 명령 안에 넣으세요. 그러면 셸이 pty 안에서 처리합니다. + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +이렇게 하면 되고, 여러 단락짜리 프롬프트를 인자 하나로 넘기는 것도 됩니다. 줄바꿈은 argv에 그대로 남습니다. 응답의 `command` 필드는 사람이 보라고 셸 이스케이프한 *표시용* 재구성이지, 자식이 받은 내용이 아닙니다. 따옴표 문제를 이 필드로 진단하지 마세요. + +### 출력 크기 제한과 스트림 분리 + +`--` 구분자 앞에 두는 플래그가 세 개 더 있고, 모두 결과의 *형태*를 바꿉니다. + +- **`--max-output=BYTES`** 는 `clean_output`과 `raw_output`을 각각 BYTES로 제한하고, 앞부분과 뒷부분을 남긴 뒤 `truncated: true`와 `omitted_bytes`를 붙입니다. "각각"에서 두 가지가 따라옵니다. 필드는 *따로* 잘리므로, 이 플래그 아래에서는 실행의 서로 다른 구간을 가리키고 `clean_output`은 `strip_ansi(raw_output)`이 아닙니다. `omitted_bytes`는 `clean_output` 쪽 개수이고, `raw_output`은 다른 개수로 자기 표식을 갖습니다. 그리고 `omitted_bytes`는 원문에서의 오프셋으로 재므로, ASCII에서는 딱 맞고 멀티바이트 텍스트에서는 잘린 자리가 글자를 쪼갤 수 있어 몇 바이트 어긋납니다. 두 조각은 이어 붙이지 않고 `[rune] ==== N bytes omitted by --max-output ====` 한 줄로 잇습니다. 돌려준 텍스트가 명령이 찍은 것처럼 읽히지 않게 하려는 것입니다. 이 표식이 없으면 `--max-output=200`에서 201바이트짜리 기록이 바이트 하나를 잃어 `chsh -s /bin/zsh`가 `chsh -s bin/zsh`로 보였습니다. 그 표식은 명령 출력이 아니라 rune의 주석이므로 BYTES 예산에 넣지 않고, 응답이 예산을 조금 넘을 수 있습니다. +- **`--tail=N`** 은 마지막 N줄만 남기고 `truncated: true`와 `omitted_lines`를 붙입니다. `--max-output`과 함께 쓸 수 없으며, 둘 다 넘기면 한쪽을 조용히 우선하지 않고 오류로 처리합니다. +- **`--separate-streams`** 는 합쳐진 `clean_output`을 대체하지 않고, 옆에 `clean_stdout`과 `clean_stderr`를 더합니다. + +`--separate-streams`에는 실제 대가가 있어서 기본값이 아니라 선택 사항입니다. pty는 스트림이 하나이므로, 둘을 나누려면 stderr에 전용 파이프를 줘야 합니다. 그러면 자식은 둘 다에 대해 하나의 제어 터미널을 보지 못하고, `isatty(2)`를 확인하는 프로그램은 오류가 리다이렉트된 것처럼 동작합니다. 많은 CLI는 색을 끄거나 비대화형 모드로 통째로 바꿉니다. 자식이 터미널에 있다고 믿게 하는 것보다 분리가 더 필요할 때만 쓰세요. + +## `rune watch`로 세션을 실시간으로 보기 + +`rune run`은 명령 출력을 전부 버퍼링했다가 끝난 뒤에야 돌려줍니다. 스크립트와 캡처에는 알맞지만, 키보드에 앉아 대화형 프로그램을 직접 다루면서 다른 쪽이 세션을 관찰해야 할 때는 맞지 않습니다. `rune watch`가 그 용도입니다. 터미널을 raw 모드로 두고, 입력한 키를 자식에게 실시간으로 넘깁니다. 줄 단위만이 아니라 화살표 키 같은 raw 이스케이프 시퀀스까지 포함합니다. 자식의 출력은 끝날 때가 아니라 나오는 즉시 화면에 흐르고, 동시에 모든 청크를 NDJSON 이벤트로 기록합니다. 사람이 세션을 다루는 동안 AI 에이전트가 실시간으로 tail할 수 있습니다. + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +이벤트 로그의 기본값은 stderr이 아니라, 충돌을 피하고 소유자만 읽을 수 있는(`0600`) 임시 파일입니다. 원래 설계는 NDJSON 이벤트를 실시간 패스스루와 같은 터미널에 섞는 것이었는데, 실제 사용에서 바로 잘못된 기본값임이 드러났습니다. JSON이 끼어들면 세션을 읽을 수 없었습니다. 경로는 처음에 한 번만 알려 줍니다. + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +다른 창에서 그 경로를 `tail -f`하거나 에이전트에게 tail시키면, 자기 터미널은 깨끗한 채로 세션을 실시간으로 볼 수 있습니다. 위치를 직접 정하려면 `--log=PATH`를 쓰세요. + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +로그의 각 줄은 JSON 객체입니다. `{"event":"start","command":"...","pid":...}`로 시작하고, 스트리밍되는 청크마다 `{"event":"output","bytes":N,"text":"..."}` 한 줄이 오며, 자식이 끝나면 `{"event":"exit","exit_code":N}`입니다. + +### 에이전트 모드의 `rune watch` + +`rune watch`도 다른 명령과 같은 출력 모드 규칙을 따릅니다. `--json`, `--ndjson`이거나 stdout이 터미널이 아니면, 실시간 패스스루는 **stderr**로 가고 stdout에는 결과 봉투만 실립니다. 감싼 프로그램은 stdout을 바로 파싱하고, 키보드 앞의 사람은 세션을 그대로 볼 수 있습니다. + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +세션을 직접 보면서 JSON은 다른 곳에 받으려면 `2>/dev/null`을 빼면 됩니다. + +`rune watch`는 실제 터미널이 필요합니다. stdin이 TTY가 아니면 실행을 거부하며, 의미 있는 비대화형 모드는 없습니다. `rune run`의 PTY 안에서 다시 돌리는 것도 되지 않으므로, 이 가이드의 나머지처럼 파이프 예시로 보여 줄 수 없습니다. `examples/humans/demo_tui.rb`의 최상위 메뉴는 숫자를 치고 Enter를 누르는 방식이 아니라, 화살표 키 선택기(↑/↓ + Enter, 종료는 `q`)입니다. raw 한 바이트와 이스케이프 시퀀스 전달을 시험하려고 그렇게 만든 것이고, 줄 단위로만 버퍼링하는 메뉴는 이 경로를 건드리지 않습니다. `examples/humans/demo_tui.rb`의 헤더 주석에는 복사해 쓸 수 있는 명령이 있고, `spec/rune/pty_watcher_spec.rb`는 전달·기록 메커니즘이 어떻게 단위 테스트되는지 보여 줍니다. 화살표 키 메뉴 자체를 처음부터 끝까지 구동하는 테스트도 있습니다. 가짜 터미널 객체와 `IO.pipe`로 실제 대화형 자식 프로세스를 돌리며, 진짜 제어 터미널은 필요 없습니다. + +### watch 제한하기 + +서로 독립된 제한이 두 가지이고, 둘 다 `--` 구분자 앞에 두며, 기본값은 꺼짐입니다. + +- **`--timeout=SECONDS`** 는 얼마나 바쁘든 벽시계 기준 N초가 지나면 세션을 죽입니다. +- **`--idle-timeout=SECONDS`** 는 **출력도 입력도 없는** 상태가 N초 지속되면 죽입니다. 긴 빌드는 idle이 아니므로, "이 에이전트가 아무 것도 안 하게 됐다"를 잡을 때 이쪽을 씁니다. + +어느 쪽이든 종료 코드는 `124`이고, `timed_out: true`와 함께 어느 쪽이 발동했는지 `timeout_kind`가 `"timeout"` 또는 `"idle_timeout"`으로 알려 줍니다. + +## 구조화된 텍스트 파싱하기 + +`Rune::Parsers::TableParser`와 `Rune::Parsers::KeyValueParser`는 구조화되지 않은 터미널 출력을 Ruby 해시로 바꿉니다. + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +`TableParser.parse`는 `format:` 키워드를 받습니다. 기본값은 `:auto`이고, 파싱 모드를 강제하려면 `:pipe` 또는 `:space`를 씁니다. 낯선 출력에 `:auto`를 기대기 전에, 휴리스틱의 알려진 한계는 [`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md)를 보세요. + +## 다음 단계 + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) — `ruby examples/smoke_test.rb` 또는 `fledge run smoke-test`. bundler/rspec 없이 실제 동작을 assertion으로 훑는 독립 투어입니다. 출력 모드, `--timeout` 검증, 파서, `Script`, 시그널 전달, 프롬프트 감지를 다룹니다. +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) — 위에서 `rune watch` 절에 쓴 대화형 데모입니다. [`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb), [`table_parser_example.rb`](../examples/agents/table_parser_example.rb), [`script_automation_example.rb`](../examples/agents/script_automation_example.rb)는 개념 하나씩만 다루는 작은 스크립트입니다. 각각 `require_relative '../lib/rune'` 외에는 준비 없이 (`ruby examples/agents/.rb`)로 바로 실행할 수 있습니다. +- [PTY 아키텍처 가이드](pty_architecture.md) — PTY 러너, 스트림 읽기, 프롬프트 감지, `rune watch`의 실시간 패스스루가 내부에서 어떻게 동작하는지 설명합니다. +- [`specs/`](../specs/) — `cli`, `parsers`, `pty_runner`, `session`, `watch`에 대한 기계 검증 모듈 계약(`spec-sync`)입니다. +- [`AGENTS.md`](../AGENTS.md) — 새 명령을 추가하고 trust 툴체인을 다룰 때의 규칙입니다. diff --git a/docs/i18n/getting_started.ru.md b/docs/i18n/getting_started.ru.md new file mode 100644 index 0000000..3670ac4 --- /dev/null +++ b/docs/i18n/getting_started.ru.md @@ -0,0 +1,330 @@ +# Начало работы с rune + +*Это перевод файла `docs/getting_started.md`. Авторитетным является английский оригинал.* + +`rune` — это CLI и библиотека на Ruby, созданные так, чтобы ими одинаково удобно мог пользоваться человек +в терминале и AI-агент, управляющий ими программно. Каждая команда возвращает один и тот же структурированный +`Result` — меняется только *отрисовка* в зависимости от того, как вы её вызываете. + +## Установка + +Неквалифицированное имя gem `rune` уже занято в публичном реестре RubyGems.org +посторонним пакетом, поэтому `gem install rune` там устанавливает не то. Поддерживаемый путь +установки для конечного пользователя — формула с закреплённой контрольной суммой в Homebrew tap CorvidLabs: + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +Homebrew автоматически добавляет tap при первой установке. Обновите Rune командой: + +```sh +brew upgrade corvidlabs/tap/rune +``` + +Клонируйте исходники только если разрабатываете сам Rune: + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +Или как плагин [fledge](https://github.com/CorvidLabs/fledge): + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## Как узнать, что доступно + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +Справка команды перечисляет её собственные флаги — `--timeout=SECONDS` для `rune run`, `--log=PATH` для +`rune watch` — вместе с глобальными. В режиме агента она тоже структурирована, поэтому обнаружение не +требует разбора человеческой отрисовки: + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +Флаги справки подчиняются тому же правилу разделителя, что и всё остальное (ниже): `rune run -- mytool --help` +передаёт `--help` в `mytool`. + +## Три режима вывода + +`rune` выбирает режим отрисовки автоматически в зависимости от того, как его вызвали, или вы можете +принудительно задать его флагом. Все три режима выполняют одну и ту же логику команды — отличается только +формат вывода. + +### 1. Человеческий режим TTY (по умолчанию, интерактивный терминал) + +Когда stdout — настоящий терминал и флаг `--json`/`--ndjson` не задан, `rune` печатает +цветной, отформатированный для человека вывод: + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. Агентский режим JSON (`--json`, или автоматическое определение канала) + +Передайте `--json` явно или просто направьте вывод `rune` в канал/перенаправление — не-TTY stdout +автоматически переключает отрисовку на JSON, флаг не нужен: + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code` — это статус завершения обёрнутого процесса, а не вердикт о работе.** Он отвечает на вопрос «завершился +> ли процесс и как», что для CLI агента почти всегда `0` — в том числе для запусков, чей +> вывод был неверным. У одного вызывающего восемь подряд вызовов `rune run` вернули `0`, несколько из +> которых дали выводы, которые потом пришлось исправлять. Если нужно знать, удалась ли *работа*, +> это должно следовать из вывода, а не из этого поля. `124` — исключение, которое стоит +> знать: оно означает, что rune убил процесс по `--timeout`. + +Каждый JSON-ответ имеет один и тот же конверт: `{"status": "ok"|"error", "data": {...}}` (или +`{"status": "error", "error": "..."}` при ошибке). + +Rune пишет итоговый конверт в stdout и при успехе, и при ошибке. Это даёт агентам один +разбираемый канал результата, но также означает, что человек, перенаправляющий stdout, перенаправляет и сообщения +об ошибках уровня Rune. Stderr зарезервирован для операционных объявлений и живого проброса `rune watch`, +который не должен портить структурированный stdout. + +Глобальные флаги вывода распознаются только до первого разделителя `--`. Токены после него принадлежат +обёрнутой команде и сохраняются, поэтому `rune run -- tool --json` передаёт `--json` в `tool`. + +Неизвестный `rune` флаг `--flag` в позиции, где стоят собственные флаги rune, — это ошибка, а не +то, что молча передаётся дальше: раньше `rune run --tiemout=5 -- echo hi` пытался *выполнить* +опечатанный флаг и отвечал `status: ok` с `exit_code: 127`. Проверяются только токены до обёрнутой +команды, поэтому `rune run cargo clippy --tests` и `rune run -- mytool --tiemout=5` остаются +без изменений — как только имя команды увидено, каждый последующий `--flag` принадлежит ей. + +### 3. Агентский режим конверта NDJSON (`--ndjson`) + +`--ndjson` оборачивает тот же результат в конверт `{"event": "result"|"error", ...}` вместо простой +формы `{"status": ...}`, которую использует `--json`, — формат, который некоторые агентские обвязки ожидают единообразно для +каждой команды, включая `rune run`: + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +Для `rune run` это по-прежнему ровно одна строка, выдаваемая после завершения команды — `PTYRunner` +буферизует весь запуск и возвращает один `Result`, поэтому `--ndjson` здесь — выбор конверта, а не +инкрементальная потоковая передача. Для настоящего живого потока событий по мере выполнения долгой или интерактивной команды +см. [`rune watch`](#watching-a-session-live-with-rune-watch) ниже, который выдаёт одну +строку NDJSON на каждый фрагмент вывода по мере его появления. + +## Запуск команд с `rune run` + +`rune run` запускает любую CLI-команду или интерактивный TUI внутри настоящего PTY, удаляет ANSI-последовательности +escape, отключает пейджеры и измеряет время выполнения: + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### Переопределение тайм-аута + +Каждый вызов `rune run` имеет тайм-аут по умолчанию 30 секунд. Переопределите его флагом `--timeout=SECONDS`, +поставленным *до* разделителя `--`, чтобы его не приняли за флаг обёрнутой +команды: + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +Команда, превысившая тайм-аут, возвращает код выхода `124` с сообщением `[rune] Execution timed out after N seconds`, +добавленным к захваченному выводу — это по-прежнему обычный `Result`, а не исключение. + +**Вывод, захваченный до убийства, всегда возвращается**, поэтому дочерний процесс, который что-то напечатал и затем завис, показывает +то, что напечатал. Если вывод *пустой*, дочерний процесс действительно ничего не напечатал, и rune так и говорит +вместе с самой частой причиной. + +**`rune run` не пересылает свой собственный stdin дочернему процессу.** tty принадлежит человеку — забирать его +— задача `rune watch`, — а пересылка канала отразила бы собственный ввод вызывающего обратно через pty +и в `clean_output`. Поэтому `echo hi | rune run -- cat` истекает по тайм-ауту: `cat` ждёт ввод, который +никогда не приходит. Поместите перенаправление внутрь команды, где оболочка выполнит его в pty: + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +Это работает, как и передача многоабзацного запроса одним аргументом — переводы строк сохраняются +в argv без изменений. Поле `command` в ответе — экранированная оболочкой *отображаемая* реконструкция для +людей, а не то, что получил дочерний процесс; не диагностируйте кавычки по нему. + +### Ограничение вывода и разделение потоков + +Ещё три флага, все до разделителя `--`, все меняют *форму* результата: + +- **`--max-output=BYTES`** ограничивает `clean_output` и `raw_output` до BYTES каждый, сохраняя начало + и конец, и добавляет `truncated: true` с `omitted_bytes`. Из «каждый» следуют две вещи: + поля ограничиваются *по отдельности*, поэтому под этим флагом они описывают разные окна + запуска и `clean_output` не является `strip_ansi(raw_output)` — `omitted_bytes` — это счётчик + `clean_output`, а `raw_output` несёт свой маркер с другим. И `omitted_bytes` измеряется + в смещениях в оригинал, поэтому он сходится точно на ASCII, но расходится на несколько + байт на многобайтовом тексте, где разрез может разрезать символ. Две половины соединяются строкой + `[rune] ==== N bytes omitted by --max-output ====` а не склеиваются, поэтому возвращённый текст + никогда не читается как то, что напечатала команда: без неё транскрипт в 201 байт при + `--max-output=200` отбрасывал ровно тот байт, который превращал `chsh -s /bin/zsh` в + `chsh -s bin/zsh`. Этот маркер — аннотация rune, а не вывод команды, поэтому он + не учитывается в BYTES и ответ может немного превысить бюджет. +- **`--tail=N`** оставляет только последние N строк, добавляя `truncated: true` с `omitted_lines`. + Взаимоисключающий с `--max-output`; передача обоих — ошибка, а не молчаливый приоритет. +- **`--separate-streams`** добавляет `clean_stdout` и `clean_stderr` рядом со слитым + `clean_output`, а не вместо него. + +`--separate-streams` имеет реальную цену, поэтому он включается явно, а не по умолчанию: у pty +один поток, поэтому разделение означает отдельный канал для stderr. Дочерний процесс тогда больше не видит +единый управляющий терминал для обоих, и программа, проверяющая `isatty(2)`, будет вести себя так, будто +её ошибки перенаправляются — что для многих CLI означает отказ от цвета или полный переход в +неинтерактивный режим. Используйте его, когда разделение нужнее, чем то, чтобы дочерний процесс +считал, что он на терминале. + +## Наблюдение за сеансом вживую с `rune watch` + +`rune run` буферизует весь вывод команды и возвращает его только после её завершения — отлично +для сценариев и захвата, но не годится, если вы хотите сидеть за клавиатурой и управлять +интерактивной программой, пока что-то ещё наблюдает за сеансом. `rune watch` создан для этого: он +переводит ваш терминал в raw-режим, пересылает каждое нажатие клавиши дочернему процессу вживую — включая +сырые escape-последовательности вроде стрелок, а не только целые строки — транслирует вывод дочернего процесса +на экран по мере появления (не в конце) и одновременно записывает каждый фрагмент как событие NDJSON — чтобы +AI-агент мог читать сеанс в реальном времени, пока человек им управляет. + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +Журнал событий по умолчанию — безопасный от коллизий временный файл только для владельца (`0600`), а не stderr — смешивание +событий NDJSON в тот же терминал, что и живой проброс, было исходным замыслом, и реальное +использование сразу показало, что это неверный выбор по умолчанию (перемешанный JSON делал сеанс +нечитаемым). Путь объявляется один раз, в начале: + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +`tail -f` этот путь из другой панели (или пусть агент его читает), чтобы наблюдать сеанс вживую, +при этом ваш терминал остаётся чистым. Укажите конкретное место флагом `--log=PATH`: + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +Каждая строка журнала — JSON-объект: `{"event":"start","command":"...","pid":...}`, затем один +`{"event":"output","bytes":N,"text":"..."}` на каждый фрагмент по мере потока, затем +`{"event":"exit","exit_code":N}`, когда дочерний процесс завершается. + +### `rune watch` в режиме агента + +`rune watch` следует тем же правилам режима вывода, что и любая другая команда. При `--json`, `--ndjson` +или всякий раз, когда stdout не является терминалом, живой проброс переносится в **stderr**, а stdout несёт +только конверт результата — чтобы оборачивающая программа могла разбирать stdout напрямую, пока человек за +клавиатурой по-прежнему видит свой сеанс: + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +Уберите `2>/dev/null`, чтобы продолжать смотреть сеанс сами, пока JSON захватывается в другом месте. + +`rune watch` требует настоящий терминал (отказывается запускаться, если stdin не TTY — осмысленного +неинтерактивного режима нет) и не работает через собственную PTY-вложенность `rune run`, поэтому его нельзя +продемонстрировать в примере с каналом так, как остальную часть этого руководства. Верхнеуровневое меню +`examples/humans/demo_tui.rb` — настоящий селектор стрелками (↑/↓ + Enter, или `q` для выхода), а не «введите-номер-и-нажмите- +Enter», специально чтобы проверить проброс сырых одиночных байтов и escape-последовательностей — то, до чего +чисто построчно буферизованное меню никогда не доходит. В собственном комментарии в шапке `examples/humans/demo_tui.rb` есть копируемые +команды, а `spec/rune/pty_watcher_spec.rb` показывает, как подлежащая механика проброса/журналирования +покрыта модульными тестами, включая тест, который прогоняет само меню со стрелками от начала до конца (поддельный терминал +плюс `IO.pipe` управляют настоящим интерактивным дочерним процессом без реального управляющего +терминала). + + +### Ограничение watch + +Два независимых ограничения, оба до разделителя `--`, оба выключены по умолчанию: + +- **`--timeout=SECONDS`** убивает сеанс через N секунд астрономического времени, какой бы занятый он ни был. +- **`--idle-timeout=SECONDS`** убивает его после N секунд **без вывода и без ввода** — тот, + который нужен для «этот агент перестал что-либо делать», поскольку долгая сборка — не простой. + +Любой даёт код выхода `124`, с `timed_out: true` и `timeout_kind` равным `"timeout"` или +`"idle_timeout"`, указывающим, какой сработал. +## Разбор структурированного текста + +`Rune::Parsers::TableParser` и `Rune::Parsers::KeyValueParser` превращают неструктурированный вывод терминала +в хеши Ruby: + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +`TableParser.parse` принимает ключевое слово `format:` (`:auto` по умолчанию, или `:pipe`/`:space` чтобы +принудительно задать режим разбора) — см. [`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md) об +известных ограничениях эвристики, прежде чем полагаться на `:auto` для незнакомого вывода. + +## Что дальше + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) — `ruby examples/smoke_test.rb` или `fledge + run smoke-test`. Автономный обзор реального поведения на основе утверждений (bundler/rspec не нужны): + режимы вывода, проверка `--timeout`, парсеры, `Script`, пересылка сигналов, обнаружение приглашения. +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) — интерактивное демо, используемое на протяжении раздела + `rune watch` выше. [`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb), + [`table_parser_example.rb`](../examples/agents/table_parser_example.rb) и + [`script_automation_example.rb`](../examples/agents/script_automation_example.rb) — более мелкие + сценарии на одну идею — каждый запускается напрямую (`ruby examples/agents/.rb`) без настройки сверх + `require_relative '../lib/rune'`. +- [Руководство по архитектуре PTY](pty_architecture.md) — как внутри работают PTY-раннер, чтение потока, обнаружение + приглашения и живой проброс `rune watch`. +- [`specs/`](../specs/) — машинопроверяемые контракты модулей (`spec-sync`) для `cli`, `parsers`, + `pty_runner`, `session` и `watch`. +- [`AGENTS.md`](../AGENTS.md) — соглашения о добавлении новых команд и работе с trust + toolchain. diff --git a/docs/i18n/getting_started.zh-CN.md b/docs/i18n/getting_started.zh-CN.md new file mode 100644 index 0000000..ac69016 --- /dev/null +++ b/docs/i18n/getting_started.zh-CN.md @@ -0,0 +1,322 @@ +> 本文译自 docs/getting_started.md;如译文与原文存在出入,以英文原文为准。 + +# rune 快速上手 + +`rune` 是一个 Ruby CLI 与程序库,设计上让终端前的人类用户和以编程方式驱动它的 AI 代理 +都能同样顺手地使用。每条命令都返回相同的结构化 `Result` —— 唯一随调用方式变化的只是 +*渲染*形式。 + +## 安装 + +在公开的 RubyGems.org 注册表上,不带限定符的 `rune` 这个 gem 名称已被一个无关的包占用, +因此在那里执行 `gem install rune` 装到的是错误的东西。受支持的最终用户安装途径是 +CorvidLabs Homebrew tap 中经过校验和锁定的 formula: + +```sh +brew install corvidlabs/tap/rune +rune version --json +``` + +首次安装时 Homebrew 会自动添加该 tap。升级 rune 请使用: + +```sh +brew upgrade corvidlabs/tap/rune +``` + +只有在开发 rune 本身时才克隆源码: + +```sh +git clone https://github.com/CorvidLabs/rune.git +cd rune +bundle install +ruby bin/rune version +``` + +也可以作为 [fledge](https://github.com/CorvidLabs/fledge) 插件使用: + +```sh +fledge plugins install CorvidLabs/rune +fledge rune run --json -- git status +``` + +## 了解有哪些功能可用 + +```sh +rune --help # or -h, or `rune help` +rune run --help # or `rune help run`, or `rune run -h` +``` + +命令帮助会列出该命令自己的 flag —— 例如 `rune run` 的 `--timeout=SECONDS`、`rune watch` 的 +`--log=PATH` —— 同时也会列出全局 flag。帮助信息在代理模式下同样是结构化的,因此做功能 +探查时无需解析面向人类的渲染结果: + +```sh +$ rune run --help --json | jq -c '.data.flags' +[{"flag":"--timeout=SECONDS","description":"Kill the wrapped command after N seconds (default 30). Before `--` only."},{"flag":"--max-output=BYTES","description":"Bound clean_output/raw_output to BYTES each, keeping head+tail and marking the join with a `[rune] ==== N bytes omitted by --max-output ====` line. Mutually exclusive with --tail. Before `--` only."},{"flag":"--tail=N","description":"Keep only the last N lines of clean_output/raw_output. Mutually exclusive with --max-output. Before `--` only."},{"flag":"--separate-streams","description":"Adds clean_stdout/clean_stderr (stderr on a pipe, not the pty) alongside the merged view. Before `--` only."}] +``` + +帮助相关的 flag 遵循与其他一切内容相同的分隔符规则(见下文):`rune run -- mytool --help` +会把 `--help` 传给 `mytool`。 + +## 三种输出模式 + +`rune` 会根据调用方式自动选择渲染模式,你也可以用 flag 显式指定一种。三种模式执行的 +命令逻辑完全相同 —— 不同的只是输出格式。 + +### 1. 人类 TTY 模式(默认,交互式终端) + +当 stdout 是真实终端且没有给出 `--json`/`--ndjson` flag 时,`rune` 会打印带颜色、面向 +人类排版的输出: + +```sh +$ rune version +rune v0.9.0 + Ruby 4.0.6 (arm64-darwin25) + fledge: ✓ available + spec-sync: ✓ available +``` + +```sh +$ rune run -- echo "hello" +✓ echo hello (6.2ms, exit 0) + +hello +``` + +### 2. 代理 JSON 模式(`--json`,或自动检测管道) + +显式传入 `--json`,或者直接把 `rune` 的输出通过管道接走/重定向 —— 只要 stdout 不是 +TTY,渲染就会自动切换为 JSON,无需任何 flag: + +```sh +$ ruby bin/rune run --json -- echo "hello agent" +{"status":"ok","data":{"command":"echo hello\\ agent","exit_code":0,"clean_output":"hello agent\n","raw_output":"hello agent\r\n","prompt_detected":false,"duration_ms":5.27}} +``` + +```sh +$ ruby bin/rune version | cat +{"status":"ok","data":{"name":"rune","version":"0.7.0","ruby":"4.0.6","ruby_platform":"arm64-darwin25","fledge":true,"specsync":true}} +``` + +> **`exit_code` 是被包装进程的退出状态,而不是对所做工作的评判。** 它回答的是「进程是否 +> 结束了、以何种方式结束」,而对于代理 CLI 来说这几乎总是 `0` —— 包括那些输出本身就 +> 是错误的运行。曾有一位调用方连续八次 `rune run` 调用都返回 `0`,其中有几次得出的结论 +> 他们后来不得不纠正。如果你需要知道*工作本身*是否成功,那只能从输出内容判断,而不能靠 +> 这个字段。`124` 是值得记住的例外:它表示 rune 因 `--timeout` 杀掉了进程。 + +每个 JSON 响应都有相同的外层信封:`{"status": "ok"|"error", "data": {...}}`(失败时为 +`{"status": "error", "error": "..."}`)。 + +无论成功还是失败,rune 都会把最终的信封写到 stdout。这让代理拥有了一条可解析的结果 +通道,但同时也意味着:人类用户重定向 stdout 时,会把 rune 层面的错误信息也一并重定向 +走。stderr 被保留给运行时的通告信息,以及绝不能污染结构化 stdout 的 `rune watch` 实时 +透传。 + +全局输出 flag 只在第一个 `--` 分隔符之前被识别。分隔符之后的 token 属于被包装的命令, +会被原样保留,因此 `rune run -- tool --json` 会把 `--json` 传给 `tool`。 + +在 rune 自身 flag 所在的位置上出现一个 rune 不认识的 `--flag`,会被当作错误,而不是被 +悄悄传递下去:`rune run --tiemout=5 -- echo hi` 在过去会真的去*执行*这个拼错的 flag, +并返回 `status: ok` 加 `exit_code: 127`。只有被包装命令之前的 token 会被检查,因此 +`rune run cargo clippy --tests` 和 `rune run -- mytool --tiemout=5` 都不受影响 —— +一旦命令名已经出现,之后的每一个 `--flag` 都归属于它。 + +### 3. 代理 NDJSON 信封模式(`--ndjson`) + +`--ndjson` 会把同一个结果包进 `{"event": "result"|"error", ...}` 信封,而不是 `--json` 所用 +的普通 `{"status": ...}` 形状 —— 有些代理运行框架(harness)要求每条命令(包括 +`rune run`)都统一使用这种格式: + +```sh +$ ruby bin/rune run --ndjson -- echo "hello stream" +{"event":"result","status":"ok","data":{"command":"echo hello\\ stream","exit_code":0,"clean_output":"hello stream\n","raw_output":"hello stream\r\n","prompt_detected":false,"duration_ms":11.45}} +``` + +对 `rune run` 来说,这仍然只有一行,在命令结束时一次性输出 —— `PTYRunner` 会缓冲整个 +运行过程并返回单个 `Result`,因此这里的 `--ndjson` 只是信封格式的选择,而不是增量流式 +输出。如果你需要在长时间运行或交互式命令推进过程中获得真正实时的事件流,请参见下文的 +[`rune watch`](#用-rune-watch-实时观看会话),它会随着每个输出块的产生逐行 +发出 NDJSON。 + +## 用 `rune run` 运行命令 + +`rune run` 会在一个真实的 PTY 中启动任意 CLI 命令或交互式 TUI,剥离 ANSI 转义序列、禁用 +分页器,并测量执行耗时: + +```sh +rune run -- git status +rune run --json -- npm test +rune run --ndjson -- fledge lanes run check +``` + +### 覆盖超时时间 + +每次 `rune run` 调用都有 30 秒的默认超时。用 `--timeout=SECONDS` 覆盖它,注意放在 `--` +分隔符*之前*,以免被误认为属于被包装命令的 flag: + +```sh +$ ruby bin/rune run --json --timeout=1 -- sleep 3 +{"status":"ok","data":{"command":"sleep 3","exit_code":124,"clean_output":"\n[rune] Execution timed out after 1 seconds","raw_output":"\n[rune] Execution timed out after 1 seconds","prompt_detected":false,"duration_ms":1005.32}} +``` + +超时的命令会返回退出码 `124`,并在捕获到的输出后附上 `[rune] Execution timed out after N +seconds` 消息 —— 它仍然是一个正常的 `Result`,而不是异常。 + +**在被杀掉之前捕获到的输出总是会被返回**,因此一个先打印了内容然后又卡住的子进程,你仍 +能看到它打印了什么。如果输出是*空的*,那说明子进程确实什么都没打印,而 rune 会把这一点 +连同最常见的原因一并告诉你。 + +**`rune run` 不会把它自己的 stdin 转发给子进程。** tty 属于人类用户 —— 接管它是 +`rune watch` 的职责 —— 而转发管道会把调用方自己的输入经 pty 回显进 `clean_output`。 +所以 `echo hi | rune run -- cat` 会超时:`cat` 在等一份永远不会到来的输入。正确的做法是 +把重定向放进命令内部,让 shell 在 pty 里执行它: + +```sh +$ rune run -- sh -c 'claude -p --output-format text < prompt.md' +``` + +这样是可行的,把多段落的提示词作为单个参数传入同样可行 —— 换行符能在 argv 中原样保 +留。回复中的 `command` 字段是经过 shell 转义的*展示用*重建串,面向人类阅读,并不是子进 +程实际收到的内容;不要拿它来诊断引号问题。 + +### 限制输出大小,以及拆分输出流 + +另外还有三个 flag,都位于 `--` 分隔符之前,都改变结果的*形状*: + +- **`--max-output=BYTES`** 把 `clean_output` 和 `raw_output` 各自限制在 BYTES 字节以内, + 保留头部和尾部,并附上 `truncated: true` 与 `omitted_bytes`。从「各自」二字可以推出两 + 点:这两个字段是*分别*限制的,因此在这个 flag 下它们描述的是同一次运行中不同的窗口, + `clean_output` 并不等于 `strip_ansi(raw_output)` —— `omitted_bytes` 是 `clean_output` + 自己的计数,`raw_output` 则带着自己的标记和另一个不同的计数。而且 `omitted_bytes` 是 + 按原始文本中的偏移量计量的,因此在纯 ASCII 下能精确对齐,但在多字节文本上会漂移几个 + 字节 —— 因为截断点可能把一个字符切开。前后两半之间用一行 + `[rune] ==== N bytes omitted by --max-output ====` 连接,而不是直接拼接,这样返回的文 + 本绝不会被读成命令自己打印的内容:如果没有这一行,一份 201 字节的会话记录在 + `--max-output=200` 下恰好丢掉的那一个字节,会把 `chsh -s /bin/zsh` 变成 + `chsh -s bin/zsh`。该标记是 rune 加的注解而非命令的输出,所以它不占 BYTES 额度,一次 + 回复因此可能略微超出预算。 +- **`--tail=N`** 只保留最后 N 行,并附上 `truncated: true` 与 `omitted_lines`。与 + `--max-output` 互斥;两个都传会报错,而不是静默地按某种优先级取其一。 +- **`--separate-streams`** 在合并视图 `clean_output` 之外,额外增加 `clean_stdout` 和 + `clean_stderr`,而不是替换掉合并视图。 + +`--separate-streams` 有真实的代价,这也是它作为可选项而非默认行为的原因:pty 只有一条 +流,要拆分它们就得给 stderr 单独配一根管道。这样一来,子进程看到的就不再是两条流共用 +同一个控制终端,一个会检查 `isatty(2)` 的程序会表现得好像自己的错误输出被重定向了 —— +对许多 CLI 来说,这意味着丢弃颜色,或者干脆切换到非交互模式。当你对流拆分的需要超过对 +「让子进程相信自己在终端上」的需要时,再使用它。 + +## 用 `rune watch` 实时观看会话 + +`rune run` 会缓冲命令的全部输出,直到命令结束才返回 —— 这对脚本化和捕获很合适,但如果你 +想真正坐在键盘前驱动一个交互式程序、同时让别的东西观察这个会话,它就不行了。`rune watch` +正是为此而生:它把你的终端置于原始模式(raw mode),把你敲下的每一次按键实时转发给子进 +程 —— 包括方向键这类原始转义序列,而不仅仅是整行输入 —— 把子进程的输出随产生随显示 +到你的屏幕上(而不是等到最后),并同时把每一个输出块记录为一条 NDJSON 事件 —— 这样在 +人类驱动会话的同时,AI 代理可以实时跟踪它。 + +```sh +# A small interactive demo program ships with rune specifically to try this against: +rune watch -- ruby examples/humans/demo_tui.rb +``` + +事件日志默认写入一个防冲突、仅所有者可读(`0600`)的临时文件,而不是 stderr —— 把 +NDJSON 事件混进与实时透传相同的终端里曾是最初的设计,而实际使用立刻证明了那是个错误的 +默认值(交错出现的 JSON 让会话根本没法读)。日志路径会在开始时声明一次: + +``` +[rune watch] live event log: /tmp/rune-watch-20260728-12345-abcd.ndjson +``` + +在另一个窗格里对该路径执行 `tail -f`(或者让一个代理去跟踪它),就能实时观看会话,而你 +自己的终端保持干净。也可以用 `--log=PATH` 把日志指到某个特定位置: + +```sh +rune watch --log=/tmp/session.ndjson -- ruby examples/humans/demo_tui.rb +``` + +日志的每一行都是一个 JSON 对象:先是 `{"event":"start","command":"...","pid":...}`, +然后是随输出流产生的每个块各一条 `{"event":"output","bytes":N,"text":"..."}`,最后是 +子进程退出时的 `{"event":"exit","exit_code":N}`。 + +### 代理模式下的 `rune watch` + +`rune watch` 遵循与其他所有命令相同的输出模式规则。在 `--json`、`--ndjson` 下,或者 +stdout 不是终端的任何情况下,实时透传会转移到 **stderr**,stdout 只承载结果信封 —— 这 +样外层的包装程序可以直接解析 stdout,而键盘前的人类仍然能看到自己的会话: + +```sh +rune watch --json -- ruby examples/humans/demo_tui.rb 2>/dev/null | jq . +``` + +```json +{ + "status": "ok", + "data": { + "command": "ruby examples/humans/demo_tui.rb", + "exit_code": 0, + "duration_ms": 4820.11, + "log_path": "/tmp/rune-watch-20260728-12345-abcd.ndjson" + } +} +``` + +去掉 `2>/dev/null`,你就可以在 JSON 被捕获到别处的同时继续自己观看会话。 + +`rune watch` 要求真实的终端(如果 stdin 不是 TTY 它会拒绝运行 —— 不存在有意义的非交 +互模式),而且无法在 `rune run` 自己的 PTY 套娃中工作,所以它不能像本指南其余部分那样 +用管道示例来演示。`examples/humans/demo_tui.rb` 的顶层菜单是一个真正的方向键选择器(↑/↓ +加 Enter,或按 `q` 退出),而不是「输入数字再按回车」那种 —— 这是专门为了检验原始单字 +节和转义序列转发而设计的,那是纯行缓冲菜单永远触碰不到的东西。`examples/humans/demo_tui.rb` +文件头部的注释里有可以直接复制粘贴的命令,`spec/rune/pty_watcher_spec.rb` 则展示了底层 +转发/记录机制是如何做单元测试的,其中包括一个端到端驱动方向键菜单本身的测试(用一个假 +终端对象加 `IO.pipe` 来驱动一个真实的交互式子进程,无需真正的控制终端)。 + + +### 限制 watch 的时长 + +两个相互独立的限制,都在 `--` 分隔符之前,默认都关闭: + +- **`--timeout=SECONDS`** 在墙上时钟走过 N 秒后杀掉会话,无论会话有多忙。 +- **`--idle-timeout=SECONDS`** 在**既没有输出也没有输入**持续 N 秒后杀掉会话 —— 这才是 + 你想要的「这个代理已经什么都不做了」的判定,因为一场漫长的构建并不算空闲。 + +两者都会给出退出码 `124`,并附带 `timed_out: true` 和值为 `"timeout"` 或 `"idle_timeout"` +的 `timeout_kind`,表明触发的是哪一个。 + +## 解析结构化文本 + +`Rune::Parsers::TableParser` 和 `Rune::Parsers::KeyValueParser` 能把非结构化的终端输出转换 +为 Ruby 哈希: + +```ruby +require 'rune' + +Rune::Parsers::TableParser.parse(<<~TABLE) + NAME STATUS VERSION + fledge-plugin active 1.0.0 +TABLE +# => [{ name: 'fledge-plugin', status: 'active', version: '1.0.0' }] +``` + +`TableParser.parse` 接受一个 `format:` 关键字参数(默认为 `:auto`,也可用 `:pipe`/`:space` +强制指定解析模式)—— 在对不熟悉的输出依赖 `:auto` 之前,请先查阅 +[`specs/parsers/parsers.spec.md`](../specs/parsers/parsers.spec.md) 中关于该启发式方法已 +知局限的说明。 + +## 下一步 + +- [`examples/smoke_test.rb`](../examples/smoke_test.rb) —— 运行 `ruby examples/smoke_test.rb` + 或 `fledge run smoke-test`。一场独立的、基于断言的真实行为之旅(无需 bundler/rspec): + 输出模式、`--timeout` 校验、解析器、`Script`、信号转发、提示符检测。 +- [`examples/humans/demo_tui.rb`](../examples/humans/demo_tui.rb) —— 上文 `rune watch` + 一节贯穿使用的交互式演示程序。[`examples/agents/pty_runner_example.rb`](../examples/agents/pty_runner_example.rb)、 + [`table_parser_example.rb`](../examples/agents/table_parser_example.rb) 和 + [`script_automation_example.rb`](../examples/agents/script_automation_example.rb) 是更小 + 的单一概念脚本 —— 每个都可以直接运行(`ruby examples/agents/.rb`),除了 + `require_relative '../lib/rune'` 之外无需任何准备。 +- [PTY 架构指南](pty_architecture.md) —— PTY 运行器、流读取、提示符检测以及 + `rune watch` 实时透传的内部工作原理。 +- [`specs/`](../specs/) —— `cli`、`parsers`、`pty_runner`、`session`、`watch` 各模块的 + 机器校验契约(`spec-sync`)。 +- [`AGENTS.md`](../AGENTS.md) —— 添加新命令以及配合信任工具链工作的约定。 diff --git a/lib/rune/commands/session_command.rb b/lib/rune/commands/session_command.rb index 5bde243..040582f 100644 --- a/lib/rune/commands/session_command.rb +++ b/lib/rune/commands/session_command.rb @@ -78,6 +78,10 @@ class SessionCommand < Command CLIENT_TIMEOUT_MARGIN = 15.0 # How many codenames a start without --name will try before giving up. # Each retry is another process having claimed the one it picked. + # A shell reports 127 for a command it could not find, so a child that exits 127 before the + # session is even ready never ran. + EXEC_FAILURE_STATUS = 127 + GENERATED_NAME_ATTEMPTS = 5 VALUE_FLAGS = { @@ -278,10 +282,31 @@ def launch(name, command) # "No such session", and `list` — the remedy the error suggests — showed # an empty array, actively confirming the wrong conclusion. Reported by # someone who was about to debug the wrong program. - Result.success({ action: 'start', name: name, command: command, - project: store.project, - child_pid: meta[:child_pid], supervisor_pid: pid, - state: meta[:state], exit_code: meta[:exit_code] }.compact) + payload = { action: 'start', name: name, command: command, project: store.project, + child_pid: meta[:child_pid], supervisor_pid: pid, + state: meta[:state], exit_code: meta[:exit_code] }.compact + launch_failure(name, meta) || Result.success(payload) + end + + # A launch that never happened is a failure, not a success with a field to check. + # + # `start -- a_binary_that_is_not_there` returned `status: "ok"` with `state: "exited"` and + # `exit_code: 127`, so a caller checking `status` — the field whose entire job is to say + # whether the call worked — saw success. It was documented as a gotcha ("check `state`"), + # which is the wrong shape of answer: an envelope should not need a footnote to be read + # correctly. Reported from a real 22-minute drive, where it cost an hour. + # + # Only 127 fails, deliberately. `start -- true` exits 0 immediately and that is a *successful* + # launch of a program that had nothing to do; treating any prompt exit as a failure would + # break every short-lived child. 127 is the shell's "command not found", which is the one + # case where the child never ran at all. + def launch_failure(name, meta) + return nil unless meta[:exit_code] == EXEC_FAILURE_STATUS + + abandon(name, meta[:supervisor_pid]) + Result.failure("Could not start #{name.inspect}: the command exited #{EXEC_FAILURE_STATUS} " \ + 'immediately, which is what a shell reports for a command that is not on PATH. ' \ + 'Check the command name and that it is installed.') end # Re-invokes rune's own executable rather than forking in-process: a fork @@ -1179,7 +1204,34 @@ def name_error(name) 'starting with a letter or digit.' end - def no_such_session(name) = "No such session: #{name.inspect}. Run 'rune session list'." + # Says where the session actually is, when it is somewhere. + # + # The old message was confidently wrong and its remedy confirmed the error: a session started + # in one directory and read from another got `No such session: "s3". Run 'rune session list'.` + # — and `list`, scoped to the caller's own project, returned nothing, which reads as proof + # the session died. Two people who had read the guide's warning about directory scoping hit + # this anyway, one of them mid-way through debugging the child instead. + # + # rune already knows the answer: `--all-projects` finds it. An error that can name the project + # should name it rather than send the reader to a command that shows them nothing. + def no_such_session(name) + elsewhere = projects_holding(name) + return "No such session: #{name.inspect}. Run 'rune session list'." if elsewhere.empty? + + "No session #{name.inspect} in this project (#{store.project}), but it exists in " \ + "#{elsewhere.length == 1 ? elsewhere.first.inspect : elsewhere.map(&:inspect).join(', ')}. " \ + 'A project is the working directory, so `cd` there, or run `rune session list --all-projects`.' + end + + # Cheap: a directory listing per project, no transcripts opened. Rescued because a + # best-effort hint must never turn a clear error into a crash. + def projects_holding(name) + Session::Store.projects(store.home).reject { |project| project == store.project }.select do |project| + Session::Store.new(home: store.home, project: project).exist?(name) + end + rescue StandardError + [] + end def render_list(data, io) return io.puts('No sessions.') if data[:sessions].empty? diff --git a/lib/rune/parsers/character_width.rb b/lib/rune/parsers/character_width.rb index 56c9d97..891cbf4 100644 --- a/lib/rune/parsers/character_width.rb +++ b/lib/rune/parsers/character_width.rb @@ -25,10 +25,41 @@ module CharacterWidth # output is overwhelmingly ASCII and this runs once per character written to the grid. ASCII_CEILING = 0x0300 + # Nonspacing marks (Mn) and enclosing marks (Me) only, which is the `wcwidth` convention every + # terminal follows: a *spacing* combining mark (Mc) advances the cursor and is width 1. + # + # The first version of this table covered Latin, Greek, Cyrillic, Hebrew, Arabic and Thai and + # omitted every Indic script, so `हिन्दी` was charged six columns for six codepoints. A report + # from translating the guide into Hindi framed that as "one column per codepoint", which + # overstates it in a way worth not copying: U+093F is Mc and legitimately takes a column, so + # zeroing every Indic mark would be as wrong in the other direction. What the fix is, is the + # Mn/Me subset — the virama and the vowel signs written above and below. + # + # Terminals genuinely disagree about the *shaping* of an Indic cluster, and this does not try + # to settle that: it follows `wcwidth`, so `हिन्दी` is five columns here and in xterm, not the + # three a shaping engine would draw. ZERO = [ 0x0300..0x036F, 0x0483..0x0489, 0x0591..0x05BD, 0x0610..0x061A, - 0x064B..0x065F, 0x0670..0x0670, 0x06D6..0x06DC, 0x0E31..0x0E31, - 0x0E34..0x0E3A, 0x0E47..0x0E4E, 0x1AB0..0x1AFF, 0x1DC0..0x1DFF, + 0x064B..0x065F, 0x0670..0x0670, 0x06D6..0x06DC, + # Devanagari, Bengali, Gurmukhi, Gujarati + 0x0900..0x0902, 0x093A..0x093A, 0x093C..0x093C, 0x0941..0x0948, + 0x094D..0x094D, 0x0951..0x0957, 0x0962..0x0963, + 0x0981..0x0981, 0x09BC..0x09BC, 0x09C1..0x09C4, 0x09CD..0x09CD, 0x09E2..0x09E3, + 0x0A01..0x0A02, 0x0A3C..0x0A3C, 0x0A41..0x0A42, 0x0A47..0x0A48, + 0x0A4B..0x0A4D, 0x0A51..0x0A51, 0x0A70..0x0A71, 0x0A75..0x0A75, + 0x0A81..0x0A82, 0x0ABC..0x0ABC, 0x0AC1..0x0AC5, 0x0AC7..0x0AC8, + 0x0ACD..0x0ACD, 0x0AE2..0x0AE3, + # Oriya, Tamil, Telugu, Kannada, Malayalam, Sinhala + 0x0B01..0x0B01, 0x0B3C..0x0B3C, 0x0B3F..0x0B3F, 0x0B41..0x0B44, + 0x0B4D..0x0B4D, 0x0B55..0x0B56, 0x0B62..0x0B63, + 0x0B82..0x0B82, 0x0BC0..0x0BC0, 0x0BCD..0x0BCD, + 0x0C00..0x0C00, 0x0C04..0x0C04, 0x0C3E..0x0C40, 0x0C46..0x0C48, + 0x0C4A..0x0C4D, 0x0C55..0x0C56, 0x0C62..0x0C63, + 0x0C81..0x0C81, 0x0CBC..0x0CBC, 0x0CBF..0x0CBF, 0x0CC6..0x0CC6, + 0x0CCC..0x0CCD, 0x0CE2..0x0CE3, + 0x0D00..0x0D01, 0x0D3B..0x0D3C, 0x0D41..0x0D44, 0x0D4D..0x0D4D, 0x0D62..0x0D63, + 0x0D81..0x0D81, 0x0DCA..0x0DCA, 0x0DD2..0x0DD4, 0x0DD6..0x0DD6, + 0x0E31..0x0E31, 0x0E34..0x0E3A, 0x0E47..0x0E4E, 0x1AB0..0x1AFF, 0x1DC0..0x1DFF, 0x200B..0x200F, 0x2060..0x2064, 0x20D0..0x20F0, 0xFE00..0xFE0F, 0xFE20..0xFE2F, 0xE0100..0xE01EF ].freeze diff --git a/lib/rune/parsers/screen_renderer.rb b/lib/rune/parsers/screen_renderer.rb index a978a54..768d9aa 100644 --- a/lib/rune/parsers/screen_renderer.rb +++ b/lib/rune/parsers/screen_renderer.rb @@ -201,7 +201,13 @@ def tail(text, tail_bytes) # dropping to the next escape in a stream that has none would discard # the whole screen. def resync(window) - escape = window.byteslice(0, RESYNC_SCAN_BYTES).to_s.index("\e") + # `.b` first, because `String#index` counts characters and `byteslice` counts bytes. On a + # multi-byte head the two differ by however many extra bytes it holds, so the slice landed + # early — measured on `日本語テキスト\e[1mAFTER`, the ESC is at character 7 and byte 21, and + # resync cut at 7: it returned `"\xAA\x9Eテキスト\e[1mAFTER"`, both cutting a character in + # half and failing to drop the pre-ESC remainder it exists to drop. `byteindex` would say + # this directly but arrived in Ruby 3.2, and this gem supports 3.0. + escape = window.byteslice(0, RESYNC_SCAN_BYTES).to_s.b.index("\e") return window if escape.nil? || escape.zero? window.byteslice(escape..).to_s diff --git a/spec/rune/parsers/screen_renderer_spec.rb b/spec/rune/parsers/screen_renderer_spec.rb index 2e5baec..5451f13 100644 --- a/spec/rune/parsers/screen_renderer_spec.rb +++ b/spec/rune/parsers/screen_renderer_spec.rb @@ -279,11 +279,58 @@ expect(frame.index('|')).to eq(3) end + # The first width table covered Latin, Greek, Cyrillic, Hebrew, Arabic and Thai and omitted + # every Indic script, so `हिन्दी` was charged six columns for six codepoints. Nonspacing marks + # only: U+093F is a *spacing* mark and takes a column in wcwidth and in xterm, so zeroing every + # Indic mark would be wrong in the other direction. + it 'gives Indic nonspacing marks no column, and spacing marks one' do + width = Rune::Parsers::CharacterWidth + expect(width.of("\u094D")).to eq(0) + expect(width.of("\u0941")).to eq(0) + expect(width.of("\u09CD")).to eq(0) + expect(width.of("\u0BCD")).to eq(0) + expect(width.of("\u093F")).to eq(1) + end + + it 'charges हिन्दी the five columns wcwidth and xterm charge it' do + expect('हिन्दी'.each_char.sum { |c| Rune::Parsers::CharacterWidth.of(c) }).to eq(5) + end + it 'leaves plain ASCII untouched' do expect(described_class.render("\e[HABC\e[1;3HX", rows: 3, columns: 20)).to eq('ABX') end end + # `resync` drops whatever precedes the first escape in a truncated window, so a render never + # starts mid-sequence. It searched with `String#index`, which counts characters, and sliced with + # `byteslice`, which counts bytes — so on a multi-byte head it cut early, both splitting a + # character and leaving the remainder it exists to drop. + describe 'resynchronising a window that was cut mid-stream' do + def resync(window) + described_class.singleton_class.instance_method(:resync).bind(described_class).call(window) + end + + it 'drops the remainder before the first escape when the head is multi-byte' do + expect(resync("日本語テキスト\e[1mAFTER")).to eq("\e[1mAFTER") + end + + it 'produces no invalid bytes, so the cut never lands inside a character' do + expect(resync("日本語テキスト\e[1mAFTER")).to be_valid_encoding + end + + it 'still drops an ASCII remainder' do + expect(resync("plain text\e[1mAFTER")).to eq("\e[1mAFTER") + end + + it 'leaves a window that already starts at an escape alone' do + expect(resync("\e[1mAFTER")).to eq("\e[1mAFTER") + end + + it 'leaves a window with no escape at all alone, rather than discarding the screen' do + expect(resync('no escapes here')).to eq('no escapes here') + end + end + # A TUI turns autowrap off to paint the last cell of a row without scrolling # the screen out from under itself. describe 'autowrap (DECAWM)' do diff --git a/spec/rune/session_spec.rb b/spec/rune/session_spec.rb index 38fbce6..5257c2e 100644 --- a/spec/rune/session_spec.rb +++ b/spec/rune/session_spec.rb @@ -50,6 +50,16 @@ def session(*args) described_class.new.call(args.map(&:to_s), {}) end + # Runs a block as though the caller were in a different working directory, which is what a rune + # project is. `Dir.chdir` is process-global, so the block is kept to the one call. + def in_project(dirname, &block) + Dir.mktmpdir do |root| + other = File.join(root, dirname) + Dir.mkdir(other) + Dir.chdir(other, &block) + end + end + def start_session(name, command) result = session('start', "--name=#{name}", '--', *command) raise "start failed: #{result.error}" if result.failure? @@ -783,6 +793,66 @@ def attach_to(name) end end + # A session started in one directory and read from another got "No such session", and the remedy + # that error printed — `rune session list` — is scoped to the caller's own project and returns + # nothing, which reads as proof the session died. Two people who had read the guide's warning + # about directory scoping hit it anyway; one went off to debug the child. + # `start` with a binary that is not on PATH returned status "ok" with exit_code 127, so a caller + # checking the field whose job is to say whether the call worked saw success. It was documented + # as "check state instead", which is the wrong shape of answer — an envelope should not need a + # footnote to be read correctly. Reported from a real drive where it cost an hour. + describe 'a launch that never happened' do + it 'reports failure rather than success with a field to check' do + result = session('start', '--name=ghost', '--', 'definitely_not_a_real_binary_xyz') + + expect(result).to be_failure + expect(result.error).to include('127').and include('PATH') + end + + # A child that exits 0 immediately launched fine and had nothing to do. Treating any prompt + # exit as a failure would break every short-lived child, so only 127 fails. + it 'still succeeds for a child that exits cleanly and at once' do + expect(session('start', '--name=quick', '--', 'true')).to be_success + end + + # The record is kept deliberately. `start` failing loudly is the fix; deleting the transcript + # that shows *why* would replace one quiet failure with another, and `list` reporting it as + # exited with 127 is exactly the diagnosis a caller needs. + it 'keeps the record visibly dead rather than deleting the evidence' do + session('start', '--name=ghost2', '--', 'definitely_not_a_real_binary_xyz') + + entry = session('list').data[:sessions].find { |s| s[:name] == 'ghost2' } + + # `dead` rather than `exited`: the supervisor is abandoned with the launch, so what `list` + # reports is a session whose supervisor is gone and whose child exited 127 — which is the + # diagnosis, and is what the record is kept for. + expect(entry[:exit_code]).to eq(127) + expect(entry[:state]).not_to eq('running') + end + end + + describe 'a session that exists in another project' do + it 'names the project it is in, rather than claiming it does not exist' do + start_session('scoped', %w[cat]) + + error = in_project('elsewhere') { session('read', '--name=scoped') }.error + + expect(error).to include('scoped').and include('another project').or include('exists in') + expect(error).not_to include('No such session') + end + + it 'points at a remedy that actually shows it' do + start_session('scoped2', %w[cat]) + + expect(in_project('elsewhere') { session('read', '--name=scoped2') }.error) + .to include('--all-projects') + end + + it 'keeps the plain message for a session that exists nowhere' do + expect(session('read', '--name=neverexisted').error).to include('No such session') + end + end + describe 'naming, project scope, and archiving' do it 'generates a - codename when --name is omitted' do result = session('start', '--', 'bash', '--norc', '-i') diff --git a/specs/parsers/parsers.spec.md b/specs/parsers/parsers.spec.md index a1a75a4..dddd8cd 100644 --- a/specs/parsers/parsers.spec.md +++ b/specs/parsers/parsers.spec.md @@ -1,6 +1,6 @@ --- module: parsers -version: 14 +version: 15 status: active files: - lib/rune/parsers/table_parser.rb @@ -297,3 +297,4 @@ Text parsing utilities for `rune`. Converts unstructured terminal text, tables, | 2026-08-17 | CHG-0064-honour-the-modes-and-charsets-that-decide-what-the-screen-contains-and-strip-th: Honour the modes and charsets that decide what the screen contains, and strip the escapes the sanitizer missed | | 2026-08-17 | CHG-0065-record-that-the-wide-character-cell-model-was-built-and-measured-worse-than-the: Record that the wide-character cell model was built and measured worse than the gap | | 2026-08-18 | CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw: Give the screen a cell model so a wide glyph occupies the two columns it is drawn in | +| 2026-08-18 | CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby: Make a failed launch loud, name the project a session is in, and fix two multibyte defects | diff --git a/specs/session/session.spec.md b/specs/session/session.spec.md index c459442..aa70dcb 100644 --- a/specs/session/session.spec.md +++ b/specs/session/session.spec.md @@ -1,6 +1,6 @@ --- module: session -version: 33 +version: 34 status: active files: - lib/rune/session/store.rb @@ -225,6 +225,7 @@ deciding who talks to whom stays the calling agent's job. | `integer` | internal method | Parses an integer flag value, enforcing positivity where required. | | `name_error` | internal method | Builds the message for a missing or invalid session name. | | `no_such_session` | internal method | Builds the message for an unknown session name. | +| `projects_holding` | internal method | The other projects that hold a session of this name, for the scoping hint. | | `render_list` | internal method | Renders the session list for a terminal. | | `render_orphan` | internal method | Prints the warning line naming a session's orphaned child pid, if it has one. | | `render_archive` | internal method | Renders an `archive` reply, printing the orphaned-child warning after the envelope. | @@ -332,6 +333,8 @@ deciding who talks to whom stays the calling agent's job. | `start_rejection` | internal method | Returns the failure that blocks a start, or nil to proceed. | | `running_conflict` | internal method | Returns a failure when the name already has a live supervisor. | | `launch` | internal method | Creates session state, spawns the supervisor, and waits for readiness. | +| `launch_failure` | internal method | Turns a child that exited 127 at launch into a failed result. | +| `EXEC_FAILURE_STATUS` | constant | 127, the shell's report for a command that is not on PATH. | | `spawn_supervisor` | internal method | Re-invokes rune's executable as the detached supervisor for one session. | > Note: `conclude`, `handshake`, `with_raw_terminal`, `connect`, `name_base`, `socket_live?`, @@ -919,6 +922,30 @@ deciding who talks to whom stays the calling agent's job. message `rune run` has always used. Accepting both applied whichever `bound_size` tested first, so the caller silently got the other one. +52. A launch that never happened returns `status: error`. `start` with a command that is not on + PATH used to return `status: "ok"` with `state: "exited"` and `exit_code: 127`, so a caller + checking the field whose entire job is to say whether the call worked saw success. It was + documented as "check `state` instead", which is the wrong shape of answer — an envelope should + not need a footnote to be read correctly. Reported from a 22-minute real drive, where it cost + an hour. + + Only 127 fails, and that is deliberate: `start -- true` exits 0 immediately and is a + *successful* launch of a program that had nothing to do, so treating any prompt exit as failure + would break every short-lived child. 127 is the shell's "command not found" — the one case + where the child never ran. + + The session record is kept rather than deleted. `start` failing loudly is the fix; removing the + transcript that shows why would trade one quiet failure for another, and `list` reporting the + session as dead with `exit_code: 127` is the diagnosis a caller needs. + +53. An error naming a session says where the session actually is, when it is somewhere. A + session started in one directory and read from another got `No such session`, and the remedy + that error printed — `rune session list` — is scoped to the caller's own project and returns + nothing, which reads as proof the session died. rune knew the answer the whole time: + `--all-projects` finds it. It has now caught three separate readers, two of whom had read the + guide's warning about directory scoping first, which is when a documented gotcha stops being a + documentation problem. + ## Behavioral Examples - `rune session start -- grok` returns immediately with a generated name such as `grok-amber`; @@ -1115,3 +1142,4 @@ deciding who talks to whom stays the calling agent's job. | 2026-08-18 | CHG-0067-make-tail-count-a-carriage-return-as-a-line-break-and-report-matched-on-a-reg: Make --tail count a carriage return as a line break, and report matched on a regex send's timeout | | 2026-08-18 | CHG-0068-correct-the-flag-message-run-gets-wrong-and-the-five-contracts-the-dogfood-foun: Correct the flag message run gets wrong, and the five contracts the dogfood found documented wrong | | 2026-08-18 | CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not: Guard the flags watch was executing, and bound the two fields max-output was not | +| 2026-08-18 | CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby: Make a failed launch loud, name the project a session is in, and fix two multibyte defects | From 765a961015e6ddcbe7a417962ab7b86ec77a8289 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Tue, 18 Aug 2026 14:02:28 -0600 Subject: [PATCH 4/4] Merge origin/main and fix a CI-only abandon race CodeQL's review missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries three things: the merge needed to keep this stacked PR's specsync evidence reachable after upstream squash-merges, a re-anchor I forgot to commit in the previous push, and a real race CI found on the resulting tree. The merge itself needed real conflict resolution, not just a repeat of the mechanical squash-SHA trap. screen.rb, its spec and both spec docs had genuinely diverged: my branch predates the CodeQL regex fix (#66), so origin's side won on to_s and the resync/Indic tests were additive on both sides. One correction caught before it landed: I initially took origin's copy of character_width.rb wholesale, which would have silently dropped this branch's own Indic-marks fix — caught by grep and the full suite before committing, not after. CI then found what local runs did not. Ruby 3.4's job failed a test this PR added: `abandon` sends SIGKILL and immediately writes state: 'failed', but SIGKILL is asynchronous, so `list` right after could still see the supervisor as alive and report 'running' — describe deliberately recomputes state from real process liveness rather than trusting the record, precisely so a supervisor killed without its cooperation is never reported as-is, which is exactly the case a not-yet-dead abandon target is. stop hit this identical shape once already, and the fix is already written down at its own await_death call: "SIGKILL is asynchronous... the very next command saw the session as running." abandon had the same fire-and-kill shape without the wait. It now calls await_death before recording the failure, matching stop exactly. Passed 3/3 full local runs before this was understood as a real race rather than the flake noted in #68's own PR body — worth correcting: that was this bug, present since abandon was written, and CI's slower runner is what surfaced it. 607 examples, 0 failures. specsync 32/32 files, 7431/7431 LOC. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018rf59AtQmJcodUJ6vXDZNY --- .../approvals.json | 485 +++++++++++++++++- .../state.json | 2 +- .../verification-attempts.json | 60 +++ .../verification.json | 20 +- .../approvals.json | 121 +++++ .../state.json | 2 +- .../verification-attempts.json | 30 ++ .../verification.json | 20 +- .../approvals.json | 325 +++++++++++- .../state.json | 2 +- .../verification-attempts.json | 60 +++ .../verification.json | 20 +- lib/rune/commands/session_command.rb | 11 +- specs/session/session.spec.md | 10 + 14 files changed, 1131 insertions(+), 37 deletions(-) diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json index 655c3de..4540f38 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/approvals.json @@ -27,7 +27,490 @@ "timestamp": 1787038297, "digest": "351fbb5c5a60597a54463c7905c1ac128cd4751a189c81949945d300897a4f65", "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787078872, + "digest": "03b2f4cb0fca9454a064fdea57dc8fd41c31ae8abc2e4e20118a84ce830aad34", + "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787082419, + "digest": "d414cd70661b778cde429a59fbacfcebc3256f445dabdf58dd04f4c96637e183", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "actor": "claude", + "reason": "merging origin/main into leif/docs-i18n (to preserve accepted-evidence commit reachability across a stacked PR chain) touched shared files these changes deliver; re-verifying against the merged tree", + "timestamp": 1787078699, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787038297, + "digest": "351fbb5c5a60597a54463c7905c1ac128cd4751a189c81949945d300897a4f65", + "note": null + }, + "prior_verification": { + "timestamp": 1787038293, + "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", + "acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "b22059658bcc96377420c687826bca763224b3ec26d9904f10192613f8b27212", + "entry_digest": "937c9cad43aa6eb8104df3d6e3d160db8c1befd414f3546b56fa660f7d1f8a1a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7c0883544e25b1c19eadfa330277339cc5984f86b6a608404ed5d5f919315322", + "entry_digest": "bac54e82ba25bf4202e51cef7d7f9e0e0606a30a37cd75baf367ac1598ec7f40", + "owners": [ + "cli" + ] + }, + { + "path": "lib/rune/commands/run_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f563628c917a909e26f524b68ab459f7e03efc9b01a9ccf32bb9f865347479b9", + "entry_digest": "2b9d735b4d5ebb1a7797bc7470b5c079d52a147dca4caf566c6a1d4f20ccf19f", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "c5e257ca12673bc0fd5267e69c0c5bf84369019dd1279b92a75abd2bea1dff45", + "entry_digest": "fda0508c6bfcdeff985e2362b5c1da88ad4e4fabab318a824d6df88c98089e76", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/commands/watch_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f78ec60ff4f17ed0739e8044b60cbee1e527627fcd57165e110f6f00fce40a7b", + "entry_digest": "9c15c7c063bbb33d736cf2ae011f327eab3fd76c9065baa6858ee9768d9fc6b1", + "owners": [ + "watch" + ] + }, + { + "path": "lib/rune/pty_runner.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "3721d699c8e934b706c6db570f2a0dfd3fd12ce003068328d63b1a4de6f75a26", + "entry_digest": "4efceba9d053a8cad51cb95986a36e94c4e9e74fe6e29ecff1b19dc3b2d40c3c", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/session/transcript.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "a8ff1157444c46abfc9746ed3d24068eca939606b13c647fb4b558bdda2147c5", + "entry_digest": "6909005a3771f84fe747776fc7f3a1cf74175a6554fcc36e4b7583356c1c5fbf", + "owners": [ + "session" + ] + }, + { + "path": "spec/rune/commands/watch_command_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "35243817b52618f480756094d672039a1c17d558c4cf5d4c8aac43079e78ba28", + "entry_digest": "5e7374088bf1b46ae354b76903b6f0c98b9cca0e2e3f4f77399dfe7d3118fb49", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/pty_runner_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "6dcf23ad4d7f1d7b4640c8a58e322183444cf1a6370356cad8882af8085a41be", + "entry_digest": "5cfe1ea1e9a52b3741290b512289e20e85af6dad39ac27485e372966ec1165c4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "447eced43e773ba84e9d879db75ee7fd563619dcb86135378d3afd0390d96eea", + "entry_digest": "a1c55527a1b1ba0206ca2f59c222241b7c4d3a7567941d2f31e3c014a7ae53fb", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/cli/cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ce1bbc0237e58537ce3aa6a3c6f35e842d298bf4ac1887940ea2101d15d3d328", + "entry_digest": "333940b198a250142aa060972c0da362b30ae18a3c3bb08dd54251c588a23ee7", + "owners": [ + "cli" + ] + }, + { + "path": "specs/cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b3223697f7d83909e9718f17e068a205d0717ed45b65df647701dffee8157858", + "entry_digest": "4bdce8f159673c64d8ee23db590ddcf7480f1e276ee48d86e2f1260b9a730ca6", + "owners": [ + "cli" + ] + }, + { + "path": "specs/pty_runner/pty_runner.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "59206e9e7948f0f15fd1a4b51dc1cfabe5ec847a900f61b8452f7da43061e5ae", + "entry_digest": "a72cc0d9edbae9e6e3fc301cfac535406686c38987159e084fbca3981fb756a1", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/pty_runner/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3070f0d5d578baa2f058503359b33d17c02641731aec9e820e782f4e58813fcf", + "entry_digest": "6e141c1e84e140b7147f958068fa75dd6c0e22153ff3c50fe8782499d2f7dfb7", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f62b06e4a54c65cc23b9da0aaa6311a639d5e7cfbebe89e2053aaf83093248bc", + "entry_digest": "5f1ab883fc19211ef586f88218a956cbd75a7041e6943b3c3c0800b42a177e69", + "owners": [ + "session" + ] + }, + { + "path": "specs/watch/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4120dc4e6971fe20155d57e0110ef66f1dc8560a9471fffb954222cb6d7fd81a", + "entry_digest": "4396e75507693c7f324e619de816b9b20564813b26b608ac4bc577d26759bbf2", + "owners": [ + "watch" + ] + }, + { + "path": "specs/watch/watch.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f36197540bce91f8e639df54d3bb0af55a0137c1344e9fd9c45bfb55d6cf6da7", + "entry_digest": "f66be4ba003f4d6805ea541fa4c288036e1d16c4ac1a7ebdf991f5e9b55ad767", + "owners": [ + "watch" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "current_acceptance_input_digest": "eacab9d75eb7d0c86a943aeecfb300eef15e2c437bcafaa3fc8227ab5540524c" + }, + { + "schema_version": 1, + "change_id": "CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not", + "actor": "claude", + "reason": "abandon now waits for its SIGKILL to land before recording the failed state, using the same await_death stop already relies on -- fixes a CI-only race (Ruby 3.4 runner) where a just-abandoned launch could still show as running in the very next list. session_command.rb changed as a result.", + "timestamp": 1787082247, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787078872, + "digest": "03b2f4cb0fca9454a064fdea57dc8fd41c31ae8abc2e4e20118a84ce830aad34", + "note": null + }, + "prior_verification": { + "timestamp": 1787078866, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "acceptance_input_digest": "eacab9d75eb7d0c86a943aeecfb300eef15e2c437bcafaa3fc8227ab5540524c", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "b22059658bcc96377420c687826bca763224b3ec26d9904f10192613f8b27212", + "entry_digest": "937c9cad43aa6eb8104df3d6e3d160db8c1befd414f3546b56fa660f7d1f8a1a", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7c0883544e25b1c19eadfa330277339cc5984f86b6a608404ed5d5f919315322", + "entry_digest": "bac54e82ba25bf4202e51cef7d7f9e0e0606a30a37cd75baf367ac1598ec7f40", + "owners": [ + "cli" + ] + }, + { + "path": "lib/rune/commands/run_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f563628c917a909e26f524b68ab459f7e03efc9b01a9ccf32bb9f865347479b9", + "entry_digest": "2b9d735b4d5ebb1a7797bc7470b5c079d52a147dca4caf566c6a1d4f20ccf19f", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", + "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/commands/watch_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "f78ec60ff4f17ed0739e8044b60cbee1e527627fcd57165e110f6f00fce40a7b", + "entry_digest": "9c15c7c063bbb33d736cf2ae011f327eab3fd76c9065baa6858ee9768d9fc6b1", + "owners": [ + "watch" + ] + }, + { + "path": "lib/rune/pty_runner.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "3721d699c8e934b706c6db570f2a0dfd3fd12ce003068328d63b1a4de6f75a26", + "entry_digest": "4efceba9d053a8cad51cb95986a36e94c4e9e74fe6e29ecff1b19dc3b2d40c3c", + "owners": [ + "pty_runner" + ] + }, + { + "path": "lib/rune/session/transcript.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "a8ff1157444c46abfc9746ed3d24068eca939606b13c647fb4b558bdda2147c5", + "entry_digest": "6909005a3771f84fe747776fc7f3a1cf74175a6554fcc36e4b7583356c1c5fbf", + "owners": [ + "session" + ] + }, + { + "path": "spec/rune/commands/watch_command_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "35243817b52618f480756094d672039a1c17d558c4cf5d4c8aac43079e78ba28", + "entry_digest": "5e7374088bf1b46ae354b76903b6f0c98b9cca0e2e3f4f77399dfe7d3118fb49", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/pty_runner_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "6dcf23ad4d7f1d7b4640c8a58e322183444cf1a6370356cad8882af8085a41be", + "entry_digest": "5cfe1ea1e9a52b3741290b512289e20e85af6dad39ac27485e372966ec1165c4", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/cli/cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ce1bbc0237e58537ce3aa6a3c6f35e842d298bf4ac1887940ea2101d15d3d328", + "entry_digest": "333940b198a250142aa060972c0da362b30ae18a3c3bb08dd54251c588a23ee7", + "owners": [ + "cli" + ] + }, + { + "path": "specs/cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b3223697f7d83909e9718f17e068a205d0717ed45b65df647701dffee8157858", + "entry_digest": "4bdce8f159673c64d8ee23db590ddcf7480f1e276ee48d86e2f1260b9a730ca6", + "owners": [ + "cli" + ] + }, + { + "path": "specs/pty_runner/pty_runner.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "59206e9e7948f0f15fd1a4b51dc1cfabe5ec847a900f61b8452f7da43061e5ae", + "entry_digest": "a72cc0d9edbae9e6e3fc301cfac535406686c38987159e084fbca3981fb756a1", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/pty_runner/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "3070f0d5d578baa2f058503359b33d17c02641731aec9e820e782f4e58813fcf", + "entry_digest": "6e141c1e84e140b7147f958068fa75dd6c0e22153ff3c50fe8782499d2f7dfb7", + "owners": [ + "pty_runner" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", + "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", + "owners": [ + "session" + ] + }, + { + "path": "specs/watch/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4120dc4e6971fe20155d57e0110ef66f1dc8560a9471fffb954222cb6d7fd81a", + "entry_digest": "4396e75507693c7f324e619de816b9b20564813b26b608ac4bc577d26759bbf2", + "owners": [ + "watch" + ] + }, + { + "path": "specs/watch/watch.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "f36197540bce91f8e639df54d3bb0af55a0137c1344e9fd9c45bfb55d6cf6da7", + "entry_digest": "f66be4ba003f4d6805ea541fa4c288036e1d16c4ac1a7ebdf991f5e9b55ad767", + "owners": [ + "watch" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "eacab9d75eb7d0c86a943aeecfb300eef15e2c437bcafaa3fc8227ab5540524c", + "current_acceptance_input_digest": "27fbb3c059ec3757c814238bc72bc2f4e554696cc26e3738ed57e0a48aa30475" + } + ] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json index 6f82334..989b5fa 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", "created_at": 1787038047, - "updated_at": 1787038297, + "updated_at": 1787082419, "affected_specs": [ "watch", "pty_runner", diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json index ed67b5f..8ca57d0 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification-attempts.json @@ -30,6 +30,66 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1787078866, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1787082413, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", + "workspace_digest": "f3b557a9feabd258d7ff3377b72e97846ecd3e2ff67a426209cc7315cf265c09", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json index d2f485c..dbfd9cd 100644 --- a/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json +++ b/.specsync/changes/CHG-0069-guard-the-flags-watch-was-executing-and-bound-the-two-fields-max-output-was-not/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1787038293, - "commit": "ad76e2237bb8215f77d4cd7bb8358cc6083a61f2", + "timestamp": 1787082413, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", "contract_digest": "c94fbf53c54c9849203f069b3e27b5099737b803485ac25b9c52c7cf94b74ff7", - "workspace_digest": "5996effd28234eb684fa3bfbbeda3596889f1a5a939ee367b2d912e750e204a0", - "acceptance_input_digest": "14c2d92c63095ba01b32895d9fb904d63dcb9493c0efe66a40d6a7066e83b791", + "workspace_digest": "f3b557a9feabd258d7ff3377b72e97846ecd3e2ff67a426209cc7315cf265c09", + "acceptance_input_digest": "27fbb3c059ec3757c814238bc72bc2f4e554696cc26e3738ed57e0a48aa30475", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -41,8 +41,8 @@ "path": "lib/rune/commands/session_command.rb", "kind": "file", "mode": 33188, - "payload_digest": "c5e257ca12673bc0fd5267e69c0c5bf84369019dd1279b92a75abd2bea1dff45", - "entry_digest": "fda0508c6bfcdeff985e2362b5c1da88ad4e4fabab318a824d6df88c98089e76", + "payload_digest": "5e2b427d01e1dc6e3ddd6e25d7319736c57ddbcae53392e9b96997260dce49f5", + "entry_digest": "05826424a54284dcc66da4f16501ec4182a88eb4df6341e54cb27058b97da694", "owners": [ "session" ] @@ -101,8 +101,8 @@ "path": "spec/rune/session_spec.rb", "kind": "file", "mode": 33188, - "payload_digest": "447eced43e773ba84e9d879db75ee7fd563619dcb86135378d3afd0390d96eea", - "entry_digest": "a1c55527a1b1ba0206ca2f59c222241b7c4d3a7567941d2f31e3c014a7ae53fb", + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", "owners": [ "@exact:delivery" ] @@ -161,8 +161,8 @@ "path": "specs/session/session.spec.md", "kind": "file", "mode": 33188, - "payload_digest": "f62b06e4a54c65cc23b9da0aaa6311a639d5e7cfbebe89e2053aaf83093248bc", - "entry_digest": "5f1ab883fc19211ef586f88218a956cbd75a7041e6943b3c3c0800b42a177e69", + "payload_digest": "579d32301e0f4c9133e95b34eb842e435d5c2a0bee18fd0c8d8ccd278784438d", + "entry_digest": "168eb5a2e33976cee7944cfcf21515c6f6068e8b918131a385ffc438bb626ee2", "owners": [ "session" ] diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json index 3d5e089..4e4f105 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/approvals.json @@ -20,6 +20,13 @@ "timestamp": 1787077030, "digest": "5a8b36036f9fa0ef21b11b85ed05bb515a4e8433ff2eef0e8dbc42712179a4f8", "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787079059, + "digest": "841b75bc623699704924330d5a17e5148afac841cb7f40e7815859d530ed195b", + "note": null } ], "reopenings": [ @@ -136,6 +143,120 @@ }, "stale_acceptance_input_digest": "4fafd52ed068ecd269e10c9fedbebb9959b05ad3c9e746c938fb30cec73f9be7", "current_acceptance_input_digest": "0c5fe0d006967e89c3d8b0cb4bb52f6972ddbd1f27585fa57d147dcfa45fbdd9" + }, + { + "schema_version": 1, + "change_id": "CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw", + "actor": "claude", + "reason": "merging origin/main into leif/docs-i18n (to preserve accepted-evidence commit reachability across a stacked PR chain) touched shared files these changes deliver; re-verifying against the merged tree", + "timestamp": 1787078878, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787077030, + "digest": "5a8b36036f9fa0ef21b11b85ed05bb515a4e8433ff2eef0e8dbc42712179a4f8", + "note": null + }, + "prior_verification": { + "timestamp": 1787077016, + "commit": "530f9a7ea1ef84dd7eda5b9b2a893ea6f91895de", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "ebf8ef020232180be3e75fce1320143509eb9dee8e06eceb5d658e7d5faf7aa8", + "acceptance_input_digest": "d080b9793f6a9a2f9d3c31a8eb220edb39d6ae8971fe47af1f2d2a70668dcad3", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "57aa7ad9684c8744c3f099bc9b4a7b490fee1519f25f51d891864355c579a49c", + "entry_digest": "49f0761f8dd5bf8a8f09cf2a645da5e09b3abee3809f33212b53093863a1142e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "75e09f7da7574fc2143a461edaecc84c1142a84136e4758cfa7c333b0f81a568", + "entry_digest": "c9b190418c153fbef63629d5acaf9897f988023d86085445c488e977942676e3", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "387995ad02b342304e2d77ee9c0f2e4bb9a0b2ad35aea73fc123eb2a8990157d", + "entry_digest": "6c80299a59accc5cbc44b0f9271de4ddaa6d77f0b1f3d880ed6cd731c056f060", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "30a5c0bdbb46c4ac70f40abcccf3c3a1839418582392e4eea78a96aa30d6604f", + "entry_digest": "75b8b3e108e59291eeb8519ae6af89f1d9e79d824a77aa4b015ddf971ae5ba95", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "0f506c33f810ab723222fa0219dc1488b602febaa35b2ba7ca91097d7ab8d520", + "entry_digest": "7192a40f44587eb45c7bb39bde898a0be757794fccf57c2009f07d9f9cb45505", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "d080b9793f6a9a2f9d3c31a8eb220edb39d6ae8971fe47af1f2d2a70668dcad3", + "current_acceptance_input_digest": "0089029089dd4974f298d98804effc3faac76cf40ffd4d7bb8b88a998d5bf2cb" } ] } diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json index 88535a7..b721bcb 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "ac38dba529ff6cb4838f825b5c3c9594af36b7d1", "created_at": 1787058231, - "updated_at": 1787077030, + "updated_at": 1787079059, "affected_specs": [ "parsers" ], diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json index 79f0bc7..8916198 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification-attempts.json @@ -85,6 +85,36 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1787079053, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json index 23a52a0..2f754aa 100644 --- a/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json +++ b/.specsync/changes/CHG-0070-give-the-screen-a-cell-model-so-a-wide-glyph-occupies-the-two-columns-it-is-draw/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1787077016, - "commit": "530f9a7ea1ef84dd7eda5b9b2a893ea6f91895de", + "timestamp": 1787079053, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", "contract_digest": "19e2e8e17f3f01899766a589f378742e4993deb9ebeb12cb743c241b6df68cc0", - "workspace_digest": "ebf8ef020232180be3e75fce1320143509eb9dee8e06eceb5d658e7d5faf7aa8", - "acceptance_input_digest": "d080b9793f6a9a2f9d3c31a8eb220edb39d6ae8971fe47af1f2d2a70668dcad3", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "acceptance_input_digest": "0089029089dd4974f298d98804effc3faac76cf40ffd4d7bb8b88a998d5bf2cb", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -21,8 +21,8 @@ "path": "lib/rune/parsers/character_width.rb", "kind": "file", "mode": 33188, - "payload_digest": "75e09f7da7574fc2143a461edaecc84c1142a84136e4758cfa7c333b0f81a568", - "entry_digest": "c9b190418c153fbef63629d5acaf9897f988023d86085445c488e977942676e3", + "payload_digest": "fefe3f4ca109ca45c1bfd2349fe185dfe3555082989a55cbdfc9a1149087bfbc", + "entry_digest": "eed613a7dbf151310c19cba34b281e791589c7054961f7eae1856efecf67bdb0", "owners": [ "parsers" ] @@ -41,8 +41,8 @@ "path": "spec/rune/parsers/screen_renderer_spec.rb", "kind": "file", "mode": 33188, - "payload_digest": "30a5c0bdbb46c4ac70f40abcccf3c3a1839418582392e4eea78a96aa30d6604f", - "entry_digest": "75b8b3e108e59291eeb8519ae6af89f1d9e79d824a77aa4b015ddf971ae5ba95", + "payload_digest": "b77784bd015c374038b462d0b66d662eb60d8c3fad5ee55b1d9fd5d51201c34c", + "entry_digest": "a96aab44524968916ababd11bccd3e369106427a8d0369df45975f0a61f94001", "owners": [ "@exact:delivery" ] @@ -51,8 +51,8 @@ "path": "specs/parsers/parsers.spec.md", "kind": "file", "mode": 33188, - "payload_digest": "0f506c33f810ab723222fa0219dc1488b602febaa35b2ba7ca91097d7ab8d520", - "entry_digest": "7192a40f44587eb45c7bb39bde898a0be757794fccf57c2009f07d9f9cb45505", + "payload_digest": "d3242f740bc8ce65716a34942848f12b911b30e74b242681e7a4905d82fcae3a", + "entry_digest": "72df09dafb06bde593202cd51eee288dc201433fc5fca15201a4e9603f7c630b", "owners": [ "parsers" ] diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json index 6b67076..bded8a1 100644 --- a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/approvals.json @@ -13,7 +13,330 @@ "timestamp": 1787065870, "digest": "4daa53f7d60fefe2aa9836155eb1fae184ac0dcd173f0fcc6d9ac9e5e9b756cc", "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787079242, + "digest": "17f1980a044c56f792cabd38d4364f5feedefd70d546bdf6b7dd0064abc67ff1", + "note": null + }, + { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787082616, + "digest": "2c85734890e2cab12fffa5c3aff0734909bf6b55a80905310b0dab6c0aba1a86", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby", + "actor": "claude", + "reason": "merging origin/main into leif/docs-i18n (to preserve accepted-evidence commit reachability across a stacked PR chain) touched shared files these changes deliver; re-verifying against the merged tree", + "timestamp": 1787079063, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787065870, + "digest": "4daa53f7d60fefe2aa9836155eb1fae184ac0dcd173f0fcc6d9ac9e5e9b756cc", + "note": null + }, + "prior_verification": { + "timestamp": 1787065852, + "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "fbf9a273d33f9f9f956bc4059249d33725870c320eb8a95ee09eeade00ea9b3c", + "acceptance_input_digest": "6d19bdabf2c16f1771dc7c016351e4fff940d8f744f73740c386733086460c01", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "cffe0258b98ea4df6a0150d1c1dc3d3c327990f99f4520183ada93eeceaae46e", + "entry_digest": "cce3ce814ebe5d102e72350883ee3524ae9f22183a5c2c248c727294eec22782", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", + "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "fefe3f4ca109ca45c1bfd2349fe185dfe3555082989a55cbdfc9a1149087bfbc", + "entry_digest": "eed613a7dbf151310c19cba34b281e791589c7054961f7eae1856efecf67bdb0", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen_renderer.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "0f50fa04a465f21f3e592fdd1c6f34ced703103f8c095f95311046888b1e7393", + "entry_digest": "f8e99a18c98b1913b06b53b343d0ee2f36f4237191d0ce0314e454ca6b733a16", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "d064a3e93b3cd62baa6bad2d2b1c0b67afedc86fb863f44c659b2774972ec6bb", + "entry_digest": "e29155b72d0b7cd7df039ecee3cb62b023e2c1cac31a47c5415f7a1ad39b057c", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d3242f740bc8ce65716a34942848f12b911b30e74b242681e7a4905d82fcae3a", + "entry_digest": "72df09dafb06bde593202cd51eee288dc201433fc5fca15201a4e9603f7c630b", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", + "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", + "owners": [ + "session" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "6d19bdabf2c16f1771dc7c016351e4fff940d8f744f73740c386733086460c01", + "current_acceptance_input_digest": "081046eefe8bfc0fc2a22982eb55044d176a064a07294647c152ae106936ec87" + }, + { + "schema_version": 1, + "change_id": "CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby", + "actor": "claude", + "reason": "abandon now waits for its SIGKILL to land before recording the failed state, using the same await_death stop already relies on -- fixes a CI-only race (Ruby 3.4 runner) where a just-abandoned launch could still show as running in the very next list. session_command.rb changed as a result.", + "timestamp": 1787082423, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "claude", + "timestamp": 1787079242, + "digest": "17f1980a044c56f792cabd38d4364f5feedefd70d546bdf6b7dd0064abc67ff1", + "note": null + }, + "prior_verification": { + "timestamp": 1787079236, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "acceptance_input_digest": "081046eefe8bfc0fc2a22982eb55044d176a064a07294647c152ae106936ec87", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "cffe0258b98ea4df6a0150d1c1dc3d3c327990f99f4520183ada93eeceaae46e", + "entry_digest": "cce3ce814ebe5d102e72350883ee3524ae9f22183a5c2c248c727294eec22782", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "lib/rune/commands/session_command.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", + "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", + "owners": [ + "session" + ] + }, + { + "path": "lib/rune/parsers/character_width.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "fefe3f4ca109ca45c1bfd2349fe185dfe3555082989a55cbdfc9a1149087bfbc", + "entry_digest": "eed613a7dbf151310c19cba34b281e791589c7054961f7eae1856efecf67bdb0", + "owners": [ + "parsers" + ] + }, + { + "path": "lib/rune/parsers/screen_renderer.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "0f50fa04a465f21f3e592fdd1c6f34ced703103f8c095f95311046888b1e7393", + "entry_digest": "f8e99a18c98b1913b06b53b343d0ee2f36f4237191d0ce0314e454ca6b733a16", + "owners": [ + "parsers" + ] + }, + { + "path": "spec/rune/parsers/screen_renderer_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "b77784bd015c374038b462d0b66d662eb60d8c3fad5ee55b1d9fd5d51201c34c", + "entry_digest": "a96aab44524968916ababd11bccd3e369106427a8d0369df45975f0a61f94001", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "spec/rune/session_spec.rb", + "kind": "file", + "mode": 33188, + "payload_digest": "4a315231fc32072bbac40dc8eb709df8826a6c1e921837452a9c507b4ab798ff", + "entry_digest": "63f260ed65fba4461f7c3df6c03c301b54167ee08da15454fe9ecf4d5a299576", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/parsers/parsers.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d3242f740bc8ce65716a34942848f12b911b30e74b242681e7a4905d82fcae3a", + "entry_digest": "72df09dafb06bde593202cd51eee288dc201433fc5fca15201a4e9603f7c630b", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/parsers/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d10eb208af6fa95b43af778fd5797b4e4586ffe9a58dc01f2ceaf31cf916fd5d", + "entry_digest": "8f365ed356648d581f18ff8f893fe741368eb763fa4556cec9243466b877810a", + "owners": [ + "parsers" + ] + }, + { + "path": "specs/session/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "d09b8d9853d949d2657ad370488d861fc69cdad8762dd787d01fa3a57c69f50d", + "entry_digest": "907337254e2eba2c5cdb797fb0e89f079d7979b85a09ecb4514fe8d06e9063f4", + "owners": [ + "session" + ] + }, + { + "path": "specs/session/session.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", + "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", + "owners": [ + "session" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "081046eefe8bfc0fc2a22982eb55044d176a064a07294647c152ae106936ec87", + "current_acceptance_input_digest": "83868d5429d4cdb45451986a330f9a251337de816f2f4a7eeefc23dc44fc7041" + } + ] } diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json index 87fb127..413e383 100644 --- a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", "created_at": 1787065644, - "updated_at": 1787065870, + "updated_at": 1787082616, "affected_specs": [ "session", "parsers" diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json index 5f1922d..6fc3345 100644 --- a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification-attempts.json @@ -30,6 +30,66 @@ } ], "requirement_ids": [] + }, + { + "timestamp": 1787079236, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "ec2c633d9f23466346e1a67ab0000f4170c5f0600f662f48f32a335c8b042fbd", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1787082610, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", + "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", + "workspace_digest": "f3b557a9feabd258d7ff3377b72e97846ecd3e2ff67a426209cc7315cf265c09", + "passed": true, + "commands": [ + { + "command": "fledge run version-check", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run lint", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run test", + "success": true, + "exit_code": 0 + }, + { + "command": "fledge run smoke-test", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] } ] } diff --git a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json index 21e4703..9fd13e0 100644 --- a/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json +++ b/.specsync/changes/CHG-0071-make-a-failed-launch-loud-name-the-project-a-session-is-in-and-fix-two-multiby/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1787065852, - "commit": "5f4d77b08f0d96682520e66e7eeb5411ed5a5da3", + "timestamp": 1787082610, + "commit": "4b122c4bf1759b263b69120d21e83eed0b46b031", "contract_digest": "ab4e97b21e173a2a95c9cd9ba68516355e8fe4506456d93157e2ea0e4162e5d3", - "workspace_digest": "fbf9a273d33f9f9f956bc4059249d33725870c320eb8a95ee09eeade00ea9b3c", - "acceptance_input_digest": "6d19bdabf2c16f1771dc7c016351e4fff940d8f744f73740c386733086460c01", + "workspace_digest": "f3b557a9feabd258d7ff3377b72e97846ecd3e2ff67a426209cc7315cf265c09", + "acceptance_input_digest": "83868d5429d4cdb45451986a330f9a251337de816f2f4a7eeefc23dc44fc7041", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -21,8 +21,8 @@ "path": "lib/rune/commands/session_command.rb", "kind": "file", "mode": 33188, - "payload_digest": "7f5b0b685348cfd37d579a7ee78b6e2d359ddb5f8e83a623225e64ca982ce2ee", - "entry_digest": "60fbcfa43223a0975206aa34b7e98b55680244a2fb6c9f74605dde3803ca160a", + "payload_digest": "5e2b427d01e1dc6e3ddd6e25d7319736c57ddbcae53392e9b96997260dce49f5", + "entry_digest": "05826424a54284dcc66da4f16501ec4182a88eb4df6341e54cb27058b97da694", "owners": [ "session" ] @@ -51,8 +51,8 @@ "path": "spec/rune/parsers/screen_renderer_spec.rb", "kind": "file", "mode": 33188, - "payload_digest": "d064a3e93b3cd62baa6bad2d2b1c0b67afedc86fb863f44c659b2774972ec6bb", - "entry_digest": "e29155b72d0b7cd7df039ecee3cb62b023e2c1cac31a47c5415f7a1ad39b057c", + "payload_digest": "b77784bd015c374038b462d0b66d662eb60d8c3fad5ee55b1d9fd5d51201c34c", + "entry_digest": "a96aab44524968916ababd11bccd3e369106427a8d0369df45975f0a61f94001", "owners": [ "@exact:delivery" ] @@ -101,8 +101,8 @@ "path": "specs/session/session.spec.md", "kind": "file", "mode": 33188, - "payload_digest": "6c922b2f3d7bbb720ac9ee6305666210b24fe6be9e0eda4886a4058f57ce6de1", - "entry_digest": "856aa60c6f859ae95cac12f095b60a3de71f89ed0aed184736314060c221d8ae", + "payload_digest": "579d32301e0f4c9133e95b34eb842e435d5c2a0bee18fd0c8d8ccd278784438d", + "entry_digest": "168eb5a2e33976cee7944cfcf21515c6f6068e8b918131a385ffc438bb626ee2", "owners": [ "session" ] diff --git a/lib/rune/commands/session_command.rb b/lib/rune/commands/session_command.rb index 040582f..74b4af6 100644 --- a/lib/rune/commands/session_command.rb +++ b/lib/rune/commands/session_command.rb @@ -333,8 +333,15 @@ def executable_path = File.expand_path('../../../bin/rune', __dir__) # failed `start` leaves nothing behind. Reuses the same tolerant kill # path as `stop`, since the pids may already be gone. def abandon(name, supervisor_pid) - meta = store.read_meta(name) || {} - kill_remaining(meta.merge(supervisor_pid: supervisor_pid)) + meta = (store.read_meta(name) || {}).merge(supervisor_pid: supervisor_pid) + kill_remaining(meta) + # SIGKILL is asynchronous, so without waiting the caller could see `list` report the + # abandoned session as still running: `describe` recomputes state from real process + # liveness rather than trusting `failed_at` below, precisely so a supervisor killed + # without its cooperation is never reported as-is — and a supervisor signalled here but + # not yet dead is exactly that case. `stop` hit the same shape first, documented at its + # own `await_death` call: the very next command sees the session as running. + await_death(meta) store.update_meta(name, state: 'failed', failed_at: Time.now.to_f) end diff --git a/specs/session/session.spec.md b/specs/session/session.spec.md index aa70dcb..6995000 100644 --- a/specs/session/session.spec.md +++ b/specs/session/session.spec.md @@ -946,6 +946,16 @@ deciding who talks to whom stays the calling agent's job. guide's warning about directory scoping first, which is when a documented gotcha stops being a documentation problem. +54. `abandon` waits for the kill it issues to land before recording the abandoned session as + failed, using the same `await_death` `stop` already relies on. SIGKILL is asynchronous: without + waiting, a launch that failed and abandoned its supervisor could still show as `running` in the + very next `list`, because `describe` recomputes state from real process liveness rather than + trusting the record `abandon` writes — deliberately, since a supervisor killed with SIGKILL + never updates its own meta and a recorded state is routinely stale. `stop` hit this exact shape + first (its own `await_death` comment: "the very next command saw the session as running"; + `archive` straight after `stop` failed on it) and `abandon` had the identical fire-and-kill + shape without the fix. Caught by a CI runner slow enough to make the race land, after passing + locally. ## Behavioral Examples - `rune session start -- grok` returns immediately with a generated name such as `grok-amber`;