Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .console/log.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,46 @@
# Log
## 2026-08-03 — fix(cli): stop console encoding from failing a command that succeeded

`cl reconcile check` computed a GREEN verdict, then died printing it:

UnicodeEncodeError: 'charmap' codec can't encode character '→'

`check.py:132` renders cross-repo routing with `→`, and a default Windows
console is cp1252. So any worksheet carrying a cross-repo item — the ordinary
case, since routing work to its owning repo is the point — reported a passing
gate as a traceback. The check had already finished; only the formatting failed.

Fixed at the stream, not the glyph. Replacing `→` would have been whack-a-mole:
the source carries nine distinct non-ASCII codepoints across ~940 occurrences,
with output-bearing lines in `cli/ledger.py`, `reconcile/check.py` and
`cli/loop.py`, and any new report line could reintroduce it. `ensure_printable_
console()` prefers UTF-8 and falls back to `errors="replace"`, so output degrades
to `?` instead of raising. Installed as a Typer root callback, which runs before
every subcommand and takes no options, so the CLI surface is unchanged.

Same fix and near-identical wording as Custodian's `cli/colors.py`, which hit
this in its verbose audit report. Duplicated rather than shared — CL does not
depend on Custodian, and it is fifteen lines.

The fallback branch is not theoretical: a stream whose buffer is detached
rejects an encoding change but still accepts an errors change, so the guard
retries with errors alone rather than giving up on not-raising.

8 tests in `tests/test_console.py`. The first asserts the cp1252 stream really
does reject the report glyphs — without it the other seven could pass against a
stream that was never capable of failing. Suite 449 -> 457 passed, the 25
pre-existing failures and the 2 `cryptography` collection errors unchanged.

Custodian's audit caught two things in the first draft, both fixed: T7 (the file
was `test_console_encoding.py`, so `cli/console.py` had no parallel test) and T2
(the skip-path test asserted nothing — it now writes through the stream and
checks the content, so "skipping is a no-op" is actually verified rather than
merely not crashing). Audit is back to 0 findings.

Note for anyone reading CI here: `Lint (ruff)` is red on this branch and was
already red on `main` — 204 findings, identical count before and after this
change, none in the files touched here.

## 2026-07-17 — docs: D3 P5 — record stopped_logged_violation as spec-deferred

Resolved P5 (the last D3 phase) as a **decision, not a build**. Investigated
Expand Down
48 changes: 48 additions & 0 deletions src/context_lifecycle/cli/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 ProtocolWarden
"""Console-encoding guard for the `cl` CLI.

Formatting must never be able to fail a command that already succeeded.
"""

from __future__ import annotations

import sys


def ensure_printable_console() -> None:
"""Stop console encoding from crashing a command that already did its work.

Windows consoles default to cp1252, which cannot encode the arrows, box
drawing, section signs and em-dashes this CLI's reports use. Printing them
raises ``UnicodeEncodeError`` *after* the command has finished, discarding
the result the operator asked for and exiting non-zero — a green check
reported as a failure.

Concretely: ``cl reconcile check`` on a worksheet containing any cross-repo
item died on the ``->`` glyph in its routing line, having already computed a
GREEN verdict. The gate passed; the operator saw a traceback.

Replacing glyphs one at a time does not hold — the source carries nine
distinct non-ASCII codepoints across ~940 occurrences, and any new report
line can reintroduce the crash. Fixing the stream instead makes the whole
class impossible: prefer UTF-8, and fall back to replacing unencodable
characters so output degrades to ``?`` rather than raising.

Idempotent, and safe to call on non-standard streams (pytest's capture
objects have no ``reconfigure``); those are skipped.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None: # pytest capture, io.StringIO, closed stream
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError, LookupError): # pragma: no cover
# Stream refuses a full reconfigure (detached buffer, exotic
# terminal). Salvage what matters: never raise on an unencodable
# character, even if the encoding itself cannot be changed.
try:
reconfigure(errors="replace")
except (ValueError, OSError):
pass
14 changes: 14 additions & 0 deletions src/context_lifecycle/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from context_lifecycle.cli import loop as loop_cmd
from context_lifecycle.cli import reconcile as reconcile_cmd
from context_lifecycle.cli import session as session_cmd
from context_lifecycle.cli.console import ensure_printable_console

app = typer.Typer(
name="cl",
Expand All @@ -20,6 +21,19 @@
add_completion=False,
)

@app.callback()
def _root() -> None:
"""Runs before every subcommand.

Only job is the console-encoding guard: reports carry non-ASCII glyphs that
a cp1252 Windows console cannot encode, and the resulting UnicodeEncodeError
fires *after* the command's real work is done. Installed here rather than in
each command so no future report line can reintroduce the crash. Takes no
options, so the CLI surface is unchanged.
"""
ensure_printable_console()


app.add_typer(hook_cmd.app, name="hook", help="Claude Code hook adapters (pre_tool_use, stop).")
app.add_typer(session_cmd.app, name="session", help="Session anchor lifecycle (start, show, end).")
app.add_typer(context_cmd.app, name="context", help="Session-boundary cognition (hydrate, capture, peek) for non-hook CLIs.")
Expand Down
146 changes: 146 additions & 0 deletions tests/test_console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 ProtocolWarden
"""Tests for the console-encoding guard.

