Skip to content

fix(cli): keep init --json stdout to a parseable envelope - #313

Merged
thecodedrift merged 4 commits into
fix/nightly-stub-package-referencefrom
fix/init-json-envelope
Sep 8, 2026
Merged

fix(cli): keep init --json stdout to a parseable envelope#313
thecodedrift merged 4 commits into
fix/nightly-stub-package-referencefrom
fix/init-json-envelope

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Summary

init --json (including init --no-interactive --json) wrote human prose to stdout ahead of the JSON envelope, so taskless init --json | jq . failed to parse. runNonInteractive, the shared body behind init and init --no-interactive, unconditionally console.log'd:

  • the "No tools detected. Using fallback: …" notice, and
  • the per-target skill/command summary ("wrote N skill canonical file(s)", the written/removed skill and command lists).

Both now route through a log helper that is console.error under --json and console.log otherwise — matching the convention ensureTasklessDirectory's migration notices and verify/test already use, and matching the reload notice's existing (already correct) handling in this same file.

Where the prose goes under --json

stderr. It stays visible to a person watching the terminal (stderr renders in a normal terminal same as stdout) without corrupting a machine consumer's view of stdout. This information is not on the JSON envelope (finer-grained than migrated/commandsInstalled), so dropping it silently was not an option — moving it off stdout was.

