Repository navigation
fix(batch): spec honesty at every boundary and attribution visibility - #96
Conversation
Close all 8 findings of the per-stage model selection review, in two
phases driven by the review-fixes batch:
- spec-honesty-at-every-boundary: parseAgentSpec additionally rejects
whitespace-bearing agent/model parts and models starting with `-`,
naming the offending value (findings 7, 8). `batch config set agent`
validates through the shared AgentSettingSchema and refuses to persist
what the loader rejects, with inline `{...}` stage maps parsed as real
YAML maps (finding 3). readProjectConfig validates the batch section
per key: warnings name `batch.<key>[.<stage>]` and the offending value
(authToken never echoed), valid siblings are preserved instead of the
whole section silently reverting to defaults (finding 2). A malformed
standalone --agent fails before any spawn with an actionable error,
and the residual skill-locus parse seam rethrows SkillLocusError so
the engine maps it to a structured failed step (finding 4).
- attribution-visibility: the model-failure attribution hint threads
into blocker/message in the mapper's single attribution branch, so
the non-JSON blocked line, parked reason, journal entry, and the
standalone renderer all carry it with zero renderer edits, asserted
at the rendered surfaces (finding 1). agentStageScopes reaches the
batch-driven pr and decompose spawns (uniformAgentScope for the
scalar-covered decompose case), giving both the attributed surface
(finding 5). The hint is gated on a real non-zero exit code and
suppressed on signal kills, so a timeout SIGKILL under a valid model
is never told its model id may be invalid (finding 6).
The phase proofs run through the real seams (readProjectConfig,
setProjectBatchSetting, rendered outputs), replacing the hand-rolled
YAML re-parse the review flagged. Full suite green: 203 files / 2879
tests; coverage 95.54% over the 95% gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019S4t1YHtiCS4H35XtyEC1b
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
ratchet | 09e3c38 | Commit Preview URL Branch Preview URL |
Jul 06 2026, 11:05 PM |
joctaTorres
left a comment
There was a problem hiding this comment.
Verdict: Request changes — 8/8 prior findings genuinely closed, but the new per-key config loader introduces one confirmed (Low) regression
All eight findings from the PR #95 review are closed by real code with tests that pin the claimed behavior at the actual seams (renderers, park store, journal, write/load paths — not just StepResult fields). Full suite passes (2879/2879) and tsc --noEmit is clean on the branch. However, the finding-2 fix (parseBatchPerKey) has a prototype-chain key bug that regresses previously-passing configs, verified by an end-to-end reproduction. One trivial fix and this is an approve.
Per-finding closure
| # | Finding | Closed? | Evidence |
|---|---|---|---|
| 1 | Hint never reached rendered surfaces | ✅ | src/core/batch/engine/outcome.ts:200-210 threads the hint into blocker/message inside the attribution branch only. Tests exercise the real renderer (renderStepResult), the parked reason, the journal entry message, and the JSON surface (test/batch-engine/model-failure-attribution.test.ts, "rendered surfaces carry the attribution hint"). Non-attributed shapes are pinned byte-for-byte via deep-equal against a no-attribution baseline. |
| 2 | Whole batch section dropped on any invalid key | parseBatchPerKey (src/core/project-config.ts:277-312) warns per key naming batch.<key>[.<path>] + JSON-stringified value, redacts authToken, preserves valid siblings — all pinned in test/core/project-config.test.ts ("per-key batch load (resilient)") and through the real readProjectConfig seam in agent-model-selection.test.ts. The batch schema has no cross-key refinements (validateRemoteSettings runs at engine time), so per-key parsing loses no load-time constraint. But see New Finding 1 below. |
|
| 3 | config set agent "claude:" persisted what the loader rejects |
✅ | validateSetting routes agent through the shared AgentSettingSchema (src/core/batch/config.ts:807-843); {-prefixed values parse as YAML flow maps and persist as real maps via SetResult.parsedValue (setProjectBatchSetting, config.ts:975-981). Write refusal pinned: file byte-for-byte unchanged on reject (test/core/batch/config.test.ts), command-level rejection (test/commands/batch/config.test.ts), loader round-trips the persisted map with no warning. |
| 4 | Standalone --agent "claude:" crashed with raw throw |
✅ | resolveChangeStepSettings validates before any mutation and throws naming the value; apply/propose/verify command tests assert the injected spawner is never invoked. ensureCommandInSpawnLocus (skill-locus.ts:201-227) wraps parseAgentSpec into SkillLocusError (asserted via instanceof and .name), and engine-skill-locus.test.ts pins the engine mapping it to a structured blocked step with zero spawns — no raw escape. |
| 5 | No attribution for batch-driven pr/decompose spawns | ✅ | agentStageScopes threaded through runPr/runStackedPr/runDecomposition (src/commands/batch/apply.ts) into PrStepContext/DecompositionStepContext. PR uses scopes.pr; decompose uses uniformAgentScope gated on spec?.model !== undefined (engine.ts:503-507 / engine.ts:680-684). Attributed + unchanged shapes both pinned on every surface for both paths. |
| 6 | Hint fired on signal kills | ✅ | Gate spawn.signal === null at outcome.ts:200; AgentSpawnResult.signal is typed NodeJS.Signals | null (never undefined) so the gate can't mis-suppress. Signal-kill suppression pinned unit-level and engine-level on every surface, plus an exit-code regression guard that the hint still fires for code-2 failures. |
| 7 | Whitespace accepted in specs | ✅ | parseAgentSpec rejects leading/trailing whitespace in bare-agent, agent-part, and model-part, naming the full spec and the offending part (agent-setting.ts:85-113); previously-valid specs (opencode:zai/glm-5.2, codex:vendor:tagged-1) pinned byte-for-byte unchanged. |
| 8 | Leading-dash model rode into argv | ✅ | model.startsWith('-') rejected naming the value (agent-setting.ts:114-118), incl. claude:--dangerously-skip-permissions; interior dashes (codex:gpt-5.2-codex) stay valid. |
New findings
1. [Low, confirmed — the request-changes item] parseBatchPerKey crashes on prototype-chain key names, nuking the ENTIRE config to null (regression vs. base).
src/core/project-config.ts:292:
if (!(key in shape)) continue; // unknown key: ignored (schema is partial)The zod shape object inherits from Object.prototype, so in matches inherited properties. A batch section containing a key named constructor, toString, hasOwnProperty, valueOf, etc. passes the guard, shape[key] resolves to the inherited function, and fieldSchema.safeParse(value) throws TypeError: fieldSchema.safeParse is not a function. That escapes to readProjectConfig's outer catch (project-config.ts:255-258), which returns null — dropping not just the batch section but schema, context, rules, and eval too, with only a generic "Failed to parse" warning.
Reproduced end-to-end on this branch:
schema: ratchet
context: hello
batch:
constructor: 1
gate: after-propose→ readProjectConfig(...) returns null; warning: Failed to parse .ratchet/config.yaml: TypeError: fieldSchema.safeParse is not a function.
On base (batch/model), this exact config loads fine: the whole-section safeParse strips the unknown key and keeps gate: after-propose. So the resilience fix regresses a previously-passing input into total config loss — the precise failure mode finding 2 set out to eliminate, amplified from one section to the whole file.
Fix is one line:
if (!Object.hasOwn(shape, key)) continue;
// or: if (!Object.prototype.hasOwnProperty.call(shape, key)) continue;Please also add a test pinning that a prototype-named unknown batch key is ignored with siblings preserved.
2. [Nit, non-blocking] Vague error for non-string / unknown-stage map entries in validateAgentSetting. For --set 'agent={apply: 123}' or {deploy: x}, the union fails before the superRefine runs, so zod emits a single invalid_union issue at path [] with message Invalid input → Invalid value for 'agent': agent: Invalid input — neither the stage key nor the offending value is named (config.ts:826-840 doesn't unpack unionErrors). The common case (malformed spec string per stage) is named correctly. Consider unpacking unionErrors for the map branch.
3. [Nit, observation] The signal gate doesn't cover the sidecar's synthesized timeout. The ReX sidecar's internal timeout resolves { exitCode: exitCode ?? 1, signal: null } (rex-sidecar-runtime.ts:254, :295), so an in-sidecar timeout under an explicit model with zero journal entries still fires the hint. The detail does carry the ReX sidecar timed out after Xms stderr line, and an agent that hangs on an invalid model is a plausible cause, so this is arguably within the hint's "if this model id is invalid…" phrasing — but it's outside the "signal kill ⇒ model likely valid" rationale of finding 6. Fine to leave; noting for the record.
Checked and clean
- Full test suite on the branch: 2879/2879 pass;
tsc --noEmitclean. - YAML flow-map edge cases verified empirically against the repo's
yamlversion:{apply: claude:fable}parses the colon-bearing plain scalar correctly;provider/modelslashes, quoted values, and multi-entry maps round-trip; unclosed braces, trailing garbage ({a: b} extra), and block-in-flow ({apply: claude: fable}) all throw and are converted to clean errors; nested braces ({apply: {agent: claude}}) are rejected by the schema (non-string stage value). uniformAgentScopecorrectness: a scalaragentsetting always materializes a full uniform scope map, and any map-typed setting makesscalarAgentreturnundefined(no model ⇒ attribution gated off), so a coincidentally-uniform scope map can never mis-attribute the decompose spawn. Partial/mixed/empty/undefined maps all returnundefined(pinned inagent-scope-resolution.test.ts).- No blocker/message enrichment leaks into non-attributed branches: bare-name, progressed, scope-less, signal-kill, and zero-exit shapes deep-equal their no-attribution baselines.
- No double-hint on any single rendered line (blocked line prints
blockeronly; journal printsmessageonly; JSON carries it in three fields by design). - Empty inline map (
agent={}) is accepted at write and by the loader alike (behaves as unset) — no write/load divergence. git statuson the review checkout left clean; no tracked files modified.
🤖 Fable 5 review via Claude Code
… config parseBatchPerKey used `key in shape`, which walks the prototype chain, so a batch key named `constructor`/`toString`/etc. passed the known-key check and then called `.safeParse` on the inherited prototype value — throwing a TypeError that unwound to readProjectConfig's catch and reverted the ENTIRE config to null (regression vs. base's bounded whole-section drop). Switch to `Object.hasOwn(shape, key)` so prototype-chain names are ignored like any other unknown key, matching the partial-schema contract. Adds a regression test proving the whole config no longer collapses to null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…95) * feat(batch): per-stage agent routing and stacked PR grouping Let a batch drive different coding agents per lifecycle stage and open pull requests in configurable groupings, across three phases: - per-stage-agent-routing: the `agent` setting accepts a scalar or a partial `{propose, apply, verify}` stage-map at project-config and manifest scope; resolution merges nearest-wins per stage (mapped -> scalar -> default) and the engine resolves the adapter per transition. Invalid stage keys, non-string values, and unknown agent names are rejected before any spawn. - whole-batch-pr-opening: `prGrouping: whole-batch` spawns a dedicated instruction-following PR agent at batch completion that commits the accumulated work (repo git-log style, semantic by default) and opens one PR via the self-contained pr-open workflow. Adds `pr` as a fourth routable stage. No forge-specific code in ratchet; idempotent on rerun via per-group run-state; failures surface as reported step failures; doctor warns when grouping is active but no git remote is configured. - stacked-pr-grouping-modes: `per-phase` / `per-change` open one stacked PR per unit (group 0 -> batch base, group N -> group N-1's branch). The engine spawns one PR agent per detected boundary with the resolved stacked base injected as instruction data, keyed per group so a resumed loop never double-opens; `batch apply` surfaces and routes the per-boundary PR steps. Docs (README + docs/) document the settings, the stacked-base rule, and carry vertical Mermaid overviews. Full suite green; line coverage 95.51%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(batch): per-stage model selection and attributed model failures Let every agent value pick the model it runs with, and make model mistakes cheap to diagnose, across two phases: - per-stage-model-selection: every string position of the `agent` setting — scalar or per-stage map, project config and manifest — widens to an `agent[:model]` spec split on the first colon, so opencode provider/model ids pass through intact. The engine parses the resolved spec once per transition, resolves the adapter by the agent part (UnknownAgentError still fires before any spawn), and each adapter emits its own model flag (claude/opencode/cursor --model, codex/gemini -m) only when a model is named; a bare name emits no flag and keeps today's argv byte-for-byte. Malformed specs (empty agent or model part) are rejected at config load naming the offending value; the model part is free-form pass-through — no model registry to rot. Nearest-wins per-stage merge still operates on whole spec strings, so agent+model move atomically across scopes. - model-failure-attribution: when a transition whose spec explicitly named a model dies with the argv-rejection signature (non-zero exit, no completion, zero session journal entries), the surfaced failure opens with an attribution hint — stage, agent, exact model string, and the scope (project config vs manifest) that supplied it, phrased as "if this model id is invalid…", never a diagnosis — above the verbatim stderr tail. Resolution now exposes per-stage supplying scopes beside the merge (results unchanged). Doctor parses configured agent values through the same spec parser, probes the agent part's binary, and fails the agent check when a configured agent's binary is missing; it never validates a model id. Docs (README + docs/) document the spec syntax, the failure hint, and doctor's spec-aware probing. Full suite green: 203 files / 2780 tests; coverage 95.55% over the 95% gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S4t1YHtiCS4H35XtyEC1b * fix(batch): spec honesty at every boundary and attribution visibility (#96) * fix(batch): spec honesty at every boundary and attribution visibility Close all 8 findings of the per-stage model selection review, in two phases driven by the review-fixes batch: - spec-honesty-at-every-boundary: parseAgentSpec additionally rejects whitespace-bearing agent/model parts and models starting with `-`, naming the offending value (findings 7, 8). `batch config set agent` validates through the shared AgentSettingSchema and refuses to persist what the loader rejects, with inline `{...}` stage maps parsed as real YAML maps (finding 3). readProjectConfig validates the batch section per key: warnings name `batch.<key>[.<stage>]` and the offending value (authToken never echoed), valid siblings are preserved instead of the whole section silently reverting to defaults (finding 2). A malformed standalone --agent fails before any spawn with an actionable error, and the residual skill-locus parse seam rethrows SkillLocusError so the engine maps it to a structured failed step (finding 4). - attribution-visibility: the model-failure attribution hint threads into blocker/message in the mapper's single attribution branch, so the non-JSON blocked line, parked reason, journal entry, and the standalone renderer all carry it with zero renderer edits, asserted at the rendered surfaces (finding 1). agentStageScopes reaches the batch-driven pr and decompose spawns (uniformAgentScope for the scalar-covered decompose case), giving both the attributed surface (finding 5). The hint is gated on a real non-zero exit code and suppressed on signal kills, so a timeout SIGKILL under a valid model is never told its model id may be invalid (finding 6). The phase proofs run through the real seams (readProjectConfig, setProjectBatchSetting, rendered outputs), replacing the hand-rolled YAML re-parse the review flagged. Full suite green: 203 files / 2879 tests; coverage 95.54% over the 95% gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019S4t1YHtiCS4H35XtyEC1b * fix(config): ignore prototype-chain batch key names instead of nuking config parseBatchPerKey used `key in shape`, which walks the prototype chain, so a batch key named `constructor`/`toString`/etc. passed the known-key check and then called `.safeParse` on the inherited prototype value — throwing a TypeError that unwound to readProjectConfig's catch and reverted the ENTIRE config to null (regression vs. base's bounded whole-section drop). Switch to `Object.hasOwn(shape, key)` so prototype-chain names are ignored like any other unknown key, matching the partial-schema contract. Adds a regression test proving the whole config no longer collapses to null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * refactor(engine): extract non-zero-exit failure branch in mapSessionToOutcome `mapSessionToOutcome` measured CC 16 (over the ≤15 gate), driven largely by the attribution-enriched non-zero-exit failure branch: the outer `if (nonZero && !completion)` plus the nested three-condition guard `input.modelAttribution && sessionEntries.length === 0 && spawn.signal === null` and its stderr-tail ternary/`||` fallbacks. Extract that branch — the attribution-enriched failure return and its sibling plain-failure return — into a named `buildNonZeroExitFailure(input)` helper. The mapper now calls it in one line, dropping ~3 decision points (the nested `if` and its two `&&`) out of `mapSessionToOutcome`, which brings it under the gate (≈CC 13); the helper itself is a small two-return function. Behavior-preserving: every returned field (`state`, `change`, `transition`, `detail`, `blocker`, `journalRefs`, `message`) is byte-for-byte identical in both the attributed and plain paths; the exported `mapSessionToOutcome` name, signature, and return shape are unchanged. Affected suites green (test/batch-engine, test/core/batch: 827 passed); lint/tsc/build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(batch): share one fold between agent-setting and stage-scope resolution `resolveAgentSetting` (CC-density ≈0.36) and `resolveAgentStageScopes` (≈0.34) both exceeded the ≤0.30 density gate, and each open-coded the SAME scalar-resets / map-merges-per-stage fold over the same low→high `agent` layers — one recording the resolved spec string, the other the supplying scope. Extract that fold into one shared generic `foldAgentLayers<T>(layers, select)` parameterized over what each layer records at a string position: the merge passes `(_scope, value) => value` (record the spec), the scope sibling passes `(scope) => scope` (record the supplying scope). Each function keeps only its own final materialization — the merge's `stages[stage] ?? base` full-map (or partial map / bare scalar), and the scope sibling's per-stage attribution map. This drops the per-stage loop and scalar/map branching out of both functions, cutting each one's decision density below the gate, and removes the duplicated fold. Behavior-preserving: the fold performs byte-identical steps to the two inlined folds (same `source`/`base`/`stages`/`mapContributed` accumulation, same nearest-wins reset), and each function's returns are unchanged. Exported names, signatures, and return shapes of both functions are identical. The agreement invariant tests over the same layers stay green (test/core/batch/ agent-scope-resolution: 22 passed; full test/core/batch: 295 passed); lint/tsc/build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(batch): decompose pickNextStep into ordered selector helpers `pickNextStep` measured CC 34 (well over the ≤15 gate): one function held all five sequential selection branches inline (runnable-change/boundary-proof → decompose → terminal proof → whole-batch PR tail → stacked PR tail), each a multi-condition block with its own loops and short-circuit returns. Split each branch into a small named selector helper — `selectChangeOrBoundary Proof`, `selectDecomposeStep`, `selectTerminalProofStep`, `selectWholeBatchPr Step`, `selectStackedPrStep` — each returning `ApplyTarget | undefined`. `pickNextStep` now just calls them in order and returns the first non-undefined via `??`, so its own CC collapses to ~1 while each helper carries only its branch's complexity (each well under the gate). A shared internal `PickPrContext` type backs the two PR selectors; the exported `pickNextStep` signature (including its inline `prContext` literal and doc) is unchanged. Behavior-preserving: the selectors run in the exact previous order with identical short-circuit semantics — the first branch to yield a target wins, and every branch's guard conditions, phase/boundary lookups, and returned `ApplyTarget` shapes are byte-for-byte the same as the prior inline body. The two PR selectors keep the positive-condition block form so TypeScript's `prContext` narrowing is identical. Affected suites green (test/commands, test/batch-engine: 764 passed); lint/tsc/build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(batch): extract prContext assembly and step-kind dispatch in batchApplyCommand `batchApplyCommand` measured CC 19 (over the ≤15 gate): after selecting a target it inlined the PR-context assembly, the no-runnable-step rendering (a nested ternary + json/text branch), and a four-way `if (target.kind === …)` dispatch chain (decompose / proof-of-work / pr-with-boundary-sub-branch / change), each with its own multi-argument engine call. Extract those into named helpers: - `buildPrContext(journal, grouping)` — the PR gating-inputs object. - `renderNoTarget(status, phases, proofByPhase, options)` — the no-step output. - `dispatchNonChangeTarget(target, ctx, status)` — a `switch` over the non-`change` kinds routing each to its engine entry point, with the `pr` branch's boundary sub-routing in its own `runPrTarget`. - `runChangeTarget(target, ctx)` — the change-step body (park precheck, context build, runStep, persist, render). A shared `ApplyDispatchContext` bundle threads the common inputs. The command now resolves state, picks the target, and branches once (`kind !== 'change'`), so its CC collapses to ~4. Behavior-preserving: control flow is identical — same order of resolution, the same single `pickNextStep` call, the same early returns, and each kind reaches the same engine call with the same arguments as the prior inline chain (verified by the `Exclude`/`Extract` narrowing matching the old `if` order). The exported `batchApplyCommand` name and signature are unchanged. Affected suites green (test/commands, test/core/batch, test/batch-engine: 1059 passed); lint/tsc/build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(batch): cut selectChangeOrBoundaryProof density via shared boundary-proof helper Reduce `selectChangeOrBoundaryProof` CC density from 0.37 (CC 11 / NLOC 30) to ~0.27 with two behavior-preserving cuts, both byte-identical to before: - Replace the three-way runnable-status check with a lookup against a new module-level `RUNNABLE_STATUSES: ReadonlySet<ChangeStatus>` (ready / in-progress / awaiting-verify); the awaiting-verify rationale moves onto the constant. - Extract the predecessor-boundary-proof guard into a shared `pendingBoundaryProof(...)` used by BOTH selectors. The two call sites keep their DISTINCT gates via `requireDoneWithProof`: the change branch passes `false` (predecessor is done by the gate invariant); the decompose branch passes `true` (adds the `status === 'done' && proofOfWork` conditions). As a side effect `selectDecomposeStep` also drops below the density band. No exported signature changes; `pickNextStep`'s public shape is unchanged. Verified via pickNextStep / boundary-proof / decompose unit tests (532 batch-engine tests green) and the full affected suites (1059 green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(batch): route setting validate/persist through one codec table Collapse the per-key `if (key === …)` chains in `validateSetting` (CC 14) and `setProjectBatchSetting` (CC 14) into a single `SETTING_CODECS` table with optional `validate`/`serialize` per key, read by BOTH functions. A future persisted key now lands at CC +0 (one table entry) instead of a branch in each. Behavior is byte-identical: - Validation order preserved (unknown-key → ALLOWED_VALUES enum → per-key shape/agent-schema) and every error-message string is verbatim (image empty, host/authToken empty, port/agentTimeoutMs integer, agent via validateAgentSetting carrying parsedValue). - Persist coercion preserved verbatim: port/agentTimeoutMs → Number(value.trim()), insecure → value.trim() === 'true', agent (parsedValue !== undefined) → parsedValue, everything else → value. No exported signature changes. Config + command suites and the full 2880-test suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(batch): route decompose per-stage; rename pr-open → open-pr Make `decompose` a first-class routable agent stage so its agent+model is selectable from `.ratchet/config.yaml` (`batch.agent.decompose`) and the batch manifest (`settings.agent.decompose`), merged nearest-wins per stage — exactly as `pr` already is. `AGENT_STAGE_KEYS` gains `decompose`; the decomposition spawn threads the `decompose` stage into `ensureCommandInSpawnLocus` and `buildSpawnRequest`, and attribution reads `agentStageScopes.decompose`. The `uniformAgentScope` workaround (helper + engine call + tests) is deleted — it existed only because decompose was not a real stage. `Transition`/`StepKind` are unchanged; unmapped/unset decompose still falls to the default agent with no model flag (byte-for-byte prior behavior). Rename the batch PR skill `pr-open` → `open-pr` via the single-source `PR_OPEN_COMMAND_ID` value, the workflow module, and the `CORE_WORKFLOWS`/ `ALL_WORKFLOWS` profile lists; the forge-agnostic body and the `pr` stage key are unchanged. Docs (README, config-yaml, agent-runtime, generated-artifacts, batch, overview) updated for both; full suite + 95% coverage gate green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Stacked on #95. Closes all 8 findings of the Fable review of #95 (verdict: request-changes), driven end-to-end by the
review-fixesratchet batch (2 phases, 6 changes, both proof-of-work gates green) — with per-stage models dogfooded mid-batch (propose: claude:opus,apply: opencode,verify: claude:sonnet).Phase 1 — spec honesty at every boundary (findings 2, 3, 4, 7, 8)
spec-parser-hygiene):parseAgentSpecrejects whitespace-bearing agent/model parts ("claude: opus","claude :m") and models starting with-, naming the offending value. Reject, never auto-trim.config-write-load-honesty):batch config set agent <value>validates through the sharedAgentSettingSchema— the loader's own authority — and refuses to persist what the loader rejects. Inline{apply: opencode, ...}values persist as real YAML maps (closing a pre-existing quoted-string trap).readProjectConfigvalidates thebatchsection per key: warnings namebatch.<key>[.<stage>]and the offending value (derived from the zod issues;authTokennever echoed), and valid siblings are preserved — no more silently reverting gate/locus/permissions behind the generic gate/strategy/proofOfWork text. The e2e now drives the realreadProjectConfig/setProjectBatchSettingseams instead of a hand-rolled YAML re-parse.standalone-agent-flag-error): malformed--agentfails before any spawn with an actionable error naming the value; the residual skill-locus parse seam rethrowsSkillLocusErrorso even a slipped-through spec maps to a structured failed step, never a raw throw.Phase 2 — attribution visibility (findings 1, 5, 6)
attribution-rendered-surfaces): the stage/agent/model/scope hint threads intoblocker/messagein the mapper's single attribution branch, so the non-JSON blocked line, parked reason on resume, journal entry, and standalone renderer all carry it — zero renderer edits. Display is asserted at the rendered surfaces (stdout/park/journal), and bare-name/progressed/scope-less shapes are pinned byte-for-byte.pr-decompose-stage-attribution):agentStageScopesreaches the batch-driven PR and decompose spawns (uniformAgentScopehandles decompose's scalar-covered case), soagent: {pr: X:bad}fast-failures get the same attributed surface.signal-kill-hint-gate): the hint requires a real non-zero exit code — a timeout SIGKILL under a valid model is never told its model id may be invalid.Tests
Full suite green: 203 files / 2879 tests, coverage 95.54% over the enforced 95% gate. Phase proofs:
agent-model-selection.test.ts(now through the real load/write/flag seams) andmodel-failure-attribution.test.ts(33 tests incl. rendered-surface assertions).Also removes a stale tracked
ratchet-rex-*.doneruntime artifact dating back to the eval commits.🤖 Generated with Claude Code
https://claude.ai/code/session_019S4t1YHtiCS4H35XtyEC1b