Design Decisions (ADR)

August 27, 2026 · View on GitHub

On-demand reference. The non-obvious decisions behind this skill's convergence-loop infrastructure, with rationale and the alternatives rejected. Read before changing any foundational choice — reverting one of these without re-deriving the rationale reintroduces the problem it solved.

Format per entry: Context / Decision / Why / Rejected alternatives.

1. Hook and script language: Python, standard library only

  • Context: hooks run on every edit across Python/Swift/Web/Rust projects; they do heavy JSON work (stdin payload, loop-state, 越权日志, decision JSON).
  • Decision: all infra/ scripts are Python with stdlib only (json, subprocess, argparse, ast, re, pathlib, …). No third-party imports. No pyproject.toml/uv.lock for the infra itself.
  • Why: any machine with python3 ≥ 3.7 works, including CI containers, iOS-only Macs with just Xcode CLT, and pure Swift/Web projects. Stdlib json is far less error-prone than macOS bash 3.2 (no associative arrays, no native JSON). Hook latency stays low (no runtime/venv bootstrap). There are no dependencies to manage.
  • Rejected: bash 3.2 (JSON parsing/threshold logic painful and bug-prone; needs jq); node (not guaranteed on iOS-only); shipping a pyproject.toml + uv.lock (implies dependencies that do not exist).

2. Distribution: ship in infra/, opt-in project-scoped install

SUPERSEDED by #19 (plugin-model install rework, 2026-06-23): install.py is retired; hooks/agents/skills now activate via the solidforge plugin (enable = Layer 1); project-side provisioning is /solidforge:arm-tools (arm.py, Layer 2). The rationale here still HOLDS — opt-in, not global hooks; project-scoped, reversible — only the MECHANISM changed from a copy-and-wire installer to a plugin.

  • Context: this is a global skill in ~/.claude/skills/; it must not impose hooks on every project.
  • Decision: hooks/scripts/templates ship inside infra/; install.py copies them into a TARGET project's .claude/ (project-scoped settings) only when a human runs it.
  • Why: user-scoped (~/.claude/settings.json) hooks fire for ALL projects — invasive and surprising. Project-scoped install is explicit, reversible, and co-located with the project that wants the gates.
  • Rejected: global user hooks; baking the settings into the skill directly.

3. --with-tools adds to PROJECT dev deps, not a global install

  • Context: the gates run the project's own contracts (.importlinter.ini, .dependency-cruiser.cjs, clippy.toml).
  • Decision: arm.py --with-tools (via /solidforge:arm-tools --with-tools) adds gate tools to the project's own dev dependencies (uv/poetry/pip/npm). For system-toolchain ecosystems (Swift, Rust) it only prints the install command.
  • Why: gates must run the SAME tool versions the project declares. import-linter contract syntax, ruff rule codes, eslint rules, and clippy lints drift across versions; a global uv tool install/brew/npm -g would version-diverge and could misparse the project's config. Version-match is a correctness requirement, not hygiene. Project-local is also reversible via the project's own lockfile.
  • Rejected: uv tool install / brew install / npm install -g (global, version-conflicting, asymmetric across toolchains).

4. Tool resolution: PATH → project .venv/bin (resolve_tool)

  • Context: after --with-tools, tools live in the project venv, which is not necessarily active when Claude Code runs a hook.
  • Decision: resolve_tool(name) checks PATH first, then the project's .venv/bin/venv/bin/env/bin. Returns an argv prefix.
  • Why: finds a project-installed tool even with the venv inactive, without paying uv run spawn overhead on every edit. Keeps the fast gate cheap.
  • Rejected: always uv run <tool> (per-edit overhead); relying on the venv being active (it often is not in the hook process).

5. Snapshot backend: git refs/pd-snap/ namespace, not tags

  • Context: snapshots need a lightweight git ref at the inner convergence point.
  • Decision: git update-ref refs/pd-snap/<task>/<stamp> <base> (a stash-commit SHA or HEAD). Non-git projects fall back to a directory copy.
  • Why: a user-global tag.gpgsign=true forces annotated+signed tags, so a lightweight git tag <name> <ref> fails with "fatal: no tag message?". The refs/ namespace bypasses the tag codepath entirely and works regardless of signing config. No history pollution.
  • Rejected: git tag (breaks under tag.gpgsign=true, a real user config encountered here); annotated tags with -m (requires a signing key, may fail in CI).

