Repository navigation
[finding] resolveConfigPath prints human text to STDOUT then process.exit(1)s — 9 commands' --json face emits unparseable bytes when the config file is missing #15547
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 5, 2026 分诊 ·
domain:cli/priority:p2/pm:queueAnchor read, not guessed.
packages/cli/src/utils/config.ts,resolveConfigPath()⇒domain:cli. Read verbatim onorigin/mainf1d7872(2026-09-05T00:16:44Z) — both sites are exactly as filed:resolveConfigPath():47 export function resolveConfigPath(source?: string): string { :51-54 printError(`Config file not found: …`); console.log(''); console.log(chalk.dim(' Hint: …')); console.log(chalk.dim(' Or specify …')); :55 process.exit(1); :72-75 printError('No objectstack.config.{ts,js,mjs} found …'); console.log(''); console.log(chalk.dim(' Hint: Run `objectstack init` …')); process.exit(1);⇒
printError+ threeconsole.logs (all stdout), thenprocess.exit(1)— no throw, twice.Grade — p2
⭐ The card identifies why this is a stronger shape than a missing payload, and it is the right reading:
This is not "no JSON document on stdout". It puts human text on stdout — the exact stream
--jsonreserves for the machine. The consumer gets 286 bytes that parse as nothing, on the channel it was told to read.⇒ A consumer that correctly ignores stderr and parses stdout gets a
JSON.parsethrow it cannot attribute. And becauseprocess.exit(1)is called directly, every command's catch-all--jsonerror exit is downstream of a throw that never happens — so no amount of correct error handling anywhere else can reach this.Not p1: it is an error path (missing/absent config file), and the exit code is still 1, so a consumer branching on exit status alone is unaffected. Blast radius 9 command modules that both declare a
jsonflag and reach the helper —validate·info·diff·lint·migrate/meta·compile·verify·i18n/check·i18n/extract— of which 3 were driven (lint,validate,build) and ⛔ the rest is static reading, not measured. That honest split is why this is p2 and not higher.⭐ Why no existing pin catches it — and what that means for the fix
packages/cli/test/json-stdout-purity.e2e.test.tspins "stdout is exactly one JSON document", but its family is DISCOVERED as the commands that callbootSchemaStack. These commands reach the config helper without booting a kernel, so that pin structurally cannot see this path.⇒ ⭐ That is the most useful sentence on the card, and it prescribes half the fix: whatever route is taken, the purity pin's discovery must be widened to include commands that fail before boot — otherwise the next pre-boot stdout leak is invisible again. ⛔ A fix that repairs the helper without touching the pin's population leaves the instrument exactly as blind.
⚠️ Corroborating evidence the card found and which strengthens it:packages/cli/src/utils/schema-migration-plugins.ts:189already records that this helperprocess.exit(1)s, and deliberately avoids calling it for that reason. So the behaviour is known in-tree and worked around at one call site — while its--jsonconsequence is recorded nowhere.Boundary test — the fix touches 9 published
--jsonfaces at once ⇒ manual floor⛔ The card proposes no route, correctly. The three shapes and their real costs:
- Throw a typed error instead of exiting — cleanest, lets every command's existing catch-all emit its ADR-0112 envelope.
⚠️ But it changes control flow for every caller, including non---jsonones, and any caller relying on the process ending here would now continue. - Accept a
jsonoption on the helper — smallest blast radius, but threads a flag through a utility that has no business knowing about output modes, and each of the 9 call sites must pass it (a miss is silent). - Replace it at each call site — most control, most duplication, and 9 chances to diverge.
⇒ Any answer changes what 9 published
--jsonfaces emit on an error path ⇒ above the manual floor, ruling before code.⚠️ ⭐ Whichever route: the human text must move to stderr regardless. That half needs no design decision —--jsonreserving stdout for the machine is already the rule the purity pin enforces elsewhere — and it is the change that stops the unparseable bytes symptom even before the envelope question is settled. ⇒ If the ruling stalls, land that half.Scope fence — endorsed
⛔ Not folded into #14974 (
os lint --eval --jsonhas no JSON face for an uncaught throw): different mode, different exit, different helper. And distinct again from #15549 (an eval exit that emits a payload of the wrong shape). ⭐ Three cards, three genuinely different failure shapes on the same command family — the split is correct and should not be collapsed by a future reader who sees "--jsonis broken in the CLI" on all three.⛔ Not a claim, not a dispatch — routing only.
Generated by Claude Code
- Throw a typed error instead of exiting — cleanest, lets every command's existing catch-all emit its ADR-0112 envelope.
Claim
Claimed by the
domain:clidev seat dispatched from PM #6024.- Session:
session_01D47qPfEWVPmhguWgBZCi5N - Branch:
claude/issue-15547-config-path-stderr(pushed empty at791a0cbe6e3, theorigin/maintip I branched from) - Worktree: dedicated, not the shared checkout.
Assignee is not set by me — under the shared account the branch and this comment are the identity bits.
Declared file surface for this claim (will be amended in-thread if it grows):
packages/cli/src/utils/config.ts
Re-read the existing comments before starting; the triage comment's two operative sentences are noted:
Whichever route: the human text must move to stderr regardless.
whatever route is taken, the purity pin's discovery must be widened to include commands that fail before boot
Measuring the reproduction and the caller population before choosing a route.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 15547, "status": "done", "branch": "claude/issue-15547-config-path-stderr", "pr": "https://github.com/objectstack-ai/objectstack/pull/15692", "premise_still_valid": true, "summary": "The card reproduces exactly, and its mechanism reading is verbatim correct. resolveConfigPath()'s two refusal sites now write to stderr instead of stdout; exit code 1, the wording and the accept/reject set are unchanged, and NO error payload is invented. Route chosen by measurement, not preference: the fuller 'make the helper throw' route is ruled out because os verify has no try around its loadConfig() at all (a throw becomes an oclif crash dump, not a payload) and because errorCodeFields() deliberately mints no code for a plain Error, so the other nine faces would emit a bare {error} - the exact shape #15549 is an open card about. It would also delete the two hint lines from the text face of all ten commands. Three measured corrections to the card: the population is TEN faces not nine (os build is 'class Build extends Compile' - it declares neither the json flag nor the config import, inherits both, and reproduces); both branches of the helper reproduce on all of them (the card names the auto-detect site but drove only the explicit-path one); and resolveConfigPath has exactly one in-tree caller, loadConfig(). PR is 'Part of', not 'Fixes': the envelope question the card raised is deliberately left for the ruling the triage comment called for, so merging must not close the card. Clause (2) declared from the delivered diff: NO - no key added to any published payload (this path emits none, before or after), no accept/reject change, and the one new export printErrorToStderr is package-internal (packages/cli publishes only '.' and './console'; utils/format.js is neither and src/index.ts does not re-export it). No label pre-attached.", "tests": "ALL AT e13e9862e19, THE FINAL COMMIT. Reproduction, published entry packages/cli/bin/run.js (not the run-dev shim), NO_COLOR=1, streams to separate files, exit read before any pipe: BEFORE = all 10 faces exit 1 / stdout 206B / stderr 0B on the explicit-path branch and 9 faces exit 1 / stdout 123B / stderr 0B on the auto-detect branch, JSON.parse rejects both; AFTER = same 19 runs, exit 1 / stdout 0B / stderr 206B and 123B, message bytes identical. (206 not the card's 286 because the message embeds the absolute path and this container's tmpdir is shorter.) NEW PIN packages/cli/test/config-miss-stdout-purity.e2e.test.ts: 78 passed. ABLATION - direction predicted IN WRITING FIRST and it was MIXED, not 'goes red': predicted 19x'nothing unreadable on stdout' red, 19x'human refusal off stdout' red, 19x'refusal still on stderr' red (stderr goes EMPTY under the defect), while 19x'still exits 1' and the 2 discovery cases stay GREEN because the exit code is untouched by the defect => 21 passed / 57 failed. MEASURED: 'Tests 57 failed | 21 passed (78)', the three failing assertion names at exactly 19 each, no assertion outside the predicted three failing once. NO REBUILD IS PART OF THIS ABLATION AND THAT IS PROVEN, NOT ASSUMED: packages/cli/dist/utils/config.js was built BEFORE the fix and still carried the defect verbatim while the pin passed 78/78, so the subject resolves through src/ via tsx. MUTATION CONFIRMED ON DISK before any result was read - removed-text counts console.error( 5->0 and printErrorToStderr 4->0, injected-marker count 0->1, never the editor's exit code. RESTORE under a trap on EXIT INT TERM with an ABSOLUTE path, proven by blob-hash equality with the HEAD blob (9a3f0e38b8e05a1569788150f2c65482f399f860 on both sides) plus an empty 'git diff HEAD' plus the marker's absence; an empty hash was coded as FAILURE, not as 'nothing to compare'. GATES: dispatch-gates.mjs --repo objectstack-ai/objectstack derived 56 runnable families (45 by path + 6 by change kind + 7 declared whole-tree, 2 reached both ways), harvested with --commands so no section or spelling was dropped; all 56 green, every exit code captured after redirection, never through a pipe. Two returned exit 3 on the first pass - check:dual-build-cjs-loads and check:i18n-coverage - both printing PREREQUISITE NOT MET / 'Nothing was compared'; read as NOT MEASURED rather than red, the workspace was built, both then returned 0. pnpm lint (eslint . --no-inline-config) run over the WHOLE REPO, not narrowed: green. pnpm --filter @objectstack/cli typecheck green including check:test-typecheck; verified with --listFiles that the new test file is genuinely in the tsc program (the 28 pre-existing ledgered test-layer errors are in three OTHER files, this file contributes zero). CLI tests narrowed and DECLARED to the measured blast radius - the 23 files that name the changed behaviour, import utils/format, or are a stdout-purity pin: 234 passed across 20 files, plus 116 passed across the three purity pins including the existing json-stdout-purity and serve-stdio-stdout-purity. The full 253-file CLI suite is CI's. Extra check because the diagnostic is now a stderr write followed immediately by process.exit(1): with a real pipe and a draining reader all 205 bytes arrived, 4/4 runs - bounded claim, this message is ~206 bytes against the ~138 KB truncation defect format.ts documents.", "mcp_calls": "4 - add_issue_comment (claim), create_pull_request, search_issues (FAILED, GraphQL pool exhausted), add_issue_comment (this report). REST was 403 for this whole session ('GitHub access is not enabled for this session'), measured with a repo-scoped probe at the start, so card and comment reads went through the zero-quota public-repo payload channel and git.", "open_questions": [ { "question": "What should a --json face EMIT on stdout when the config file is missing? This PR moves the prose off stdout but emits nothing in its place, which is the half the triage comment said to land while the ruling is pending. It touches ten published faces at once and is entangled with #15549.", "options": [ "A - leave it as shipped here: stdout empty, exit 1, diagnostic on stderr. Settles nothing, breaks nothing, and a consumer must branch on exit status (which it must today anyway).", "B - make resolveConfigPath throw a plain Error so the nine existing catch-alls emit their envelopes. MEASURED COST: os verify has no try at all, so that face becomes an oclif crash dump instead; errorCodeFields() mints no code for a plain Error, so the nine emit a bare {error} with no code and no httpStatus - which answers #15549 implicitly; and the text face of all ten commands loses its two hint lines.", "C - mint an ADR-0112 code for this refusal and throw that. Gives every face a real envelope, but errorCodeFields()'s own doc says the ADR-0112 ledger is the authority on who may mint a code, so this needs the ledger's sign-off and is a clause-(2) change." ], "recommendation": "A now, and route B-or-C to the maintainer as one ruling that covers this card AND #15549 together. B is what most reviewers will reach for and it is the one option that is measurably worse than it looks - it leaves os verify broken in a new way and silently answers a second open card. C is the honest fix but it is a contract change with a named authority, so it wants the ruling before the code, exactly as the triage comment said." } ], "out_of_scope_findings": [ "NOT FILED - handing it to the PM to file, with the reason. os diff --json with missing args is an INDEPENDENT instance of the same defect class at a DIFFERENT site (packages/cli/src/commands/diff.ts, the missing-paths usage error, which sits ABOVE the 'if (!flags.json)' guard so it fires in both faces): printError plus three console.log calls, then process.exit(1). Driven on the published entry with NO_COLOR=1 and separate streams: exit 1, stdout 141 bytes of prose, stderr 0 bytes, JSON.parse throws. Outside resolveConfigPath and outside this card. DELIBERATELY NOT fixed in this PR: the triage comment endorsed the scope fence between the three cards on this command family and warned against collapsing them, and the bounded in-place exemption does not apply because this is a different site with its own diff. COULD NOT FILE IT MYSELF: the mandatory dedup check is unavailable to this session in both channels - repo-scoped REST returns 403 'GitHub access is not enabled for this session', and MCP search_issues returned 'API rate limit already exceeded for user ID 314681334'. Filing without a dedup check is the one thing worse than not filing, so it comes back to the PM instead of being dropped.", "OBSERVATION, no card proposed. printError() writes to stdout at 196 call sites in packages/cli. In the catch-alls I read, the --json branch returns before printError runs, so those sites are correct as written - I did NOT sweep all 196 and make no claim beyond the ones I read. Recording it only because it is the shared root shape, and because a future 'diagnostics belong on stderr' pass over that helper is a much bigger card than either of the two above." ] }
Generated by Claude Code
⛔ PM seat error, named: this card's delivered half landed on 2026-09-05 and its open question was never routed
domain:cliexecution PM seat (#6024).pm:dispatched→pm:awaiting-maintainer.What actually happened, and what this seat got wrong
PR #15692 merged —
ee79099fd38"fix(cli): route resolveConfigPath's refusals to stderr, and pin the pre-boot --json face". The implementer's report was explicit that merging must not close this card, and it was right; the PR was deliberately "Part of", not "Fixes".⛔ But this seat then left the card at
pm:dispatchedand never carried the open question forward. The consequence is not cosmetic: a decision the implementer asked for on 2026-09-05T03:17Z has been sitting unasked for over a day, and the card looked taken the whole time, so nothing else could pick it up either. Recording it here rather than quietly relabelling.The delivered half (landed, settled, not the question)
The prose is off stdout. Measured before/after through the published entry
packages/cli/bin/run.js,NO_COLOR=1, streams separated, exit read before any pipe:- before — 10 faces × explicit-path branch:
exit 1 / stdout 206B / stderr 0B; 9 faces × auto-detect branch:exit 1 / stdout 123B / stderr 0B;JSON.parserejects both. - after — same 19 runs:
exit 1 / stdout 0B / stderr 206Band123B, message bytes identical.
⭐ Three corrections to this card's own text came out of that round and are worth keeping: the population is ten faces, not nine (
os buildisclass Build extends Compileand inherits both the flag and the config import); both branches of the helper reproduce on all of them, where the card drove only the explicit-path one; andresolveConfigPathhas exactly one in-tree caller,loadConfig().⚠️ The open question — maintainer's, and ⛔ not this seat's to answerWhat should a
--jsonface emit on stdout when the config file is missing? Today, after #15692: nothing. The prose moved to stderr and no payload replaced it.The implementer's three options, with the costs it measured rather than estimated:
- A — leave it as shipped. stdout empty, exit 1, diagnostic on stderr. Settles nothing, breaks nothing; a consumer must branch on exit status, which it must today anyway.
- B — make
resolveConfigPaththrow a plainErrorso the existing catch-alls emit their envelopes.⚠️ Measured costs, and this is the option that looks best and measures worst:os verifyhas notryat all, so that face becomes an oclif crash dump;errorCodeFields()mints no code for a plainError, so the others emit a bare{error}with nocodeand nohttpStatus— which implicitly answers open cardos lint --eval --json's generator-load failure emits a bare{error}— nocode, nohttpStatus— while the same command's project-lint catch-all emits the full ADR-0112 envelope #15549 without ruling it; and the text face of all ten commands loses its two hint lines. - C — mint an ADR-0112 code for this refusal and throw that. Gives every face a real envelope. But
errorCodeFields()'s own doc names the ADR-0112 ledger as the authority on who may mint a code ⇒ needs that sign-off, and is a clause-② change.
Implementer's recommendation, relayed verbatim as a recommendation only: A now, and route B-or-C to the maintainer as one ruling that covers this card and #15549 together.
⭐ That coupling is the part worth the maintainer's attention: B answers #15549 as a side effect, so ruling this card in isolation would settle another open card by accident.
Why this seat does not simply pick A
Triage already set the floor: "Any answer changes what 9 published
--jsonfaces emit on an error path ⇒ above the manual floor, ruling before code." (Ten, as measured.) ⛔ Not this seat's call.Handed-back findings from that round — both since closed out
The implementer could not file them (dedupe channel down that session). Checked now that the channel is back:
os diff --json's missing-paths usage error was fixed by PR #15875 (ad0b3e763c7, merged). ⇒ Nothing outstanding from that hand-back. The 196-siteprintErrorobservation was explicitly not proposed as a card and is not being filed.
Generated by Claude Code
- before — 10 faces × explicit-path branch:
状态转换:
pm:awaiting-maintainer→pm:queue(2026-09-09)维护者回批逐字:「C 桶那 24 张的批量转
pm:queue同意」。判据:
--json面本就承诺输出可解析;resolveConfigPath往 STDOUT 打人类可读文本再process.exit(1),令 9 个命令的--json面在配置文件缺失时吐出不可解析的字节。⇒ 这是拉回已声明契约,按SKILL.md:397的机械边界测试属代裁车道,⛔ 不是扩大接受面。同笔清 assignee(
SKILL.md:104:pm:queue卡恒无 assignee,有即半态)。bug、domain:cli、finding、priority:p2留下。⚠️ 根因见 #17017。
Generated by Claude Code
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actions代裁 · 本卡残留问题的裁定 + Claim
domain:cliexecution PM seat (seat post #6024, sessionsession_015QE8qk46e5CHJxyQEUjbf8). 维护者 2026-09-09 回批逐字:「C 桶那 24 张的批量转pm:queue同意」,判据同笔写明:--json面本就承诺输出可解析;resolveConfigPath往 STDOUT 打人类可读文本再process.exit(1),令 9 个命令的--json面在配置文件缺失时吐出不可解析的字节。⇒ 这是拉回已声明契约,按SKILL.md:397的机械边界测试属代裁车道,⛔ 不是扩大接受面。⇒ 本席在该授权内裁定。⛔ 不再上送:上一任已于 2026-09-06(
5560174904) 把 A/B/C 完整上送,维护者读过之后把卡压回pm:queue,再送一次就是环。四轴分析
① 判据落在哪一边。 维护者的理由是「
--json面本就承诺输出可解析」。⚠️ 那句话否掉了 A:#15692 之后 stdout 是空的,而空字节流不是可解析的文档。A 让症状不再是「吐出坏字节」,但没有兑现那句承诺。⇒ ⛔ A 不是本卡的终局。② C 出界。 实现席自己写明 C 「是 clause-② change」,且
errorCodeFields()的文档指名 ADR-0112 台账是铸码的权威。铸一个新错误码就是扩大接受面 —— 与维护者同笔的「⛔ 不是扩大接受面」直接冲突。⇒ ⛔ C 出本次授权,非本席可裁。③ B 的实测代价里,只有一条是 B 本身的。 实现席测出三条:(a)
os verify完全没有try,抛出会变成 oclif 崩溃转储;(b)errorCodeFields()对裸Error不铸码,⇒ 九个面吐{error},无code无httpStatus;(c) 十个命令的文本面丢掉两行 hint。⭐ 其中 (a) 与 (c) 是可修的实现缺口,不是 B 的语义;只有 (b) 是真正的取舍 —— 而 (b) 恰好就是 #15549 的题目。④ 因此裁的是 B 的修复版,且把 (b) 明确围栏出去。
裁定 —— B′
resolveConfigPath的两处拒绝抛出而非process.exit(1),使九个面既有的 catch-all 能发出各自的信封;同笔补掉 B 的两条实现缺口:- 给
os verify补上它缺的try,⇒ ⛔ 没有任何一个面因为本次改动变成崩溃转储。这一条先于抛出落地或与之同笔落地,⛔ 不得留到后续。 - 两行 hint 必须在文本面保留(由抛出的错误携带 hint 文本,文本面照旧打印)。⛔ 十个命令的文本面不得因为本次改动变窄。
- ⛔ 不铸新码。 裸
Error走既有 catch-all,{error}的形状问题是os lint --eval --json's generator-load failure emits a bare{error}— nocode, nohttpStatus— while the same command's project-lint catch-all emits the full ADR-0112 envelope #15549 的,本卡不回答它 —— PR 正文须写明这一点,且 ⛔ 不得Fixes #15549。
判据:每个
--json面已经声明了它在错误路径上发信封;resolveConfigPath是唯一绕开该声明的路径。让它抛出是把它拉回已声明契约,新增接受面成员为零、新增错误码为零 ⇒ 与维护者判据一致。⚠️ 本裁定 ⛔ 不预判 #15549。若 #15549 后续裁出「裸Error必须带码」,本卡的落地是它的输入,不是它的答案。
Claim
Assignee 与本
Claim:均由派发席写;dev 继承二者,⛔ 不再发第二条 claim,⛔ 不动 assignee。- Branch:
claude/issue-15547-config-refusal-throws - Worktree: dedicated, ⛔ not the shared checkout.
Clause-②: no — 面是
packages/cli/src/utils/config.ts的两处拒绝、os verify的try、以及十个面的既有 catch-all。⛔ 无packages/spec/src/**、⛔ 无*.zod.ts、⛔ 无错误码台账、⛔ 无接受面移动 —— 不铸码是本裁定的硬条款,所以这个no是被裁定钉住的,不是估计。⚠️ 交付后按实际 diff 复导一次;若面越过上述围栏,停下回报,⛔ 不得静默改声明。验收条件
① 先复现,读数先于改动。 通过已发布入口
packages/cli/bin/run.js(⛔ 不是bin/run-dev.jsshim),NO_COLOR=1,stdout / stderr 分文件,exit code 在任何管道之前读取。上一轮测得的基线是 10 个面(⭐ 不是卡面的 9 ——os build是class Build extends Compile,两者都继承)× 两个分支(显式路径 + 自动探测),共 19 次运行。今天的树上重跑这 19 次,⛔ 不要引用上一轮的数字。②
os verify是本卡的尖点,单独立证。 先证明它今天没有try(读源码 + 驱动一次抛出路径,看到崩溃转储),再证明补完之后同一次抛出得到信封。⛔ 这一条不能只靠读源码交付。③ 文本面不得变窄。 十个命令的非
--json面,改动前后的 stderr 字节逐字比对,两行 hint 必须仍在。⛔ 用全串相等,不要toContain。④ 既有 purity pin 的 population 必须加宽。 分诊席写死了这一条,本席照抄:
whatever route is taken, the purity pin's discovery must be widened to include commands that fail before boot — otherwise the next pre-boot stdout leak is invisible again. ⛔ A fix that repairs the helper without touching the pin's population leaves the instrument exactly as blind.
packages/cli/test/json-stdout-purity.e2e.test.ts的家族是由「调用bootSchemaStack的命令」发现的,而本路径不启动内核 ⇒ 该 pin 结构上看不见它。加宽它,并证明加宽后的 population 真的包含这十个面。⑤ 消融,方向先写后测。 上一轮的消融是混合结果而不是「全红」,那是对的读法:exit code 不受缺陷影响,所以断言 exit 1 的那些恒绿。先把预测写下来,再测,再比对;任何一条预测外的断言变红都要解释。每次变异证明落盘(出现次数前后 + blob hash 离开 HEAD blob),每次还原证明干净(
git diff HEAD空 + blob hash 等于 HEAD 的),trap 挂 EXIT INT TERM 且用绝对路径。空 hash 记为失败,⛔ 不记为「无可比对」。⑥ 单写者持有量在 claim 时重测,从 OPEN PR 列表建。 ⛔ 不从远端分支建 —— 本仓有数百条已合并未删的
claude/*ref,会读成幻影持有者。每个开着的 PR 的 head 与它自己的 merge-base 相比,报packages/cli/src/utils/config.ts与packages/cli/src/commands/verify.ts的命中。点名一个真的会响的阳性对照。围栏
- PR 用
Fixes #15547(本裁定就是这张卡的终局),⛔ 但 绝不Fixes #15549,正文须写明{error}形状是那张卡的题目。 - ⛔ 不铸任何 ADR-0112 错误码。
- ⛔ 不动
content/docs/releases/;本 PR 对发版说明的唯一输入是它的.changeset/*.md。 - ⛔ 不用
git stash(refs/stash在共用 git dir,跨 worktree 共享)。 - 已知的邻居,⛔ 不要顺手收编:[finding]
os lint --eval --jsonhas no JSON face for an uncaught throw —runEvalis dispatched above thetry, so the error escapes as oclif's human output #14974(--eval模式)、os lint --eval --json's generator-load failure emits a bare{error}— nocode, nohttpStatus— while the same command's project-lint catch-all emits the full ADR-0112 envelope #15549(信封形状)。分诊席明确警告过不要把这三张卡塌成一张。 - PR 开 draft;⛔ 不自行转 ready、⛔ 不自行挂 auto-merge —— 上弹是派发席的动作。
Generated by Claude Code
- 给
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsClaim
Clause-②: no
⛔ Corrected re-claim — the defect is mine, and it is the third instance of it this round.
node scripts/pm/check-clause2-carriers.mjs --pair 17143returned exit 4: the declaration was written in the fixed spelling but inside a comment the gate does not read as the claim. Verbatim:the fixed spelling appears on the thread … but NOT in the card's claim comment, which is the carrier the enqueue gate's content limb reads. The thinking was done and written down; it is in a place the predicate does not look.
My earlier comment
5600782550carried the ruling and the claim in one stroke, led by a## 代裁heading. The declaration was there, correct, and unreadable.⚠️ I had already recorded this exact failure in my own instrument log after hitting it twice this round, and then reproduced it a third time by burying a claim under a ruling. ⛔ No MCP tool edits a comment, so this standalone re-claim is the fix — the line above is the carrier.- Branch:
claude/issue-15547-config-refusal-throws· PR: fix(cli): resolveConfigPath's refusals throw, so the ten --json faces emit their envelopes — and os verify gains the catch-all it never had #17143 - Worktree: dedicated, ⛔ not the shared checkout.
The
nore-derived from the DELIVERED diff (headfb18504c74, ⛔ not from the claim's prediction): the face ispackages/cli/src/utils/config.ts's two refusals,os verify's new catch-all, and the ten existing--jsoncatch-alls. ⛔ Nopackages/spec/src/**, ⛔ no*.zod.ts, ⛔ no error-code ledger, ⛔ no ADR. No error code is minted — that is a hard clause of the ruling, not an implementation choice — so no accept-set member moves and the envelope emitted is the one each command already declared for every other throw. The two new exports (ConfigRefusalError,isReportedError) are package-internal:packages/clipublishes.,./consoleand./hook-body, andutils/config.js/utils/format.jsare none of those, nor re-exported fromsrc/index.ts.⚠️ ⛔ Nothing about the ruling itself changes. B′ stands exactly as written in5600782550and that comment remains the record of it: throw instead ofprocess.exit(1), giveos verifythetryit never had, keep the two hint lines in the text face, ⛔ mint no error code — the{ error }shape is #15549's question and this PR does not answer it.
Generated by Claude Code
- Branch:
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsClaim:
domain:cliexecution PM seat (#6024), branchclaude/issue-15547-config-refusal-throws, PR #17143.Clause-②: no
⛔ Second corrected re-claim, and this time I read the predicate instead of guessing at it. My first correction (
5602672290) also failed, because it repeated the same mistake in a new comment. The marker is:scripts/pm/check-half-states.mjs:1068 export const CLAIM_COMMENT_MARKER = /^\s*>?\s*Claim(?:ed)?\s*:/mi;⇒ a claim comment is one with a line beginning
Claim:.## Claimis a heading, and the#characters precede the word, so it never matches — the checker's own self-test says so in as many words: 「a heading-style claim is NOT a claim comment — the thread reads ABSENT, not missing-a-line」 (check-clause2-carriers.mjs:1988).⚠️ This is systemic, not a slip. Every claim comment this seat wrote today opens with## Claim, so every one of them is invisible to the enqueue gate — #165345601347984, #161945602636036, #168045600760464, this card's5600782550, and my own first correction. ⛔ Three times I diagnosed it as a spelling error in theClause-②line, when theClause-②line was fine and the carrier was not. I twice wrote "the declaration must be in the claim comment" into my own instrument log while writing claim comments the predicate does not classify as claim comments at all. Corrected re-claims are going onto the other live cards in the same stroke.The declaration, re-derived from the DELIVERED diff at head
fb18504c74— ⛔ not carried over from the dispatch's prediction: the face ispackages/cli/src/utils/config.ts's two refusals,os verify's new catch-all, and the ten existing--jsoncatch-alls. ⛔ Nopackages/spec/src/**, ⛔ no*.zod.ts, ⛔ no error-code ledger, ⛔ no ADR. No error code is minted — a hard clause of ruling B′, not an implementation choice — so no accept-set member moves, and the envelope now emitted is the one each command already declared for every other throw. The two new exports (ConfigRefusalError,isReportedError) are package-internal:packages/clipublishes.,./consoleand./hook-body;utils/config.jsandutils/format.jsare none of those and are not re-exported fromsrc/index.ts.⛔ Nothing about ruling B′ changes;
5600782550remains its record.
Generated by Claude Code
os-dev-report
{ "issue": 15547, "status": "done", "branch": "claude/issue-15547-config-refusal-throws", "pr": "https://github.com/objectstack-ai/objectstack/pull/17143", "premise_still_valid": true, "summary": "Finish-only run on an already-delivered PR: no implementation change, no commit, no push. Head is unchanged at fb18504c749a3739212cc05f1ea56db09fe9c914 and the tree is clean. Item 1 (docs-drift) discharged: the bot's list was re-derived on the bot's own tree — ef4b9856a579's content/docs tree object 5165b92107 and its full tree object 6c44525eb3 are the SAME objects as this head's, so the corpus is identical, not a lookalike — and it reconciles on every published figure (52 rows = 42 hand-written + 10 release-owned, 21 anchors, os validate dropped as over-broad at 49 pages, bridge 60/215, 23 package-mention rows). All 62 anchor occurrences across the 42 rows are of kind 'command'; zero symbol, zero route, zero sdk, and none of the diff's twelve symbol anchors appears in any hand-written page. Every page was judged against the three claim classes actually at risk, plus a hand re-read for the input-vs-emitter blind spot. NO hand-written page needs an edit; two claims are repaired by this change rather than broken. The 10 release-owned pages were read, never edited, and none is wrong. Item 2 (stale SHA) discharged: the body no longer claims a reading at 2004e5a6c6 as 'the final commit'. It now names fb18504c749a as the head, attributes each section to the head it was taken at, and proves what carries across — 15 of the PR's 16 files are blob-identical at the two commits, only generate.ts moved (+98/-20, the #16887 integration), and generate.ts is not one of the ten --json faces.", "tests": "No code changed, so nothing was rebuilt and no gate family was owed; the runs below re-anchor the body's readings to the head being merged. (1) Docs-drift re-derivation at head: `node scripts/docs-audit/affected-docs.mjs --json ce7bae8b4424...` EXIT=0, 52 docs / 10 releaseOwned / 21 anchors — matches the bot exactly. Tree identity proved with `git rev-parse ef4b9856...:content/docs` = `git rev-parse HEAD:content/docs` = 5165b92107, and full trees both 6c44525eb3. (2) Behaviour re-run at head through the PUBLISHED entry packages/cli/bin/run.js, NO_COLOR=1, stdout/stderr to separate files, exit code read before any pipe: 19 runs (10 explicit-path + 9 auto-detect), 19/19 JSON.parse, exit 1 on all 19; explicit stdout 174-253 B / stderr 301 B, auto stdout 73-152 B / stderr 123 B. The +5 B vs the body's 296 B is decomposed, not waved away: that branch's stderr is 163 B path-independent plus the absolute path (163+138=301 measured; 296 implies a 133-byte path), and the path-independent auto branch is 123 B at BOTH commits. Zero of the 19 envelopes carry `code` or `httpStatus` — the no-minted-code fence re-confirmed at the head. (3) Pins at head, under the shared verify lock (OS_VERIFY_LOCK_SLOT=issue-15547-finish, waited 200s, held 369s): `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/config-miss-stdout-purity.e2e.test.ts test/json-stdout-purity.e2e.test.ts` with OS_TEST_TIERS=nightly — VERDICT command-exit 0, 2 files passed, 212 tests passed (212). (4) Gate family re-derived at head because the merge changed scripts/pm/dispatch-gates.mjs itself by +558 lines: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` EXIT=0, 61 commands — same family as the body claims, over the same 16-path change set. It also warns STALE TREE vs today's origin/main (44c917a47a, 2 ahead, scripts/check-dispatcher-error-vocabulary.mjs changed in the range) — a fact about main moving after this head, left for the PM seat, not closed by a silent rebase. (5) check-clause2-carriers --pair 17143 re-run AFTER the body edit (my edit touches exactly what its declaration limb reads): EXIT=0, declaration readable in the fixed spelling, both carriers agree, no widening tell. The Clause-② section was preserved byte-for-byte (626 bytes, diff-identical). (6) Body read back to the tail: stored body startsWith the bytes I sent (true), platform appended exactly one bare footer block, one footer total, zero angle-bracket fragments, zero control bytes, and exactly one closing-keyword occurrence in the whole body — `Fixes #15547` on line 1, with no closer near #15549/#14974/#16887. (7) My body edit fired the pull_request:edited event and re-ran two gates ('No other open PR may claim the same issue', 'Check Changeset'); both completed success. Final state re-read: 39 check-runs = 34 success + 5 skipped, mergeable_state clean, draft true, head and labels unchanged.", "mcp_calls": "0 — every GitHub read and the one write went through repo-scoped REST (probe: GET /repos/objectstack-ai/objectstack = 200) plus git; no MCP GitHub call was made.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: `os verify` is documented NOWHERE in the hand-written corpus — 0 hits for 'os verify' across content/docs, and it is absent from the command tables in content/docs/deployment/cli.mdx — so the docs-drift caveat 'document os verify's behaviour on a bad config' has an empty population. This PR gives that command its first catch-all, which makes the gap more visible, but writing a reference section for an undocumented command is a docs change this PR has no mandate for. Carrier: none queued — 承接者:无.", "noted, not filed: content/docs/deployment/cli.mdx L1535-1536 and L1620-1621 assert 'Every other ObjectStack command writes exactly one JSON document to stdout under --json'. On main that is FALSE for ten faces on the config-refusal path (zero documents); this PR is what makes it true. Recorded as the strongest doc-alignment evidence for the change, not as a defect to file. Carrier: this PR.", "noted, not filed: the derived-gate run at head warns STALE TREE against origin/main 44c917a47a (2 commits ahead; scripts/check-dispatcher-error-vocabulary.mjs changed in that range). Whether to merge main again before arming is the PM seat's call; the brief forbade rebase/merge/push here. Carrier: the PM seat, at arming time.", "noted, not filed: the worktree /home/user/objectstack-issue-15547 was left in place (clean, no uncommitted work, dist built at head). I did not create it and the PM seat may want it for arming; removing another seat's worktree was not mine to do. Carrier: the PM seat." ] }
Generated by Claude Code
os-project-manager commented
on Sep 9, 2026 CollaboratorMore actionsACCEPT — landed as PR #17143, squash
df8a16deee, verified from the refdomain:cliexecution PM seat (#6024). Merged 2026-09-09T14:32:16Z. Round total 25.Landing verification — every reading via
git show origin/main:<path>, ⛔ never a working-tree grepcheck reading squash df8a16deeesingle-parent parents = 1 · firing control 6d19f4839055reads 2, so the counter discriminatesfiles 16, +638/−230 — the ten --jsonfaces,utils/config.ts,utils/format.ts, both purity pins, the sharedtest/helpers/config-miss-family.ts, and the changesetrelease-owned pages 0 the ruling's clause 1 config.ts:69export class ConfigRefusalError extends Error,:101throw error;— the two refusals throw instead of exitingthe ruling's clause 2 verify.ts:97"The catch-all this command did not have (#15547)",tryat:121, body moved verbatim intorunVerificationat:143— as promised, so the guard is the diff rather than 120 lines of re-indentationthe ruling's clause 3 ⛔ no error code minted — ConfigRefusalErrorcarries neithercodenorhttpStatus; measured at the head as 0 of 19 envelopes carrying eitherLanding prechecks
- ① ⛔ not applicable — neither clause-② limb fires.
- ②
check-clause2-carriers --pair 17143→ clean: declaration readable in the fixed spelling, both carriers agree, no widening tell. - ③ 39 check-runs, collapsed latest-per-name → all success or skipped. Judged on every check, ⛔ not the required subset.
What the round is credited with
The reproduction and its inverse, through the published entry. 19 runs (ten faces × the explicit-path branch, nine × auto-detect),
packages/cli/bin/run.js,NO_COLOR=1, streams separated, exit read before any pipe: 0/19 parsed before, 19/19 parse after, exit 1 throughout.os verifyproved separately — stdout 0 B before,{"error":"exploding config module"}after.⭐ The text-face comparison caught a regression before it shipped. All 19 non-
--jsonruns compared byte-for-byte;os compile's branch ends in oclif'sthis.error(), which re-rendered the sentence and raised the exit status from 1 to 2 (483 stderr bytes vs 296 elsewhere). Fixed and pinned. ⛔ That is a defect the acceptance criteria would not have caught — it came from the seat comparing what it was not asked to change.⭐ The purity pin's blindness was measured, not argued. Its previous form accepted "empty OR JSON", which passed straight through this defect. Against the 19 real captured stdouts: the old assertion passes 19/19 (blind), the new one fails 19/19. And its population now reconciles against the
bootSchemaStack-discovered family, so neither half can be lost silently — the half triage said must not be left as blind as it found it.The ablation predicted MIXED and was. Written before mutating: exit code and stderr are untouched by the defect, so assertions about them stay green ⇒ 38 failed / 136 passed of 174. Measured exactly that, with the two failing assertion names at 19 each and nothing outside the prediction moving.
dist/was deliberately not rebuilt and still carried the fix while the pin went red — which is what proves the pin resolves throughsrc/via tsx.⚠️ check:nul-byteswent red and it was a genuine finding of the seat's own making — two literal ESC bytes written while writing about the escape sequence, the exact slip that gate's header describes. Rewritten as escape text; green over 8059 files.Docs drift — 52 rows, 0 needing an edit, and ⭐ a seventh failure mode
The bot named 42 hand-written pages + ⛔ 10 release-owned, from 21 anchors, with the list above its display cap. Re-derived and reconciled on every published figure. ⭐ Tree identity was proved by tree object, not by the bot's sha —
content/docsis5165b92107and the full tree6c44525eb3on both the bot's tree and this head — which closes the bot's own disclosed caveat that its checkout carried uncommitted changes, instead of ignoring it.All 62 anchor occurrences are of kind
command; zero symbol, zero route, zero sdk, and none of the diff's twelve symbol anchors appears in any hand-written page. The input-vs-emitter class was re-read by hand, since this diff is entirely emitter-side and the bot structurally cannot list those pages.⭐ The seventh mode: a page the change makes TRUE.
content/docs/deployment/cli.mdxL1535-1536 and L1620-1621 assert "Every other ObjectStack command writes exactly one JSON document to stdout under--json" — false onmainfor ten faces on this path, true after this PR. Six prior round-trips were pages a change might falsify; this is the first where the docs were ahead and the code caught up. ⛔ Zero release-owned pages edited.Fenced out, and staying out
The
{ error }envelope carries nocodeand nohttpStatus. ⛔ Whether that is the right shape is #15549's open card and this PR does not answer it — the new pin asserts only that a document arrives and that it names the refusal, never what else is in it, so whoever settles #15549 changes the payload without touching that file. #15549 remains open. #14974 (--evalmode) likewise untouched: triage warned against collapsing the three cards and the fence held.⚠️ Handed back, not filed:os verifyis documented nowhere in the hand-written corpus — 0 hits acrosscontent/docs, absent from the command tables incli.mdx. This PR gives it its first catch-all, which makes the gap more visible; writing a reference section for an undocumented command is a docs change this PR had no mandate for. Successor: whoever documents the CLI command set.Residue
pm:queue,pm:dispatchedand the assignee were all still on after merge. Stripped by hand.⚠️ That is 4 of the 5 merges this seat has landed today (#16954, #17122, #16611, #15547 not stripped; #15405 stripped) ⇒ ⛔ treat non-stripping as the default, not as an exception, and read the labels back every time.Implemented-by:PR #17143 ·Reviewed-by:in-seat,domain:cliexecution PM seat (#6024) · ruling B′ at5600782550
Generated by Claude Code
Filed unassigned, recording only — measured while running the phase-1 reachability probe for #14974 (
os lint --eval --jsonhas no JSON face for an uncaught throw). Different mode, different exit, different helper, so it is recorded separately rather than folded in.The measurement
Driven on
origin/mainated9d87653ebthrough the published entry pointpackages/cli/bin/run.js(not thebin/run-dev.jsshim),NO_COLOR=1, stdout and stderr captured to separate files, exit code read before any pipe.JSON.parseon that stdout throws. The identical shape reproduces onos validate ./nope-does-not-exist.ts --jsonandos build ./nope-does-not-exist.ts --json— exit 1, stdout 286 bytes, not JSON, stderr empty in all three.Where it comes from
packages/cli/src/utils/config.ts,resolveConfigPath()at L47:printErrorandconsole.log— both write to stdoutprocess.exit(1)directlySo this path never throws. Every command's catch-all
--jsonerror exit sits downstream of a throw; with no throw, no catch runs and no payload is emitted.resolveConfigPathhas no--jsonawareness at all — the flag is not in scope there.The same helper does it a second time at L72-75, for the auto-detect miss (
No objectstack.config.{ts,js,mjs} found in current directory).Why this is a stronger shape than a missing payload
--jsonreserves for the machine. The consumer gets 286 bytes that parse as nothing, on the channel it was told to read.Blast radius
9 command modules both declare a
jsonflag and reachloadConfig/resolveConfigPath:validate.ts·info.ts·diff.ts·lint.ts·migrate/meta.ts·compile.ts·verify.ts·i18n/check.ts·i18n/extract.tsThree of those faces were driven (
lint,validate, plusbuild, which reaches the helper as well); the rest is the static reading, ⛔ not measured.Not already covered
packages/cli/test/json-stdout-purity.e2e.test.tspins "stdout is exactly one JSON document", but its family is DISCOVERED as the commands that callbootSchemaStack. These commands reach the config helper without booting a kernel, so that pin never sees this path.packages/cli/src/utils/schema-migration-plugins.tsL189 already records that the helperprocess.exit(1)s, and deliberately avoids calling it for that reason. The--jsonconsequence of the same behaviour is recorded nowhere.⛔ What is NOT claimed
No route is proposed. Whether the helper should throw a typed error, accept a
jsonoption, or be replaced at each call site is a design question this card does not settle — and any answer touches 9 published--jsonfaces at once.Refs
os lint --eval --jsonhas no JSON face for an uncaught throw —runEvalis dispatched above thetry, so the error escapes as oclif's human output #14974 — the phase-1 probe this was measured beside; that one is about--evalmode and stands on its own termspackages/cli/src/utils/config.tsL47-76