Test fix (the other half, from #284)

test/migrated-envelope.test.ts's parseEnvelope helper only ever read stdout's last line via .trim().split("\n").at(-1). A helper shaped that way cannot fail on anything printed before the envelope — which is exactly how the init --json prose-on-stdout bug went undetected by this file's own tests. It now does JSON.parse(stdout.trim()) on the whole thing, so any extra output anywhere in stdout fails the parse.

Mutation check

  • Reintroduced the bug (const log = console.log;, ignoring the json option) and rebuilt.
  • The fixed parseEnvelope (whole-stdout parse): failed, SyntaxError: Unexpected token 'N', "No tools d"... is not valid JSON, on both init --json tests in the file.
  • The OLD .at(-1) helper, run by hand against the same corrupted stdout: parsed successfully — it read only the JSON line and never saw the prose above it. This is the concrete demonstration that the test-helper fix is the load-bearing half of this PR, not just the production fix.
  • Reverted the mutation, rebuilt: full suite green again (83 files / 1345 tests).

Other tests with the same blind spot

packages/cli/test/no-implicit-migration.test.ts uses the identical stdout.trim().split("\n").at(-1) idiom in four places (lines 117, 234, 281, 334) plus once on stderr (303). Same shape of bug, same fix would apply, but that file is outside this PR's assigned scope — noting it here rather than editing it.

Verification

  • pnpm typecheck — pass
  • pnpm test — 83 files / 1345 tests pass
  • pnpm lint (builds first, runs pnpm cli check) — pass, "No issues found."
  • Built CLI, ran init --no-interactive --json -d . in a scratch dir, piped stdout through JSON.parse — parses cleanly:
    stdout: {"success":true,"commandsInstalled":false,"migrated":{"from":0,"to":6,...}}
    stderr: Detected non-interactive context...
    Migrating .taskless/...
    ...
    No tools detected...
    Taskless canonical store (.taskless/): wrote 1 skill canonical file(s)
      - taskless
    ...
    

Fixes #279
Refs #284

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 28s —— View job


Code Review

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no comments, reviews, or review threads for this PR, so this is the first review of PR #313; everything below is assessed fresh.

  • Read .prior-review.json (empty — first review)
  • Read PR diff (gh pr diff) and metadata
  • Reviewed correctness, security, reuse/simplification, altitude/conventions, and test adequacy
  • Verified findings against full file contents (not just diff hunks)
  • Posted inline comments and summary

Summary

The fix does what it says: runNonInteractive's per-target summary and the "no tools detected" notice now route through log (console.error under --json, console.log otherwise), and migrated-envelope.test.ts / no-implicit-migration.test.ts's parseEnvelope helpers were correctly widened from .at(-1) (blind to anything printed before the last line) to JSON.parse(stdout.trim()) on the whole string. I verified the ternary direction, flag threading, and that every console.log inside runNonInteractive was converted — no missed call sites, no inverted condition. The changeset is correctly scoped as patch.

Three [New] findings from a closer read:

  1. packages/cli/src/commands/init.ts:263 — The comment says this diff "matches … the verify/test convention of routing prose off stdout under --json." But ensureTasklessDirectory(cwd) (line 277) is called with no onNotice, so its migration prose always goes to console.error unconditionally, regardless of --json. verify.ts (75-79) actually suppresses that notice entirely under --json, and EnsureOptions.onNotice's own doc comment says callers under --json should do exactly that, since the same info is already on the envelope's migrated field. Doesn't corrupt stdout (default is already stderr), but the comment overstates what convention is actually being followed here.
  2. packages/cli/test/no-implicit-migration.test.ts:117 — The JSON.parse(stdout.trim()) fix is copy-pasted at 4 call sites (117, 234, 281, 334) instead of factoring it into a shared parseEnvelope-style helper the way migrated-envelope.test.ts does in this same diff (whose comment documents why whole-string parsing matters — the regression guard for this exact bug). Also, narrowing to JSON.parse(stdout.trim()) dropped the ?? "{}" empty-stdout fallback uniformly at all 4 sites; minor, since the test still fails either way, just with a less legible error on genuinely empty stdout.
  3. packages/cli/test/error-envelope.test.ts (not modified by this PR, so no inline anchor exists) — its parseEnvelope (lines 44-51) still filters stdout to lines starting with { and takes the last match, which is blinder than even the pre-fix .at(-1) idiom (it would silently accept a stray {-prefixed line anywhere in stdout). The companion commit's message ("widen the other last-line envelope parsers, which were blind too") only targeted no-implicit-migration.test.ts's four call sites explicitly, so this may just be a residual out-of-scope case rather than an oversight — flagging for awareness rather than as a defect in this diff. Separately, the PR body's "outside this PR's assigned scope" note about no-implicit-migration.test.ts appears stale, since the second commit did in fact fix that file — worth reconciling the description.

Nothing else stood out as a correctness, security, or performance issue. The remaining candidate observations from my review passes (e.g., no shared Reporter/Logger abstraction across init.ts/check.ts/verify.ts for --json-gated output, or the single-caller options: { json?: boolean } parameter object) are architectural preferences rather than defects, and I didn't post them as findings.

Comment thread packages/cli/test/no-implicit-migration.test.ts Outdated
Comment thread packages/cli/src/commands/init.ts
thecodedrift added a commit that referenced this pull request Sep 8, 2026
…, and share the whole-stdout envelope parser

Review feedback on #313:

1. init.ts's comment claimed this diff matches the verify/test convention
   of routing --json prose off stdout, but ensureTasklessDirectory(cwd) was
   called with no onNotice, so its migration notice always fell back to an
   unconditional console.error regardless of --json. verify.ts actually
   SUPPRESSES that notice entirely under --json (its info already lives on
   the envelope), matching EnsureOptions.onNotice's own doc comment. Wired
   the same onNotice here so the code matches what the comment says, added
   a regression test asserting stderr carries nothing about the migration
   under --json, and mutation-checked it (dropping the !json guard fails
   the new test; restoring it passes).

2. no-implicit-migration.test.ts's four JSON.parse(stdout.trim()) call
   sites (from a prior commit widening them off the old blind .at(-1) read)
   are now a single parseEnvelope<T> helper, documented the same way
   migrated-envelope.test.ts's version is, so the reasoning for whole-string
   parsing isn't duplicated four times without its rationale attached.
   Mutation-checked: reintroducing the original init --json stdout-prose bug
   fails both init --json tests in this file through the shared helper.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude — "Claude finished @thecodedrift's task in 2m 28s"

Addressed both findings from that review in 820cdbb:

  1. init.ts:263 (comment vs. convention). Verified against verify.ts (75-79) and EnsureOptions.onNotice's doc comment: the claim was right about intent but wrong about this call site — ensureTasklessDirectory(cwd) had no onNotice, so its migration prose always went to console.error regardless of --json, where verify/test actually suppress it entirely because the info duplicates the envelope's migrated field. Wired the same onNotice here (code now matches the comment) and added a regression test asserting stderr carries nothing about the migration under --json.

  2. no-implicit-migration.test.ts:117 (duplicated parse). Extracted the 4 call sites into a shared parseEnvelope<T> helper carrying the same documented rationale as migrated-envelope.test.ts's version, so the "why whole-string parsing matters" reasoning isn't silently absent at 3 of the 4 sites. Kept the ?? "{}" fallback dropped, per the reviewer's own "minor" framing — an unambiguous SyntaxError on genuinely empty stdout is still a hard test failure, just differently worded.

Both changes mutation-checked (details in the inline replies): reintroducing either original bug fails the relevant test(s); reverting the mutation restores a full green run (pnpm typecheck, pnpm test — 83 files / 1346 tests, pnpm lint).

The third finding in that review (error-envelope.test.ts's stricter-but-still-blind filter, and a note that the PR body's "out of scope" line about no-implicit-migration.test.ts was stale after the coordinator's follow-up commit) was flagged for awareness rather than as a defect in this diff, and is left as-is.

— AI Coding Agent

runNonInteractive (the shared body of `init` and `init --no-interactive`)
unconditionally console.log'd the "no tools detected" fallback notice and
the per-target skill/command summary, so `init --json` wrote prose to
stdout ahead of the JSON envelope and `taskless init --json | jq .` failed
to parse.

That prose now goes to stderr under --json (stdout otherwise, matching
the existing human output), following the same convention already used by
ensureTasklessDirectory's migration notices and by verify/test.

Also fixes test/migrated-envelope.test.ts's parseEnvelope helper, which
only ever read stdout's LAST line via .at(-1) and so could never detect
anything printed before it -- exactly how this bug went unnoticed. It now
JSON.parses the whole trimmed stdout.
…nd too

`no-implicit-migration.test.ts` carried the same `stdout.trim().split("\n")
.at(-1)` idiom in four places. A helper that reads only the last line cannot
fail on anything printed before it, which is exactly how the bug this PR fixes
stayed invisible, so leaving four more of them in place leaves four more places
for it to hide.

Measured rather than assumed, twice. Widening them to parse the whole of stdout
leaves all 25 tests passing, so no command in that file prints prose ahead of
its envelope today. Reintroducing the `--json` bug then fails four of them,
where the old helpers passed it silently.

The remaining `.at(-1)` in that file reads the last line of STDERR to check a
message, which is a legitimate use and is left alone.

Refs #284
…, and share the whole-stdout envelope parser

Review feedback on #313:

1. init.ts's comment claimed this diff matches the verify/test convention
   of routing --json prose off stdout, but ensureTasklessDirectory(cwd) was
   called with no onNotice, so its migration notice always fell back to an
   unconditional console.error regardless of --json. verify.ts actually
   SUPPRESSES that notice entirely under --json (its info already lives on
   the envelope), matching EnsureOptions.onNotice's own doc comment. Wired
   the same onNotice here so the code matches what the comment says, added
   a regression test asserting stderr carries nothing about the migration
   under --json, and mutation-checked it (dropping the !json guard fails
   the new test; restoring it passes).

2. no-implicit-migration.test.ts's four JSON.parse(stdout.trim()) call
   sites (from a prior commit widening them off the old blind .at(-1) read)
   are now a single parseEnvelope<T> helper, documented the same way
   migrated-envelope.test.ts's version is, so the reasoning for whole-string
   parsing isn't duplicated four times without its rationale attached.
   Mutation-checked: reintroducing the original init --json stdout-prose bug
   fails both init --json tests in this file through the shared helper.
`error-envelope.test.ts` filtered stdout to lines starting with `{` and took
the last one. Like the two helpers already fixed here, it cannot fail on
anything printed beside the envelope, which is precisely how #279 stayed
invisible.

Its comment is worth recording, because it is what made the gap easy to miss:
it said stderr progress was being ignored, which is true and harmless, while
the filter it described was quietly dropping STDOUT prose, which is neither. A
comment that explains a lesser thing the code also does reads as a
justification for the whole line.

Measured before changing it: all 24 tests pass against the whole string, so no
command in this file prints anything beside its envelope today. The parse now
fails loudly the moment one starts.

That is the third and last instance of this idiom in the suite.

Refs #284
@thecodedrift
thecodedrift force-pushed the fix/init-json-envelope branch from 8f60b2b to 5c7c170 Compare September 8, 2026 16:51
@thecodedrift
thecodedrift changed the base branch from main to fix/nightly-stub-package-reference September 8, 2026 16:51
@thecodedrift
thecodedrift merged commit 5d16027 into main Sep 8, 2026
8 checks passed
@thecodedrift
thecodedrift deleted the fix/init-json-envelope branch September 8, 2026 16:55
thecodedrift added a commit that referenced this pull request Sep 8, 2026
…, and share the whole-stdout envelope parser

Review feedback on #313:

1. init.ts's comment claimed this diff matches the verify/test convention
   of routing --json prose off stdout, but ensureTasklessDirectory(cwd) was
   called with no onNotice, so its migration notice always fell back to an
   unconditional console.error regardless of --json. verify.ts actually
   SUPPRESSES that notice entirely under --json (its info already lives on
   the envelope), matching EnsureOptions.onNotice's own doc comment. Wired
   the same onNotice here so the code matches what the comment says, added
   a regression test asserting stderr carries nothing about the migration
   under --json, and mutation-checked it (dropping the !json guard fails
   the new test; restoring it passes).

2. no-implicit-migration.test.ts's four JSON.parse(stdout.trim()) call
   sites (from a prior commit widening them off the old blind .at(-1) read)
   are now a single parseEnvelope<T> helper, documented the same way
   migrated-envelope.test.ts's version is, so the reasoning for whole-string
   parsing isn't duplicated four times without its rationale attached.
   Mutation-checked: reintroducing the original init --json stdout-prose bug
   fails both init --json tests in this file through the shared helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

init --json writes prose to stdout, so the envelope cannot be parsed

1 participant