Regression cover for: `cl reconcile check` computed a GREEN verdict, then died
with UnicodeEncodeError printing the `->` glyph in its cross-repo routing line
on a cp1252 Windows console. The gate passed; the operator saw a traceback and
a non-zero exit.
"""

from __future__ import annotations

import io
import sys

import pytest
from typer.testing import CliRunner

from context_lifecycle.cli.console import ensure_printable_console
from context_lifecycle.cli.main import app

# One of each non-ASCII codepoint the CLI's report lines actually use.
REPORT_GLYPHS = "→ ⇒ — ─ § … ≥ ✓ ✗"


class _Cp1252Stream(io.TextIOBase):
"""A stream that encodes as cp1252 — i.e. a default Windows console.

`reconfigure` mutates the declared encoding/errors the way a real
TextIOWrapper does, so the guard can be observed taking effect.
"""

def __init__(self) -> None:
super().__init__()
self._encoding = "cp1252"
self._errors = "strict"
self.written: list[str] = []

@property
def encoding(self) -> str:
return self._encoding

@property
def errors(self) -> str:
return self._errors

def reconfigure(self, *, encoding: str | None = None, errors: str | None = None) -> None:
if encoding is not None:
self._encoding = encoding
if errors is not None:
self._errors = errors

def write(self, s: str) -> int:
# Mimic a real console: encoding happens at write time and raises
# under 'strict', which is exactly how the original crash surfaced.
s.encode(self._encoding, self._errors)
self.written.append(s)
return len(s)


def test_cp1252_stream_rejects_report_glyphs_before_the_guard():
"""Without the guard the failure is real — otherwise this suite proves nothing."""
stream = _Cp1252Stream()
with pytest.raises(UnicodeEncodeError):
stream.write(REPORT_GLYPHS)


def test_guard_makes_report_glyphs_printable(monkeypatch):
stream = _Cp1252Stream()
monkeypatch.setattr(sys, "stdout", stream)
ensure_printable_console()
stream.write(REPORT_GLYPHS)
assert stream.written == [REPORT_GLYPHS]


def test_guard_reconfigures_both_streams(monkeypatch):
out, err = _Cp1252Stream(), _Cp1252Stream()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)
ensure_printable_console()
for stream in (out, err):
assert stream.encoding == "utf-8"
assert stream.errors == "replace"


def test_guard_falls_back_to_errors_only_when_encoding_is_refused(monkeypatch):
"""A stream with a detached buffer rejects an encoding change.

Never raising on an unencodable character still matters, so the guard must
retry with errors alone rather than give up.
"""

class _RefusesEncoding(_Cp1252Stream):
def reconfigure(self, *, encoding=None, errors=None):
if encoding is not None:
raise ValueError("cannot change encoding of a detached stream")
super().reconfigure(errors=errors)

stream = _RefusesEncoding()
monkeypatch.setattr(sys, "stdout", stream)
ensure_printable_console()
assert stream.encoding == "cp1252" # unchanged — the refusal stood
assert stream.errors == "replace" # but output can no longer raise
stream.write(REPORT_GLYPHS) # would raise under 'strict'


def test_guard_skips_streams_without_reconfigure(monkeypatch):
"""pytest capture objects and StringIO have no reconfigure; must not raise.

Asserts the stream stays usable afterwards rather than just that the call
returned — skipping must be a no-op, not a half-applied change.
"""
out = io.StringIO()
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", io.StringIO())
ensure_printable_console()
out.write(REPORT_GLYPHS)
assert out.getvalue() == REPORT_GLYPHS


def test_guard_is_idempotent(monkeypatch):
stream = _Cp1252Stream()
monkeypatch.setattr(sys, "stdout", stream)
ensure_printable_console()
ensure_printable_console()
assert stream.encoding == "utf-8"
assert stream.errors == "replace"


def test_root_callback_runs_the_guard(monkeypatch):
"""The guard must be wired to the app, not merely importable."""
calls: list[int] = []
monkeypatch.setattr(
"context_lifecycle.cli.main.ensure_printable_console",
lambda: calls.append(1),
)
CliRunner().invoke(app, ["reconcile", "--help"])
assert calls, "root callback did not invoke the console guard"


def test_cli_surface_is_unchanged_by_the_callback():
"""The callback takes no options, so `cl --help` still lists every command."""
result = CliRunner().invoke(app, ["--help"])
assert result.exit_code == 0
for name in ("hook", "session", "context", "reconcile", "ledger", "loop"):
assert name in result.output
Loading