Today — the report alone is clean; the run that also saves the plan is not:
$ mossaic-art VYNCINT --year 2027 --track --plan /dev/null --format json --no-colour | jq . >/dev/null && echo ok
ok
$ mossaic-art VYNCINT --year 2027 --track --save --plan ./plan.json --format json --no-colour > out.json 2> out.err
exit=0 stdout=845 bytes stderr=0 bytes
$ head -4 out.json
saved ./plan.json — from now on:
mossaic-art --track
{
$ python3 -c "import json;json.load(open('out.json'))"
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
Through a pipe, as a script would write it:
$ … --format json --no-colour 2>/dev/null | jq .
jq: parse error: Invalid numeric literal at line 1, column 6
art exit=101 jq exit=5
--format markdown is the same shape: the first three lines of stdout are the saved … block, then the ### … heading.
Why it is worth fixing — --help describes --format as "text (default), json or markdown — what --track prints. The GitHub Action reads json and markdown", and CHANGELOG.md:842 calls json "a shape a machine and a message can carry". A machine shape means stdout carries the document and nothing else, and every other thing a track run says already obeys that: all five notes go to stderr (src/bin/mossaic-art.rs:205, :487, :589, :1191, :1664). The saved … confirmation at :233 and :613 is the single println! and the only message that can run ahead of the document.
Because the exit code is 0 and stderr is empty, there is no signal anywhere. A wrapper that checks $? before parsing sees success; a wrapper that pipes into jq sees jq's parse error plus mossaic-art turning into 101 from the resulting broken pipe, which points at the pipe rather than at the extra line. It bites only on the run that writes the plan — the run after it is clean — so it reads as a flake on first use. The binary is already careful about this class of pairing: src/bin/mossaic-art.rs:2057-2069 refuses --track --snapshot, --track --write and --track --repo with "…would do nothing. Drop one of them." --save is the one side-effect flag --track still permits, and it is the one that corrupts the machine stream.
Scoped honestly: the shipped Action is unaffected — action/action.yml:315-316 runs both formats without --save — and docs/ART.md:576-580 teaches the two flags as separate invocations rather than together, so the exposure is a hand-written script. This is filed on "machine-readable output silently non-parsable at exit 0", not on breadth.
Fix — println! → eprintln! at src/bin/mossaic-art.rs:233 and :613. That is the whole change, and it keeps the message in front of a human at a terminal. If the line is felt to belong on stdout for the text report, gate it on options.format == Format::Text instead; json and markdown are the two modes where stdout is a document. The --track guard list at :2057 is not the problem — leave --save permitted.
Done when — mossaic-art VYNCINT --year 2027 --track --save --plan p.json --format json | jq . succeeds and p.json is written; the same with --format markdown produces a document whose first line is the ### … heading; the saved … confirmation is still shown, on stderr, in all three formats; and a case in tests/art_cli.rs asserts stdout parses as JSON for a --track --save --format json run and starts with ### for markdown.
Today — the report alone is clean; the run that also saves the plan is not:
Through a pipe, as a script would write it:
--format markdownis the same shape: the first three lines of stdout are thesaved …block, then the### …heading.Why it is worth fixing —
--helpdescribes--formatas "text (default), json or markdown — what --track prints. The GitHub Action reads json and markdown", and CHANGELOG.md:842 calls json "a shape a machine and a message can carry". A machine shape means stdout carries the document and nothing else, and every other thing a track run says already obeys that: all five notes go to stderr (src/bin/mossaic-art.rs:205, :487, :589, :1191, :1664). Thesaved …confirmation at :233 and :613 is the singleprintln!and the only message that can run ahead of the document.Because the exit code is 0 and stderr is empty, there is no signal anywhere. A wrapper that checks
$?before parsing sees success; a wrapper that pipes into jq sees jq's parse error plus mossaic-art turning into 101 from the resulting broken pipe, which points at the pipe rather than at the extra line. It bites only on the run that writes the plan — the run after it is clean — so it reads as a flake on first use. The binary is already careful about this class of pairing: src/bin/mossaic-art.rs:2057-2069 refuses--track --snapshot,--track --writeand--track --repowith "…would do nothing. Drop one of them."--saveis the one side-effect flag--trackstill permits, and it is the one that corrupts the machine stream.Scoped honestly: the shipped Action is unaffected — action/action.yml:315-316 runs both formats without
--save— and docs/ART.md:576-580 teaches the two flags as separate invocations rather than together, so the exposure is a hand-written script. This is filed on "machine-readable output silently non-parsable at exit 0", not on breadth.Fix —
println!→eprintln!at src/bin/mossaic-art.rs:233 and :613. That is the whole change, and it keeps the message in front of a human at a terminal. If the line is felt to belong on stdout for the text report, gate it onoptions.format == Format::Textinstead; json and markdown are the two modes where stdout is a document. The--trackguard list at :2057 is not the problem — leave--savepermitted.Done when —
mossaic-art VYNCINT --year 2027 --track --save --plan p.json --format json | jq .succeeds and p.json is written; the same with--format markdownproduces a document whose first line is the### …heading; thesaved …confirmation is still shown, on stderr, in all three formats; and a case in tests/art_cli.rs asserts stdout parses as JSON for a--track --save --format jsonrun and starts with###for markdown.