6. Breaker priority and budgets

  • Context: the state machine must force convergence when the LLM thrashes.
  • Decision: priority hard-terminate > escalate > degrade > suspend > ok. Triggers: same fingerprint ≥ N=3 → escalate; iteration ≥ M=8 → degrade (suspend if budget ≥80%); total steps ≥ step_cap_S (default 200) → hard-terminate; any of token_T/time_W/cost_C exhausted → hard-terminate.
  • Why: deterministic ordering prevents the loop from picking a cheap non-action over a necessary one.
  • Time is a cost/hang guard, NOT a capability signal. Wall-clock = work / provider throughput, so time_W confounds the token provider (throttling, queuing, model downgrade). It is "reliable" only in the sense that hooks can measure it — not that it measures capability. A slow provider must not make a healthy run look like a thrash. The provider-independent hard limit is therefore step_cap_S (work units), and time_W stays only to catch a genuinely hung/stuck run and to bound spend. See #13.
  • Rejected: a flat "5 iterations then pause" (the original); a precise token budget (hooks cannot read Claude's real token usage — documented honestly as an approximation); treating time_W as a capability axis (the trap this ADR corrects).

13. Provider-normalized metrics: step cap + inconclusive-on-resource-cap

  • Context: the maturity self-assessment judges L4 by whether a run converges unattended on a hard, novel task. Wall-clock duration is the wrong axis — it equals work divided by provider throughput, so it measures the provider, not the agent. Worse, a time_cap_W hard-terminate flipped error_compounding_defended/context_rot_defended to False, so a slow provider could misjudge a genuinely-L4 run as not-yet.
  • Decision: (a) the provider-independent hard limit is total steps ≥ step_cap_S (check_breakers), not time; (b) the run record classifies the terminal cause provider-normally via terminal_cause ∈ {converged, suspended, resource-capped, step-capped, manual, non-terminal}; (c) a resource-capped termination (time/token/cost budget hit) yields provisional_verdict: inconclusive and does NOT flip the defense flags — capability is simply unjudged, not failed; (d) only a step-capped termination is a capability signal (not-yet); (e) the defense flags are decoupled from raw outcome and driven by terminal_cause.
  • Why: this separates "the loop could not converge in provider-independent work units" (step-capped — real capability signal) from "a budget wall was hit" (resource-capped — could be a slow provider, a hung tool call, or a spend cap; inconclusive on capability). The cleanest capability metric is generated-token count, but hooks cannot read it, so total steps is the measurable proxy. The ideal maturity axis is provider-normalized effort-to-converge (steps/tokens) on a qualified task, never wall-clock.
  • Rejected: removing time_W entirely (it remains the only reliable way to catch a hung tool call and to bound spend when token count is unreadable); auto-detecting provider throughput to "correct" wall-clock (fragile, and throughput varies mid-run); treating any resource-cap hit as a capability failure (the bug this fixes).

7. Fast-gate dispatch: explicit per-language elif + safe else

  • Context: fast_gate.py routes by classify(file_path).
  • Decision: an explicit elif platform == "<lang>": branch for every language, with a final else: sys.exit(0) (unknown platform = no-op).
  • Why: an implicit else: check_web(...) silently routes a new language's files through the web check, which no-ops (silently passes) because no eslint config matches. The explicit branch makes adding a language fail loud (the disconnect checker requires the branch) rather than fail silent.
  • Rejected: implicit else catch-all (the original — a trap for the next language).

8. Rust architecture-contract gate: honestly thin

  • Context: Rust has no first-class layer/dependency-direction enforcer (no import-linter/dependency-cruiser equivalent).
  • Decision: arch_contract_rust.py runs cargo clippy --message-format=json (correctness/concurrency) + optional cargo-modules (orphans). The coverage array explicitly states layer-direction is NOT enforced deterministically and remains an outer-ring concern. A 0-finding clippy run notes that clippy is incremental (0 = no source change since the last pass).
  • Why: faking a green gate would violate the deterministic contract the whole loop depends on. Honest degradation via coverage is the correct behavior for a language with weak arch tooling.
  • Rejected: pretending clippy is a layering gate (false confidence); silently passing when clippy is incremental (misleading clean).

9. Two-tier gate cadence

  • Context: the inner ring has both cheap (lint/format) and heavy (whole-graph architecture) checks.
  • Decision: fast gate = per-edit, single-file, ms–sec (PostToolUse hook). Architecture-contract gate = once at the inner convergence point, explicit script invocation, full context.
  • Why: cost axis. The cheap gate must run every edit without lag; the arch gate needs the whole graph and is too slow per-edit. Matches the dual-axis layering (deterministic-first, fast-fail).
  • Rejected: run the full arch gate per-edit (too slow); run type-check only at convergence (loses fast-fail).

10. Loading-chain rule: registry + data-driven checker

  • Context: a language's touch points span ~9 files; a prose checklist is forgettable, and a hardcoded checker must itself be edited per language.
  • Decision: infra/test/platforms.json is the single source of truth (languages + the decision-point docs each must route through); disconnect_check.py reads it and verifies structural + loading-chain integrity with actionable guidance.
  • Why: the original Rust loading-chain break (rust-patterns.md reachable from arch-contracts.md but NOT from parallel-patterns.md, the doc the scheduler reads) passed structural checks. Data + an automated gate convert "avoid 断裂" from a remembered rule into an enforced one; the checker never needs editing for a new language.
  • Rejected: prose-only checklist (forgettable); hardcoded language list in the checker (a new failure mode — "added a language but forgot to update the checker").

11. Run-record evidence: append-only event log + schema'd aggregate

  • Context: the maturity self-assessment's "unproven at L4 scale" caveat requires an empirical record of a real run. Asking a human to manually note step count / outcome / every breaker firing and its trigger is unreliable and never happens. But loop-state.json is a snapshot (overwritten each mutation), so the temporal sequence of breaker firings / outer verdicts / rollbacks is lost — and the outer block was a dead field nothing ever wrote.
  • Decision: every anchor appends to an append-only events[] in loop-state (bounded to the last 1000); new record-outer / mark-rollback subcommands wire up the previously-dead outer ring + rollback trace; at terminal status run-record aggregates events + state into a record conforming to infra/schemas/run-record.schema.json, validated in dev by infra/test/run_record.py + run_record_schema.py.
  • Why: the data already flows through loop_state, so the record is a pure rollup — zero extra burden on the running loop. The append-only log preserves the sequence a snapshot cannot. Run-time does NOT import the validator (mirrors arch-gate: emit conforming JSON, validate in dev) so install ships it via the scripts glob with no schema copy.
  • Rejected: manual run-notes (unreliable, never done); a separate telemetry process (extra moving part); importing the validator at runtime (couples state mutation to dev artifacts).

12. L4 assessment: computed provisional verdict, human-confirmed

  • Context: the run record's job is to let a human judge whether a run evidences L4 — but L4 capacity binds to the 3-degradation defense (ADR #38: demand-independent), while demand (novel codebase, fuzzy requirements, high difficulty, unattended) is a human-declared stress-test weight that a script cannot measure. The final call is a graded judgment, not a measurement.
  • Decision: the record carries a computed l4_assessment block — a provisional verdict (l4-evidenced / not-yet / not-a-probe) derived from a human-declared task descriptor (at init) plus the three degradation-defense evidence streams. human_confirm_required is always true; caveats_addressed names exactly which maturity caveat a l4-evidenced run retires (caveat-2-unproven-at-scale).
  • Why: this closes the loop from "raw telemetry" to "a judgment" without pretending the script can make the final call. The descriptor is honest about what only a human knows (is this codebase genuinely unfamiliar to the model?). The provisional verdict is the instrumented form of the maturity.md rubric, so the doc and the tool cannot drift.
  • Rejected: auto-detecting codebase contamination / difficulty (a script cannot know the model's training exposure); a purely-computed verdict with no human gate (overclaims); leaving the verdict entirely to the human reading raw telemetry (the gap this whole feature exists to close).

14. Execution-context model: per-task Coder subagent; sequential ≠ direct

  • Context: a run faced two tasks (I16, I17) that both wrote the same integration files. The skill correctly decided they could not run concurrently, but then concluded "execute directly (no subagent fanout)" — collapsing two orthogonal decisions into one. Axis 1 (concurrency / timing): can they run at the same time? No — shared files. Axis 2 (execution context): whose context does the inner-ring TDD churn happen in? This is independent of axis 1. "Shared files → execute directly" treats "sequential" as implying "in the orchestrator's context", which is the error. The whole skill is built around context as the scarce resource (context folding keeps inner churn out of the outer reviewer); running inner loops directly in the orchestrator undoes that across N tasks. The decision was also amplified by an over-generalized memory ("converged directly (no subagents, per user signal)") from an earlier run.
  • Decision: (a) Three roles / three contexts. Orchestrator (main agent) owns the control plane — scheduling + conflict detection, dispatching the per-task Coder, running the Architecture-Contract Gate + 附加条件 at inner convergence, dispatching the outer reviewer, verdict dispatch + rollback, run-record — and does NOT generate code. Coder = a per-task subagent (Agent tool, developer role per role-agent-mapping.md) that runs one task's implementation + inner-ring churn under fast_gate.py and returns Diff + a one-line folded summary (loop_state.py summary); its churn is discarded on return. Reviewer = code-reviewer, unchanged. (b) Sequential ≠ direct. Conflict-serializable tasks (shared files_touched / depends_on) occupy the SAME slot serialized — dispatch Coder A, await return, dispatch Coder B. Never concurrent (no write conflict), yet both still subagents (context isolation preserved). Direct orchestrator execution is the EXCEPTION, for: a trivial single edit (subagent spawn overhead exceeds the context saved), or tight-coupling continuity a Diff + summary cannot bridge. It is NOT the default for "sequential". (c) Cross-task orchestrator context folding. On a Coder's return the orchestrator keeps ONLY {task status, files actually changed, one-line folded summary} — never the task's inner stderr / iteration trail. This is the existing inner→outer fold applied at the task→orchestrator boundary, so orchestrator context grows ~linearly with (task count × small folded record), not with total inner churn.
  • Why: context is the resource this skill protects throughout — context folding already isolates the outer reviewer from inner churn; by symmetry the orchestrator must be isolated from per-task churn, or it bloats unboundedly across N tasks. Sequential subagent dispatch dominates direct execution: it ties on the conflict-safety axis (both never write concurrently) and strictly wins on the context axis. It is also consistent with the scheduling model in parallel-patterns.md (one Agent per task even when serialized) and the role mapping in role-agent-mapping.md.
  • Rejected: "shared files → execute directly" (collapses the two axes; amplified by a stale, over-generalized memory); a separate per-task loop-runner subagent that owns the whole inner+outer loop with the orchestrator absent from the control plane (contradicts the existing docs and the loop_state.py call sites that the orchestrator drives — state machine, snapshot, reviewer dispatch, run-record); abolishing direct execution entirely (loses the legitimate trivial-task case where spawn overhead dominates).

15. Scope isolation at the subagent boundary: a belonging check (correctness gates don't catch out-of-scope writes)

  • Context: every existing gate verifies CORRECTNESS — "is the code that is there right?" The fast gate checks lint/type/test; the Architecture-Contract Gate checks layering/dependency-direction/concurrency; 附加条件 checks coverage/flakiness. None verifies BELONGING — "did this task's agent produce only changes that belong to it?" An observed run made the gap concrete: an interrupted implementation subagent went out of scope, rewriting the skill's own .claude/parallel-dev/scripts/* infra and adding pyright/pip-audit/pytest-json-report deps + uv.lock. Those edits were individually plausible and correct, so every gate stayed green; the drift was caught only by a human inspecting the working tree before building on it (recorded as a project-memory lesson: interrupted agents leave broken partial work AND rogue infra churn). The skill already holds the data to mechanize this — per-task files_touched — but uses it ONLY for scheduling/conflict detection, never as a write boundary. Sibling to ADR #14: #14 keeps subagent churn out of the orchestrator's CONTEXT; this keeps subagent writes inside the task's declared FILES. Same boundary, two directions.
  • Decision: design + contract. Implemented as infra/scripts/scope_check.py (cross-platform — ships alongside snapshot.py/loop_state.py and runs from the plugin root; NOT a per-language gate) with offline + git-repo integration tests at infra/test/scope_check.py. The sacred-path hard-deny HOOK (c) remains future. (a) Add a BELONGING axis alongside the correctness axis. A Coder subagent's actual changed-file set must be ⊆ its declared files_touched ∪ an allowlist; otherwise it is a scope violation — a recoverable failure — regardless of whether the code is correct. (b) infra/scripts/scope_check.py (runs at the TASK boundary: after a Coder returns, before aggregation / building-on). actual = git diff --name-only <task-base> (tracked changes since dispatch, committed or not) ∪ git ls-files --others --exclude-standard (new untracked files the Coder may have created); allowed = files_touched ∪ allowlist. A changed file is in_scope (declared), sacred (matches a sacred glob — highest severity), allowlisted (matches an allow glob — tolerated, NOT a violation), or violation (else). verdict ∈ {clean, flag} — flag iff any violation or sacred; exit non-zero on flag so the orchestrator must handle it. Being POST-HOC, scope_check cannot block writes that already happened (no block verdict); it flags for the orchestrator to discard the out-of-scope paths (git checkout HEAD -- <paths>), keep a coherent in-scope diff, or snapshot.py restore; never silently auto-merge. The default allowlist is intentionally EMPTY (strict): a well-declared task lists its own regenerables in files_touched; --allow opts into tolerating incidental artifacts (prefer false flags over false negatives). (c) Sacred-path hard-deny is a SEPARATE mechanism, not scope_check: generalize the existing PreToolUse blueprint_guard.py to also deny writes to the skill's own .claude/parallel-dev/ infra and to undeclared lockfiles/manifests. This is the ONLY hard-deny tier (those paths are never legitimate targets of an implementation subagent); everything else is flag. It reuses the blueprint_guard idiom already in the skill. Implementation notes (correcting the earlier spec): scope_check is platform-agnostic (the belonging check is identical in every language), so it is NOT a per-language platforms.json entry — it ships as one cross-platform script alongside snapshot.py/loop_state.py, run from the plugin root. disconnect_check.py stays green (it does not enumerate scripts; only language wiring + skill-level .md integrity). The verdict is {clean, flag} — there is no block: post-hoc it cannot refuse writes that already happened, so the earlier 3-valued spec was wrong on this point; sacred paths surface at the highest severity within flag, and the actual hard-deny is the separate (future) hook in (c).
  • Why: a belonging failure passes every correctness gate (code can be correct yet not belong to the task), so without a dedicated check it propagates silently into aggregation and downstream tasks — the compounding error the skill exists to prevent (maturity.md). files_touched already exists; this elevates it from a scheduling hint to an enforced boundary, mirroring ADR #10 (data + automated gate ⇒ a remembered rule becomes an enforced one). Flag-not-block sidesteps the false-positive trap while still ending silent propagation; sacred-path hard-deny uses the existing blueprint_guard idiom for the paths that are never legitimate.
  • Rejected: relying on the remembered habit "inspect the tree before building" (forgettable — the exact failure that occurred); hard-denying ANY write outside files_touched (false-positive machine — incidental regenerables are legitimate); silently auto-merging out-of-scope changes when they pass gates (correctness ≠ belonging — defeats the purpose); folding this into the Architecture-Contract Gate (that checks structural correctness of the dependency graph, not task-level write boundaries — different axis and granularity).

16. Convergence is DoD-gated: mark-converged refuses without an outer-ring review

  • Context: the Definition of Done requires BOTH rings — inner gates AND an outer-ring review — to pass. But mark-converged (loop_state.py) set status=converged UNCONDITIONALLY, with no precondition, and build_run_record/derive_outcome labeled the record "converged" purely from that status. An observed P2 run exposed the gap: it marked converged with inner.iteration=0 and outer.iterations=0 (no bump-iteration, no record-outer, no outer-verdict event in 39 events). The orchestrator had executed directly (the ADR #14 anti-pattern), never drove the loop instrumentation, skipped the outer ring entirely, then called mark-converged. The emitted run record claimed outcome: converged with steps.total: 0 and outer_verdicts: [] — a false convergence claim and a meaningless L4 artifact. DoD was a documented requirement, not a state-machine invariant. This is the third harm of "execute directly" (#14 context bloat, #15 scope leak, #16 DoD / convergence-accounting integrity).
  • Decision: (a) mark-converged now REFUSES — exit non-zero, no state mutation — when outer.iterations < 1. Fail-loud, matching blueprint_guard/counters. To converge, the orchestrator must run the outer ring (record-outer --verdict <v>) first; or use set-status inner_converged if not at the terminal phase. (b) build_run_record honesty backstop (defense-in-depth for the raw set-status converged escape hatch and legacy states): a converged-without-outer-ring state is exposed via a new top-level dod_satisfied: false and l4_assessment.outcome_met: falseoutcome_met now tracks DoD, not raw status. The record can no longer lie about convergence even if status was set by a bypass.
  • Why: without enforcement, a correctness-green but outer-skipped run claims convergence — exactly the silent DoD violation the loop exists to prevent — and corrupts the L4 evidence (a "converged / 0-step / 0-outer" record is meaningless as a capability signal). Enforcing at mark-converged makes the violation impossible regardless of orchestrator behavior; the build_run_record backstop covers the set-status escape hatch so even a bypassed state is reported honestly. This closes the "execute directly → false converged" path that #14 (context isolation) and #15 (scope isolation) do not address.
  • Rejected: leaving DoD as a doc requirement only (the exact failure that occurred — forgettable); downgrading to inner_converged instead of refusing (softer, but fail-loud is consistent with blueprint_guard/counters and surfaces the bug immediately, per user choice); also gating on inner.iteration > 0 (cannot distinguish a clean first-pass from a never-bumped direct run — the outer review is the clean, unambiguous DoD signal); auto-running the outer ring from mark-converged (mixes concerns — the orchestrator owns reviewer dispatch).

17. Cross-language API-contract gate: advisory (warnings), OpenAPI-centric

  • Context: each per-language Architecture-Contract Gate is language-local. Neither arch_contract_java.py nor arch_contract_web.py can answer "does the frontend match the backend's API?" — for a mixed frontend+backend repo (e.g. Java/Spring + React/Next in one tree) that question was entirely an outer-ring concern. Agents hallucinate FE/BE contract drift (a frontend call to a path the backend removed/renamed); without a deterministic gate it propagates to integration. But FULL contract verification — semantic request/response shape, field types, required-ness, response codes — is not statically determinable without codegen or runtime contract tests.
  • Decision: add arch_contract_api.py, a sibling cross-ecosystem gate (like deps/tests; NOT a platforms.json language). For mixed FE+BE repos (a package.json AND a pom.xml/build.gradle, found recursively per #18) it checks: (a) OpenAPI/Swagger artifact presence; (b) generated-client freshness (mtime vs the contract); (c) coarse path/method consistency (JSON contracts only — scan the frontend's fetch/axios call sites against the spec's paths). YAML contracts get presence/freshness only (no stdlib YAML parser). All findings are warning (advisory) in v1.
  • Why: (1) OpenAPI is the most mature cross-language contract format (springdoc-openapi on the Java side; widely generated); centralizing on it keeps the gate stdlib-only and deterministic rather than chasing N format parsers. (2) Advisory/warnings, not Blocker: the checks are best-effort heuristics — path-scanning false-positives on dynamic/base-prefixed URLs, and mtime-staleness can mislead (a client touched for an unrelated reason). A Blocker must be a real violation, not a guess; blocking on heuristics would make the gate noisy and undermine the deterministic-inner-ring contract. The gate still never silently greens (it always reports coverage + findings); a team that trusts the signal can promote severities. (3) The honest gap — semantic shape matching — is stated in coverage and deferred to the outer ring (or the test gate via contract tests).
  • Rejected: GraphQL / gRPC / shared-TS-types as the contract format (fragmented; each needs its own parser; OpenAPI covers the common case first); Block-on-path-mismatch (false-positive machine — dynamic URLs); full static semantic-shape matching (not determinable without codegen); folding into arch_contract_web.py/arch_contract_java.py (it is cross-language — neither owns it); no gate (leaves contract drift entirely outer-ring — the gap this closes).

18. Nested language detection: recursive in the existing gates, not a registry split

  • Context: detection was root-only. A repo with a root pom.xml and a nested frontend/package.json was detected as Java but NOT Web — so the web arch gate was never pointed at the frontend, npm audit/vitest didn't fire, and no .dependency-cruiser.cjs was copied. The cross-ecosystem gates (arch_contract_deps.py, arch_contract_tests.py) and arm.py's detection all checked the repo root only.
  • Decision: detection is now RECURSIVE. A shared find_marker_dirs(root, names) (bounded os.walk, prunes node_modules/target/build/.git/.venv/…, depth ≤4) finds a language's marker at the root OR nested. The cross-ecosystem gates loop and run each check per marker dir (a mixed FE+BE repo gets BOTH npm audit/vitest AND dependency-check/mvn test). arm.py's ARCH_CONFIGS predicates + toolchain note use recursive existence so configs copy for nested layouts. The per-language arch gates (web/java/python/rust/swift) get NO code change — they already accept a path/cwd arg (arch_contract_web.py takes src; arch_contract_java.py honors CLAUDE_PROJECT_DIR), so nested invocation is orchestrator-pointed and documented in the L4 docs. Web stays ONE platform and Java ONE platform — no frontend/backend split.
  • Why: (1) Nested is a DETECTION change, not a platform-identity change. Frontend and backend share their respective toolchains regardless of where they sit in the tree; the registry models LANGUAGES, not LAYOUTS. Splitting "web" into "web-frontend" + "node-backend" would multiply platforms without adding signal and break the one-platform-one-toolchain invariant (and multiply disconnect_check wiring). (2) Looping per-marker-dir in the cross-ecosystem gates is the minimal change that covers nested: the check_* functions already parameterize on their dir (cwd + report base), so d="" reproduces the old root behavior and nested dirs just work. (3) The per-language arch gates need no refactor — pointing one at a subdir is a documented orchestrator action, preserving the self-contained-gate convention. (4) find_marker_dirs is DUPLICATED per script (arm.py, deps, tests, api) rather than shared — matching the existing run/have/emit duplication; each gate stays independently deployable (scripts run from the plugin root with no shared-lib dependency to provision).
  • Rejected: splitting web into frontend/node-backend platforms (breaks one-platform-one-toolchain; multiplies wiring); a recursive registry of per-subdir platforms (over-engineering — layouts aren't platform identity); a shared detection lib (breaks the self-contained-gate deploy convention — each gate would import it, coupling independently-deployable scripts); refactoring every per-language arch gate to self-discover nested (5 files of churn for a case the orchestrator handles by pointing the gate); root-only detection with a "cd into the subdir" doc workaround (leaves the cross-ecosystem gates blind to nested — the exact gap).

19. Plugin-model install: retire install.py; Layer 1 enable + Layer 2 arm

  • Context: the skill shipped as a hand-rolled, copy-and-wire installer (install.py) that duplicated hooks + scripts + settings into each target project's .claude/. This split the install across four locations (dev repo → ~/.agents/skills staging → ~/.claude/skills symlinks → per-project .claude/ copies), was not publishable as a unit, and conflated three distinct duties (hook activation, project-file provisioning, version reconciliation) into one script. The migration to a single solidforge (Solid Forge) Claude Code plugin (migrated 2026-06-22) retires this model.
  • Decision: install.py is retired; its duties split by what must happen WHERE:
    • Layer 1 (on plugin ENABLE) — hooks + 13 scoped subagents + the two skills activate. The hooks (blueprint_guard.py, counters.py, fast_gate.py) and all infra/scripts/*.py run from the plugin root (${CLAUDE_PLUGIN_ROOT}/skills/parallel-development/infra/...); they already resolve the project root via $CLAUDE_PROJECT_DIR or cwd and resolve their lib/sibling scripts via __file__ (detect_toolchain.loop_state_path() has a dev-location-first fallback). So they need NO copy and NO code change — the "copy hooks/scripts into the project" duty is eliminated, not migrated.
    • Layer 2 (/solidforge:arm-tools, backed by arm.py) — provisions the PROJECT-SIDE files only: per-language arch-configs (copied to project root, gated by recursive language detection), optional --with-tools dev-deps, the L1 Constitution + Gate-Toolchain CLAUDE.md sections, the intent-blueprint templates, and .gitignore entries; plus a gate-status report and an LSP advisory. arm.py --revert is the project-side inverse (dry-run default, --apply to execute; keeps user-edited configs). This stays a deliberate per-project command because plugins do not mutate host-project build files — it cannot collapse into enable.
    • RETIRED (plugin-manager operations) — the --upgrade reconcilers (settings/section/prune/drift) and the full --uninstall become plugin update / plugin disable respectively. They are NOT deterministically testable from within the skill (workspace rule 3: stated as an explicit coverage gap in arm_copy_config.py / arm_revert.py), so their tests retire; only the idempotency of the surviving provisioning is salvaged (t_arm_idempotent).
  • Why: (1) Plugins already own activation-on-enable and per-project scoping — reimplementing hook registration + copy + version-stamp as an installer duplicates the platform and splits one logical unit across four locations. (2) The hooks/scripts were already source-location-clean ($CLAUDE_PROJECT_DIR + __file__), so the copy was pure dead weight — the plugin runs them from source. (3) Separating Layer 1 (activate) from Layer 2 (provision project files) matches the real constraint: enable is global-to-the-plugin but hooks must not fire until scoped to a project; arming mutates the host project, which plugins refuse, so it stays an explicit command. (4) Upgrade/uninstall as plugin operations means the skill no longer carries a reconciler it cannot fully test — the honesty rule (never fake green) is served by retiring those tests with a coverage note rather than fabricating plugin-manager behavior in a unit test.
  • Rejected: (a) collapse ALL provisioning into enable — violates the plugin-can't-mutate-host-project-build-files contract (arch-configs/dev-deps/constitution must be opt-in per project). (b) keep copying hooks/scripts into each project — dead weight; the scripts already run from source via $CLAUDE_PROJECT_DIR. (c) keep install.py as a shim alongside the plugin — two install models invite drift and contradict the single-publishable-unit goal. (d) fabricate tests for the retired upgrade/uninstall reconcilers — would fake green (rule 3). Hooks/scripts needing code changes for the plugin model — FALSE: verified they already run from the plugin root unchanged. Supersedes ADR #2 (mechanism); ADR #3 (--with-tools) is preserved, now in arm.py.

20. Plugin manifest author must be an object; self-check mirrors the loader

  • Context: at the Phase 4 cutover, claude plugin list reported "Failed to load skill folder as plugin: invalid manifest file" — yet plugin_layout.py had passed (it only asserted name/version/description present). Root cause: .claude-plugin/plugin.json had "author": "solidforge" (a bare string). The Claude Code plugin loader requires author to be an OBJECT ({"name": ...}, optionally email) — every official plugin manifest confirms this shape. A string author fails the loader's schema validation, so the plugin does NOT load. Worse, the failure is silent at enable time: claude plugin enable still wrote enabledPlugins, reported "Successfully enabled", and only plugin list surfaced Status: ✘.
  • Decision: (1) plugin.json author is an object ({"name": "solidforge"}). (2) plugin_layout.py now asserts isinstance(author, dict) so a string-author regression fails the deterministic self-check, not just the runtime loader. (3) claude plugin list (Status ✔/✘ loaded) is the authoritative load signal; the self-check is a fast structural proxy, and where the loader enforces a deterministic rule the self-check must mirror it (no gap between "self-check green" and "actually loads").
  • Why: (1) A self-check's job (workspace rule 1) is to BE the definition of done — passing while the plugin fails to load is a false green. The author-object rule is deterministic and codable, so it belongs in the inner-ring self-check, not deferred to the outer ring (the loader). (2) The defect was silent at enable (enabledPlugins written, success printed); only plugin list revealed it — so the self-check must catch what enable silently accepts. (3) Mirroring official plugins' manifest shape (object author; version optional) avoids re-deriving the schema from scratch.
  • Rejected: (a) keep the lenient name/version/description-only check — false green; a self-check that doesn't preempt the loader defeats its purpose. (b) bundle + validate against a full JSON schema of plugin.json — heavier than warranted; one targeted assertion mirrors the one rule that actually bit, and the loader stays the final authority. (c) treat the loader as the only gate — it is a runtime/CLI check outside the deterministic inner ring (rule 1), so a structural proxy must exist.

21. External-skill integration: LEVERAGE Impeccable; side-car freeze; craft reserved

  • Context: a design needed to bring an external design skill into the convergence loop. Anthropic's /frontend-design is a demo (reference value, uncertain broad production readiness); an earlier design used it as the exemplar and hand-rolled a parallel design artifact (*.visual.md), design gate (arch_contract_visual.py presence-heuristics), and a wrapper subagent (uiux-designer). Impeccable (pbakaus/impeccable) is frontend-design's mature evolution AND a full design-governance system — 23 commands, a 44-rule deterministic detector + provider-native PostToolUse hook, PRODUCT.md/DESIGN.md artifacts, and its own asset-producer subagent. It already provides the artifact + gate + review we hand-designed, and a stronger gate (44 real deterministic rules vs presence-heuristics).
  • Decision: LEVERAGE Impeccable rather than reinvent. (1) Artifact: Impeccable's DESIGN.md is the frozen anchor. (2) Gate: Impeccable's detector/hook IS the design gate — per-edit advisory via the installed PostToolUse hook + a convergence detect.mjs --json sweep translated to 越权日志 (the --json output is a bare array {file, line?, antipattern, snippet, description, importedBy?}; adapter wraps it, maps antipattern→rule/line→line ?? 0, assigns severity:"warning"). (3) Roles: frontend-developer implements from the frozen DESIGN.md; Impeccable's asset-producer + /impeccable commands do design production (no wrapper subagent). Drop the hand-rolled *.visual.md / arch_contract_visual.py / uiux-designer. The four-seam model, the frozen-anchor concept, the reviewer visual line, and Fork 3 (advisory rewrite / opt-in enforcement: strict → hard rollback) all stay.
  • Why: (1) A parallel hand-rolled gate would be weaker than Impeccable's 44 deterministic rules AND would clash with Impeccable's own PostToolUse hook on the same UI edits. (2) DESIGN.md's frontmatter is a machine-readable token export — frontend-developer consumes structured tokens directly, and detect already loads DESIGN.md as context (--no-design-system skips), so it cross-checks the design system. (3) DESIGN.md's frontmatter is an Impeccable token-export with no status field, so blueprint_guard's frontmatter-status check would not fire — the freeze is a SIDE-CAR sentinel (loop-state flag / .design.frozen), the first anchor whose freeze signal is not its own frontmatter; this also survives an Impeccable document/extract regen. (4) /impeccable craft is shape→build — the designer building makes the visual-fidelity check reflexive, so the loop uses shape (Seam A) + frontend-developer (Seam C, independent implementer) and reserves craft for standalone non-loop use. (5) Impeccable's asset-producer subagent makes a uiux-designer wrapper redundant.
  • Rejected: (a) build a parallel hand-rolled design gate — weaker + clashes with Impeccable's hook. (b) a uiux-designer wrapper subagent — redundant with Impeccable's asset-producer. (c) frontmatter-status freeze for DESIGN.md — won't fire (no status field). (d) /impeccable craft in-loop — reflexive. (e) self-contained uiux-designer (own design competence) — duplicates the external skill, violating the engines-not-reimplement premise.

22. markdownlint MD024 siblings_only + MD033 allowed_elements: config exemption, not doc rewrite

  • Context: the line-length-off pass (commit 46f4170) dropped markdownlint violations from ~549 to 24. The residual 24 were NOT formatting debt — they were 5 MD024 (duplicate headings), 12 MD033 (inline HTML), 3 MD040 (fence without language), 2 MD051 (stale TOC anchors), and 2 MD036 (bold used as a heading). Two of those buckets are markdownlint FALSE POSITIVES against an established repo convention: the MD033 inline-HTML hits are <task>/<action>/<context>/<path>/<anti-pattern> placeholder tags — a repo-WIDE fill-in convention used not only in infra/templates/intent-blueprint.template.md but throughout the references (<task>-<stamp>.json, docs/intent-blueprints/<task>-v<n>.blueprint.md, refs/pd-snap/<task>/<stamp>); and the MD024 duplicates are legitimate same-named subsections (### Python/FastAPI, ### Learning/Research/Personal Projects) appearing under DIFFERENT parent sections.
  • Decision: split the 24 by true nature. (1) 7 are real defects — FIXED in content: MD040 fences get text (ASCII diagrams and directory trees are not code), MD051 TOC anchors in feature-dev.md updated to match the enriched Phase 5/6 headings, MD036 **ADR-00x** bold pseudo-headings promoted to real ### headings. (2) 17 are markdownlint false positives against convention — handled in .markdownlint.json by MD024: { siblings_only: true } (flags duplicates only under the SAME parent — a standard, widely-recommended setting) and MD033: { allowed_elements: [task, must-implement, context, action, observable, path, anti-pattern] } (the 7 placeholder tag names; none are real HTML5 elements, so allowing them is safe).
  • Why: (1) Honesty rule (rule 3 — never fake green). The 24 are not all debt; treating false positives as defects-to-rewrite would MANGLE the docs — e.g., renaming ### Python/FastAPI per occurrence destroys the by-language structure, and rewording <task> placeholders erases a convention readers and arm.py/disconnect_check rely on. The honest split is: real defects get fixed in content; false positives get a NAMED, REVIEWABLE exemption in config, not a silent global disable. (2) siblings_only: true is the idiomatic MD024 config — it preserves the rule's real signal (accidental exact-duplicate siblings) while permitting the intentional cross-section repeat. (3) allowed_elements is surgical and self-documenting: the tag list itself states "these are intentional placeholders," and a future stray <div>/<script> still fires. (4) The placeholder tags are repo-wide (<task> is documented in intent-blueprint.md, convergent-loop.md, design-decisions.md), so a global allow is consistent — changing the placeholder SYNTAX (<task>{task}) would ripple across the loading chain (rule 8) for zero lint benefit.
  • Rejected: (a) disable MD024/MD033 globally — loses real signal; allowed_elements/siblings_only keep the rule live for genuine violations. (b) change the <...> placeholder syntax repo-wide — loading-chain churn (many reference docs + templates) for no benefit; the convention is clear and intentional. (c) rewrite the duplicate headings to be unique — destroys the by-language/by-project subsection structure that IS the content. (d) inline <!-- markdownlint-disable --> pragmas in the template — clutters a model-read file; a named config exemption is cleaner and applies wherever the convention recurs. (e) treat all 24 as formatting debt and "fix" them — would be fake green (rule 3): silencing false positives by mangling docs rather than naming the exemption.

23. Spectral external-skill gate: API-spec ruleset compliance (companion-armed, complementary to arch-contract-api)

  • Context: the convergence loop had design-fidelity via Impeccable (ADR 21) but NO rule-axis gate over the OpenAPI SPEC itself. arch_contract_api.py is an advisory heuristic — presence, generated-client freshness, coarse frontend↔backend path consistency (ADR 17 explicitly flags it advisory and notes semantic shape-matching stays outer-ring). It does NOT lint the spec's OWN ruleset compliance (operation-ids, naming, parameters, security, tags). Spectral (Stoplight @stoplight/spectral-cli) is the canonical mature deterministic OpenAPI linter; spectral:oas + a project .spectral.yaml is the ruleset.
  • Decision: integrate Spectral as a SIBLING convergence-point adapter infra/scripts/spectral_adapter.py (GATE = "spectral-openapi"), mirroring impeccable_detect_adapter.py. It shells out to the armed spectral lint -f json, translates findings → 越权日志, is ADVISORY (never blocker — a linter is a heuristic, rule 4), and degrades to a coverage-noted no-op when Spectral is not armed or no spec exists (never silently green, rule 3). COMPLEMENTARY to arch_contract_api.py, NOT a replacement: arch-contract-api keeps presence/freshness/path; Spectral adds the spec's ruleset compliance. Severity COLLAPSES to warning (越权日志 schema enum is blocker|warning only; the Spectral error/info/hint level is preserved in detail text). Depth-2 (the unique 4-seam story vs Semgrep/Vale): the OpenAPI spec optionally freezes as a Phase-0 side-car anchor via a new openapi anchor kind in blueprint_guard.py (mirroring the design kind / .design.frozen sentinel), so backend-developer codes against a stable contract. Spectral is COMPANION-armed by the user (brew install spectral-cli or npm i -g @stoplight/spectral-cli), exactly like Impeccable — NOT provisioned by arm.py.
  • Why: (1) Rule-axis gap — no gate checked the spec's ruleset; this is the cheapest local-deterministic-CLI fill (no LLM, no network — fits the per-convergence hot loop; contrast the heavyweight deep-research harness excluded for cost, see the loop-integration-cost memory). (2) Engines-not-reimplement — Spectral's mature spectral:oas ruleset is stronger than any hand-rolled checker and is the canonical tool. Tool-choice note: Vacuum (daveshanley/vacuum, Go) is a faster ruleset-compatible alternative (~0.16s vs Spectral's Node overhead on large specs; it consumes .spectral.yaml verbatim, so a swap is near-zero-cost), kept as a documented swap candidate if convergence latency is ever MEASURED to matter — not adopted now (Spectral is adequate for typical spec sizes and is the ecosystem standard — Speakeasy/Redocly/Vacuum all orbit Spectral rulesets). (3) Complementary-not-replacement — the two gates cover orthogonal axes (presence/path vs ruleset); deleting either loses signal. (4) Companion model — consistent with Impeccable: external skills arm themselves per-project; arm.py provisions only first-party arch-configs/dev-deps and reports only first-party tools, so Spectral stays out of arm.py/report_gates (matching the Impeccable precedent). (5) Advisory severity collapse is honest — the schema forces blocker|warning; an advisory gate never emits blocker; the lost error/info granularity is stated as a coverage gap, not hidden.
  • Rejected: (a) provision Spectral via arm.py --with-tools / add it to report_gates — inconsistent with the companion model (Impeccable is neither arm.py-provisioned nor in report_gates); external skills arm themselves. (b) replace arch_contract_api.py with Spectral — loses presence/freshness/path checks Spectral does not do. (c) make Spectral a Blocker gate — violates rule 4 (a linter is a heuristic; Blocker must be a real violation, not a guess). (d) hand-roll an OpenAPI-rules checker — weaker than Spectral's mature ruleset and duplicates an engine (engines-not-reimplement). (e) preserve Spectral's error/info severity as distinct levels — the 越权日志 schema enum is blocker|warning only; introducing new severities would require a schema change + every consumer update; collapse to warning and keep the level in detail. (f) full 4-seam freeze for Semgrep/Vale too — their ruleset is repo-wide config, not a per-feature frozen anchor; only Spectral has the Depth-2 story.

24. Semgrep external-skill gate: source SAST (Depth-1 advisory, complementary to /security-review + arch-contract-deps)

  • Context: the loop's security coverage was two-axis — /security-review (an LLM skill review, semantic but slow/token-costly) and arch_contract_deps.py (leaked secrets via gitleaks + dependency CVEs). Neither deterministically scans SOURCE code for CVE-pattern implementations (injection, path-traversal, hardcoded-credential patterns, weak crypto, unsafe deserialization). That source-SAST axis was entirely outer-ring. Semgrep is the canonical mature deterministic SAST engine.
  • Decision: integrate Semgrep as a SIBLING convergence-point adapter infra/scripts/semgrep_adapter.py (GATE = "semgrep-sast"), mirroring spectral_adapter.py minus the freeze. It shells out to the armed semgrep --json --config <ruleset>, translates results → 越权日志, is ADVISORY (never blocker — SAST is heuristic and false-positive-prone, rule 4), and degrades to a coverage-noted no-op when Semgrep is not armed (rule 3). Ruleset: a committed .semgrep/ / semgrep.yml (offline-deterministic, preferred); --config auto fallback fetches the Semgrep registry (coverage-noted as a network fetch). Severity COLLAPSES to warning (schema enum blocker|warning; ERROR/WARNING/INFO level kept in detail). This is DEPTH-1: no per-feature frozen anchor (the ruleset is repo-wide config), so no blueprint_guard anchor kind and no implementer-against-anchor seam — unlike Spectral. Companion-armed (pip/brew), NOT arm.py-provisioned (matches Impeccable/Spectral).
  • Why: (1) Source-SAST axis gap — /security-review is LLM (token-costly, not deterministic; see loop-integration-cost memory) and arch-contract-deps is secrets/deps, not source-code patterns; Semgrep is the cheap local-deterministic-CLI fill (no LLM, scan is local). (2) Engines-not-reimplement — Semgrep's mature ruleset (p/owasp-top-ten, p/security-audit, p/<lang>) is stronger than any hand-rolled checker. (3) Complementary-not-replacement — three security layers (LLM review / secrets+deps / source SAST) cover orthogonal axes; deleting any loses signal. (4) Advisory is mandatory — SAST false-positives are common; auto-Blocker would thrash the loop (rule 4). (5) Depth-1 (no freeze) is honest — a SAST ruleset is repo-wide config, not a per-feature anchor; forcing a freeze would be contrived (contrast Spectral's spec, which IS a natural anchor). (6) Companion model — consistent with Impeccable/Spectral.
  • Rejected: (a) make Semgrep a Blocker gate — SAST false-positives would thrash the loop (rule 4); advisory only. (b) replace /security-review or arch-contract-deps — they cover different axes (semantic LLM review / secrets+dep-CVEs); Semgrep is source-pattern SAST only. (c) provision via arm.py — companion model (external skills arm themselves). (d) hand-roll a source-pattern checker — weaker than Semgrep's ruleset, duplicates an engine. (e) Depth-2 freeze for Semgrep — a SAST ruleset is repo-wide config, not a per-feature anchor; no natural freeze point (unlike Spectral's spec). (f) default to --config auto silently — it fetches the registry (network); require/prefer a committed .semgrep/ and coverage-note the fallback.

25. Vale external-skill gate: docs prose quality (Depth-1 advisory, fills the docs-quality axis)

  • Context: the loop had NO prose-quality axis. blueprint-crafting converges upstream docs' STRUCTURE (anchors present + authority-chain consistent + sources-cited) — it does not assess prose quality (terminology, voice, spelling, inclusiveness). The language arch gates lint CODE, not prose. So docs prose quality was entirely outer-ring / human. Vale is the canonical mature deterministic prose linter (.vale.ini + styles/).
  • Decision: integrate Vale as a SIBLING convergence-point adapter infra/scripts/vale_adapter.py (GATE = "vale-prose"), mirroring semgrep_adapter.py. It shells out to the armed vale --format=JSON, translates the file→alerts object → 越权日志, is ADVISORY (never blocker — prose style is opinion/heuristic, rule 4), and degrades to a coverage-noted no-op when Vale is not armed OR there is no .vale.ini (rule 3 — Vale styles are opinion with no objective default; no config is a no-op, NOT a silent green). Severity COLLAPSES to warning (schema enum blocker|warning; suggestion/warning/error level kept in detail). DEPTH-1: no per-feature frozen anchor (style config is repo-wide, like Semgrep). Companion-armed (brew/release), NOT arm.py-provisioned.
  • Why: (1) Docs-quality axis gap — no gate covered prose; blueprint-crafting is structural, arch gates are code. Vale is the cheap local-deterministic-CLI fill (no LLM, no network — fits the hot loop; contrast the heavyweight deep-research harness, loop-integration-cost memory). (2) Engines-not-reimplement — Vale's mature style packs (Microsoft, proselint, alex, Vale.Terms) are stronger than any hand-rolled prose checker. (3) .vale.ini required — styles are opinion; an objective default would impose a house style on every project, so absence is an honest no-op, not a silent green (rule 3). (4) Advisory mandatory — prose style is subjective; auto-Blocker would thrash (rule 4). (5) Depth-1 (no freeze) is honest — a style config is repo-wide, not a per-feature anchor. (6) Companion model — consistent with Impeccable/Spectral/Semgrep.
  • Rejected: (a) ship a default .vale.ini / style pack and make it Blocker — imposes a house style + style is opinion (rule 4); advisory + project-owned config only. (b) hand-roll a prose checker — weaker than Vale, duplicates an engine. (c) provision via arm.py — companion model. (d) Depth-2 freeze — prose style is repo-wide config, no per-feature anchor. (e) run without .vale.ini using Vale's empty default — would be a silent green (no rules = no findings = misleading); require explicit config and no-op otherwise (rule 3). (f) preserve suggestion/warning/error as distinct severities — schema enum is blocker|warning; collapse to warning, keep the level in detail.

26. oasdiff external-skill gate: API breaking-change detection (Depth-1 advisory, complementary to Spectral + arch-contract-api)

  • Context: an adversarial re-review of the API-contract axis found a real gap. Spectral lints ONE spec's STYLE (operation-ids, naming) and arch_contract_api.py checks presence/freshness/path-consistency — but NEITHER diffs two spec versions. So removing a required response field, changing a type, or deleting an endpoint (backward-incompatible edits) went undetected by the deterministic inner ring; they were entirely outer-ring. oasdiff is the canonical mature deterministic OpenAPI breaking-change differ (1.2k★, 8M downloads, brew install, even an MCP server).
  • Decision: integrate oasdiff as a SIBLING convergence-point adapter infra/scripts/oasdiff_adapter.py (GATE = "openapi-breaking"), mirroring spectral_adapter.py. For each tracked spec it materializes the git-HEAD base (git show HEAD:<relpath> to a temp file) and runs oasdiff breaking --format json <base> <working>, translating the change entries → 越权日志. ADVISORY (never blocker — a breaking change may be INTENTIONAL, e.g. a major-version bump, rule 4); severity collapses to warning. Degrades to a coverage-noted no-op when oasdiff is not armed, no spec exists, OR a spec is untracked (no base to diff — explicit note, NOT a silent green, rule 3). DEPTH-1: no per-feature frozen anchor (it diffs the working tree vs HEAD, both transient — distinct from Spectral's Phase-0 freeze). Companion-armed (brew), NOT arm.py-provisioned.
  • Why: (1) Backward-compat axis gap — the one credible missing axis from the re-review; Spectral/arch-contract-api provably don't diff versions. (2) Engines-not-reimplement — oasdiff detects 479 distinct change types; stronger than any hand-rolled differ. (3) Complementary-not-replacement — three API-contract gates now cover orthogonal axes (style / presence+path / backward-compat); deleting any loses signal. (4) Advisory is mandatory — breaking changes are often intentional (versioned API bumps); auto-Blocker would block legitimate work (rule 4). (5) git-HEAD base is the natural "previous contract" — no extra anchor needed (Depth-1, not a Phase-0 freeze). (6) Companion model — consistent with the other external skills.
  • Rejected: (a) make oasdiff a Blocker gate — breaking changes can be intentional (rule 4); advisory only. (b) fold into spectral_adapter — different operation (diff two versions vs lint one); separate adapter keeps each single-purpose. (c) replace arch_contract_api.py — it covers presence/path, not version-diff; orthogonal. (d) require a frozen Phase-0 base instead of git HEAD — git HEAD IS the natural previous-version reference; a separate freeze is redundant (Depth-1). (e) fail on an untracked/new spec — a brand-new spec has no previous version to break; honest no-op with a coverage note (rule 3), not an error. (f) hand-roll a breaking-change differ — weaker than oasdiff's 479-change coverage, duplicates an engine.

27. Trivy external-skill gate: dependency license compliance (Depth-1 advisory, complementary to arch-contract-deps)

  • Context: arch_contract_deps.py covers leaked SECRETS (gitleaks) + dependency CVEs (pip-audit/npm-audit/cargo-audit/dependency-check) — but NOT dependency LICENSES. Whether a transitive dep is GPL/AGPL/copyleft/unknown is a legal/compliance question the deterministic inner ring didn't cover at all. There is NO single purpose-built canonical cross-ecosystem license linter; the closest is Trivy, which is security-scanner-shaped but ships a multi-ecosystem license scanner (trivy fs --scanners license).
  • Decision: integrate Trivy (license mode) as a SIBLING convergence-point adapter infra/scripts/license_adapter.py (GATE = "license-compliance"), mirroring semgrep_adapter.py. It shells out to the armed trivy fs --scanners license --format json --exit-code 0, translates license findings → 越权日志, is ADVISORY (never blocker — license acceptability is opinion/policy, rule 4), severity collapses to warning. Degrades to a coverage-noted no-op when Trivy is not armed OR no dependency/lockfile markers exist (rule 3). DEPTH-1: no per-feature frozen anchor (license inventory is repo-wide). Companion-armed (brew), NOT arm.py-provisioned.
  • Why: (1) License-compliance axis gap — arch-contract-deps provably does not cover licenses. (2) Trivy is the closest-to-canonical CROSS-ecosystem license scanner (no purpose-built single tool exists; per-ecosystem tools like pip-licenses / license-checker / cargo-deny would fragment the cross-cutting external-skill model — stated honestly as the best-available, not perfect). (3) Complementary-not-replacement — arch-contract-deps keeps secrets+CVEs; Trivy adds licenses; orthogonal axes. (4) Advisory is mandatory — whether a license is acceptable is policy/opinion, not a code defect (rule 4); auto-Blocker would impose a house policy. (5) Depth-1 (no freeze) is honest — license inventory is repo-wide, not a per-feature anchor.
  • Rejected: (a) make license a Blocker gate — license acceptability is opinion (rule 4); advisory only. (b) replace arch-contract-deps — it covers secrets+CVEs, not licenses; orthogonal. (c) per-ecosystem tools (pip-licenses/license-checker/cargo-deny) — fragment the cross-cutting model into N gates; Trivy gives one cross-ecosystem sweep. (d) ship a default allow/deny license policy — imposes a house legal stance; policy stays project-owned. (e) provision via arm.py — companion model. (f) hand-roll a license scanner — duplicates Trivy; weaker ecosystem coverage. Honest note: Trivy is security-scanner-shaped, not purpose-built for licenses — it is the best-available cross-ecosystem option, and without a project policy the output is a raw INVENTORY (coverage-noted, not a verdict).

28. Checkov external-skill gate: IaC misconfig (Depth-1 advisory, opt-in; external-skill gate, NOT a platform)

  • Context: the app-language arch gates (clippy/checkstyle/swiftlint/eslint/dependency-cruiser) lint CODE; infrastructure-as-code files (Terraform/Kubernetes/Dockerfile) are outside that model — platforms.json models app LANGUAGES, not infra. So IaC misconfig (open S3 buckets, permissive security groups, privileged containers) had no deterministic coverage. As of 2026 the live IaC scanners are Checkov (Palo Alto) and Trivy (Aqua, absorbed tfsec's checks); tfsec is feature-frozen and Terrascan was archived (2025-11). Checkov is the canonical IaC-native choice.
  • Decision: integrate Checkov as a SIBLING convergence-point adapter infra/scripts/iac_adapter.py (GATE = "iac-misconfig"), mirroring semgrep_adapter.py. It shells out to the armed checkov --directory <root> --output json, translates failed_checks → 越权日志, is ADVISORY (never blocker — IaC misconfig is context-dependent and false-positive-prone, rule 4), severity collapses to warning. Degrades to a coverage-noted no-op when Checkov is not armed OR no IaC markers are present (rule 3 — opt-in for infra-bearing projects; an app-only repo gets a clean no-op, not noise). DEPTH-1 (repo-wide config, no per-feature anchor). Companion-armed (brew/pip), NOT arm.py-provisioned. This is an EXTERNAL-SKILL GATE registered in external-skills.md, NOT a platform in platforms.json — infra is cross-cutting, not an app language, so adding it as a platform would break the one-platform-one-toolchain invariant.
  • Why: (1) IaC axis gap — infra files aren't covered by app-language gates. (2) Checkov is canonical post-tfsec-deprecation (Trivy is the alternative; Checkov is IaC-native). (3) Opt-in no-op keeps app-only projects clean (no noise when there's nothing to scan — rule 3). (4) External-skill gate, NOT a platform — platforms.json models app languages with one-toolchain-each; infra isn't a language, so it stays in the cross-cutting external-skill registry (mirroring how Impeccable/Spectral/etc. are NOT platforms). (5) Advisory is mandatory — IaC scans are false-positive-prone (rule 4). (6) Companion model.
  • Rejected: (a) make iac a Blocker gate — IaC misconfig is context-dependent (rule 4); advisory only. (b) add IaC as a platform in platforms.json — infra isn't an app language; would break one-platform-one-toolchain + multiply disconnect_check wiring; it belongs in the cross-cutting external-skill registry. (c) tfsec (feature-frozen) / Terrascan (archived 2025) — dead tools. (d) run on app-only projects noisily — no-op when no IaC files (rule 3 honest, not a silent green). (e) provision via arm.py — companion model. (f) hand-roll an IaC checker — weaker than Checkov's built-in policies, duplicates an engine. NOTE on test-quality (the other half of the original "IaC / test-quality" item): deliberately NOT a gate — there is no canonical cross-language test-quality linter (coverage + mutation are per-language, heavy); documented as a remaining gap in maturity.md caveat 13 rather than forced (rule 3).

29. ios-developer + ios-tester replace the general-purpose fallback

  • Context: iOS/Apple is a first-class platform in the skill description (Xcode/Swift/SwiftUI/XCTest/XCUITest/SPM/Simulator), but role-agent-mapping.md routed iOS Developer and iOS Test Engineer to generic general-purpose with an expertise prompt, while Web has dedicated frontend-developer/backend-developer/tester/playwright-test-*. The fallback meant iOS work ran in an undifferentiated context with no description-as-router trigger and no platform-specific discipline — the weakest roster spot.
  • Decision: add solidforge:ios-developer (impl + XCTest unit) and solidforge:ios-tester (XCUITest UI/E2E + .xcresult analysis). The parity argument: ios-testerplaywright-test-* (platform E2E specialist), NOT a second unit-tester — ios-developer owns XCTest unit like backend-developer does. Route the iOS Developer row, the Test Engineer row, the E2E Test Engineer row, and the worked examples (SKILL.md, feature-dev.md, bug-fix.md, refactoring.md, parallel-patterns.md, extending.md) to the new agents. Disambiguate Apple Platform Architect (→ architect, module/architecture decisions) vs iOS Developer (→ impl).
  • Why: description-as-router now triggers on Swift/iOS keywords; platform-specific discipline (Swift concurrency, Sendable, accessibility identifiers, xcresulttool) rides on a dedicated agent instead of a prose prompt; the iOS/Web parity removes the one weak roster spot. Two agents (not one) match the Web split (developer + E2E-test specialist).
  • Rejected: (a) keep general-purpose + prompt (the status quo — no routing trigger, no platform discipline); (b) a single ios-developer that also owns XCUITest (UI/E2E testing is a distinct specialty with its own toolchain — .xcresult parsing, flake triage — matching playwright-test-*); (c) broaden the existing tester to cover iOS instead of a dedicated ios-tester (tester is the Web/Backend generalist; XCUITest is a platform E2E specialty like Playwright).

30. security-specialist: outer-ring only, complementary to the deterministic gates

  • Context: code-reviewer covers incidental security (OWASP mentions) within general code review, but a dedicated pre-production security pass (auth/authz design, threat model, secret audit, IaC security) had no owner. The trap: a security agent that re-runs what the inner-ring deterministic gates already enforce (semgrep_adapter, license_adapter/Trivy, arch_contract_deps, iac_adapter/Checkov) would duplicate already-Blocked checks.
  • Decision: add solidforge:security-specialist, strictly outer-ring — the semantic security review the deterministic gates cannot encode (auth/authz logic flaws, access-control design, cross-file secret flows, threat modeling), plus triaging gate output into a ranked findings list. It must NOT re-run what the gates enforce. Read-only (tools: Read, Grep, Glob, Bash, ast-grep), reports findings, does not fix. Route the Code Reviewer row's security mention to defer to security-specialist for dedicated security work.
  • Why: closes the dedicated-security gap without duplicating the deterministic gates; the inner/outer division keeps the two layers non-redundant (gates Block on codable violations; specialist covers the semantic residue). Read-only + severity-ranked findings mirror code-reviewer's discipline.
  • Rejected: (a) code-reviewer-only (no dedicated security owner for the pre-production pass); (b) a second inner-ring scanner (redundant with the deterministic gates — would duplicate already-Blocked checks); (c) a security agent with Edit access (reviewers report; the convergence loop's repair step is a separate agent).

31. .patterns.md relocation: reference docs are not agents

  • Context: 5 .patterns.md companion files (architect / backend-developer / frontend-developer / code-reviewer / devops-engineer) sat in agents/ with NO frontmatter. The plugin loader registers any .md in agents/ as an agent (deriving a name from the filename when frontmatter is absent), so they registered as ghost agents (solidforge:architect.patterns, etc.) with a dead default description — pure namespace pollution that can never route correctly (no description to match on). The workspace's own plugin_layout.py already treated them as non-agent companions (excluding .patterns.md from the 13-count). Reference docs registered as agents is a known anti-pattern (a doc-as-agent won't route; the correct home is a bundled reference file).
  • Decision: relocate the 5 companions out of agents/ into skills/parallel-development/references/agent-patterns/<role>.md (suffix dropped — the dir carries "patterns"; -patterns.md stays reserved for the language files references/<lang>-patterns.md). Update the 5 agent companion links, external-skill-integration.md, README.md, plugin.json. In plugin_layout.py this is a structural move (analogous to the Phase-4 skill-cutover that moved skills off the repo root and touched this checker's path logic via _find_plugin_root): the companion-coverage regex becomes references/agent-patterns/([\w-]+)\.md resolved under SKILLS_DIR/parallel-development/references/agent-patterns/, and EXPECTED_AGENTS grows to 17 — adding NO new decision-point logic (rule 2). Filed in parallel-dev because all 5 companions belong to parallel-dev agents.
  • Why: removes 5 ghost agents from the namespace; keeps the reference content bundled (under skills/ it ships with the plugin but is not scanned for agents); aligns runtime with the workspace's own intent (companions, not agents); one suffix convention (-patterns.md for language refs, none for role refs whose dir says "patterns").
  • Rejected: (a) promote the companions to real agents with frontmatter (a patterns-regurgitating agent is itself the anti-pattern — that is a reference doc, not a role); (b) leave them as ghost agents (namespace pollution + dead descriptions that can never route); (c) relocate to a subdirectory of agents/ (the loader scans recursively — would still ghost them). Loading-chain guard note (rule 3): after relocation the agent→companion link is validated ONLY by the rewritten plugin_layout.py companion check; disconnect_check.py does not guard it (it scans references/ for per-language L4 files, not agent companions).

32. /loop is prompt-convergence (outer/meta); the skill's convergence loop is engineering-convergence (gated + breakered) — not interchangeable

  • Context: Claude Code ships a /loop command that re-fires a prompt/command on a recurring interval or (dynamic mode) self-paces until a stop condition. It is a natural fit for "review the skill against its own rules, fix, repeat until no defects" — exactly the meta-convergence used to validate this skill's own changes (the subagent-collection remediation ran PASS 1→fix→PASS 2→fix→PASS 3 manually). The trap: the skill's identity is "Loop Engineering," so a future maintainer could read "the skill is a loop" and try to replace the convergence loop with a blind /loop re-fire — silently losing the deterministic gates (fast-gate, arch-contract), the circuit-breaker state machine (Thrashing/cap/budget), the Intent Blueprint state, and the context-folding that make the loop safe and bounded.
  • Decision: name two distinct loop mechanisms and keep them at different layers. (1) /loop is a HARNESS-LEVEL prompt re-fire scheduler — unbounded, no deterministic gates, no circuit breaker, expensive per run (full-context re-fire; cache miss after ~5 min). It belongs at the OUTER/META layer: skill self-maintenance (review-fix-until-converged), plan-reviewer precision eval, adversarial multi-pass review. (2) The skill's convergence loop is an IN-SKILL, stateful, deterministic-gated, circuit-breaker-bounded, context-folding engineering loop that runs inside a single orchestrator invocation. They are complementary, not interchangeable: /loop drives meta-convergence ON the skill; the convergence loop drives engineering-convergence WITHIN the skill.
  • Why: the convergence loop's safety comes from gates + breaker + state, none of which /loop provides. The memory note (loop-integration-cost) already established that LLM/web harnesses are too costly per-run for the hot loop and rule-axis gates must be cheap local deterministic linters — /loop's full-context re-fire is exactly that banned cost profile, so it is correctly excluded from the inner ring and admitted only at the meta layer. Naming the layering prevents the conflation regression the "Loop Engineering" identity otherwise invites.
  • Rejected: (a) replace the convergence loop with /loop (loses gates + breaker + state — unbounded re-fire with no deterministic stop, and the semantic verdict dispatch is a model judgment, not a re-fire); (b) ban /loop entirely (it is the right tool for the meta/maintenance layer — review-fix-until-converged is its canonical use); (c) leave the layering implicit (rule 6: the conflation risk is non-obvious and recurring).

33. ultracode / Dynamic Workflows is script-driven meta/sweep orchestration; the skill's convergence loop is model-driven engineering orchestration — complementary, not interchangeable

  • Context: Claude Code ships Dynamic Workflows (opt-in via the ultracode keyword in a prompt, or /effort ultracode for session-wide auto-orchestration; shipped with Opus 4.8 / v2.1.154, ~2026-05). A workflow is a JavaScript script Claude writes; the SCRIPT is the orchestrator (deterministic routing/loops/stop/model-tiering), executed by a background runtime; intermediate results live in script variables, not the model's context. It composes six patterns (classify-and-act, fan-out-and-synthesize, adversarial verification, generate-and-filter, tournament, loop-until-done) and is built for codebase-wide sweeps, large migrations, and cross-checked research — up to 1000 agents/run. Anthropic's guidance: "use a workflow when a task needs more agents than one conversation can coordinate"; "avoid for repeatable, well-defined tasks with predictable token budgets — a custom Subagent is more efficient." The conflation trap (worse than /loop's, ADR #32, because BOTH fan out agents): a maintainer reads "parallel-development fans out agents" and "ultracode fans out agents" as the same thing and either (i) replaces the gated, breakered convergence loop with a blind workflow script, or (ii) double-spends by running a workflow that re-implements what the skill already does.
  • Decision: name them as two orchestration primitives at different layers. (1) Dynamic Workflows (ultracode) are SCRIPT-DRIVEN, deterministic, background, high-token, designed for UNKNOWN-SIZE / one-off / cross-cutting work the per-feature loop does not cover (repo-wide bug sweep, 500-file migration, cross-checked research, adversarial multi-reviewer panels, the skill's own review-fix-until-converged meta-loop). (2) The skill's convergence loop is MODEL-DRIVEN, stateful, deterministic-GATED, circuit-breaker-BOUNDED, cost-PREDICTABLE, designed for per-feature engineering convergence. They are complementary: workflows for the meta/sweep layer, the convergence loop for the engineering layer. The non-interchangeability is structural — the loop's verdict dispatch (pass / rewrite / rollback / revision-channel) is a SEMANTIC model judgment ("is this intent drift? a real defect?"), not code routing, so the loop cannot be reduced to a deterministic script; only its deterministic parts (gate→action, breaker counters, parallel scheduling) could be scripted, and doing so would lose the Intent-Blueprint + breaker integration for no semantic gain. Per ADR #32 and the loop-integration-cost memory, workflows' many-agent cost profile excludes them from the inner hot loop (cheap deterministic gates) — they sit at the outer/meta layer only.
  • Why: the convergence loop's value is gates + breaker + state + the semantic verdict; a workflow script provides scale + determinism + context isolation but none of those four. Naming the split prevents the double-spend (re-implementing the loop as a workflow) and the regression (replacing the gated loop with a blind one). It also surfaces the real complement: workflows fill the cross-cutting-sweep gap the per-feature loop does not cover, and the researcher/plan-reviewer/maintenance patterns are natural workflow candidates.
  • Rejected: (a) replace the convergence loop with a ultracode workflow script (loses gates + breaker + Intent-Blueprint state; the semantic verdict dispatch cannot become code — would regress to an unbounded agent fan-out); (b) ban workflows (they are the right tool for repo-wide sweeps/migrations/cross-checked research the skill's per-feature loop does not cover, and for the skill's own review-fix meta-loop); (c) leave it implicit (rule 6: the all-fan-out conflation is the highest-risk misunderstanding of the skill's identity — both primitives fan out agents, so the layering must be explicit). Cross-ref ADR #32 (same layering for /loop).

34. External-skill smoke arming surfaced 3 adapter/version-drift defects (Vale 3.x, Spectral 6.x, Impeccable smoke resolution)

  • Context: All 7 external-skill gates (Impeccable, Spectral, Semgrep, Vale, oasdiff, Trivy/license, Checkov/iac) had smoke tests in smoke_gates.py, but every one SKIP'd because the companion tools were never armed — so the adapters had never been exercised against real tooling. Arming them per install.md (6 via brew install, global + Homebrew-preferred; Impeccable via npx impeccable install into a /tmp/ project) and running smoke_gates.py from that project moved 4 to PASS (semgrep/oasdiff/license/iac) and exposed 3 latent defects the SKIPs had masked: (1) Vale 3.x renamed --format=JSON to --output=JSON (the adapter's flag was rejected with "unknown flag: --format"); additionally the smoke fixture's .vale.ini used Test.Substitution = yes and Vale SILENTLY disables a rule on a lowercase boolean (emits {}, no alert — YES is required). (2) Spectral 6.x no longer auto-applies the bundled spectral:oas ruleset when no ruleset file is present (5.x did); --ruleset spectral:oas is NOT accepted as a value (Spectral reads it as a file path → "Could not read ruleset at .../spectral:oas") — a ruleset FILE is required. (3) smoke_impeccable's skip-check used the OUTER cwd (os.getcwd(), the /tmp/ project), but the adapter runs with root=tempdir (CLAUDE_PROJECT_DIR set to a fresh mkdtemp), so detect_mjs_path could only resolve a global ~/.claude arm — a project-cwd arm was invisible to the adapter, producing a false FAIL ("Impeccable not armed").
  • Decision: (1) vale_adapter.py: --format=JSON--output=JSON (Vale 3.x); smoke_vale fixture yesYES (Vale's uppercase-boolean requirement). (2) spectral_adapter.py: add resolve_ruleset(root) — prefer the project's .spectral.{yaml,yml,json}; when none exists, synthesize a temp extends: ["spectral:oas"] and pass --ruleset <tmpfile> (preserves the pre-6.x default-oas intent; temp file unlinked in finally). (3) smoke_impeccable: capture the armed detect.mjs path and symlink the armed skill dir into the tempdir's .claude/skills/impeccable so detect_mjs_path(root=tempdir) resolves it — works for EITHER a project-cwd arm OR a global ~/.claude arm, no global install required. All 7 external-skill smoke tests now PASS; the full self-gate set (11 tests incl. disconnect_check, lint_self, plugin_layout, run_record) is green.
  • Why: rule 1 (a skill's self-gates are the definition of done) and rule 3 (never silently green) — the SKIPs were honest no-ops, but they masked real CLI drift that would surface as a silent failure the first time a real convergence run armed a current tool version. The fixes are version-correct (verified against the installed Vale 3.15.1 / Spectral 6.16.0 / impeccable 3.1.0), minimal, and keep each adapter ADVISORY (no new blocker). No new gate or capability was added → no enumeration sweep (rule 5); install.md's global + Homebrew-preferred table and the external-skills.md contract are unchanged.
  • Rejected: (a) leave the SKIPs, or skip-on-version-mismatch — violates rule 1 (gates unverified) and rule 3 (hides drift behind a skip). (b) pin old tool versions via brew@ / npm@ — diverges from what real projects install; the adapters must work with CURRENT tooling, not a frozen legacy version. (c) for Vale, make the adapter tolerate lowercase yes — the adapter cannot rewrite a project's .vale.ini; the SMOKE fixture is ours, so it uses the correct YES (the load-bearing fix for real configs is the --output flag). (d) for Spectral, pass --ruleset spectral:oas as a bare value — rejected by 6.x (read as a path); a synthesized ruleset FILE is the only working form. (e) for Impeccable, arm globally at ~/.claude/npx impeccable install defaulted to global (stdin closed) and wrote skill dirs into 5 home harness locations (~/.claude, ~/.agents, ~/.cursor, ~/.gemini, ~/.opencode); reverted to a per-project /tmp/ arm + the smoke symlink, which resolves the adapter WITHOUT polluting the user's home (containment in the /tmp/ project env). Cross-ref ADR #21 (Impeccable integration), #23 (Spectral Depth-2 freeze), #28 (Checkov).

35. Markdown self-lint folded into lint_self.py; markdownlint-cli is the dev tool (config passed via --config, not auto-discovery)

  • Context: The repo-root .markdownlint.json (tuned in ADR #17 — MD013/MD041 off, MD024 siblings_only, MD033 allowed_elements) governed markdown quality but had NO deterministic self-check — docs were linted only when a human remembered to run it. After ADR #34, this surfaced concretely: the ADR could not be linted because the reporter checked for markdownlint-cli2 / mdl and found neither, concluding "markdownlint not installed." That was a DETECTION ERROR — markdownlint-cli 0.49.0 (the markdownlint binary, installed via brew) was present all along; the two CLIs share the .markdownlint.json format. The real gap: no self-gate ran it, and the binary name was non-obvious.
  • Decision: fold markdown linting into the existing lint_self.py dogfood gate (it already lints the skill's own Python infra with ruff) — it now ALSO lints the skill's own markdown (SKILL.md + references/ + docs/, via glob **/*.md) with markdownlint, applying the repo-root config. Same graceful-skip shape as ruff: markdownlint is a dev tool, so a missing binary prints a coverage note (advisory) rather than failing (rule 3 — never silently green, never hard-fail for being unarmed). The config is resolved by walking up from the skill root (find_md_config) and passed explicitly via --config <path>.
  • Why: rule 1 (self-gates are the definition of done) and rule 3 — a config without a deterministic check is an unenforced rule; folding it into lint_self makes markdown quality part of the inner ring WITHOUT adding a new gate to the self-gate enumeration (rule 5 — lint_self is already listed in CLAUDE.md and install.md; its scope simply grew from "Python" to "Python + markdown", which is the same "lint the skill's own files" contract). Verified: all 73 repo .md files lint clean; a negative test (injected MD025 duplicate-H1) makes lint_self FAIL and revert to PASS on cleanup — the step genuinely catches violations, not a silent green.
  • Rejected: (a) install markdownlint-cli2 — a DUPLICATE; markdownlint-cli (markdownlint binary) was already installed; the gap was the wrong binary name in detection, not a missing tool. (b) add a separate lint_docs.py gate — needlessly multiplies the self-gate list; lint_self already owns "lint the skill's own files", so markdown is its natural second axis (Python + markdown), mirroring how fast_gate lints per file type. (c) rely on markdownlint auto-discovery — REJECTED empirically: from the skill-root cwd it did NOT apply the repo-root .markdownlint.json (it flagged MD013, which the config disables); auto-discovery is cwd-sensitive, so --config is passed explicitly (deterministic, independent of cwd). (d) run with cwd=repo-root instead of --config — brittle (couples the check to the invocation directory); --config is explicit and survives any cwd. Cross-ref ADR #17 (the .markdownlint.json tuning this gate enforces), #1 (infra stays stdlib-only — glob/os walk-up is stdlib), #34 (the self-gate run that exposed the gap).

36. Go is a first-class language with a STRONG arch-contract gate (internal/ + depguard + compiler cycle rejection), not thin like Java/Rust

  • Context: Go was mentioned aspirationally (SKILL.md go vet/golangci-lint/go test, go.mod, a .go backend-detection row) but never registered — a Go project armed today got ZERO deterministic gate (.go files fell through fast_gate.py's implicit else: check_web, which no-ops). Promoting it to first-class (a platforms.json entry that passes disconnect_check.py's loading chain + a functional arch/test/supply-chain gate) required deciding the gate's strength. The recent exemplars (Java, Rust) are documented THIN ("no first-class layer/dependency-direction enforcer") — the easy path was to clone that framing.
  • Decision: Go is a STRONG gate — the strongest of the backend languages. Layer isolation is enforced TWO complementary ways: (1) the internal/ compiler-enforced structural boundary (always on; importing internal/* from outside its parent tree is a compile failure — Go's strongest encapsulation, fedaot-wiki go-project-layout-conventions), and (2) golangci-lint depguard (v2 rules: config) for the finer dependency-direction rules between non-internal packages that internal/ cannot express. Import cycles are compiler-rejected (go build fails "import cycle not allowed") — far stronger than Java's jdeps --cyclic best-effort parse. The arch gate arch_contract_go.py runs go build ./... FIRST (cycles/compile; on failure it emits one compile-or-cycle Blocker and SKIPS go vet — vet shares the compiler front-end and would double-report the same cycle, inflating the thrashing breaker's distinct-fingerprint count), then go vet ./... (static findings), then optional golangci-lint whose severity maps by FromLinter (depguard/govet/staticcheck → blocker; gofmt/style → warning — golangci-lint's Severity field is empty by default). The concurrency baseline -race is runtime/test-time (no go vet -race), so it runs in the TEST gate (go test -race -json ./...), not the arch gate. fedaot-wiki go-tooling-ecosystem / go-project-layout-conventions / go-testing-philosophy corroborate each axis; the canonical convergence-repair-loop framework lists layer-isolation + concurrency-baseline as core architecture-contract axes. The full per-language formula applied: one platforms.json entry (data-driven — disconnect_check.py needed NO edit for Go), a 6-round adversarial review-fix convergence loop on the plan, and the rule-5 enumeration sweep across ~20 files. The two cross-ecosystem gates (arch_contract_deps.py, arch_contract_tests.py) hardcode their ecosystem dispatch and needed hand-edits (the most damaging gap the convergence review caught — without them Go would be "first-class" in name with no functional supply-chain/test gate, a rule-3 silent green): govulncheck NDJSON osv↔finding join, and go test -race dispatch.
  • Why: rule 3 (never silently green) + rule 6 (record non-obvious decisions). Dishonestly calling Go "thin" would understate a genuinely strong toolchain and mislead maintainers into thinking layering is unenforced when internal/ enforces it at compile time. The build→vet-skip ordering avoids the double-report trap. Severity-by-FromLinter is required because Severity is empty by default. Keeping internal/ as the PRIMARY mechanism puts depguard in its correct (complement) role. The -race-in-test-gate placement reflects that Go has no static concurrency equivalent (unlike Python's sync-in-async ast scan or Swift's -strict-concurrency).
  • Rejected: (a) make Go thin like Java/Rust — dishonestly downgrades a genuinely strong toolchain; (b) skip depguard, rely only on go vet/build — loses the semantic layer rules internal/ alone cannot express (e.g. domain↔web direction); (c) detect import cycles via a separate go list/AST parse — unnecessary, the compiler already rejects them; (d) run go vet unconditionally (not skip-on-build-fail) — double-reports cycles; (e) wire -race into the static arch gate — impossible, -race is runtime/test-time (no go vet -race); (f) mirror Java's severity-string-equality for golangci-lint — its Severity field is empty by default, so map by FromLinter. Cross-ref ADR #34/#35 (the self-gate discipline this follows); fedaot-wiki go-tooling-ecosystem / go-project-layout-conventions / go-testing-philosophy; docs/go-first-class-plan.md (the 6-round convergence log).

37. plan_queue drives loop_state per-item (claim→init, complete→mark-converged+run-record); complete enforces record-outer; no plan-level L4

  • Context: In a plan-driven run (kindly project), loop_state stayed empty (inner.iteration: 0, outer.iterations: 0, l4: not-a-probe) despite plan-queue-state showing 4/4 converged. CGC confirmed the structural root: loop_state has zero Python callers (find_importers loop_state = 0; find_callers record_fingerprint all internal to loop_state.main). Every bookkeeping anchor (mark-converged / bump-iteration / record-outer / run-record) was driven only by the LLM agent at runtime (CLI per convergent-loop.md prose), except gate-fail (driven by the fast_gate.py hook — reliable). plan_queue.complete updated plan-queue state only — it did not drive loop_state. The agent jumped complete without the per-item loop_state lifecycle (init → converge inner+outer → mark-convergedrun-record) that plan-driven-mode.md:150-152 required. Bookkeeping reliability = agent reliability, and the agent was unreliable.
  • Decision: wire plan_queueloop_state at the per-item lifecycle anchors in code (not prose): claim subprocesses loop_state.py init --task-id <item> (per-item); complete subprocesses mark-converged (REFUSES unless record-outer ran — ADR #16, enforces per-item dual-ring DoD) + run-record; blockmark-suspend+run-record; skipset-status skipped+run-record. The hooks drive bookkeeping only — inner (Coder + gates) + outer (plan-reviewer) work stays the agent's; complete just refuses if the agent skipped outer, forcing it back. No plan-level L4 aggregate: plan-driven chaining is L3 (plan-driven-mode.md:202); per-item l4 stays per-item (L4 kernel, mostly not-a-probe for short items — run-record's value is per-item convergence evidence, not an l4 true-value).
  • Why: bc's produce.py lesson (deterministic beats agent-prose) directly applies — loop_state was a complete state machine with no deterministic driver. The code-layer hooks make per-item loop_state bookkeeping unavoidable (claim can't happen without init; complete can't happen without the outer ring). record-outer enforcement at complete closes the false-convergence hole (a plan-driven item can't be marked converged without an outer review) at the state-machine layer, not prose. Refusing plan-level L4 keeps the L3/L4 split honest (plan-driven-mode.md:200/202).
  • Rejected: (a) plan-level L4 aggregate — violates plan-driven-mode.md:202 (chaining is L3, not L4); (b) leave loop_state driving to agent prose — CGC proved the agent doesn't drive it (counters empty in kindly); (c) drive inner/outer from the hook — hooks are bookkeeping only; convergence work (Coder + reviewer) stays the agent's; (d) extend loop_state with a skipped status — set-status accepts the string (lean: no new enum). Cross-ref docs/plan-driven-loop-state-wiring-design.md; ADR #16 (the mark-converged refusal the hooks rely on); plan-driven-mode.md:200/202 (the L3/L4 split).

38. L4 framework re-aligned to fedaot-wiki (4th degradation + capacity/demand split + orthogonal verification axis)

  • Context: fedaot-wiki ai-coding-agent-maturity had a major L4-framework change. SolidForge's L4 material was based on the old version (3 degradations; L4-probe fuzzy/novel/high as capacity criteria; horizon as capacity). The new version: (1) a 4th degradation — Specification Gaming (agent optimizes a proxy spec, not the real goal; silent semantic failure) — orthogonal to L1-L4; (2) capacity vs demand split — L4 capacity = 3-degradation defense, independent of demand (fuzzy/novel/high AND horizon are demand, NOT capacity; "运行生存期是 demand 的时间外显量纲、非能力成分"); (3) orthogonal verification axis — Specification-Gaming defense needs an 异源 oracle (external verification source), NOT L5; L4 = intrinsic-flow-control ceiling (self-certification paradox: L4's "proactive test self-verification" is same-source, a carrier of spec-gaming, not a defense).
  • Decision: adopt the new model. loop_state.py build_run_record: capacity_l4 = converged AND 3 defended (removed is_l4_probe AND horizon_met from the capacity gate — both are demand). is_l4_probe + horizon_met kept as demand markers (evidence weight, not the gate). provisional_verdict enum unchanged (back-compat); not-a-probe semantics narrow to "non-probe + capacity-not-met". maturity.md: +4th degradation (orthogonal) + orthogonal-axis section + L4 capacity/demand split + L4-probe demand reframe + self-review gate +spec-gaming. run-record.schema.json: descriptions updated (capacity/demand); enum unchanged. bc↔L4 fuzzy-tension analysis revoked (capacity doesn't require fuzzy).
  • Why: the old model conflated capacity with demand (a fuzzy/novel/high/horizon run was denied l4-evidenced even when the 3-degradation defense held — a demand-gated capacity criterion, wrong). The 4th degradation (Specification Gaming) was missing. The self-certification paradox (L4's "proactive test self-verification" is same-source → carrier of spec-gaming, not defense) was unrecognized. The new model separates capacity (what L4 grades) from demand (what stress-tests it), adds the 4th degradation on an orthogonal axis, and marks L4 as the intrinsic ceiling (异源 oracle is future work, not L5).
  • Rejected: (a) keep 3 degradations — misses specification gaming; (b) number the orthogonal axis L5 — false same-axis progression; (c) let is_l4_probe/horizon_met stay capacity criteria — conflates capacity with demand; (d) drop not-a-probe enum — breaks back-compat for existing run-records (narrowed semantics instead). Cross-ref docs/l4-definition-sources.md; ADR #16 (mark-converged DoD invariant, still holds); fedaot-wiki ai-coding-agent-maturity (the upstream).

39. Inline mode is legitimate but MUST drive loop_state; dispatch is fan-out/long-horizon escalation, not the blanket default

  • Context: ADR #14 set serialized subagent dispatch as the default and orchestrator-direct (inline) as the exception (trivial edit / tight-coupling continuity). ADR #16 made the OUTER-ring accounting a state invariant (mark-converged refuses without record-outer) and explicitly REJECTED an inner-iteration GATE ("cannot distinguish a clean first-pass from a never-bumped direct run"). An observed 5-item skill-self-maintenance plan ran inline for every item — legitimate per #14's tight-coupling carve-out, since each edit needed accumulated skill idiom (rules 5/7/8/10, the parse/check exemplar, disconnect_check semantics) a fresh Coder subagent would regress on — and correctly ran record-outer per item. But it never called bump-iteration, so all 5 run records show steps.inner=0 despite real inner work. The OUTER DoD held (#16); the INNER telemetry silently under-reported. Two confusions followed in the post-mortem: the orchestrator-worker pattern (Anthropic multi-agent research) was over-applied as a "default dispatch" fix for what is actually a bookkeeping gap; and inline mode was mis-read as exempting bookkeeping.
  • Decision: (a) Bookkeeping is orthogonal to dispatch and always mandatory. Whether the inner ring runs inline or in a dispatched subagent, the loop manager (orchestrator) drives loop_state every inner round: bump-iteration at the start of each fix attempt, gate-fail <fingerprint> on each gate failure, snapshot create at inner-converge, record-outer per outer review. Inline exempts WHERE the edit happens, not WHETHER the bookkeeping happens. A clean first-pass inline run bumps once (the single round attempted); a multi-round run bumps per round; steps.inner=0 then unambiguously means "the loop was never engaged" (a real signal), not "ran inline". This is NON-GATING discipline (respecting #16's rejection of an inner-iteration gate); the DoD signal stays the outer review. (b) Dispatch routing is a function of fan-out + horizon + coupling, not a blanket default. Dispatch (orchestrator-worker) pays off ONLY when its two upsides are reachable: parallelism (independent items / parallel_group) and context-unbottlenecking (long horizon, projected >~50% of context). A sequential dependency chain gets ZERO parallelism upside from dispatch and only partial context-isolation (the orchestrator still reads every diff for the outer ring), so inline remains the default for sequential / short-horizon / tight-coupling work; dispatch is the ESCALATION when fan-out or horizon binds. (c) Skill-self-maintenance is a first-class inline carve-out alongside trivial-edit and tight-coupling: edits to the skill's own infra (gate scripts, hooks, agent defs, registry, platforms.json) stay inline even under dispatch escalation, because the orchestrator's accumulated idiom is the quality guarantee.
  • Why: the "execute directly" harms (#14 context bloat, #15 scope leak, #16 DoD-bypass) are real, but inline mode itself is legitimate for a large class of work (skill-self-maintenance, sequential chains, short plans). The evidence: the 5-item inline plan used ~29% of context with no rot, and the only real bug was skipped bookkeeping, not capacity. Over-applying dispatch (default-dispatch-the-inner-ring) to fix a bookkeeping gap is a category error — instrumentation is a discipline orthogonal to where the work runs. Treating inline + forced-bookkeeping as the legitimate default, with dispatch as fan-out/long-horizon escalation, matches Anthropic's "start simple, escalate when needed" and fedaot-wiki workflow-over-agent's predictability criterion (sequential skill edits are predictable = workflow territory, not fan-out agent territory). The bookkeeping-always rule makes steps.inner=0 mean "loop not engaged" so the telemetry stops lying, without re-introducing the gate #16 rejected.
  • Rejected: (a) add an inner-iteration GATE to mark-converged (refuse if inner.iteration==0) — explicitly rejected in #16 (cannot distinguish a clean first-pass from a never-bumped direct run) and the outer review is the clean DoD signal; (b) flip the default to dispatch — over-rotates the orchestrator-worker pattern; loses skill-continuity on sequential/coupled plans; the multi-agent research's context-isolation finding applies to broad fan-out, not 5-item sequential self-maintenance; (c) "use dispatch to fix instrumentation" — orthogonal: a dispatched run can still skip bump-iteration if the orchestrator doesn't drive it; (d) a build_run_record telemetry-honesty backstop flagging steps_undercounted: true when steps.inner=0 at converge — reasonable defense-in-depth (mirrors #16's dod_satisfied backstop) but deferred: the doc discipline + the now-clean steps.inner=0-means-never-engaged semantics suffice; revisit if doc discipline proves insufficient in practice (the under-reporting already occurred once; the backstop is the defense if the rule is not followed). Sibling to #14 (where the dispatch decision lives) and #16 (whose outer-ring invariant this completes on the inner ring). Industry: Anthropic "Building Effective Agents" + "How we built our multi-agent research system"; fedaot-wiki workflow-over-agent + ai-assisted-code-quality (evaluator-optimizer is the mature test-stage pattern; context engineering > prompt engineering).

40. 异源 (heterogeneous-source) adversarial review via non-interactive Claude Code subprocess — additive, multi-round, capped; same-source primary retained

  • Context: ADR #38 established the orthogonal 异源 verification axis — same-source verification (same model family) shares blind spots and cannot defend specification gaming on test quality; crossing needs an 异源 oracle. Mutation testing (the first 真·异源) is heavy and per-language, not near-term (maturity.md caveat 13). The same-source test-quality layer (P1-P5, commit 3f820f9) is landed but has a hard ceiling. A lighter 异源 defense was wanted: route the adversarial-review stage to a different model family. Verified constraints: (a) Claude Code's provider config (ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN + ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL) is process-level — the built-in Agent tool's model param is a tier enum (haiku/sonnet/opus/fable) that resolves through these, with NO per-call base_url/api_key/env, so an in-process subagent CANNOT cross providers; (b) this very session proves multi-provider-via-Anthropic-compatible-endpoint works (BigModel GLM-5.2 + tier remapping); (c) Claude Code non-interactive mode (verified v2.1.197) exposes --output-format json --json-schema (structured return), --input-format stream-json --include-hook-events (streaming + deterministic-gate observability), --settings/--model/--agents per-invocation (per-process provider config), --bg + claude agents (background-agent primitive), --max-budget-usd/--fallback-model (cost/failover). A second practical fact: a single adversarial round rarely resolves reviewer disagreement — multi-round debate is the realistic pattern, so a round cap is required to bound cost.
  • Decision: (a) 异源 adversarial review runs as a non-interactive Claude Code subprocess (claude -p --settings <profile>.json --model <alias> --output-format json --json-schema <findings-schema> ...), spawned by a thin wrapper (infra/scripts/hetero_review.py). Per-process provider config gives the 异源 backend (e.g. DeepSeek) without an aggregator proxy; structured I/O gives typed findings; --include-hook-events lets the wrapper observe the deterministic gates. The subprocess inherits SKILL.md / hooks / Skills / MCP — the skill substrate is NOT stranded (the decisive advantage over a custom Agent SDK harness); tool-call reliability of the non-Claude backend is a Phase 0 measurement gate, not an assumed property (the Morph caveat). (b) ADDITIVE, not substitutive: the same-source reviewer (ADR #16 outer ring) remains PRIMARY and always runs; 异源 is an opt-in adversarial second opinion, triggered only on high-stakes items (ADR-level decisions, security/correctness-sensitive, or same-source verdict "partially-satisfied / low-confidence"). Reconciliation: both-report → high-confidence; same-source-only → adopt (primary); 异源-only → strong signal, escalate; neither → pass. (c) Multi-round adversarial debate: reviewers alternate (same-source primary → 异源 challenge → same-source response → …) until one of three terminations: converge (≥1 round with zero new findings), no-movement early-exit (same disagreement fingerprint ≥2 rounds — reuses loop_state's thrashing breaker), or cap. The 异源 prompt is adversarial ("find what the primary missed or got wrong", not "validate"). (d) Cap — precisely defined: max_adversarial_rounds = the maximum number of 异源 invocations (NOT total rounds). The same-source primary always responds after each 异源 challenge, so the same-source final-word is guaranteed BY CONSTRUCTION (no parity dependency on an odd/even cap). cap=0 = opt-out (异源 does not run); cap=1 = one 异源 challenge + one same-source response (minimum for any 异源); default conservative (e.g. 2); per-item overridable; plus --max-budget-usd as a hard cost breaker on each subprocess. (e) Cap-hit policy: on cap exhaustion without convergence, escalate to human (outcome axis) and record outer verdict adversarial-stalemate; NEVER silently adopt either side (silent-pick on timeout defeats the 异源 purpose). (f) Schema delta — minimal, acknowledged: adversarial-stalemate is NOT in the current outer_verdict enum ({pass, rewrite, intent-drift, blueprint-defect, visual-drift} — confirmed against record-outer argparse + run-record.schema.json); recording it requires ADDING the value to that enum + the argparse choices (a one-line-each schema delta). outer_verdicts[] is already a list — N debate rounds = N entries, outer.iterations increments; loop_state thrashing-fingerprint breaker is reused for no-movement. The earlier "no schema change" framing was wrong (异源-review Round 1, Critical); this ADR now states the delta honestly. (g) Closes ADR #39's inner-ring bookkeeping from outside — from Phase 1, not deferred: the wrapper drives loop_state (bump-iteration / gate-fail / snapshot / record-outer) around the subprocess from day one of 异源 use, using --include-hook-events to observe gates — steps.inner becomes truthful at the wrapper layer. Deferring this to a later phase would emit knowingly-dishonest run records (the exact bug ADR #39 fixed). (h) Session lifecycle — default stateless, refined after its own 异源 review (the original "default new session" answer overclaimed; this is the corrected version): each 异源 invocation runs as a NEW stateless session (claude -p --no-session-persistence, no --resume/--session-id). The DIRECTION (default stateless) is defensible (异源 conceded this), but the justifications are the REFINED ones: (1) cross-item isolation is the mechanical, strongest argument — item A's verdict must not anchor item B (the only non-speculative reason); (2) multi-round debate continuity is carried by EXPLICIT prompt context (round-N findings injected into the round-N+1 prompt), not session — auditable, no hidden state, forces re-derivation; the conservative cap bounds the linear prompt growth. THREE claims from the original answer were DOWNGRADED after 异源 review found them overclaimed: (i) the cache concern was RESOLVED post-Phase-0: DeepSeek AUTO-CACHES via its own Context Caching (dashboard: ~99% hit rate; input_cache_hit_tokens massively exceeds input_cache_miss_tokens); the Phase-0 probe's cache_creation=0/cache_read=0 was a REPORTING GAP (Anthropic-format usage fields), not a caching gap; Claude Code's cache_control markers are redundant (DeepSeek caches on prefix similarity, no explicit markers needed); stateless benefits from prefix amortization; (ii) anchoring bias is a PRECAUTIONARY hypothesis (consistent with prior-turn-bias literature), NOT established fact — Phase 0 adversarial test: reused-session reviewer + seeded bug, does it miss?; (iii) the n=1 dogfood proves stateless is VIABLE, not superior — A/B (stateless vs reuse, same plan/backend) deferred to Phase 0. The accumulated-context trade-off is acknowledged (异源's strongest challenge): stateless loses cross-item pattern detection ("this author consistently forgets X"); for consistency-review tasks where cross-item pattern recall is the goal, opt-in to a session that accumulates DELIBERATELY. A warmed-session pool is the cost-efficient third option (异源-found, originally missed): pre-load M sessions with the shared context once, dispatch items round-robin — amortizes cold reads while REDUCING (not eliminating) cross-item contamination: for M<N, items sharing a session still cross-contaminate (true per-item isolation needs M≥N = pure stateless); the pool's operational mechanism (non-interactive CC has no "pre-load, stay resident" mode — --bg creates ephemeral workers, not persistent pools) must be specified in Phase 0 if adopted. Its cost justification is weakened since DeepSeek auto-caches (the pool was originally proposed for the no-cache case); it remains valid for reducing cross-item contamination when pattern isolation matters. Reuse exceptions (opt-in, documented): human-driven interactive 异源 debugging (--resume); cumulative multi-file 异源 Coder (last/riskiest 异源 application); determinism-replay (same-model-family ONLY — heterogeneous-backend replay is confounded by model stochasticity). ADR #14 (intra-run subagent isolation) is EXTENDED to cross-invocation as a consistency principle, not as #14's direct claim. NOTE: session statelessness is orthogonal to loop_state bookkeeping — the wrapper drives bump-iteration/record-outer per invocation regardless of session lifecycle (per (g)).
  • Why: this operationalizes ADR #38's 异源 axis without waiting for mutation testing, at the orchestration layer rather than the test-engine layer. 异源 is PARTIAL, not a complete defense (ADR #38): different commercial model families share training-data and RLHF overlap, so 异源 is not a true orthogonal oracle — it raises the same-source ceiling (catches different blind spots) but does NOT remove the spec-gaming gap; mutation testing remains the eventual engine-level 异源. ADR #40 keeps this partialness honest. Non-interactive CC is the chosen substrate because it replicates the custom-harness wins (per-agent provider config, structured I/O, isolation, 异源) WITHOUT stranding the skill assets (the subprocess inherits hooks/Skills) or rebuilding a harness — the SWOT (custom Agent SDK vs Claude Code vs non-interactive CC) showed non-interactive CC dominates for this workspace (single maintainer; the skills ARE the deliverable). Additive + opt-in keeps cost controlled: default items pay zero (same-source only); high-stakes items pay ≤ 2×cap calls. Multi-round + cap reflects real debate dynamics (single-round rarely converges); cap-hit-escalate is the honest move (irreducible disagreement is human territory, ADR #38 outcome axis). Same-source-primary-retained is load-bearing: it is the reliability FLOOR (异源 non-Claude backends carry tool-call risk — the Morph caveat), the cost floor, and the primary signal — 异源 raises the ceiling without dropping the floor. DO NOT regress to "异源 replaces same-source" or "cap-hit → trust same-source" — both are documented rejections, not implementation liberties.
  • Rejected: (a) custom Agent SDK harness — over-fit for a feature need; strands the parallel-dev/blueprint-crafting skills (50+ files, ADRs, golden-paths); adds harness maintenance; only pays if productizing (deferred to a conditional Phase 4 of the proposal doc); (b) global LiteLLM as primary — unnecessary; non-interactive CC gives per-process config without a proxy; retained as FALLBACK if process-per-agent overhead proves unacceptable at scale; (c) built-in Agent-tool tier routing — CANNOT cross providers (tier enum resolves through process-level env); 异源 is impossible in-process; (d) model-as-tool as primary — loses agent capability (the reviewer cannot grep/read to verify); retained as DEGRADED fallback if the 异源 subprocess's tool-call reliability is unacceptable (Morph caveat bites); (e) single-round 异源 — rarely resolves disagreement; multi-round debate with a cap is the realistic pattern; (f) cap-hit silent-pick ("timeout → trust same-source") — defeats the 异源 purpose; escalate-to-human is the only honest cap-hit policy. Sibling to #14 (background-agent = process/session isolation, the 异源 boundary), #16 (outer-ring invariant — N debate rounds = N outer_verdicts), #38 (异源 orthogonal axis — this operationalizes it without mutation testing), #39 (inline bookkeeping — the wrapper closes the inner-ring residue from outside). Full operational plan: docs/hetero-orchestration-proposal.md. Industry: Anthropic "Building Effective Agents" (orchestrator-workers + evaluator-optimizer); "How we built our multi-agent research system" (context isolation).

41. 异源 wrapper substrate-error handling — surface CC stdout errors; DEGRADE recoverable caps (not rewrite); persisted degrade fingerprint

  • Context: ADR #40's wrapper (hetero_review.py) treated ANY non-zero claude -p exit as a malformation → verdict=rewrite with 0 findings, and run_claude short-circuited on returncode != 0 returning a generic hetero-subprocess-rc{N} fingerprint WITHOUT parsing stdout. A 2026-07-07 dogfood failed opaquely (verdict=rewrite, findings_count=0, malformation=hetero-subprocess-rc1). A forced-budget test confirmed the mechanism: when Claude Code hits --max-budget-usd it exits rc=1 with EMPTY stderr and puts the reason in STDOUT as a clean envelope {"type":"result","subtype":"error_max_budget_usd","is_error":true,"errors":[...]}. So (a) the diagnostic was discarded (it was in stdout, never parsed), and (b) a RECOVERABLE cost cap (just raise the cap) corrupted the verdict — forcing a spurious rewrite of the work under review. The default cap (--budget-usd 2.0) was also too low for a cold review (a cold call costs ~$0.23 just for the 45k-token system prompt; a multi-tool cold review plausibly exceeds $2).
  • Decision: (a) run_claude parses stdout on rc!=0 via _parse_cc_substrate_error — CC substrate errors carry the reason in stdout (subtype/errors), not stderr. (b) Recoverable subtypes in DEGRADABLE_CC_SUBTYPES (error_max_budget_usd, error_max_turns, error_overwhelmed, error_session_expired, error_session_not_found) DEGRADE: the 异源 leg contributes 0 findings + a coverage note + a degraded:true flag in the wrapper's stdout JSON, and the verdict stays pass/rewrite from the OTHER providers (异源 is additive — ADR #40; the same-source primary stands). (c) Non-degradable subtypes (error_invalid_* = wrapper/flag drift, error_permission_denied/error_unauthorized = auth misconfig) and unparseable output still malform → rewrite, with a richer hetero-cc-error:<subtype> fingerprint — NEVER silently mask a regression (rule 3; the FLAG-SURFACE MANIFEST is the precedent for treating CC surface drift as non-silent). (d) Honesty at the PERSISTENCE layer (rule 3 / ADR #39): the degrade subtype is stamped into record-outer --notes AND recorded as a hetero-degraded-<subtype> fingerprint via gate-fail, so the thrashing breaker can escalate persistent degradation across rounds instead of it masquerading as clean convergence (converged:true, verdict:pass, findings:0). The run-record schema stays CLOSED — the signal rides in notes (free-form) + the fingerprint, NOT a new field. (e) degraded is a FLAG in the wrapper's stdout (its own shape), NOT a new record-outer verdict value — so loop_state's verdict enum + build_run_record are untouched. (f) Default --budget-usd raised 2.0 → 4.0 (headroom under loop_state's global cost_cap_C=5.0; ships WITH the degrade path so a future larger review degrades instead of corrupting). FLAG-SURFACE MANIFEST re-verified on CC v2.1.201 (was v2.1.199; no flag drift). (g) Multi-provider caveat: drive_lifecycle records the combined fingerprints via one gate-fail call, so in a multi-provider run the thrashing breaker matches only on an identical combined string (per-fingerprint gate-fail is a documented follow-up); the single-provider path — the common case the dogfood exercises — records exactly hetero-degraded-<subtype>.
  • Why: a recoverable substrate cap is not a defect in the work under review — mapping it to rewrite forced spurious rework and, worse, the discarded stdout made it undiagnosable (the dogfood blocker). Degrade preserves ADR #40's "additive, primary-retained" contract: 异源 failing to run ≠ 异源 endorsing or rejecting — the primary stands, the round contributes nothing, honestly labeled. Persisting the fingerprint (not just a non-persistent stdout flag) is load-bearing: without it, the run-record lies (clean convergence) and persistent degradation never escalates — relocating the rule-3 violation, not fixing it. The allowlist (not blanket degrade) prevents masking real regressions (flag drift / auth) — unknown subtypes malform, conservative per the manifest precedent. The flag (not a new verdict) avoids rippling the verdict enum across loop_state + build_run_record + every doc enumeration (rule 5) for a substrate condition that is not a review verdict.
  • Rejected: (a) in-wrapper retry-once-at-2×cap — recovers the review (cheap via DeepSeek auto-cache) but redundant with the loop's outer next-round retry + adds cost/complexity; the raised cap + degrade handle the common case. (b) blanket-degrade any is_error subtype — would silently mask error_invalid_*/auth (regressions); the allowlist is conservative (rule 3). (c) a new degraded verdict enum value — cleaner semantically but ripples loop_state choices + build_run_record + every verdict enumeration (rule 5) for a non-verdict condition; the stdout flag + persisted fingerprint carry the same signal with no enum change. (d) carry the degrade signal only in non-persistent stdout — rejected: violates ADR #39 at the persistence layer (run-record would lie); the notes + gate-fail fingerprint are required. Sibling to #16 (outer-ring invariant — degrade still drives record-outer, satisfying the DoD guard), #38 (异源 axis — preserves its additive nature on substrate failure), #39 (bookkeeping honesty — degrade is persisted truthfully), #40 (异源 wrapper — this fixes its substrate-error handling). Live-substrate verified: forced --budget-usd 0.05 reproduces rc=1 + empty stderr + stdout envelope; the wrapper degrades (verdict=pass, degraded=true, coverage + fingerprint persisted).

42. The USD-denominated budget is a structural fiction for non-Anthropic backends — a runaway breaker, not an accounting instrument; steps/rounds are the trustworthy provider-independent bounds

  • Context: The loop's budget surface has two USD-denominated layers: (1) the per-异源-subprocess --max-budget-usd (enforced BY Claude Code, not the wrapper — ADR #41 parses the resulting error_max_budget_usd envelope); (2) the global cost_cap_C in loop_state, whose cost_used accumulates from explicit add-budget --cost <delta> deltas the orchestrator stamps (loop_state.py — a caller-stamped accumulator, NOT auto-read from any provider). Both are denominated in USD, and a maintainer reading "cost budget" can reasonably believe the figure reflects real spend. It does not, for any non-Anthropic backend — and the reason is structural, not a calibration quirk. The Anthropic-compatible Messages API surface returns token counts ONLY (usage.input_tokens / output_tokens / cache_*); there is NO price field, so no client can derive real cost from the response. Claude Code computes its reported USD from token usage against its internal price table; for a custom model name reached via ANTHROPIC_BASE_URL (e.g. glm-5.2, deepseek-chat) that table has no accurate entry, so the reported figure is structurally disconnected from what the provider actually charges — whether it reads as ~$0 (the breaker then never fires) or as an Anthropic-tier fallback (wrong by whatever ratio the provider's real price differs from Anthropic's). The existing partial acknowledgments framed this as a DeepSeek-specific magnitude/cache quirk — "CC total_cost_usd may over-report vs DeepSeek cache-aware billing" (model-routing.md, convergent-loop.md) — which understates it: the mismatch is not DeepSeek's cache math, it is that the API surface carries no price for ANY non-Anthropic provider, so the USD is fictional for the whole 异源 class. The same-source leg (Claude = Anthropic pricing) is the only place USD is real.
  • Decision: (a) State the role honestly (rule 3): the USD budget is a RUNAWAY BREAKER — catastrophe prevention (stop an infinite tool-loop / a hung subprocess from burning unbounded resources) — NOT an accounting instrument. Do not read cost_used / CC's total_cost_usd as real dollars for any 异源 leg, and do not deduce provider cost-comparisons from it. (b) The trustworthy bounds are provider-independent: step_cap_S (total work units, counted by the loop — ADR #13) and max_adversarial_rounds (异源 invocation count). These depend on nothing the provider reports; they are the first-class convergence/effort bounds. This extends the existing principle in maturity.md caveat 4 + ADR #13 (the step cap is the provider-independent limit; budget is a cost/hang guard, not a capability signal) explicitly to the budget UNIT. (c) Do not rely on --budget-usd as the sole per-subprocess bound: because CC's USD may read as ~$0 for an unknown custom model (breaker silently never fires), the reliable per-subprocess bound is CC's turn limit (it fires error_max_turns, already in the DEGRADABLE set — ADR #41), and the reliable global bound is step_cap_S. --budget-usd stays as a SECONDARY backstop for the case CC's price table DOES map the alias to a real Anthropic-tier price (some OpenAI-compat proxies bill by the alias), kept generous (default 4.0, ADR #41) so it never under-fires on a mispriced alias. Exposing an explicit --max-turns knob on the wrapper to make the turn bound tunable is a documented code follow-up, NOT part of this honesty fix. (d) Real cost accounting is out-of-band: to know actual spend on a 异源 leg, reconcile against the PROVIDER's own usage/billing API (e.g. the DeepSeek/GLM dashboard's input_cache_hit_tokens / billing total — ADR #40 (h)(i) already cites the DeepSeek dashboard for cache rates), NOT CC's reported USD. If the global cost_cap_C is to carry real meaning, the orchestrator's add-budget --cost deltas must be stamped from the provider dashboard, not from CC's fictional USD; otherwise leave cost_cap_C as an approximate runaway guard and rely on step_cap_S + CC's turn limit as the real caps.
  • Why: conflating "breaker" with "accounting" has two failure modes. (1) A breaker that silently never fires (USD≈$0 on a custom alias) defeats catastrophe prevention — the exact risk ADR #41's degrade handling + the turn cap contain, now made explicit for the budget axis. (2) Reading the USD as real yields wrong cost conclusions and wrong provider comparisons ("DeepSeek is cheaper" deduced from CC's fictional USD is meaningless). Naming the USD a fiction (rule 3 — never silently green) and pointing the real bound at steps/rounds keeps the breaker useful without letting it masquerade as a measurement. This SHARPENS, does not reverse, the existing design: maturity caveat 4 already demoted budget to "cost/hang guard only, not a capability signal" and made the step cap the provider-independent limit; ADR #41 already put error_max_turns in the degradable set. #42 makes the USD-itself-is-a-fiction point explicit and generalizes it from DeepSeek to all non-Anthropic backends — closing the gap where a reader could mistake a provider-specific cache quirk for the whole story.
  • Rejected: (a) drop --budget-usd entirely and rely on the turn limit + step_cap_S only — loses the backstop for the case CC's price table DOES map the alias to a real Anthropic-tier price, and breaks the ADR #41 envelope-parse path the degrade handler + hetero_review_wiring.py::check_budget_exhaustion_degrades exercise; keep it as a secondary backstop, honestly labeled. (b) build a per-provider price table into the wrapper so cost_used reflects real spend — correct in principle but high-maintenance (prices change; the provider's own dashboard is already the source of truth), redundant with out-of-band reconciliation, and out of proportion to what a breaker needs. (c) treat this as a code/behavior change (reordering caps, changing defaults, adding --max-turns) — deferred; #42 is a doc/honesty fix (rules 3, 6, 10) clarifying the role of an existing surface, and the code already treats steps/rounds as primary via ADR #13 + the degrade handler. Sibling to #13 (step cap is the provider-independent capability axis), #41 (substrate-error degrade — a budget firing is recoverable, not a defect in the work), #38 (异源 axis — a partial, non-orthogonal oracle), #40 (异源 wrapper — this clarifies its --budget-usd knob's real meaning).
  • Amendment (2026-08-21, ADR #52): the mismeasure is now MEASURED, not hypothetical — CC v2.1.238 prices UNRECOGNIZED models at premium fallback rates ($0.24 for ONE tiny turn, num_turns=1, duration_ms=4717, measured through the wrapper's own materialization path), so the old default 4.0 held only ~16 tiny turns of headroom, inverting the intended layering (the turn cap should bind runaways first, budget only as backstop). Default raised to 12.0; the "headroom under the global 5.0 cap" framing is retired (both figures are fictional for non-Anthropic legs — this entry's own thesis). Point (c)'s "reliable per-subprocess bound is CC's turn limit" was WRONG in practice: error_max_turns fires only when --max-turns is passed (print mode has NO default limit) and neither wrapper ever passed it — a phantom boundary. ADR #52 landed the knob (default 60) and closes this entry's documented --max-turns follow-up.

43. Timeout ⊥ model-tier selection — the cold-start timeout override is a per-call env (HETERO_TIMEOUT / HETERO_DOC_TIMEOUT), NOT a profile alias remap

  • Context: A 2026-07-08 external-project run hit --timeout's 600s default on a cold-start 异源 review — the opus-tier alias deepseek-v4-pro[1m] (1M context; the deepseek profile maps BOTH opus and sonnetdeepseek-v4-pro[1m]) was too slow for a cold multi-tool review over a ~1,018-line diff, returning a clean hetero-subprocess-timeout malformation (no findings). The wrapper has NO retry/fallback (run_claude returns the timeout fingerprint and stops — ADR #41's DEGRADABLE set is for CC stdout substrate errors, NOT for subprocess.TimeoutExpired), so the recovery observed — re-running on the lighter haiku/flash tier with a shorter timeout — was an ORCHESTRATOR improvisation (legitimate per model-routing.md "the orchestrator decides per item", but NOT deterministic; another run may handle it differently). The temptation: make this "just work" by remapping the profile alias (ANTHROPIC_DEFAULT_OPUS_MODELdeepseek-v4-flash) so the default tier never times out. That is a category error.
  • Decision: (a) Timeout and model-tier are orthogonal axes — separate knobs. The timeout override is a per-call/per-project env: HETERO_TIMEOUT (pd hetero_review.py) and HETERO_DOC_TIMEOUT (csr hetero_doc_review.py), mirroring the HETERO_PROFILE / HETERO_DOC_PROFILE convention — the --timeout default is now int(os.environ.get("HETERO_TIMEOUT" / "HETERO_DOC_TIMEOUT", "600")); the --timeout CLI flag still wins. An external project fixes the cap in .env once (durable, not orchestrator-improvised) WITHOUT touching model selection. (b) Cold-start is TRANSIENT — DeepSeek auto-caches (~99% hit rate, ADR #40 (h)(i)), so only the FIRST review in a run pays the cold penalty; subsequent reviews on the same prefix are warm and the pro tier is fast enough. Remapping the alias to dodge a one-time cold-start cost would PERMANENTLY degrade review depth on every warm call afterward. (c) For a known-cold large review, raise --timeout (e.g. 1200–1800s) to keep the pro tier, OR drop to --model haiku (→ deepseek-v4-flash) for that call — a per-call tier choice, not a global remap. model-routing.md gains the cold-large-diff guidance (the doc gap that left the run blind).
  • Why: conflating "which model" with "how long to wait" loses capability silently — the L1 constitution's "naming must reflect intent" + "no emergent coupling" applied to config. A profile is a MODEL-SELECTION artifact (a deliberate per-tier depth/speed/cost choice); a timeout is a WAIT-CAPACITY artifact (varies per call: cold vs warm, short vs long diff). Routing a timeout problem through model-selection config makes every later call shallower to fix one slow call, and hides the real signal (this call was cold/large). The env knob separates them cleanly and mirrors the HETERO_PROFILE precedent (rule 7). This SHARPENS the substrate, not changes it: the --timeout flag + 600s default are unchanged when the env is unset (existing behavior + tests untouched); the env only adds a default source.
  • Rejected: (a) remap ANTHROPIC_DEFAULT_OPUS_MODEL → flash in the profile to "fix" the timeout — the 邪道 the user named: permanently sacrifices depth on warm calls for a transient cold-start cost, and conflates two axes. (b) in-wrapper auto-fallback (timeout → retry on flash) — makes the wrapper non-deterministic (which tier actually reviewed? the run-record would lie about the model used — ADR #39 bookkeeping honesty) and masks the cold/large signal; orchestrator-layer adaptive tier choice is the right place (it is already the human-judged classifier per model-routing.md). (c) a dedicated test for the env default — HETERO_PROFILE (the mirrored precedent) has none either; the dry-run gates are timeout-immune and the default is unchanged when env unset, so the risk is a one-line int(os.environ.get(...)) typo caught by reading. (d) lower the 600s default — no; 600s is a reasonable cold cap for the common case, and the env + flag let large/cold reviews opt up. Sibling to #40 (substrate — timeout is a run_claude axis, not a CC substrate error), #41 (DEGRADABLE set — hetero-subprocess-timeout is a malformation, NOT degradable; the orchestrator handles it), #42 (budget fiction — a sibling "a figure is not what it naively means" honesty), #13 (step cap — the OTHER provider-independent bound, alongside the per-subprocess timeout).

44. LangGraph is NOT introduced to this workspace — no structural fit (CC IS the agent runtime; ADR #1 + the self-containment convention + ADR #40(h) stateless-default all weigh against); cross-source-review-converged

  • Context: The question was raised whether LangGraph (the graph-based LLM-orchestration framework — StateGraph + conditional edges + checkpointer/interrupt-resume + streaming, purpose-built for LLM agent orchestration via direct model calls) has a fit point in this workspace. This is an ARCHITECTURE question, not a feature request. It was answered by a cross-source-review run (3 rounds, 同源 doc-reviewer + 异源 DeepSeek, cap=4) on a 7-core-claim analysis; the convergence-record is substantive_converged=true, stalemate=false, rightness=human_confirm_required, core-claims-coverage 7/7, with rounds 2–3 each at 0 new blockers (round 1 found 2 blockers — both citation-errors, fixed). The 7 claims (C1–C7) and their verification: (C1) ALL project LLM calls occur INSIDE Claude Code — the Python infra makes NO direct LLM API calls (grep-verified across skills/*/infra/: no requests/urllib/httpx/aiohttp/anthropic/openai imports; the only anthropic/openai MENTIONS are env-var names + prose, never imports); (C2) converge.py is LLM-free (stdlib imports, no subprocess/network/LLM-client — independent of its self-claim docstring); (C3) the convergence control flow is a bounded for-loop over input rounds producing 2 predicates (substantive_converged, stalemate), with the multi-round loop at the orchestrator layer (SKILL.md) — NOT a graph; (C4) ADR #1 (stdlib-only) + rule 7 (self-containment): LangGraph cannot fit the conditional-import-with-graceful-skip pattern that jsonschema uses, because graceful-skip of an ORCHESTRATION RUNTIME = no feature (jsonschema-absent degrades to a no-op; LangGraph-absent = no orchestration); (C5) ADR #40 (h) made stateless-per-invocation the DEFAULT (continuity in the prompt), in tension with LangGraph's flagship checkpoint-resume; (C6) the ONLY structural fit is productization (extract the methodology out of CC), which is REJECTED — ADR #40 rejected (a) ("custom Agent SDK harness... only pays if productizing") + the proposal doc §"No Phase 4" ("stays rejected... do not pre-plan a harness"), and LangGraph is a fortiori more invasive than an Agent SDK harness — and per ADR #46 that "productization / extract-out-of-CC" scenario is itself NON-VIABLE for this product form, so C6's premise is doubly moot; (C7) the proportionate response to the one genuine gap (long-loop resume) is a separate findings-checkpoint file + resume flag, not a framework.
  • Decision: DO NOT introduce LangGraph. The workspace's agent runtime IS Claude Code (interactive CC + claude -p subprocesses); LangGraph would be a COMPETING second runtime whose core value (direct-LLM-orchestration graph) has no surface to bite on (there are no direct LLM calls to graph — they are opaque inside CC). It collides with ADR #1 (the conditional-import bar, which it fails categorically), the self-containment convention (rule 7), and ADR #40 (h) (stateless-default). Outcome-axis (whether adopting LangGraph is the right PRODUCT move) stays HUMAN-ONLY — rightness=human_confirm_required — this ADR records the PROCESS-AXIS conclusion (no structural fit) only.
  • Why: introducing a framework should solve a real structural problem, not decorate an existing one. Each LangGraph value-prop maps to nothing here: StateGraph/conditional-edges → the loop is already a bounded for-loop + 2 predicates; checkpointer/resume → ADR #40 (h) deliberately chose stateless + prompt-carried continuity, and the gap is closable with a checkpoint file; streaming/native-tool-calling → the LLM calls are inside CC, invisible to an outer framework. The cross-source review CONVERGED this (substantive-converged, 7/7 claims coverage-verified against source, rounds 2–3 zero blockers); the 异源 leg (DeepSeek, a different model family than the orchestrator) independently sharpened the argument (the jsonschema conditional-import nuance, the proposal-doc "No Phase 4" strengthening C6, the ADR-#1 "in practice" framing being inferred-not-sourced). The single genuine gap (long-loop resume) does not justify a heavyweight framework when a findings-checkpoint file suffices.
  • Rejected: (a) introduce LangGraph to orchestrate the convergence loop — the loop is a bounded for-loop, not a graph; a graph framework adds structure the control flow does not need (C3). (b) introduce LangGraph for checkpoint/resume of long loops — proportionate response is a separate findings-checkpoint file + resume flag (the convergence-record carries per-round COUNTS only, so resume needs its own findings file either way; C7); ADR #40 (h) made stateless the default, partly intentionally. (c) introduce LangGraph via the conditional-import-with-graceful-skip pattern (the jsonschema precedent) — cannot: graceful-skip of an orchestration runtime IS the runtime being absent, which collapses to "don't use it" (C4). (d) defer LangGraph to a productization Phase 4 — that path is REJECTED, not deferred (ADR #40 rejected (a) + proposal doc §"No Phase 4"), and LangGraph is MORE invasive than the already-rejected Agent SDK harness, so a fortiori rejected (C6). ADR #46 retires this further: the standalone-form Phase 4 does not exist for a plugin product, so there is no future point at which this reopens. Sibling to #1 (stdlib-only — the conditional-import bar LangGraph fails), #40 (CC IS the agent runtime — LangGraph would be a competing second runtime), #40 (h) (stateless-default — tension with checkpoint-resume), #46 (plugin is the only viable product form — retires the "Phase 4 = SDK/MA" fork this ADR's C6 / Rejected (d) leaned on), #45 (the substrate fixes that made the 异源 leg of this very review runnable under CC v2.1.207). Authority: the cross-source-review convergence-record (workspace/cross-source-review/convergence-record.json); the converged artifact (workspace/cross-source-review/langgraph-adoption-analysis.md).

45. CC v2.1.207 --json-schema substrate regressions — strip $schema at the CC boundary + auto-fallback on structured-output-retry exhaustion (adapt-at-the-edge, do NOT mutilate the committed schema; defensive parse as the Morph-caveat floor)

  • Context: A CC upgrade v2.1.201 → v2.1.207 broke the 异源 substrate (csr hetero_doc_review.py; pd hetero_review.py has the SAME surface) in TWO ways, both surfaced live during the cross-source-review of ADR #44. (1) CC's --json-schema validator now bundles ONLY Draft-07 in its draft registry — it REJECTS schemas declaring Draft 2019-09 / 2020-12, treating the $schema marker as an unresolvable ref: error: --json-schema is not a valid JSON Schema: no schema with key or ref "https://json-schema.org/draft/2020-12/schema". The committed doc-findings.schema.json / violation-log.schema.json / convergence-record.schema.json are all Draft 2020-12 (they use $defs). Probe matrix (CC v2.1.207, minimal schema with $defs + $ref): Draft 2020-12 / 2019-09 / Draft-06 $schema → REJECT; Draft-07 $schema → ACCEPT; NO $schema → ACCEPT; the real doc-findings schema minus $schema → ACCEPT. (2) Even with the schema accepted, a heterogeneous backend (DeepSeek) exhausts CC's structured-output retries → error_max_structured_output_retries (the ADR #40 Morph caveat: non-Claude backends carry structured-output/tool-call reliability risk). This subtype is NOT in ADR #41's DEGRADABLE_CC_SUBTYPES, so the wrapper malformationed it → spurious verdict=rewrite (the artifact under review was fine; the substrate failed).
  • Decision: (a) $schema strip shim at the CC boundary — csr hetero_doc_review.py adds _strip_schema_marker_for_cc(schema_json), applied where the schema is prepared for the --json-schema argv. The COMMITTED schema stays Draft 2020-12 (correct; $defs is 2019-09+): the OTHER consumer, converge.py, uses an EXPLICIT jsonschema.Draft202012Validator(schema) (not auto-detect from $schema), so stripping the marker does not affect it. The shim serves ONLY the CC --json-schema consumer. Backward-compat: $schema is an OPTIONAL field — a validator that accepted a schema WITH it (CC v2.1.201 per the FLAG-SURFACE MANIFEST) accepts one WITHOUT (v2.1.207); so the shim is safe across both. (b) Auto-fallback on structured-output-retry exhaustionrun_claude now wraps a single-spawn _run_claude_once: if the result is the exact STRUCTURED_OUTPUT_RETRY_FP (hetero-cc-error:error_max_structured_output_retries) AND argv contains --json-schema, retry ONCE without --json-schema (the live-substrate defensive-parse path — _extract_json_object handles fenced/preamble-prose JSON; _validate_findings_shape still validates) and stamp fell_back_to_unstructured=True on the result, which main() surfaces in the coverage trail (rule 3 — never silent). Backward-compat: compliant backends never hit the retry (strict --json-schema first, unchanged); the retry is gated on --json-schema in argv (a non-schema caller is unaffected) AND the EXACT fingerprint (a different malformation never retries — never mask a regression). (c) The FLAG-SURFACE MANIFEST is re-probed at v2.1.207 with both drifts documented inline.
  • Why: ADAPT-AT-THE-EDGE (the wrapper), not MUTILATE-THE-SOURCE (the committed schema). The schema is consumed by TWO validators — CC's --json-schema (regressed at 2.1.207) and Python jsonschema (explicit validator class, fine); the $schema shim serves only the regressed consumer, leaving the source-of-truth schema correct for the other and for any future spec-strict consumer. The auto-fallback is the principled answer to the Morph caveat AT THE SUBSTRATE LEVEL: --json-schema is a strictness BOOSTER, not a necessity (the wrapper's defensive parse already handles fenced/preamble JSON per the documented live-substrate caveat, lines ~640-645); a heterogeneous backend that cannot satisfy CC's strict retries falls back to that path and STILL yields shape-validated findings, rather than malformationing and forcing a spurious rewrite. This RECOVERS 异源 findings (the live review got 3 real DeepSeek findings via the fallback) where DEGRADE alone would have surrendered them. ADR #41's DEGRADE handling is UNCHANGED (degradable subtypes still degrade; the fallback is an ADDITIVE recovery for one specific non-degradable subtype, with degrade/malform as the floor if the fallback ALSO fails).
  • Rejected: (a) strip $schema from the committed schema FILES — loses the Draft 2020-12 declaration for ALL consumers (incl. any future spec-strict one), ripples across 3 schemas, and touches pd (out of csr's files_touched boundary); the wrapper shim serves only the CC consumer and leaves the source correct. (b) change $schema to the Draft-07 URI — declares Draft-07 for a schema that uses $defs (a 2019-09+ feature), a semantic lie (CC's validator does not enforce draft features — the probe kept $defs under a Draft-07 $schema and ACCEPTED — but the lie is needless when the strip shim is cleaner). (c) ADD error_max_structured_output_retries to DEGRADABLE_CC_SUBTYPES (degrade) — surrenders 异源 findings on every flaky-backend round (the 异源 leg contributes nothing); the auto-fallback RECOVERS them via defensive parse, strictly better when it works, with degrade as the floor. (d) DROP --json-schema entirely (default to defensive parse) — loses strict structured-output enforcement for COMPLIANT backends (Claude, and DeepSeek on a good round — round 3 held --json-schema with no fallback); the auto-fallback keeps strict-first, fallback-only-when-needed. (e) a runtime capability-probe (try schema, on-reject strip-and-retry at call time) — needless latency/cost when proactive strip is unconditionally safe across the observed CC versions. Sibling to #40 (substrate — this fixes its --json-schema surface at a CC drift), #41 (DEGRADE — UNCHANGED; the fallback is additive recovery, not a new degrade path), #43 (timeout — another per-call substrate adaptation; both are "the wrapper adapts a CC surface, the committed config stays canonical"). FOLLOW-UP (out of this change's scope): port BOTH fixes (the $schema shim + the structured-output-retry auto-fallback) to pd's hetero_review.py — it shares the identical surface and the identical Draft-2020-12 violation-log.schema.json, so it has the SAME two drifts under CC v2.1.207; csr's files_touched boundary prevented editing pd here. Verified: the csr 异源 leg ran end-to-end (3 rounds, real DeepSeek findings rounds 1+2 via the fallback, round 3 held --json-schema); csr self-gates green (findings_shape_check, convergence_policy_check, lint_self); ruff green under the per-skill ruff.toml.

46. Plugin is the ONLY viable product form → the claude -p subprocess substrate is the TERMINAL state, not a Phase-4 fork (standalone-app / Managed-Agents migration rejected; retires the "Phase 4 = SDK/MA" framing in #44)

  • Context: The question arose whether productization should migrate the 异源/convergence substrate off the claude -p subprocess to either (a) the Claude Agent SDK (a standalone app embedding CC) or (b) Managed Agents (hosted SaaS). The SDK overview (code.claude.com/docs/en/agent-sdk/overview) frames it as "Claude Code as a library" (TS SDK bundles a native CC binary); the Python SDK reference confirms query() spawns "the Claude process" as a LOCAL subprocess (an optional custom channel can replace the local subprocess) — i.e. the Python SDK is itself a subprocess manager with a typed wrapper + stdio-JSONL protocol, the SAME architectural pattern as our hetero_*review.py. BUT the product form is a Claude Code plugin (skills/*/plugin.json; solidforge:cross-source-review). A plugin runs INSIDE the user's already-installed Claude Code; plugin form and Agent SDK are DIFFERENT DISTRIBUTION MODELS, not layers (the SDK is for standalone apps that must embed CC because there is no host; a plugin already has the host). Two constraints make the alternatives non-viable for THIS product: (1) a standalone-app form would require reimplementing CC's upper layer — skill progressive-disclosure, PostToolUse hooks, agent orchestration, the deterministic-gate system, the session model — i.e. rewriting a CC agent runtime, which is exactly ADR #40 rejected (a) ("strands the 50+ skill files; adds harness maintenance"); the product's value IS the CC-embedded skill/hook/agent/gate stack, so extracting it strands the value. (2) Managed Agents has TWO compounding mismatches: (a) every integration point must be remapped (CC-filesystem skills → MA custom skills; project files → per-session resource mounts shipped over the wire; CC hooks → MA permission_policy); AND (b) the deeper orchestration-model mismatch — the convergence methodology is designed around "the orchestrator = an interactive CC reading SKILL.md, wielding hooks/agents/gates in-process," whereas MA runs the agent loop server-side driven by events. Critically, the deterministic gates (fast_gate, arch-contract gate) are CC PostToolUse hooks with NO MA equivalent — MA's permission_policy is per-tool always_allow/always_ask, which CANNOT "run this checker after every tool use and block on failure"; so porting to MA is not "ship context over" but "redesign the methodology around a different orchestration model." The only extractable CC-free piece is converge.py (pure stdlib), but its 同源/异源 legs still need CC, so it is at best "a library assuming CC present," which collapses back to the plugin form — no true standalone exists.
  • Decision: Plugin is the ONLY viable product form for this workspace. The claude -p subprocess substrate (ADR #45) is the TERMINAL state, not a "Phase-4 fork." ADR #1 (stdlib-only) + rule 7 (self-containment) govern INDEFINITELY. Retire the "Phase 4 = migrate to SDK / Managed Agents" framing in ADR #44 C6 / Rejected (d) (and the productization premise in ADR #40 rejected (a)): the standalone-form / hosted-form path does not open for a plugin product whose value is the CC-embedded stack — the Agent SDK and Managed Agents are not deferred alternatives, they are NON-APPLICABLE to this product form. The Agent SDK becomes relevant ONLY if the product direction pivots away from "a Claude Code plugin" to "a standalone app / hosted service," which would require abandoning the CC-embedded skill/hook/agent/gate system that IS the product — that is a different product, not a migration.
  • Why: the substrate choice is gated on DISTRIBUTION FORM, not on "productizing." Three mutually-exclusive forms: plugin (current + only viable — runs in host CC; substrate = subprocess to host claude; ADR #1/rule 7 govern); standalone app (pip install brings the CC engine via the SDK — TS bundles a native binary, Python spawns "the Claude process"; ADR #1 does not apply as it is a product not a skill infra script — non-viable here, strands the stack); Managed Agents (Anthropic runs engine + sandbox; no SDK, no plugin — non-viable here, gate has no equivalent + orchestration-model mismatch). Because the Python SDK ITSELF spawns a claude subprocess, even the standalone-form "switch to SDK" would NOT eliminate the subprocess — it would replace our hand-rolled subprocess.run with the SDK's managed subprocess + typed wrapper; the CC-engine drift risk (e.g. the v2.1.207 --json-schema regression, ADR #45) MOVES (to the SDK's Python API + stdio-JSONL protocol surface) but does not vanish, so the SDK offers no drift-immunity advantage even in its own form. Recording this prevents a future maintainer re-deriving "should we migrate to the SDK/MA when we productize?" every time the CC flag surface drifts — the answer is fixed: no, unless the product stops being a plugin.
  • Rejected: (a) migrate the 异源/convergence substrate to the Claude Agent SDK at productization — only applies to the standalone-app form, which is non-viable (would strand the CC-embedded skill/hook/agent/gate stack; ADR #40 rejected (a)); and the Python SDK spawns the same claude subprocess we already spawn, so it buys a typed wrapper, not a different architecture. (b) migrate to Managed Agents — non-viable: the deterministic gates (CC PostToolUse hooks) have no MA equivalent (MA permission_policy is per-tool, cannot run-a-checker-and-block), and the orchestrator-driven methodology assumes CC-as-orchestrator (MA runs the loop server-side); porting = redesigning the methodology, not shipping context. (c) keep "Phase 4 = SDK/MA" as a deferred option — rejected: it implies the fork might open at productization, but for a plugin product it never opens; "deferred" invites repeated re-derivation, this ADR closes it. (d) extract converge.py as a standalone pip library — the one CC-free piece, but its 同源/异源 legs still need CC, so it is "a library assuming CC present," collapsing back to plugin form; not a true standalone. Sibling to #1 (stdlib-only — governs the plugin substrate indefinitely), rule 7 (self-containment — same), #40 (CC IS the agent runtime; rejected (a) — this sharpens "stays rejected" from "SWOT + scale" to "product-form constraint"), #44 (LangGraph — its C6 / Rejected (d) "Phase 4" framing is retired here), #45 (substrate — confirmed terminal, not transitional). Corrects the "Phase 4 = SDK/MA" implication in #44 C6 / Rejected (d).

47. Token namespace isolation — <FILENAME>_ANTHROPIC_AUTH_TOKEN is the SOLE token source; the provider's native <FILENAME>_API_KEY is NEVER read (a native-_API_KEY fallback was reverted — isolation prevents credential collision)

  • Context: An external project (fedaot-kb) ran /solidforge:cross-source-review review plan. Its .env carried DEEPSEEK_API_KEY (the universal DeepSeek-native SDK var) and its .env.solidforge carried DEEPSEEK_ANTHROPIC_AUTH_TOKEN (this substrate's convention var) — the same sk-... secret under two names, split across two files. A first-pass diagnosis read this as "naming friction" and proposed + implemented a wrapper fallback: consult the provider's native <FILENAME>_API_KEY (e.g. DEEPSEEK_API_KEY) when the convention var is absent, since DeepSeek's Anthropic-compatible endpoint (api.deepseek.com/anthropic) reuses the native key. That fallback was REVERTED on the user's design steer: the <PROFILE_NAME>_ANTHROPIC_AUTH_TOKEN convention was chosen PRECISELY to namespace the credential to this substrate's Anthropic-gateway use, so it CANNOT collide with the provider's native <FILENAME>_API_KEY — which may be set in the same environment for a DIFFERENT tool/SDK (e.g. the project's own native DeepSeek integration), possibly bound to a different key/quota/rate-limit. Reading the native var would risk picking up a credential meant for another purpose. Separately, the csr orchestrator had GUESSED the wrong var name (DEEPSEEK_API_KEY) because SKILL.md did not inline the real one, and pd's --profile help text was stale (it named DEEPSEEK_API_KEY).
  • Decision: (a) The convention var <UPPERCASE-FILENAME>_ANTHROPIC_AUTH_TOKEN is the SOLE token source for the hetero substrate (pd hetero_review.py + csr hetero_doc_review.py — rule 7 shared surface). The provider's native <FILENAME>_API_KEY (e.g. DEEPSEEK_API_KEY, DASHSCOPE_API_KEY, ZAI_API_KEY) is NEVER read, for ANY provider. An explicit _token_env override remains the only way to point at a non-convention var (and it pins that single var — no fallback chain). (b) Document the invariant at every enumeration of the token-var convention (rule 5): both wrappers' --profile help text + module docstrings; csr SKILL.md Quick Start (inline DEEPSEEK_ANTHROPIC_AUTH_TOKEN so the orchestrator checks the right var, with an explicit "do NOT look for or set DEEPSEEK_API_KEY"); csr install.md; pd model-routing.md + install.md; and the .env.solidforge.example template. (c) No code change to token resolution — the wrappers already read ONLY the convention var; this ADR records that as a DELIBERATE invariant (not an oversight to "fix" with a fallback) and fixes the stale/muddled help text + the SKILL.md var-name gap that caused the orchestrator's guess. (d) Pre-existing gap fixed in passing: .env.solidforge.example was missing the MINIMAX_ANTHROPIC_AUTH_TOKEN placeholder (a committed minimax.json profile had no example line) — added.
  • Why: namespace isolation is a security/correctness invariant, not convenience. The _ANTHROPIC_AUTH_TOKEN suffix scopes a credential to "the key for THIS substrate's Anthropic-compatible gateway call." A project environment is SHARED: the same shell/.env may carry DEEPSEEK_API_KEY for the project's OWN native DeepSeek SDK use (a different code path, possibly a different key, a different quota bucket, a different rate-limit). A fallback that silently read DEEPSEEK_API_KEY would (1) bind this substrate to a credential possibly intended for another purpose, (2) make the substrate's behavior depend on an env var OUTSIDE its namespace (emergent coupling — L1 constitution), and (3) mask the real "you have not armed the substrate" signal (the convention var being absent) by silently substituting. The "smell" of one project carrying both DEEPSEEK_API_KEY and DEEPSEEK_ANTHROPIC_AUTH_TOKEN is in fact the invariant WORKING AS DESIGNED — two credentials for two purposes, even when they happen to hold the same secret value. The convention var is ALREADY config-independent and zero-ceremony for new profiles (filename-derived: drop profiles/foo.json + set FOO_ANTHROPIC_AUTH_TOKEN), so the "easy extension" goal needs NO native fallback. The real bugs were documentation: the orchestrator guessed the var name, and pd's help text named the wrong one — both fixed by inlining the correct name + the invariant.
  • Rejected: (a) native <FILENAME>_API_KEY fallback (the reverted change) — breaks namespace isolation; risks reading a credential meant for another tool/SDK; masks the "not armed" signal; introduces emergent coupling on an out-of-namespace var. The convenience (user need not set the convention var if the native one is present) does NOT outweigh the isolation loss, because the two vars are DIFFERENT credentials by intent even when coincidentally equal. (b) per-profile _token_env_aliases list (a config-declared set of native vars to try) — still config-dependent and still breaks isolation for the same reason; rejected on BOTH the user's "config-independent rule" steer AND the isolation steer. (c) rename profiles to match each provider's native SDK prefix (e.g. dashscope.json, zhipu.json) so the convention var itself equals the native name — conflates "profile name" (a model-routing handle referenced by HETERO_*_PROFILE) with "SDK var prefix," breaks existing HETERO_*_PROFILE=deepseek/qwen3/... references, and still would not isolate (the substrate would then read the native var, same problem as (a)). (d) doc-only with NO invariant recorded — leaves a future maintainer (or the same agent) free to re-propose the fallback; this ADR exists to make the rejection re-derivable. Sibling to #40 (substrate — the token-injection pattern this sharpens from "convention" to "invariant"), #43 (per-call env precedent — HETERO_*_PROFILE/HETERO_*_TIMEOUT are also namespaced, in-namespace knobs), rule 7 (the invariant applies identically to pd + csr). Verified: both wrappers' token resolution is unchanged (convention var only — ruff green, --profile help + module docstrings updated, no _resolve_token_fallback_var in either file); the convention-var-only behavior is the pre-existing, now-documented invariant.

48. dotenv MUST load before argparse captures env-derived defaults — _load_dotenv() runs at the TOP of main(), else .env-only HETERO_*_PROFILE / HETERO_*_TIMEOUT are invisible and --profile silently falls back to the hardcoded "deepseek" (drops every other configured provider)

  • Context: Both hetero wrappers (pd hetero_review.py + csr hetero_doc_review.py — rule 7 shared surface) select the provider via --profile, whose argparse default was os.environ.get("HETERO_PROFILE" / "HETERO_DOC_PROFILE", "deepseek"), and the timeout via --timeout default int(os.environ.get("HETERO_TIMEOUT" / "HETERO_DOC_TIMEOUT", "600")) (ADR #43). Python's argparse evaluates default= EAGERLY at add_argument time. _load_dotenv() — which populates os.environ from <project>/.env.solidforge then .env (shell wins via setdefault; the DOCUMENTED home for these vars per install.md) — was called AFTER parse_args. So a HETERO_DOC_PROFILE set ONLY in .env (not shell-exported) was not yet in os.environ when the default was captured → args.profile fell back to the hardcoded "deepseek" → only deepseek ran, every other configured provider (e.g. HETERO_DOC_PROFILE=deepseek,minimax → minimax) was SILENTLY dropped. Reproduced: .env-only =minimax,deepseek ran ['deepseek']; shell export ran ['minimax','deepseek']. The --timeout / HETERO_*_TIMEOUT axis had the identical bug (.env cap invisible → 600s fallback). This is the "hard-bound to deepseek" symptom an external project observed — NOT a positional/list-parsing bug (the provider loop was always correct; the hardcoded fallback simply won).
  • Decision: _load_dotenv() now runs as the FIRST statement of main(), BEFORE ap = argparse.ArgumentParser(...) (and thus before any add_argument(default=os.environ.get(...)) captures its default). One-line relocation per wrapper; fixes both --profile and --timeout (and any future env-derived default). Shell still wins (setdefault); the CLI --profile / --timeout flags still win over both. .env is now a first-class source for these knobs, matching install.md.
  • Why: the resolution order must be CLI flag > env var (shell OR .env) > hardcoded fallback. Loading dotenv AFTER parse_args makes .env — the documented, durable, per-project config home (install.md; ADR #43 calls these "per-project env") — invisible to the very defaults that read it, so the hardcoded "deepseek" fallback masquerades as the user's choice. Loading dotenv at main()-top is safe (setdefault = shell wins; best-effort; no dependency on args) and is the minimal fix that covers the WHOLE CLASS of env-derived defaults at once. Because this is a correctness invariant for the copy-patterned substrate (rule 7: pd + csr identical), BOTH wrappers were fixed together so the bug cannot survive in one and not the other.
  • Rejected: (a) resolve the default AFTER _load_dotenv with a sentinel (default=None, then if args.profile is None: args.profile = os.environ.get(...)) — correct but touches each env-derived arg individually and obscures the general rule; the dotenv-first relocation fixes the class of bug, not just --profile/--timeout. (b) move _load_dotenv to MODULE top (import-time) — would mutate os.environ on every import (e.g. the test harness importing the module), a side-effect smell; main()-top scopes it to actual invocations. (c) document "export HETERO_*_PROFILE in the shell" as the workaround — contradicts install.md (which documents .env) and pushes a per-shell ceremony onto every project. Sibling to #40 (the substrate), #43 (the HETERO_*_PROFILE / HETERO_*_TIMEOUT per-call env knobs this fixes), #47 (namespace isolation — same shared wrapper surface), rule 7 (pd + csr fixed together). Verified: .env-only =minimax,deepseek now runs ['minimax','deepseek'] and =qwen3 runs ['qwen3'] (no deepseek fallback) in BOTH wrappers; csr + pd self-gates green; ruff clean.

49. Placeholder arch-configs are neutral-by-default + auto-detected root + layering human opt-in + gate 0-active detect/skip/note

  • Context: arm.py copied arch-config templates verbatim (shutil.copy2). 3 of 6 templates carried hardcoded example layering (.importlinter.ini root_package=app + 4 layers; .dependency-cruiser.cjs src/core→src/ui|server; .swiftlint.yml included:[Sources,Tests] + custom_rules). For any project not matching that exact structure, a freshly-armed gate was RED/ERROR (root_package=app can't resolve) or MISLEADING (the Python gate unconditionally emits "contracts checked" even when 0 contracts are active). The other 3 templates (.golangci.yml already neutral; checkstyle.xml generic lint; clippy.toml commented thresholds) are project-agnostic — no problem.
  • Decision: (a) 3 templates neutralized — layer/boundary contracts COMMENTED OUT (gate green by default, 0 active); universal structural rules KEPT (.dependency-cruiser.cjs no-circular). (b) arm.py auto-detects the Python root package (pyproject [project] name / src-layout / flat-layout) and substitutes it into .importlinter.ini's root_package = __REPLACE_ME__ token; detection failure → __REPLACE_ME__ literal (the full-line verify-me comment, NOT inline — configparser rejects inline comments). (c) The Python gate's check_import_linter counts active contracts (format-aware: INI [importlinter:contract:] vs TOML [[tool.importlinter.contracts]], skipping commented lines); 0 active → SKIP lint-imports + emit a DISTINCT note "0 active contracts — layering NOT enforced; uncomment to opt in" (NOT the blanket "checked" — rule 3). (d) arm prints a per-config advisory for the 3 placeholder configs. (e) --revert's matches-template check is widened to ignore the substituted root token (a 1-token delta = still template/removable). Layer decomposition stays a human opt-in (arm cannot invent the architecture).
  • Why: a freshly-armed project must have a GREEN + HONEST gate (not red, not misleading). "Green by default + human opts in by uncommenting" is the honest contract for a template scaffold: the gate enforces nothing until the human declares their layers, then it enforces exactly those. The gate's 0-active distinct note prevents the "checked" misrepresentation (silent-green) the old unconditional note produced. Auto-detecting root_package removes the most common manual-edit friction. This was csr-converged (2 rounds, same+異源, 11 findings adopted) + bc plan-reviewed.
  • Rejected: (a) ship active example layering + "human must fix a red gate" — poor UX, the user arms and immediately hits red; (b) auto-detect the user's layer decomposition — impossible by construction (it's their architecture); (c) leave the gate's "checked" note for 0-active — silent-green (rule 3 violation, csr r1 blocker B2); (d) inline # verify me comment on root_package — configparser reads it as part of the value (csr r1 异源 blocker); (e) substitute .dependency-cruiser.cjs — after commenting the layer example, no project-specific token remains (bc plan-reviewer finding).

50. Fast-gate format remediation is commit-stratified (Option C) — format stays a Blocker, its diff churn isolates into a standalone style: commit; lint keeps fix-in-ring; full-pipeline-converged

  • Context: format-as-Blocker had a real MR/PR cost — touching a legacy-unformatted file made the fast-gate's remediation (inline-fix) rewrite the whole file, so a one-line logical change became a whitespace-dominated diff a third-party reviewer cannot adjudicate. The design (docs/fast-gate-format-advisory-design.md) went through the full workspace pipeline before implementation: psv gate GO (external claim C5 NARROWED — g-j-f DOES have --lines/--offset/google-java-format-diff.py; fixed pre-csr), csr substantive_converged (same-family ×4 rounds, R3+R4 blocker-free; the different-family leg was substrate-deferred — 3 DeepSeek cold-start timeouts — so the convergence is single-leg, honestly labeled), psv full-M authoritative N=10/R=0/W=0/K=0 (including the initially-narrowed C6 ratchetFrom, upgraded by fetching the plugin-gradle #ratchet hop), bc process_converged (6 outer warnings fixed: hooks-reference.md enumeration gap, NO behavioral fast-gate test existed, C-pre/C-post authorization conflation, inline-mode blind spot, Option-A coverage-gap clause, /tmp-gap item-level trace).
  • Decision: (a) Option C — commit-stratified format. Format STAYS a Blocker; only the REMEDIATION splits (fast_gate.py guidance): format failures (ruff format/google-java-format/gofmt/rustfmt) → stratification guidance (C-pre: run the formatter, commit the pure-format change as a standalone style: commit, redo the logic edit — do NOT inline-rewrite into the logic diff); lint failures (ruff check/eslint/swift-format) → fix-in-ring, unchanged. Detection/breaker/fingerprints untouched. (b) Determinism split, stated honestly: detection is deterministic (PostToolUse formatter-diff); the stratify step is model/orchestrator-executed and heuristic — never claimed a deterministic gate (rules 3/4). (c) Commit authorization is per-mechanism, NOT a rule-9 override: auto-per-stage (plan-driven-mode.md §Commit policy) already overrides "do not auto-commit" for the skill's runs; C-post splits that one per-stage commit into two at the same point; C-pre adds a NEW mid-stage style: commit point before the logic edit — an additional auto-per-stage extension, still not a rule-9 override. (d) C-pre canonical, C-post fallback (post-hoc hunk-separation is the more heuristic of the two). (e) C-wholesale default; C-range deferred (g-j-f range tooling + Spotless ratchetFrom documented as pointers in commit-stratification.md; wire only if wholesale proves insufficient). (f) Option A (advisory demotion at the convergence adapter) is a documented FALLBACK, not implemented — under A, Java/Go/Rust (format-only fast-gate checks) would lose their ONLY fast-gate check: a coverage-note gap, never silently greened (maturity caveat 14). (g) Plan deviation recorded: fg-2's link anchor fail-fast.md was corrected to convergent-loop.md (fail-fast.md is the Playwright Fail-Fast Reporter doc — wrong anchor, L1 emergent-coupling concern; the convergent-loop link lands via fg-4's own DoD). (h) The known fast-gate /tmp lint gap (dogfood gap #4) stays OPEN — orthogonal, not bundled into this change.
  • Why: lint failures are code fixes carrying real defect signal (undefined names, unused imports) and are NOT diff noise — they keep the early per-edit Blocker with fix-in-ring. Format is a stylistic non-violation whose Blocker enforcement imposed its cost on the wrong surface (the human MR/PR review). Demotion (A) fixes the severity mismatch but not the review problem — advisory is reported-not-enforced, so the code may stay unformatted or the model's fix re-injects churn into the logic diff. Stratification keeps both: format still Blocks (hygiene) AND the churn is isolated into a skippable style: commit (clean logic diff). Option B (a hook-helper emit_advisory) is structurally infeasible: PostToolUse's decision vocabulary is block/allow with no platform warn-level severity (psv-verified C7) — a helper cannot manufacture a severity the platform would interpret.
  • Rejected: (a) Option A as default — does not cleanly solve the MR-review problem (reported-not-enforced); kept as documented fallback with its coverage gap named. (b) Option B (hook-helper advisory emit) — precluded by C7; a platform-level warn tier is out of scope for a skill. (c) Auto-format-on-touch (PreToolUse formatter in-place) — the same whole-file rewrite lands in the working diff (arguably worse) and silently mutates files; Option C's isolated commit is this idea done right. (d) Drop format entirely — loses convergence hygiene (drift accumulates unchecked). (e) Scope-limited format as default — the real industry fix but disproportionate per-language range machinery for this skill's scope; folded into C-range and deferred. Sibling to: ADR #39 (inline mode — stratification's protocol covers the inline path explicitly: the orchestrator itself executes C-pre mid-loop, bookkeeping still driven), ADR #16 (the DoD outer ring gated each queue item's completion), ADR #13/#41/#43 (the substrate honesty lineage this run also exercised), rules 4/5/6 (heuristic-not-Blocker labeling; the enumeration audit that caught hooks-reference.md; this very ADR). Authority: docs/fast-gate-format-advisory-design.md (+ its .convergence/.psv-gate/.psv-full records), docs/fast-gate-format-advisory.plan.md + frozen queue (10 items), outer findings docs/fast-gate-format-advisory.outer-findings.json.

51. auto-per-stage commits aggregate by LOGICAL CHANGE (not per queue item); commit messages carry business content, not loop metadata

  • Context: 2026-08-13, the fast-gate format-stratification landing (ADR #50's plan, 10 queue items) was committed as 10 git commits whose later messages were one template ("Consolidated outer ring: pass, item ok. DoD per frozen plan §3.") — one logical change became commit spam, and the messages carried process metadata (item_id, outer-ring verdicts, DoD refs) already recorded in the run-record. The user called it: git history is a change log for humans; queue-item staging is convergence-loop plumbing.
  • Decision: auto-per-stage's per-stage commit aggregates by LOGICAL CHANGE (rule 9's substance over the policy's former letter): a plan's items usually batch into ONE commit (code+docs+records, or code / records as two). Queue-item completion state lives ONLY in the run-record. Commit messages carry business content (problem → change → tradeoffs → docs); loop metadata stays out. Policy text updated in plan-driven-mode.md §Commit policy; rule 9 sharpened in CLAUDE.md.
  • Why: git history and the run-record answer different questions — "how did the codebase evolve" (human reviewer) vs "how did the convergence run go" (auditor). Duplicating the second into the first harms the first's primary reader while adding nothing (the run-record already audits per-item). Per-item commits were defended as bisect granularity, but this plan's items were doc-audit-scale edits that never bisect independently.
  • Rejected: (a) keep per-queue-item commits for bisectability — doc-audit-scale items don't bisect independently, and 10-commit noise for one change actively harms review (this landing was the counterexample). (b) fix only the message template (drop item_id) but keep per-item commits — still 10 commits for one logical change; granularity is the bigger half of the harm. (c) keep the lesson in agent memory without touching the policy text — the next pd run reads the policy doc, not my memory (rule 8: reachable at the decision point). Sibling to: rule 9 (one coherent commit per logical change — this sharpens its substance), ADR #16 (DoD outer ring — unaffected: per-item review still happens; only the COMMIT cadence changes), ADR #50 (the landing that surfaced this).

52. The hetero substrate runs BOUNDED + OBSERVABLE — explicit --max-turns (print mode has NO default turn limit; the old "CC's turn limit" citation was a phantom), DEFAULT stream-json read incrementally (stderr heartbeat + resolved-model capture + --max-stream-bytes runaway breaker + stderr tail), budget demoted to coarse breaker (default 12.0), --no-stream json fallback — CC v2.1.238 re-probe recorded

  • Context: 2026-08-21, a third-party csr invocation burned the FULL HETERO_DOC_TIMEOUT=1200 on the deepseek leg with 0 bytes externally visible; the operator's socket forensics showed a live ~15KB/s stream accumulating 3MB+, and a same-task minimax control run completed normally (~7 min, 6KB findings) — root-cause locus = provider×task (deepseek-v4-pro runaway output), NOT the wrapper chain. The chain was exonerated as root cause but CONVICTED on four defects that made the incident undiagnosable and unbounded: (1) double buffering — CC -p --output-format json emits only at exit AND subprocess.run(capture_output=True) buffers again, so a 20-min run is externally indistinguishable from a hang; (2) NO turn bound — the module docstring's USD-caveat cited "CC's turn limit (error_max_turns)" as a provider-independent bound, a PHANTOM: error_max_turns fires only when --max-turns is passed (print mode has NO default limit — code.claude.com/docs/en/cli-reference.md: 'Limit the number of agentic turns (print mode only)', 'No limit by default') and neither wrapper ever passed it (this also corrects #42(c)'s wrong premise and closes its documented follow-up); (3) CC's own stderr was captured then DISCARDED ([claude-code:unrecognized_model] telemetry never reached the record); (4) the resolved model name was unverifiable from outside — the incident report itself hypothesized a model-name mismatch, refuted by the same-day probe. CC v2.1.238 re-probe (the incident's exact version, through the wrapper's own _materialize_profile + --model opus path): (a) -p --output-format stream-json now REQUIRES --verbose (0.3s rc=1 without it — the pre-2.1.238 --observe-hooks argv was already broken); (b) alias remap verified end-to-end — the API request carries model deepseek-v4-pro ([1m] stripped client-side; measured from a stream-json assistant event's message.model); (c) CC prices UNRECOGNIZED models at premium fallback rates — $0.24 for ONE tiny turn — so the $4 budget default held ~16 tiny turns (see the #42 amendment).
  • Decision: csr-first port (the incident was a csr invocation), mirrored to pd identically (rule 7 lockstep; ADR #45's csr→pd precedent direction). (1) _claude_argv always passes --max-turns (default 60, env HETERO_DOC_MAX_TURNS / HETERO_MAX_TURNS; a hit DEGRADES via error_max_turns ∈ DEGRADABLE_CC_SUBTYPES, ADR #41). (2) The DEFAULT spawn is stream-json read INCREMENTALLY (_run_streamed: Popen + reader threads): --verbose (the 2.1.238 requirement) + --include-partial-messages (token deltas → liveness + byte counting BEFORE a message completes — message-level events alone would be blind to an endless single response, the exact incident shape); a heartbeat JSON line to STDERR every 30s (HEARTBEAT_INTERVAL_S; fields provider/elapsed_s/stream_bytes/events/assistant_events/model/idle_s/killed); the first assistant event's message.model is captured as the RESOLVED model and stamped into the result's new provider_runs[] (name/model/assistant_events/stream_bytes/elapsed_s + cc_stderr_tail when present). (3) --max-stream-bytes runaway breaker (default 64MiB, counts partial deltas; trip = malformation fingerprint hetero-stream-bytes-cap, NOT degradable — CC produced no envelope; rule 3 loud). (4) CC's stderr is tailed (cc_stderr_tail). (5) --no-stream restores the legacy single-envelope json spawn — the OUTPUT-surface fallback analogous to --bare for hooks (incompatible with --observe-hooks, fails fast rc=2). (6) --budget-usd default 4.0 → 12.0 (#42 amendment). Signatures extended ADDITIVELY (_claude_argv trailing max_turns/stream kwargs; run_claude/_run_claude_once trailing guards kwarg) — the Phase-A positional contract holds (csr's divergence.md table updated). Field naming: the telemetry counter is assistant_events (stream assistant-event count), NOT "turns" — measured live, assistant events ≠ CC's turn accounting (minimax: 114 events vs the 40-turn cap hit), so a turn-named field would contradict its own semantics.
  • Why: the incident's cost was not the provider misbehavior (unavoidable) but the 20-minute BLIND, UNBOUNDED burn — with heartbeat + caps the same run surfaces its resolved model within seconds, shows stream_bytes climbing, and dies at a cap with telemetry attached. The phantom-boundary citation had to be corrected in code, not just docs, because the docstring's reasoning justified NOT adding a bound. The partial-messages flag is load-bearing (Decision 2). idle_s is REPORTED but deliberately never a kill condition. Verified live 2026-08-21 (the landing's own dogfood: csr wrapper, dual different-family on hetero_doc_review.divergence.md): deepseek COMPLETED at 36 assistant events / 17.6MB stream / 551s (verbose-but-finite; provider_runs[0].model="deepseek-v4-pro"; cc_stderr_tail carrying the unrecognized_model telemetry); minimax hit --max-turns at 300s → DEGRADED honestly instead of burning 1200s — the cap's first live firing, in the incident's exact shape. The dogfood's different-family findings against the landing's own divergence.md (2 blockers + 6 warnings, incl. "ADR #52 cited before it existed" and stale cross-refs) were adjudicated and fixed in the same landing — the substrate reviewing its own change record is the intended self-hosting loop. Offline gates: csr's new hetero_doc_guards.py (6 checks: argv surface / streamed telemetry + heartbeat / bytes-cap / wall-clock kill / CLI conflict / provider_runs) + all csr self-gates green; pd's 12 self-gates green.
  • Rejected: (a) idle-kill — false-aborts legitimate cold-start single responses (ADR #43 documents cold large reviews >600s; a long prefill is line-silent at message granularity); observability without killing instead. (b) byte cap on message-level stream only (no partial messages) — blind to the endless-single-response shape until completion; the flag is documented and cheap. (c) dropping the json mode entirely — no escape hatch if a future CC breaks the stream surface (the manifest's own doctrine; --bare precedent). (d) keeping budget at 4.0 — at the measured $0.24/turn mismeasure it strangles legitimate reviews before the (now real) turn cap binds; 12.0 restores headroom while ADR #41 degrade still catches true runaways. (e) heartbeat to stdout — stdout is the single-result-JSON contract consumed by the CSR-I4 driver / the pd loop; stderr is the progress channel. (f) turn-cap default 40 — the live dogfood showed a dense 113-line doc legitimately spending 40+ CC turns (minimax) and a completing deepseek run at 36 assistant events; 60 gives legitimate headroom while a fast-cycling loop still dies early and the wall-clock bounds the slow-verbose case regardless. Sibling to #40 (the substrate), #41 (degrade lineage — the new caps reuse it), #42 (amended by this entry), #43 (timeout ⊄ idle: reported, never killed), #45 (CC-drift lineage + the csr→pd port precedent), #47/#48 (the same shared-wrapper invariant class), rule 7 (lockstep both wrappers). Authority: the third-party incident report + minimax control experiment (2026-08-21); the CC v2.1.238 probe transcripts (the three drifts above); the live-dogfood record summarized in Why.

53. deepseek profile demotes v4-pro → v4-flash as the review-leg default — measured basis (untethered reasoning: ~4× variance; the effort dial is inert unset→low and 2.2× NEGATIVE when force-sent); supersedes ADR #43's no-remap rule FOR THIS CASE; *_MODEL_NAME vars are display-only and never adopted

  • Context: ADR #52's landing left deepseek-v4-pro as the default review leg under the new guards. Follow-up probes (2026-08-21/22, all through the wrapper's own _materialize_profile path, complex-prompt single turns, n=3 per config): (1) thinking length under IDENTICAL config varies ~4× (161KB–636KB) — heavy-tailed stochastic reasoning, which is the statistical signature of the incident's erratic completions (sometimes 36 turns done in 9 min, sometimes 1200s burnt); (2) CLAUDE_CODE_EFFORT_LEVEL=low alone is INERT (distribution overlaps unset) — CC enables effort/thinking features by matching the model ID against known patterns and does not send the effort parameter for unrecognized IDs (documented; needs CLAUDE_CODE_ALWAYS_ENABLE_EFFORT=1 to force); (3) forced send (low + force flag) made deepseek think 2.2× MORE (442–809KB vs 285–366KB, non-overlapping distributions) — deepseek's endpoint reads an explicit effort/thinking payload as reasoning-UP, not down (consistent with their docs ignoring budget_tokens and the known reasoning-block compat issues); (4) v4-flash on the same probes: ~60% of pro's thinking volume at ~2× speed. deepseek wires thinking on/off to the model VARIANT (their chat/reasoner split), so the model name is the only working dial on this backend.
  • Decision: both skills' profiles/deepseek.json map ALL tiers to the flash variant (ANTHROPIC_DEFAULT_OPUS_MODEL / _SONNET_ / ANTHROPIC_MODEL / the model field = deepseek-v4-flash[1m]; the documented ANTHROPIC_DEFAULT_FABLE_MODEL tier added for completeness so a manual --model fable cannot fall through to an unmapped Anthropic ID; HAIKU / SUBAGENT / FAST were already flash). The tier ladder DELIBERATELY collapses — pro is unreachable via aliases; a future high-stakes pro need re-adds it as a variant profile. The _comment records the demotion + basis at the failure site.
  • Why: ADR #43 forbade alias remaps to dodge TRANSIENT cold-start; this demotion rests on MEASURED PERSISTENT pathology (the incident class, reproduced locally on a 113-line doc) plus the effort dial being unavailable (inert or negative — there is no configuration path back to a light pro). The ADR #52 guards bound the damage, but every pro run still spends minutes and megabytes for an ADDITIVE second-opinion leg; flash halves that with a full model class of headroom. Finding-yield of flash is UNVALIDATED — the next real csr different-family run under the new default is the validation, measurable via provider_runs[] telemetry (findings count/severity vs elapsed/stream_bytes against the 2026-08-21 pro baseline: 9 findings incl. 2 blockers at 551s/17.6MB).
  • Rejected: (a) a separate deepseek-flash.json variant + HETERO_*_PROFILE opt-in — keeps the incident class as the DEFAULT; the intent is flash as the standing default, so the direct edit is simpler and the profile name no longer lies about what runs. (b) effort-level configuration (unset/low/forced-low, incl. the max that third-party CC guides recommend) — measured inert to NEGATIVE on this backend (Context 2–3); provider-specific, never portable across profiles. (c) adopting the ANTHROPIC_DEFAULT_*_MODEL_NAME family (seen in third-party GLM guides) — documented as /model-picker DISPLAY names, inert in headless -p; dead weight in a routing-only template. (d) keeping pro + relying on ADR #52 guards alone — bounds the burn but still pays a heavy-tailed reasoner's cost every run. Sibling to #43 (its no-remap rule is superseded FOR THIS CASE — measured persistent pathology vs transient cold-start — not reversed generally), #52 (the guards that make the flash default safe + the telemetry that will validate it), #42 (unrecognized-name budget mismeasure — unchanged), rule 7 (both profiles edited byte-identically). Verified: csr + pd self-gates green after the profile edit; the flash probe data in Context; the first real different-family run under the new default is the pending yield validation.

54. fast-gate rust edition is DERIVED from the checked file's nearest manifest (_rust_edition) — a hardcoded --edition is a systematic false-positive generator on newer-edition projects; gate truthfulness cuts BOTH ways (a phantom Blocker is as contract-breaking as a fake green)

  • Context: 2026-08-22 tianwang-waf handoff (consumer repo, edition = "2024"): the fast gate's check_rust hardcoded rustfmt --edition 2021; rustfmt parsed 2024-only syntax (let-chains) with 2021 grammar and failed on EVERY edit — while cargo fmt --check on the same files was green. The phantom Blocker repeatedly tripped the thrashing breaker (≥5 inner-ring interruptions in the consumer's v0.4 run), each carrying the self-certifying error text ("let chains are only allowed in Rust 2024 or later"). Three independent adjudications in the consumer's loop converged on the same line (dual-baseline mismatch; outer-ring root-cause location; a direct --edition 2021 rc=1 vs 2024 rc=0 reproduction) plus an independent subagent report. Survey at fix time: that line was the ONLY language-version hardcode in the fast gate (ruff / swift-format / google-java-format / gofmt / eslint checks carry none — their tools are versionless or derive from project config). Re-verified at fix time on the consumer's two historical false-positive files: derived edition 2024 + check GREEN post-fix, forced-2021 control RED on both.
  • Decision: check_rust derives the edition per file via _rust_edition(file_path) — walk parent dirs to the nearest Cargo.toml: a direct [package].edition = "YYYY" wins; edition.workspace = true (both spellings, incl. the edition = { workspace = true } table form) resolves via the first ANCESTOR's [workspace.package].edition (an ancestor's own [package].edition is NOT the workspace default and is skipped); a nearest manifest declaring NO edition ENDS the walk (cargo defaults that crate; an ancestor's edition must not be poached); no manifest / unresolved → "2021" — the historical hardcoded default, preserved so manifest-less paths keep pre-fix behavior (rustfmt's own bare default would be 2015). Section-aware regex parse, stdlib floor-compatible. Behavioral coverage: smoke_gates.py::smoke_rust_edition (six resolution fixtures + an e2e where an edition-2024 let-chains file passes the hook while bare rustfmt --edition 2021 still reproduces the incident error, pinning the toolchain basis; graceful skip when rustfmt is absent — rule 1/3).
  • Why: the convergence loop's contract is gate TRUTHFULNESS in both directions — rule 3 forbids faking green, and the mirror holds: a false Blocker burns inner-ring turns, erodes trust in the thrashing breaker (which fired on phantoms), and blocked legitimate edits with an error whose own text named the root cause. Edition is a per-project fact living in the manifest; any per-file formatter invocation that pins it in hook code is a latent false-positive generator against every future edition bump. The derived approach reads the same source of truth cargo fmt uses, at per-file cost.
  • Rejected: (a) keep --edition 2021 — measured systematic false positives on edition-2024 projects (the handoff). (b) a cargo fmt-based check (derives the edition automatically) — whole-crate work in a per-file ms~sec PostToolUse hook. (c) tomllib parsing (stdlib 3.11+) — more correct in principle but raises the hook's interpreter floor; the section-aware regex handles direct + both inheritance spellings and the silent-manifest stop. (d) dropping --edition entirely (rustfmt bare default 2015) — strictly worse than the bug for every modern project. (e) requiring a consumer-side rustfmt.toml carrying the edition — pushes hook-internal knowledge into every consumer repo. Sibling to #50 (the same hook's format-stratification remediation — orthogonal and unchanged), #41 (the substrate-honesty lineage; this extends rule 3 to its both-directions reading), rule 4 (a Blocker must be a REAL violation — a phantom is the inverse failure), rule 7 (the hook stays stdlib, floor-compatible, single-file deployable). Verified: smoke_gates.py green incl. the new cases; the consumer's settings.rs + compliance_harness.rs resolve 2024 and check GREEN with the forced-2021 control RED; the full pd suite green.

55. Plan-driven Coder dispatch names the subagent_type AT the decision point — the platform→agent formula is duplicated inline in plan-driven-mode.md step 4 (dispatch table + row-matching + companion routes) rather than trusted to a doc the dispatcher never loads; PLUS the precedence doctrine and the mixed-prefix convention

  • Context: 2026-08-24 drift audit of the tianwang-waf consumer session (evidence pinned below): 22 of 94 subagent dispatches ran Rust implementation tasks as general-purpose, solidforge:tester zero times, and the drift split exactly at the plan-driven-mode adoption point — v0.6 typed dispatches (backend/frontend/devops) before it, general-purpose for all v0.7/v0.8 Coder items after it. Root cause: the platform→agent formula lives in role-agent-mapping.md + the per-platform pattern docs (rust-patterns.md:4, go-patterns.md:3, java-patterns.md:3 — "uses backend-developer"), none of which the orchestrator loads at the plan-driven chaining-loop dispatch moment; plan-driven-mode.md carried zero type-selection content. This contradicts ADR #29 (which REJECTED the "keep general-purpose + prompt" fallback for exactly this reason) — the drift was the rejected alternative re-manifesting through a reachability hole (workspace rule 8). Evidence source + counting rule (for auditability): session 5ebbbf1c-55f4-4cac-9ac8-41862772de4e under ~/.claude/projects/-Users-solosus-dev-ws-waf-tianwang-waf/, counted as subagents/*.meta.json agentType values in dispatch order, at the 2026-08-24 extraction snapshot (94 metas; the session has since grown).
  • Decision: plan-driven-mode.md step 4 now names the subagent_type inline — a dispatch table (8 rows: backend platforms → solidforge:backend-developer; Web/UI → solidforge:frontend-developer; iOS platform → solidforge:ios-developer; test corpus/unit/integration/coverage → solidforge:tester; iOS XCTest unit → solidforge:ios-developer; iOS XCUITest → solidforge:ios-tester; browser E2E → solidforge:playwright-test-* with the MCP-absent fallback to solidforge:tester; CI/CD/infra → solidforge:devops-engineer), a row-matching line, and a companion sentence routing non-Coder shapes (architecture → solidforge:architect; detailed design → Plan; requirements → solidforge:requirements-manager; visual design → the /impeccable skill). Step 5 gains a review-leg carve-out: security-semantic DoD (auth/authz, pre-production security, threat model) spawns solidforge:security-specialist instead of the code-reviewer; record-outer is unchanged. The prefix fix makes the two authority files self-consistent: every bare agent-name citation of a dispatch-table/carve-out/companion-cited name in role-agent-mapping.md (14 instances) + e2e-testing.md (13 instances) now carries solidforge:. NEW DOCTRINE (recorded here as its source): the DoD-shape rows win over the platform rows; Node.js/TypeScript backend-vs-frontend resolves by the role table's trigger keywords; Apple-platform architecture / module-boundary DoD routes to solidforge:architect despite the iOS platform row. MIXED-PREFIX CONVENTION (recorded here as its source): step 5's existing bare code-reviewer text is not rewritten; the appended carve-out uses the prefixed form. refactoring.md's Task(developer) (an unregistered agent name) was FOUND by the rule-5 sweep and is deferred to a separate fix, recorded not silent.
  • Why: the dispatch moment is the only place the formula can be enforced — the same reason ADR #29 gave for dedicated agents (a fallback with no routing trigger invites drift), applied one layer up to the doc the dispatcher reads. The precedence doctrine needed a durable home; without it the row-matching line is an unsourced rule. The prefix fix removes the bare-vs-prefixed inconsistency the dispatch table's citations would otherwise absorb silently.
  • Rejected: (a) trust the existing loading chain (role table "is loaded somewhere") — measured drift, the hole is real. (b) add an agent_type field to the plan-driven Queue Format and derive per item from the queue — schema change to a frozen format plus the plan interpreter would need to author the field; the table is cheaper and keeps the queue format unchanged. (c) keep the design/requirements rows INSIDE the Coder dispatch table — a "Dispatch the Coder" table carrying non-Coder agents (R4 different-family finding); the companion sentence keeps the table Coder-scope. (d) rewrite step 5's existing text to prefix code-reviewer — out of the fix's authority-file scope; the convention is recorded instead. (e) extend the prefix fix to the repo-root docs/subagent-review-plan.md (21 bare roster names on 2 lines) and the Task(<agent>) worked-example files — repo-facade / role-description docs, deferred to a separate naming sweep, enumerated in the proposal's rule-5 audit not silent. AMENDED 2026-08-24: the deferred Task(<agent>) sweep LANDED as a follow-up commit — Task(<agent>) + bare backtick citations + agent_type values across refactoring.md / bug-fix.md / documentation.md / ast-grep-patterns.md / feature-dev.md / parallel-patterns.md now carry the solidforge: prefix; the unregistered Task(developer) example is fixed to Task(solidforge:frontend-developer or solidforge:backend-developer). The repo-root review-plan doc remains deferred. This proposal was itself cross-source-reviewed to substantive convergence (6 rounds, 4 blockers found and fixed, csr convergence-record at docs/coder-dispatch-formula.convergence-record.json). Sibling to #29 (the general-purpose rejection this enforces at the decision point), #14 (dispatch-as-subagents).

56. TDD trinity borrows: the seam freezes IN the blueprint (not a chat confirmation), tracer-bullet-first is normalization DOCTRINE (not a queue schema field), review-axis separation is a DEFERRED ODP — plus the deliberate RED-batch exception to the horizontal-slicing anti-pattern and the refactor-placement tension the external evidence exposed

  • Context: 2026-08-24 the TDD-Seam-Tracer-Bullet trinity article (卡颂, X) + mattpocock/skills were researched (fedaot-wiki: test-seam, tracer-bullet, review-axis-separation, mattpocock-skills, frontier-delta-progress-invariant — concepts verified against Feathers WELC 2004 ch.4 + Hunt & Thomas 1999). The borrowable substance: seam doctrine (P7), tracer-bullet-first decomposition (P8), review-axis separation (P9, deferred). The full proposal was cross-source-reviewed to substantive convergence (6 rounds, 65 findings, 6 blockers fixed, record at docs/tdd-seam-tracer-bullet.convergence-record.json).
  • Decision: (a) Seam freezes in the blueprint, not a chat confirmation — each AC line carries seam: <public-boundary-name> + a one-clause catch/miss note, declared at Phase 0 pre-freeze. Architecture choice (recorded): bc carries NO seam field — the seam is a pd-side DERIVATIVE (the Planner derives it at Phase-0 from the AC's Given/When/Then boundary), not a bc-negotiated upstream anchor (the wiki's 规格阶段协商 point; the P7 landing's bc non-goal made this choice without recording its downstream consequences). Consequences, stated: derivation quality = one LLM act (coverage-noted, not gate-enforced); a wrong-boundary AC survives bc's plan-reviewer (no seam-quality dimension there) and surfaces only at pd's reviewer check (g), one stage later; bc author and pd Planner share the same-family blind spot. Follow-up: bc-side seam upstream-anchor proposal (Option A). mattpocock's "pre-agreed seam, confirmed with the user before any test is written" translates to the frozen-anchor idiom: the upstream anchor is the blueprint, not a mid-RED conversation (the wiki's upstream-anchoring point — a guard whose precondition is never anchored upstream silently degrades to "just write code"). The AC→test mapping line format is UNCHANGED (parse_ac_test_map untouched, rule 2). The absent-seam degrade has NO schema'd record carrier (run-record schema closed; loop_state.py summary() deterministic; folded summary is the GREEN-phase Coder's; record-outer --notes bumps outer.iterations) — unverifiable at the producer end, stated honestly; the consumer end closes via the reviewer's seam-conformance check (g) at warning severity (never blocks, workspace rule 4). (b) Tracer-bullet-first is normalization doctrine, not a plan_queue schema field — plan-driven-mode.md Phase −1 capability 8: wave-1 = the thin vertical slice through all layers (single item or items jointly forming one slice; nothing else rides wave-1); per-item shape rules (demoable alone / fits one Coder fresh context / AC-subset isolation / skip when the whole change fits one context); precedence: shape rules bind semantic-infer decompositions, on LATCHED ones they are checkpoint-flagged advisories via the revision channel, never a silent re-split. (c) Review-axis separation deferred as a skill-level ODP — mattpocock's two-axis parallel sub-agents (standards/spec, never merged) address a masking risk our single-agent triple-line review shares in principle; the codable share of standards is already inner-ring deterministic and blind-spot crossing is the opt-in 异源 leg (high-stakes only — imperfect on default items, stated). Trigger for re-evaluation: an observed masking incident (incl. on a default item). Incident feed: 异源-leg disagreements on high-stakes items + human post-mortems (the merged verdict emits no per-axis signal — the record cannot self-observe a masked failure, stated). Carrier: this ADR entry — design-decisions.md has no ODP machinery, no automated re-surface point (stated honestly). Known cost of the split: per-axis sub-agents can recursively fan out (observed to tens of agents) — a split would need an explicit no-delegation rule. (d) Deliberate Phase-4 RED-batch exception: the third wiki anti-pattern — test-batch horizontal slicing (all tests first, then all code) — IS our Phase 4 ordering, rejected-with-rationale: the RED batch is the parallel-contract design (tests = the shared API contracts parallel implementers converge against, feature-dev.md Phase 4). (e) Refactor-placement tension, exposed by external evidence: mattpocock dropped refactor from its TDD loop in June 2026 ("agents essentially never performed it, and because review and implementation work better as separate sessions" — mattpocock docs/engineering/tdd.md:31, fetched + verified) and moved it to code-review. Our own placement is mixed: TDD Default nominally lists REFACTOR as step 3 of the per-task cycle (SKILL.md), while operationally refactoring runs as an optional phase AFTER convergence (feature-dev.md Phase 7 tail) and the convergence loop carries no refactor step (0 occurrences). Mattpocock's change validates the OPERATIONAL half of that split. OPEN QUESTION (unresolved): demote step 3 in the TDD Default text to match the operational shape? Plus the validation finding: mattpocock's implement does not close work units (closing is human-mediated) — our plan_queue.py complete drives the machine-observable closure/status delta, which is the frontier-delta-progress invariant at the task layer; the implement run itself lands code, which IS a frontier delta (landed artifacts count) — the contrast is closure state, not artifact landing. OPEN QUESTION (pre-existing repo, recorded not solved): the AC→test mapping is declared at RED (post-freeze) yet blueprint_guard.py wholesale-denies frozen blueprints — how the mapping section enters the frozen blueprint is unreconciled; the seam field deliberately avoids that path by being pre-freeze.
  • Why: the seam must be fixed upstream of RED or the discipline silently degrades (the wiki's precondition-anchoring point); a chat confirmation is prose the workspace rules 4/10 reject. Tracer-bullet-first is semantic (L3) — a schema field would pretend determinism where the normalizer's judgment is the carrier. The ODP deferral rests on measured coverage: inner-ring determinism + the opt-in 异源 leg cover most of the two-axis benefit; the fan-out cost is real.
  • Rejected: (a) mattpocock's prose seam confirmation (chat-time) — frozen anchors replace prose gates, consistent with rule 4. (b) an agent_type-style schema field carrying tracer-bullet structure in the queue format — same rejection as ADR #55(b): the plan interpreter would need to author it. (c) importing the horizontal-slicing anti-pattern wholesale (breaking Phase 4's parallel-contract ordering) — the batch is load-bearing for parallel convergence. (d) splitting the outer reviewer into per-axis sub-agents NOW — masked-risk coverage already partial via inner-ring + 异源; the fan-out cost is documented. (e) leaving the RED-batch exception unrecorded (silent skip, rule 3). (f) claiming the REFACTOR separation already exists without checking the docs — the initial claim was factually wrong (found + fixed by the csr different-family leg); this entry records the verified facts. Sibling to #29 (dedicated agents over general-purpose fallback — the seam/ODP discipline inherits its rationale), #38 (same-family ceiling — checks (d)(e)(f)(g) are same-family, the root-gap's resolution stays 异源 territory), #55 (decision-point inlining — capability 8 lives at the normalizer decision point, rule 8). Authority: the csr convergence-record (docs/tdd-seam-tracer-bullet.convergence-record.json); the vendored authority snapshots at workspace/csr-seam-tracer-proposal/authority/ (gitignored). AMENDED 2026-08-25 (Option A landed — bc ADR #19): the Architecture-choice sentence's DERIVATIVE half is REVERSED (bc now declares the seam in spec text — "bc carries NO seam field" remains TRUE, the seam is spec text, not a schema field; the upstream anchor is the SPEC, the blueprint LATCHES); the Follow-up line is FULFILLED (skills/blueprint-crafting/docs/seam-upstream-anchor.proposal.md, csr-converged 12 rounds, record alongside); the FIRST consequence transmutes (declaration quality = one LLM act, still coverage-noted); the SECOND consequence partially transmutes (bc's plan-reviewer gains the seam-quality dimension, advisory — the wrong-seam AC is detected earlier but still passes bc; check (g) remains a WARNING net that never blocks and fires only for the cannot-observe sub-case); the THIRD consequence REMAINS (same-family blind spot). The loop_state.py set-blueprint-ref subcommand landed (the path-versioned re-freeze ref-update GAP the Option-A proposal surfaced). AMENDED 2026-08-25 (both OPEN QUESTIONs resolved): the (e) REFACTOR demotion LANDED — TDD Default is RED→GREEN per task with REFACTOR stated as the post-convergence tail / own workflow (SKILL.md, references/README.md, maturity.md ×2, feature-dev.md Phase 7 tail reworded to post-convergence); the AC→test-mapping question is resolved by ADR #58 — the mapping enters the frozen blueprint at RED through a guard mechanism (append-only carve-out), not a doc contradiction.

57. Profile naming follows the three-level doctrine ROUTE/FAMILY/MODEL (borrowed from the pi + dsh ports) — _family becomes an explicit field; aliyun-bailian -> qwen-bailian and minimax -> minimax-cn renames; pd's bigmodel copy synced

  • Context: the pi port (solidforge-pi) and the DeepSeek-harness port (solidforge-dsh) both settled a naming doctrine our CC-substrate profiles lacked: FILENAME = ROUTE (the credential+endpoint channel), _family = MODEL LINEAGE (an explicit field), model = the pinned per-generation id. Our profile names mixed axes with no family vocabulary: bigmodel (a BRAND whose lineage is glm — zai/bigmodel/zhipu are one lineage), aliyun-bailian (a PLATFORM hosting qwen/glm/deepseek/kimi — ambiguous about which lineage the file pins), minimax (actually the CN-platform route, api.minimaxi.com). The same/different-family judgment — load-bearing in model-routing.md ("BigModel is same-family there") — had no declarative anchor and was made on route names. The 2026-08-25 lockstep audit also found pd's bigmodel.json still at GLM-5.2 while csr's was bumped to GLM-5.3 (the 5af48fe commit edited one copy — the exact drift rule 7 exists to prevent).
  • Decision: adopt the three-level doctrine. (1) Every profile gains _family (deepseek / glm / minimax / qwen) — declarative metadata the wrapper ignores (unknown-key tolerant, no code change; the same-family judgment reads it, docs + humans + future tooling). (2) Renames per FILENAME=ROUTE: aliyun-bailian -> qwen-bailian (family-first — the pi port's name for the same route; zero credential migration, no env var existed); minimax -> minimax-cn (the endpoint IS the CN platform; _token_env: MINIMAX_ANTHROPIC_AUTH_TOKEN bridges the pre-existing var — pi's bridging pattern). bigmodel keeps its name (it IS our route name) + _family: glm. (3) Multi-family routes: _family names the PINNED MODEL's lineage (token-plan serves qwen/glm/deepseek/kimi — the dsh principle). (4) Profile names stay version-free; the generation lives in model. (5) The same-family judgment re-anchored: read _family, never the route name; two routes of one family are redundant for blind-spot crossing. (6) pd's bigmodel.json synced to GLM-5.3[1M] (the served model).
  • Why: route and family are orthogonal axes — conflating them made "which lineages do I have on tap?" unanswerable from the profile dir and let the same-family judgment ride brand names (bigmodel ≠ a family; glm is). The ports paid the naming cost first; adopting their vocabulary keeps the three solidforge variants mutually intelligible.
  • Rejected: (a) rename bigmodel -> glm.json — the filename is our ROUTE name (BigModel's CC endpoint), and the pi-adapter route (zai-coding-cn) does not exist on the CC substrate; _family: glm carries the lineage without lying about the channel. (b) derive family from the model id at runtime — parsing lineage out of marketing ids is brittle and hides the judgment unit from grep/docs. (c) drop _family as redundant with the comment — comments are prose; the field is the machine-greppable judgment anchor. (d) migrating the minimax credential var to MINIMAX_CN_ANTHROPIC_AUTH_TOKEN — a no-op rename of a live secret buys nothing the _token_env bridge doesn't. Sibling to #40 (the substrate the profiles ride), #47/#48 (the token-namespace conventions the routes derive from), rule 7 (the lockstep the bigmodel drift violated). Authority: the pi/dsh profile sets (../solidforge-pi, ../solidforge-dsh — read 2026-08-25); the live qwen-token-plan-cn csr rounds R11-R12.

58. blueprint_guard gains an APPEND-ONLY AC→test mapping carve-out — "declared at RED" becomes mechanism-backed; the mapping region is the one post-freeze open path, everything else stays byte-frozen

  • Context: ADR #56 recorded the unreconciled path: the AC→test mapping's values are RED artifacts (real test names exist only after RED writes the tests), yet blueprint_guard.py wholesale-denied edits to a frozen blueprint. "Declared at RED phase" (intent-blueprint.md, the template, feature-dev.md) was therefore mechanism-unreachable — the de facto paths were predict-at-freeze (brittle: per-language nodeid formats, test design leaking into Phase 0) or absent (the test-name set gate dormant behind its optional degrade). The seam field (ADR #56a) dodged the question by landing pre-freeze; that dodge does not transfer: seam names are boundary KNOWLEDGE (held before code exists), mapping values are RED ARTIFACTS.
  • Decision: the guard allows an Edit/Write/MultiEdit to a frozen blueprint iff BOTH hold. (1) Content outside the mapping region is byte-identical — the region = the section's H2 header line + its - AC-x -> test bullet lines + blank runs adjacent to them (section-aware walk mirroring parse_ac_test_map's H1/H2 toggle; the regexes are DELIBERATELY duplicated from arch_contract_tests.py per self-contained-script rule 7 — the open region must match the parser's read region byte-shape exactly), with a symmetric EOF trailing-blank normalization on both residuals (a file frozen with trailing blanks would otherwise deny every section append; blanks carry no content). (2) The (ac_id, test_name) pair set only grows — no removal, no value rename — and the mapping-header count does not decrease (deleting the heading would silently re-dormant the gate). A bullet correction goes through the Revision Channel. Prose inside the mapping section stays byte-frozen (only header + bullets + adjacent blanks strip). feature-dev.md Phase 4 step 3 now instructs the RED phase to record the mapping once the tests stabilize. An un-reconstructable edit (missing/non-unique target) falls back to deny, conservative.
  • Why: the mapping is an OBSERVATION record (which tests ended up verifying which AC), not intent — freezing observations at Phase 0 is fiction, and the optional-degrade left the set gate (the anti-deleted-mapped-test tripwire) permanently dormant. The no-removal invariant closes the weakening hole a bare region-carve-out would open (delete the bullet → delete the test → gate silenced); the stripped-byte-compare closes the relocation hole (moving UC content under the mapping heading fails the compare — those lines do not strip). Garbage appends are policed downstream: a mapped name missing from the collected set is a Blocker. parse_ac_test_map itself is untouched (rule 2).
  • Rejected: (a) pre-freeze prescription (the Planner names tests at Phase 0; RED copies) — makes the set gate near-tautological and leaks test design into Phase 0. (b) side-car mapping file — splits the anchor across two carriers (the reviewer diffs ONE file) and requires editing the checker's input path. (c) honest-degrade doc-only (rewrite "declared at RED" as unreachable, accept the dormant gate) — leaves load-bearing machinery permanently off by design. (d) revision-channel-per-mapping — the channel is for defects with Planner+human escalation; a routine RED observation is not a defect. (e) time-windowed carve-out (mapping edits allowed only during RED→convergence) — needs loop-state coupling for no gain: intent edits are denied regardless, and garbage appends Block downstream. Sibling to #56 (the open question this resolves; the seam/mapping freeze-line split — seam pre-freeze as knowledge, mapping post-freeze as artifact), #29 (read-only anchors doctrine). Tests: infra/test/blueprint_guard_carveout.py (append via Edit/Write/MultiEdit allow; delete/rename/unmap/section-prose/gutting/mixed-MultiEdit deny; revising/plan-queue/non-anchor/malformed regressions).

59. fast_gate scopes to the project root — out-of-repo scratch is a silent no-op (no lint, no fingerprint, no breaker feed); closes dogfood gap #4

  • Context: the 2026-07-07 hetero-wrapper dogfood (ADR #41's session) surfaced a fourth gap left open when #1-#3 landed: the PostToolUse fast_gate linted a throwaway /tmp/*.py diagnostic (E401 → SIM105 → format churn), feeding the thrashing breaker until it escalated "≥3x same root cause" — a spurious inner→outer exception handoff manufactured by scratch files. The gate's contract is per-file lint/format on the PROJECT's edit stream (the fingerprints, breaker, and loop-state all live under the project root; the outer ring reviews the project diff), so gating out-of-repo files was over-reach with a measured cost and no consumer of the signal.
  • Decision: main() resolves the file's containment (abspath + commonpath vs dt.project_root(), i.e. CLAUDE_PROJECT_DIR-or-cwd) and exits 0 silently for files outside the root — no lint, no fingerprint, no breaker query. On any path-comparison failure (e.g. ValueError on mixed drives) the file is gated anyway — conservative: a false lint beats a silent skip (rule 3 cuts both ways). A project living under /tmp keeps ALL its files gated (containment is against the root, not against any path pattern — the smoke test pins this with the in-repo control still blocking).
  • Why: the convergence machinery is project-scoped end to end; /tmp diagnostics never enter the reviewed diff, so their lint findings are signal without a receiver, and the breaker escalation they caused is a real observed failure mode (the inner→outer channel is supposed to be the exception path, not scratch noise). Scoping the hook at the entry point is one deterministic check, vs. accepting defense-in-depth whose depth defends nothing downstream.
  • Rejected: (a) keep gating everything as defense-in-depth — no downstream consumer exists for out-of-repo lint signal (the outer ring diffs the repo; arch gates run in-repo), and the spurious escalate was observed, not hypothetical. (b) scope by path denylist (e.g. skip /tmp, /var) — pattern-matching paths is brittle (XDG tempdirs, project-under-/tmp false positives); containment against the project root is the actual contract. (c) record an out-of-scope fingerprint without blocking — persists noise into loop-state for no consumer; the silent no-op is the honest coverage boundary, stated in the module docstring (rule 3). Sibling to #41 (the dogfood session that surfaced this gap; its #1-#3 landed there), #54 (gate truthfulness — a phantom Blocker is as contract-breaking as a fake green; this removes a phantom-Breaker generator), #50 (the breaker feed this stops polluting). Tests: smoke_gates.py smoke_fast_gate_scope (out-of-repo lint-dirty file silent; same-hook same-session in-repo control still blocks).

60. The breaker is INERT when no active loop exists — gate-fail on a terminal state (converged/suspended/hard_terminated) records the fingerprint for audit but returns ok; stale fingerprint counts no longer echo across sessions

  • Context: observed live 2026-08-25 in this repo — every fast-gate format block carried Breaker=ESCALATE ... hetero_review.py:ruff ... citing fingerprints from the 2026-08-22 session, days stale. Mechanism: check_breakers escalates when ANY fingerprint_log entry reaches thrash_N, the log is only ever rebuilt by init, and mark-converged does not clear it — so a converged loop's counts persist forever in the project-scoped loop-state, and any later non-task editing session (skill development dogfooding its own fast gate) rides them into a false escalate whose text actively misdirects ("package this context and hand to the outer Reviewer" — the inner→outer exception channel, invoked by nothing).
  • Decision: check_breakers short-circuits FIRST on terminal status (converged / suspended / hard_terminated): return ok with reason no active loop (status=<s>) — fingerprint recorded for audit only. record_fingerprint still appends/increments (audit trail intact, rule 3); only the DECISION is inert. The terminal set deliberately differs from counters.py's edit-deny set (suspended/hard_terminated only): converged loops allow post-loop edits — that is how this repo keeps linting itself — so their gate activity must be breaker-free, not denied.
  • Why: the breaker protects a RUNNING convergence process (that is what escalate/degrade/suspend advise); a terminal loop has nothing to protect, so its leftover counters are noise with misleading guidance attached. The task boundary for fingerprint counting is init (a new pd task re-inits, fresh log) — the guard makes the terminal side of that boundary honest too. Verified live mid-landing: the very next fast-gate block in THIS repo (status=converged) flipped from the stale ESCALATE to Breaker=OK: fix in the inner ring.
  • Rejected: (a) clear fingerprint_log in mark-converged — deletes the audit trail the run-record's breakers_fired_count rollup reads post-converge (scenario A asserts ≥1 from pre-converge gate-fails). (b) wall-clock windowing (ignore entries older than X) — time confounds provider throughput and would silently re-allow genuinely repeated root causes in long loops (the ADR #6 family of rejections). (c) session-scoping fingerprints — the state file has no session identity; init is the only honest boundary, and gate-fail cannot know which session it serves. (d) suppress the escalate only in fast_gate's display — the lie would remain in every other check_breakers consumer (check-breakers CLI, mark-escalation, run-record path). Residual, stated honestly: a CRASHED inner_running loop's leftovers still count until the next task's init resets them — the crash path has no terminal marker to key on. Sibling to #41 (the fingerprint-persistence doctrine this respects — record, don't delete), #52 (bounded-and-observable — the breaker's advice must stay truthful), #16 (honesty at the persistence layer). Tests: run_record.py scenario_g (inner_running 3x→escalate pinned; converged 4th gate-fail → ok + no-active-loop reason + audit count reaches 4).

61. csr gains a run-progress SIDECAR — every orchestrator state boundary + wrapper heartbeat appends as JSONL to a per-run file (csr_progress.py append/status + wrapper --progress-file); extends ADR #52's liveness contract from the wrapper's stderr to the WHOLE run; best-effort by contract

  • Context: a long csr run (same-family + different-family multi-round review; the motivating case was a hours-long plan-doc review in another session, 2026-08-27) is externally opaque — ADR #52's heartbeat goes to the wrapper subprocess's stderr, which the invoking session's Bash tool captures INSIDE the pending tool call, so an outside observer (the user at another terminal, another session) sees nothing refresh until the run ends. The CC session-transcript JSONL is tail-able as a stopgap, but it is a harness accident rather than a contract: tool_use events are visible live while tool_results (where stderr lands) appear only at call completion.
  • Decision: an append-only per-run progress sidecar at workspace/cross-source-review/runs/<stamp>-<slug>/progress.jsonl (gitignored, rule 11). Orchestrator boundary events (run-start / same-family legs / reconcile / round-end / run-end) are written via a new csr_progress.py append — a STRICT event registry (unknown type or unknown field exits non-zero; bool/int/float coerced). Wrapper events (hetero-leg-start / hetero-heartbeat / hetero-leg-end) are written by hetero_doc_review.py itself via a new --progress-file flag, using a module-global _PROGRESS_PATH — NOT a run_claude/_run_streamed kwarg, so the divergence.md preserved-signature contract stays byte-true. Read side: csr_progress.py status [--watch N] renders round-of-cap / phase + last-event age / leg + reconcile totals / terminal state; torn last lines (the concurrent tail -f reality) and unknown types are counted, never fatal. The vocabulary is single-sourced (EVENT_REGISTRY ↔ SKILL.md Run-progress sidecar bullets) and gate-enforced by a new 9-check self-gate csr_progress_gates.py.
  • Why: observability must be a CONTRACT, not a harness accident — ADR #52's reasoning ("bounded observable") extended one layer up: #52 answered "is the subprocess alive" for the orchestrator INSIDE the session; the sidecar answers "is the run alive and where is it" for anyone OUTSIDE it. Best-effort severity (an unwritable path warns once and never kills a review) keeps the observability layer from becoming a new failure axis — the same degrade-honestly family as ADR #41. Strict-vocabulary appends + tolerant status reads mirror the write-strict/read-tolerant split the convergence loop already uses elsewhere.
  • Rejected: (a) stderr-only + documenting the transcript-tail trick — harness-dependent, tool_use-live-but-not-results, and the transcript is a CC implementation detail, not a contract surface. (b) a file-watching daemon or socket — a new long-lived state to operate and secure for zero value over tail -f / status --watch. (c) folding progress events INTO the convergence-record — the record is an audit artifact with a frozen schema (ADR #3's version discipline); live telemetry is operational, and mixing them couples refresh cadence to record format. (d) an additive run_claude(progress_path=...) kwarg — would re-open the divergence.md preserved-signature table for a csr-only concern; the module-global keeps the contract table untouched. (e) a csr-local design-decisions.md — the workspace keeps ONE ADR log (this file; csr-originated ADR #52 already lives here), and rule 6 names this path. Sibling to #52 (bounded-observable — this extends its liveness contract outward), #41 (degrade-honestly — warn-once, never kill), #58 (registry/append-only discipline adjacent — strict vocabulary, single source). Tests: csr's new csr_progress_gates.py (registry↔SKILL sync / append shape + misuse / status render incl. torn-line + RUNNING / wrapper --dry-run flag surface / heartbeat tee / best-effort no-raise / enumeration sync) + the full 8-gate csr self-gate suite green; the wrapper half verified offline end-to-end via its --dry-run path.

62. csr's different-family leg runs as a BACKGROUND task with an in-session narration loop — zero-interaction status reporting is part of the protocol, not external tooling; stderr is captured to the run dir (the ADR #61 sidecar supersedes it for live telemetry)

  • Context: ADR #61 made a csr run externally observable (the sidecar file), but the user INTERACTING with the orchestrating session still sees silence during a long leg: the synchronous wrapper call blocks the session mid-turn, and mid-turn no text can stream — the original complaint, restated from first principles: when csr runs it must report status AUTOMATICALLY, with zero special interaction (no manual status request, no manual cron, no second terminal). Session-level cron watchers were prototyped in design discussion and rejected at first-principles review: anything the user must SET UP violates the zero-interaction requirement; the reporting must be a property of running the skill itself.
  • Decision: SKILL.md's step-2 protocol now launches the wrapper as a BACKGROUND task (run_in_background, stderr captured to <run-dir>/wrapper.stderr — the heartbeat lines are already tee'd to the progress file by ADR #61, and the wrapper's PRE-LEG fail-fast diagnostics (missing token / unknown profile — sys.exit prints to stderr BEFORE any progress event) are preserved there; the task's output file stays a pure result JSON) and instructs the orchestrator to poll the task roughly every 2 minutes (e.g. a foreground sleep 120 between renders), rendering ONE condensed status line per poll to the conversation from the sidecar (csr_progress.py status); on completion it parses the result JSON from the task output — on a non-zero exit with EMPTY stdout it reads wrapper.stderr for the cause — and continues the round loop. Narration lines are ordinary assistant text between tool calls — they stream to the user live. A synchronous call remains the documented fallback for harnesses without background execution (there the stderr heartbeat lands in the tool result, ADR #52).
  • Why: automatic reporting can only live where the user already is — the orchestrating conversation; a skill protocol step is zero-interaction BY CONSTRUCTION. The enabler is mechanical, not cosmetic: background execution leaves the session un-blocked (text between tool calls streams; the completion notification re-invokes the loop), while a synchronous call makes intra-leg narration impossible no matter what the protocol says. The sidecar (ADR #61) is the data source; this adds the in-session consumer. Cadence ~2 min balances liveness against transcript/context cost (a 10-min leg ≈ 5 one-liners); 30s would flood. Capturing stderr to a file (rather than discarding it) keeps the output file parseable (interleaved heartbeat lines would corrupt result-JSON extraction) WITHOUT losing the pre-leg fail-fast diagnostics an initial /dev/null draft would have thrown away — caught by outer-ring review (WA) and amended via the Revision Channel before landing.
  • Rejected: (a) session-level cron watcher — requires manual setup per observing session (violates zero-interaction) and only fires when idle anyway. (b) wrapper printing narration to stdout — stdout is the single-result-JSON contract since CSR-I3; interleaving breaks every consumer. (c) narrating only between rounds — leaves the intra-leg gap, which is the exact pain (a single leg runs 5–10 min). (d) harness-level streaming display of tool output — a CC feature request, not a skill contract; out of scope. Sibling to #52 (bounded-observable), #61 (the sidecar this consumes; its best-effort doctrine unchanged — narration failure degrades to the external sidecar, never kills the review). Tests: csr_progress_gates.py gains the narration-contract static check (scoped to the step-2 different-family bullet: run_in_background + wrapper.stderr capture + status narration instruction; this ADR cross-referenced); all csr self-gates green (7 offline gates + dogfood skip path). Honest coverage note: whether the orchestrator ACTUALLY narrates at runtime is not statically checkable — the first real csr run under the new protocol is the live validation (same deferred-dogfood pattern as #61).