Skip to content

fix(batch): spec honesty at every boundary and attribution visibility - #96

Merged
joctaTorres merged 2 commits into
batch/modelfrom
batch/model-review-fixes
Jul 6, 2026
Merged

joctaTorres merged 2 commits into
batch/modelfrom
batch/model-review-fixes

Conversation

@joctaTorres

Copy link
Copy Markdown
Owner

Summary

Stacked on #95. Closes all 8 findings of the Fable review of #95 (verdict: request-changes), driven end-to-end by the review-fixes ratchet 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)

  • Parser (spec-parser-hygiene): parseAgentSpec rejects whitespace-bearing agent/model parts ("claude: opus", "claude :m") and models starting with -, naming the offending value. Reject, never auto-trim.
  • Write path (config-write-load-honesty): batch config set agent <value> validates through the shared AgentSettingSchema — 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).
  • Load path: readProjectConfig validates the batch section per key: warnings name batch.<key>[.<stage>] and the offending value (derived from the zod issues; authToken never 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 real readProjectConfig/setProjectBatchSetting seams instead of a hand-rolled YAML re-parse.
  • CLI flag (standalone-agent-flag-error): malformed --agent fails before any spawn with an actionable error naming the value; the residual skill-locus parse seam rethrows SkillLocusError so even a slipped-through spec maps to a structured failed step, never a raw throw.

Phase 2 — attribution visibility (findings 1, 5, 6)

  • Rendered surfaces (attribution-rendered-surfaces): the stage/agent/model/scope hint threads into blocker/message in 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 coverage (pr-decompose-stage-attribution): agentStageScopes reaches the batch-driven PR and decompose spawns (uniformAgentScope handles decompose's scalar-covered case), so agent: {pr: X:bad} fast-failures get the same attributed surface.
  • Signal-kill gate (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) and model-failure-attribution.test.ts (33 tests incl. rendered-surface assertions).

Also removes a stale tracked ratchet-rex-*.done runtime artifact dating back to the eval commits.

🤖 Generated with Claude Code

https://claude.ai/code/session_019S4t1YHtiCS4H35XtyEC1b

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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 joctaTorres left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ⚠️ mostly 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 --noEmit clean.
  • YAML flow-map edge cases verified empirically against the repo's yaml version: {apply: claude:fable} parses the colon-bearing plain scalar correctly; provider/model slashes, 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).
  • uniformAgentScope correctness: a scalar agent setting always materializes a full uniform scope map, and any map-typed setting makes scalarAgent return undefined (no model ⇒ attribution gated off), so a coincidentally-uniform scope map can never mis-attribute the decompose spawn. Partial/mixed/empty/undefined maps all return undefined (pinned in agent-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 blocker only; journal prints message only; 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 status on 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>
@joctaTorres
joctaTorres merged commit f18740c into batch/model Jul 6, 2026
5 checks passed
@joctaTorres
joctaTorres deleted the batch/model-review-fixes branch July 6, 2026 23:15
joctaTorres added a commit that referenced this pull request Jul 9, 2026
…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>
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.

1 participant